models.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999
  1. import enum
  2. import random
  3. import uuid
  4. from email.utils import parseaddr, formataddr
  5. import arrow
  6. import bcrypt
  7. from flask import url_for
  8. from flask_login import UserMixin
  9. from sqlalchemy import text, desc, CheckConstraint
  10. from sqlalchemy_utils import ArrowType
  11. from app import s3
  12. from app.config import (
  13. EMAIL_DOMAIN,
  14. MAX_NB_EMAIL_FREE_PLAN,
  15. URL,
  16. AVATAR_URL_EXPIRATION,
  17. JOB_ONBOARDING_1,
  18. )
  19. from app.extensions import db
  20. from app.log import LOG
  21. from app.oauth_models import Scope
  22. from app.utils import convert_to_id, random_string, random_words, random_word
  23. class ModelMixin(object):
  24. id = db.Column(db.Integer, primary_key=True, autoincrement=True)
  25. created_at = db.Column(ArrowType, default=arrow.utcnow, nullable=False)
  26. updated_at = db.Column(ArrowType, default=None, onupdate=arrow.utcnow)
  27. _repr_hide = ["created_at", "updated_at"]
  28. @classmethod
  29. def query(cls):
  30. return db.session.query(cls)
  31. @classmethod
  32. def get(cls, id):
  33. return cls.query.get(id)
  34. @classmethod
  35. def get_by(cls, **kw):
  36. return cls.query.filter_by(**kw).first()
  37. @classmethod
  38. def filter_by(cls, **kw):
  39. return cls.query.filter_by(**kw)
  40. @classmethod
  41. def get_or_create(cls, **kw):
  42. r = cls.get_by(**kw)
  43. if not r:
  44. r = cls(**kw)
  45. db.session.add(r)
  46. return r
  47. @classmethod
  48. def create(cls, **kw):
  49. r = cls(**kw)
  50. db.session.add(r)
  51. return r
  52. def save(self):
  53. db.session.add(self)
  54. @classmethod
  55. def delete(cls, obj_id):
  56. cls.query.filter(cls.id == obj_id).delete()
  57. def __repr__(self):
  58. values = ", ".join(
  59. "%s=%r" % (n, getattr(self, n))
  60. for n in self.__table__.c.keys()
  61. if n not in self._repr_hide
  62. )
  63. return "%s(%s)" % (self.__class__.__name__, values)
  64. class File(db.Model, ModelMixin):
  65. path = db.Column(db.String(128), unique=True, nullable=False)
  66. user_id = db.Column(db.ForeignKey("users.id", ondelete="cascade"), nullable=False)
  67. def get_url(self, expires_in=3600):
  68. return s3.get_url(self.path, expires_in)
  69. class PlanEnum(enum.Enum):
  70. monthly = 2
  71. yearly = 3
  72. class AliasGeneratorEnum(enum.Enum):
  73. word = 1 # aliases are generated based on random words
  74. uuid = 2 # aliases are generated based on uuid
  75. @classmethod
  76. def has_value(cls, value: int) -> bool:
  77. return value in set(item.value for item in cls)
  78. class User(db.Model, ModelMixin, UserMixin):
  79. __tablename__ = "users"
  80. email = db.Column(db.String(256), unique=True, nullable=False)
  81. salt = db.Column(db.String(128), nullable=True)
  82. password = db.Column(db.String(128), nullable=True)
  83. name = db.Column(db.String(128), nullable=False)
  84. is_admin = db.Column(db.Boolean, nullable=False, default=False)
  85. alias_generator = db.Column(
  86. db.Integer,
  87. nullable=False,
  88. default=AliasGeneratorEnum.word.value,
  89. server_default=str(AliasGeneratorEnum.word.value),
  90. )
  91. notification = db.Column(
  92. db.Boolean, default=True, nullable=False, server_default="1"
  93. )
  94. activated = db.Column(db.Boolean, default=False, nullable=False)
  95. profile_picture_id = db.Column(db.ForeignKey(File.id), nullable=True)
  96. otp_secret = db.Column(db.String(16), nullable=True)
  97. enable_otp = db.Column(
  98. db.Boolean, nullable=False, default=False, server_default="0"
  99. )
  100. # some users could have lifetime premium
  101. lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0")
  102. # user can use all premium features until this date
  103. trial_end = db.Column(
  104. ArrowType, default=lambda: arrow.now().shift(days=7, hours=1), nullable=True
  105. )
  106. # the mailbox used when create random alias
  107. # this field is nullable but in practice, it's always set
  108. # it cannot be set to non-nullable though
  109. # as this will create foreign key cycle between User and Mailbox
  110. default_mailbox_id = db.Column(
  111. db.ForeignKey("mailbox.id"), nullable=True, default=None
  112. )
  113. profile_picture = db.relationship(File, foreign_keys=[profile_picture_id])
  114. @classmethod
  115. def create(cls, email, name, password=None, **kwargs):
  116. user: User = super(User, cls).create(email=email, name=name, **kwargs)
  117. if password:
  118. user.set_password(password)
  119. db.session.flush()
  120. mb = Mailbox.create(user_id=user.id, email=user.email, verified=True)
  121. db.session.flush()
  122. user.default_mailbox_id = mb.id
  123. # create a first alias mail to show user how to use when they login
  124. Alias.create_new(user, prefix="my-first-alias", mailbox_id=mb.id)
  125. db.session.flush()
  126. # Schedule onboarding emails
  127. Job.create(
  128. name=JOB_ONBOARDING_1,
  129. payload={"user_id": user.id},
  130. run_at=arrow.now().shift(days=1),
  131. )
  132. db.session.flush()
  133. return user
  134. def lifetime_or_active_subscription(self) -> bool:
  135. """True if user has lifetime licence or active subscription"""
  136. if self.lifetime:
  137. return True
  138. sub: Subscription = self.get_subscription()
  139. if sub:
  140. return True
  141. manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
  142. if manual_sub and manual_sub.end_at > arrow.now():
  143. return True
  144. return False
  145. def in_trial(self):
  146. """return True if user does not have lifetime licence or an active subscription AND is in trial period"""
  147. if self.lifetime_or_active_subscription():
  148. return False
  149. if self.trial_end and arrow.now() < self.trial_end:
  150. return True
  151. return False
  152. def should_upgrade(self):
  153. if self.lifetime_or_active_subscription():
  154. # user who has canceled can also re-subscribe
  155. sub: Subscription = self.get_subscription()
  156. if sub and sub.cancelled:
  157. return True
  158. return False
  159. return True
  160. def next_bill_date(self) -> str:
  161. sub: Subscription = self.get_subscription()
  162. if sub:
  163. return sub.next_bill_date.strftime("%Y-%m-%d")
  164. LOG.error(
  165. f"next_bill_date() should be called only on user with active subscription. User {self}"
  166. )
  167. return ""
  168. def is_cancel(self) -> bool:
  169. """User has canceled their subscription but the subscription is still active,
  170. i.e. next_bill_date > now"""
  171. sub: Subscription = self.get_subscription()
  172. if sub and sub.cancelled:
  173. return True
  174. return False
  175. def is_premium(self) -> bool:
  176. """
  177. user is premium if they:
  178. - have a lifetime deal or
  179. - in trial period or
  180. - active subscription
  181. """
  182. if self.lifetime_or_active_subscription():
  183. return True
  184. if self.trial_end and arrow.now() < self.trial_end:
  185. return True
  186. return False
  187. def can_create_new_alias(self) -> bool:
  188. if self.is_premium():
  189. return True
  190. return Alias.filter_by(user_id=self.id).count() < MAX_NB_EMAIL_FREE_PLAN
  191. def set_password(self, password):
  192. salt = bcrypt.gensalt()
  193. password_hash = bcrypt.hashpw(password.encode(), salt).decode()
  194. self.salt = salt.decode()
  195. self.password = password_hash
  196. def check_password(self, password) -> bool:
  197. if not self.password:
  198. return False
  199. password_hash = bcrypt.hashpw(password.encode(), self.salt.encode())
  200. return self.password.encode() == password_hash
  201. def profile_picture_url(self):
  202. if self.profile_picture_id:
  203. return self.profile_picture.get_url()
  204. else:
  205. return url_for("static", filename="default-avatar.png")
  206. def suggested_emails(self, website_name) -> (str, [str]):
  207. """return suggested email and other email choices """
  208. website_name = convert_to_id(website_name)
  209. all_aliases = [ge.email for ge in Alias.filter_by(user_id=self.id)]
  210. if self.can_create_new_alias():
  211. suggested_alias = Alias.create_new(self, prefix=website_name).email
  212. else:
  213. # pick an email from the list of gen emails
  214. suggested_alias = random.choice(all_aliases)
  215. return (
  216. suggested_alias,
  217. list(set(all_aliases).difference({suggested_alias})),
  218. )
  219. def suggested_names(self) -> (str, [str]):
  220. """return suggested name and other name choices """
  221. other_name = convert_to_id(self.name)
  222. return self.name, [other_name, "Anonymous", "whoami"]
  223. def get_name_initial(self) -> str:
  224. names = self.name.split(" ")
  225. return "".join([n[0].upper() for n in names if n])
  226. def get_subscription(self):
  227. """return *active* subscription
  228. TODO: support user unsubscribe and re-subscribe
  229. """
  230. sub = Subscription.get_by(user_id=self.id)
  231. # TODO: sub is active only if sub.next_bill_date > now
  232. # due to a bug on next_bill_date, wait until next month (April 8)
  233. # when all next_bill_date are correctly updated to add this check
  234. if sub and sub.cancelled:
  235. # sub is active until the next billing_date + 1
  236. if sub.next_bill_date >= arrow.now().shift(days=-1).date():
  237. return sub
  238. # past subscription, user is considered not having a subscription = free plan
  239. else:
  240. return None
  241. else:
  242. return sub
  243. def verified_custom_domains(self):
  244. return CustomDomain.query.filter_by(user_id=self.id, verified=True).all()
  245. def mailboxes(self) -> [str]:
  246. """list of mailbox emails that user own"""
  247. mailboxes = []
  248. for mailbox in Mailbox.query.filter_by(user_id=self.id, verified=True):
  249. mailboxes.append(mailbox.email)
  250. return mailboxes
  251. def __repr__(self):
  252. return f"<User {self.id} {self.name} {self.email}>"
  253. def _expiration_1h():
  254. return arrow.now().shift(hours=1)
  255. def _expiration_12h():
  256. return arrow.now().shift(hours=12)
  257. def _expiration_5m():
  258. return arrow.now().shift(minutes=5)
  259. def _expiration_7d():
  260. return arrow.now().shift(days=7)
  261. class ActivationCode(db.Model, ModelMixin):
  262. """For activate user account"""
  263. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  264. code = db.Column(db.String(128), unique=True, nullable=False)
  265. user = db.relationship(User)
  266. expired = db.Column(ArrowType, nullable=False, default=_expiration_1h)
  267. def is_expired(self):
  268. return self.expired < arrow.now()
  269. class ResetPasswordCode(db.Model, ModelMixin):
  270. """For resetting password"""
  271. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  272. code = db.Column(db.String(128), unique=True, nullable=False)
  273. user = db.relationship(User)
  274. expired = db.Column(ArrowType, nullable=False, default=_expiration_1h)
  275. def is_expired(self):
  276. return self.expired < arrow.now()
  277. class SocialAuth(db.Model, ModelMixin):
  278. """Store how user authenticates with social login"""
  279. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  280. # name of the social login used, could be facebook, google or github
  281. social = db.Column(db.String(128), nullable=False)
  282. __table_args__ = (db.UniqueConstraint("user_id", "social", name="uq_social_auth"),)
  283. # <<< OAUTH models >>>
  284. def generate_oauth_client_id(client_name) -> str:
  285. oauth_client_id = convert_to_id(client_name) + "-" + random_string()
  286. # check that the client does not exist yet
  287. if not Client.get_by(oauth_client_id=oauth_client_id):
  288. LOG.debug("generate oauth_client_id %s", oauth_client_id)
  289. return oauth_client_id
  290. # Rerun the function
  291. LOG.warning(
  292. "client_id %s already exists, generate a new client_id", oauth_client_id
  293. )
  294. return generate_oauth_client_id(client_name)
  295. class Client(db.Model, ModelMixin):
  296. oauth_client_id = db.Column(db.String(128), unique=True, nullable=False)
  297. oauth_client_secret = db.Column(db.String(128), nullable=False)
  298. name = db.Column(db.String(128), nullable=False)
  299. home_url = db.Column(db.String(1024))
  300. published = db.Column(db.Boolean, default=False, nullable=False)
  301. # user who created this client
  302. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  303. icon_id = db.Column(db.ForeignKey(File.id), nullable=True)
  304. icon = db.relationship(File)
  305. def nb_user(self):
  306. return ClientUser.filter_by(client_id=self.id).count()
  307. def get_scopes(self) -> [Scope]:
  308. # todo: client can choose which scopes they want to have access
  309. return [Scope.NAME, Scope.EMAIL, Scope.AVATAR_URL]
  310. @classmethod
  311. def create_new(cls, name, user_id) -> "Client":
  312. # generate a client-id
  313. oauth_client_id = generate_oauth_client_id(name)
  314. oauth_client_secret = random_string(40)
  315. client = Client.create(
  316. name=name,
  317. oauth_client_id=oauth_client_id,
  318. oauth_client_secret=oauth_client_secret,
  319. user_id=user_id,
  320. )
  321. return client
  322. def get_icon_url(self):
  323. if self.icon_id:
  324. return self.icon.get_url()
  325. else:
  326. return URL + "/static/default-icon.svg"
  327. def last_user_login(self) -> "ClientUser":
  328. client_user = (
  329. ClientUser.query.filter(ClientUser.client_id == self.id)
  330. .order_by(ClientUser.updated_at)
  331. .first()
  332. )
  333. if client_user:
  334. return client_user
  335. return None
  336. class RedirectUri(db.Model, ModelMixin):
  337. """Valid redirect uris for a client"""
  338. client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
  339. uri = db.Column(db.String(1024), nullable=False)
  340. client = db.relationship(Client, backref="redirect_uris")
  341. class AuthorizationCode(db.Model, ModelMixin):
  342. code = db.Column(db.String(128), unique=True, nullable=False)
  343. client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
  344. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  345. scope = db.Column(db.String(128))
  346. redirect_uri = db.Column(db.String(1024))
  347. # what is the input response_type, e.g. "code", "code,id_token", ...
  348. response_type = db.Column(db.String(128))
  349. user = db.relationship(User, lazy=False)
  350. client = db.relationship(Client, lazy=False)
  351. expired = db.Column(ArrowType, nullable=False, default=_expiration_5m)
  352. def is_expired(self):
  353. return self.expired < arrow.now()
  354. class OauthToken(db.Model, ModelMixin):
  355. access_token = db.Column(db.String(128), unique=True)
  356. client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
  357. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  358. scope = db.Column(db.String(128))
  359. redirect_uri = db.Column(db.String(1024))
  360. # what is the input response_type, e.g. "token", "token,id_token", ...
  361. response_type = db.Column(db.String(128))
  362. user = db.relationship(User)
  363. client = db.relationship(Client)
  364. expired = db.Column(ArrowType, nullable=False, default=_expiration_1h)
  365. def is_expired(self):
  366. return self.expired < arrow.now()
  367. def generate_email(
  368. scheme: int = AliasGeneratorEnum.word.value, in_hex: bool = False
  369. ) -> str:
  370. """generate an email address that does not exist before
  371. :param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated
  372. :type in_hex: bool, if the generate scheme is uuid, is hex favorable?
  373. """
  374. if scheme == AliasGeneratorEnum.uuid.value:
  375. name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
  376. random_email = name + "@" + EMAIL_DOMAIN
  377. else:
  378. random_email = random_words() + "@" + EMAIL_DOMAIN
  379. # check that the client does not exist yet
  380. if not Alias.get_by(email=random_email) and not DeletedAlias.get_by(
  381. email=random_email
  382. ):
  383. LOG.debug("generate email %s", random_email)
  384. return random_email
  385. # Rerun the function
  386. LOG.warning("email %s already exists, generate a new email", random_email)
  387. return generate_email(scheme=scheme, in_hex=in_hex)
  388. class Alias(db.Model, ModelMixin):
  389. """Alias"""
  390. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  391. email = db.Column(db.String(128), unique=True, nullable=False)
  392. enabled = db.Column(db.Boolean(), default=True, nullable=False)
  393. custom_domain_id = db.Column(
  394. db.ForeignKey("custom_domain.id", ondelete="cascade"), nullable=True
  395. )
  396. # To know whether an alias is created "on the fly", i.e. via the custom domain catch-all feature
  397. automatic_creation = db.Column(
  398. db.Boolean, nullable=False, default=False, server_default="0"
  399. )
  400. # to know whether an alias belongs to a directory
  401. directory_id = db.Column(
  402. db.ForeignKey("directory.id", ondelete="cascade"), nullable=True
  403. )
  404. note = db.Column(db.Text, default=None, nullable=True)
  405. # an alias can be owned by another mailbox
  406. mailbox_id = db.Column(
  407. db.ForeignKey("mailbox.id", ondelete="cascade"), nullable=False
  408. )
  409. user = db.relationship(User)
  410. mailbox = db.relationship("Mailbox")
  411. @classmethod
  412. def create_new(cls, user, prefix, note=None, mailbox_id=None):
  413. if not prefix:
  414. raise Exception("alias prefix cannot be empty")
  415. # find the right suffix - avoid infinite loop by running this at max 1000 times
  416. for i in range(1000):
  417. suffix = random_word()
  418. email = f"{prefix}.{suffix}@{EMAIL_DOMAIN}"
  419. if not cls.get_by(email=email):
  420. break
  421. return Alias.create(
  422. user_id=user.id,
  423. email=email,
  424. note=note,
  425. mailbox_id=mailbox_id or user.default_mailbox_id,
  426. )
  427. @classmethod
  428. def create_new_random(
  429. cls,
  430. user,
  431. scheme: int = AliasGeneratorEnum.word.value,
  432. in_hex: bool = False,
  433. note: str = None,
  434. ):
  435. """create a new random alias"""
  436. random_email = generate_email(scheme=scheme, in_hex=in_hex)
  437. return Alias.create(
  438. user_id=user.id,
  439. email=random_email,
  440. mailbox_id=user.default_mailbox_id,
  441. note=note,
  442. )
  443. def mailbox_email(self):
  444. if self.mailbox_id:
  445. return self.mailbox.email
  446. else:
  447. return self.user.email
  448. def __repr__(self):
  449. return f"<Alias {self.id} {self.email}>"
  450. class ClientUser(db.Model, ModelMixin):
  451. __table_args__ = (
  452. db.UniqueConstraint("user_id", "client_id", name="uq_client_user"),
  453. )
  454. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  455. client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
  456. # Null means client has access to user original email
  457. alias_id = db.Column(db.ForeignKey(Alias.id, ondelete="cascade"), nullable=True)
  458. # user can decide to send to client another name
  459. name = db.Column(
  460. db.String(128), nullable=True, default=None, server_default=text("NULL")
  461. )
  462. # user can decide to send to client a default avatar
  463. default_avatar = db.Column(
  464. db.Boolean, nullable=False, default=False, server_default="0"
  465. )
  466. alias = db.relationship(Alias, backref="client_users")
  467. user = db.relationship(User)
  468. client = db.relationship(Client)
  469. def get_email(self):
  470. return self.alias.email if self.alias_id else self.user.email
  471. def get_user_name(self):
  472. if self.name:
  473. return self.name
  474. else:
  475. return self.user.name
  476. def get_user_info(self) -> dict:
  477. """return user info according to client scope
  478. Return dict with key being scope name. For now all the fields are the same for all clients:
  479. {
  480. "client": "Demo",
  481. "email": "test-avk5l@mail-tester.com",
  482. "email_verified": true,
  483. "id": 1,
  484. "name": "Son GM",
  485. "avatar_url": "http://s3..."
  486. }
  487. """
  488. res = {
  489. "id": self.id,
  490. "client": self.client.name,
  491. "email_verified": True,
  492. "sub": str(self.id),
  493. }
  494. for scope in self.client.get_scopes():
  495. if scope == Scope.NAME:
  496. if self.name:
  497. res[Scope.NAME.value] = self.name
  498. else:
  499. res[Scope.NAME.value] = self.user.name
  500. elif scope == Scope.AVATAR_URL:
  501. if self.user.profile_picture_id:
  502. if self.default_avatar:
  503. res[Scope.AVATAR_URL.value] = URL + "/static/default-avatar.png"
  504. else:
  505. res[Scope.AVATAR_URL.value] = self.user.profile_picture.get_url(
  506. AVATAR_URL_EXPIRATION
  507. )
  508. else:
  509. res[Scope.AVATAR_URL.value] = None
  510. elif scope == Scope.EMAIL:
  511. # Use generated email
  512. if self.alias_id:
  513. LOG.debug(
  514. "Use gen email for user %s, client %s", self.user, self.client
  515. )
  516. res[Scope.EMAIL.value] = self.alias.email
  517. # Use user original email
  518. else:
  519. res[Scope.EMAIL.value] = self.user.email
  520. return res
  521. class Contact(db.Model, ModelMixin):
  522. """
  523. Store configuration of sender (website-email) and alias.
  524. """
  525. __table_args__ = (
  526. db.UniqueConstraint("alias_id", "website_email", name="uq_contact"),
  527. )
  528. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  529. alias_id = db.Column(db.ForeignKey(Alias.id, ondelete="cascade"), nullable=False)
  530. # used to be envelope header, should be mail header from instead
  531. website_email = db.Column(db.String(512), nullable=False)
  532. # the email from header, e.g. AB CD <ab@cd.com>
  533. # nullable as this field is added after website_email
  534. website_from = db.Column(db.String(1024), nullable=True)
  535. # when user clicks on "reply", they will reply to this address.
  536. # This address allows to hide user personal email
  537. # this reply email is created every time a website sends an email to user
  538. # it has the prefix "reply+" to distinguish with other email
  539. reply_email = db.Column(db.String(512), nullable=False)
  540. alias = db.relationship(Alias, backref="contacts")
  541. def website_send_to(self):
  542. """return the email address with name.
  543. to use when user wants to send an email from the alias"""
  544. name = self.website_email.replace("@", " at ")
  545. if self.website_from:
  546. website_name, _ = parseaddr(self.website_from)
  547. if website_name:
  548. # remove all double quote
  549. website_name = website_name.replace('"', "")
  550. name = website_name + " | " + name
  551. return f'"{name}" <{self.reply_email}>'
  552. # cannot use formataddr here as this field is for email client, not for MTA
  553. # return formataddr((self.website_email.replace("@", " at "), self.reply_email))
  554. def last_reply(self) -> "EmailLog":
  555. """return the most recent reply"""
  556. return (
  557. EmailLog.query.filter_by(contact_id=self.id, is_reply=True)
  558. .order_by(desc(EmailLog.created_at))
  559. .first()
  560. )
  561. class EmailLog(db.Model, ModelMixin):
  562. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  563. contact_id = db.Column(
  564. db.ForeignKey(Contact.id, ondelete="cascade"), nullable=False
  565. )
  566. # whether this is a reply
  567. is_reply = db.Column(db.Boolean, nullable=False, default=False)
  568. # for ex if alias is disabled, this forwarding is blocked
  569. blocked = db.Column(db.Boolean, nullable=False, default=False)
  570. # can happen when user email service refuses the forwarded email
  571. # usually because the forwarded email is too spammy
  572. bounced = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
  573. # Point to the email that has been refused
  574. refused_email_id = db.Column(
  575. db.ForeignKey("refused_email.id", ondelete="SET NULL"), nullable=True
  576. )
  577. refused_email = db.relationship("RefusedEmail")
  578. forward = db.relationship(Contact)
  579. contact = db.relationship(Contact)
  580. class Subscription(db.Model, ModelMixin):
  581. # Come from Paddle
  582. cancel_url = db.Column(db.String(1024), nullable=False)
  583. update_url = db.Column(db.String(1024), nullable=False)
  584. subscription_id = db.Column(db.String(1024), nullable=False, unique=True)
  585. event_time = db.Column(ArrowType, nullable=False)
  586. next_bill_date = db.Column(db.Date, nullable=False)
  587. cancelled = db.Column(db.Boolean, nullable=False, default=False)
  588. plan = db.Column(db.Enum(PlanEnum), nullable=False)
  589. user_id = db.Column(
  590. db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True
  591. )
  592. user = db.relationship(User)
  593. def plan_name(self):
  594. if self.plan == PlanEnum.monthly:
  595. return "Monthly ($2.99/month)"
  596. else:
  597. return "Yearly ($29.99/year)"
  598. class ManualSubscription(db.Model, ModelMixin):
  599. """
  600. For users who use other forms of payment and therefore not pass by Paddle
  601. """
  602. user_id = db.Column(
  603. db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True
  604. )
  605. # an reminder is sent several days before the subscription ends
  606. end_at = db.Column(ArrowType, nullable=False)
  607. # for storing note about this subscription
  608. comment = db.Column(db.Text, nullable=True)
  609. user = db.relationship(User)
  610. class DeletedAlias(db.Model, ModelMixin):
  611. """Store all deleted alias to make sure they are NOT reused"""
  612. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  613. email = db.Column(db.String(256), unique=True, nullable=False)
  614. class EmailChange(db.Model, ModelMixin):
  615. """Used when user wants to update their email"""
  616. user_id = db.Column(
  617. db.ForeignKey(User.id, ondelete="cascade"),
  618. nullable=False,
  619. unique=True,
  620. index=True,
  621. )
  622. new_email = db.Column(db.String(256), unique=True, nullable=False)
  623. code = db.Column(db.String(128), unique=True, nullable=False)
  624. expired = db.Column(ArrowType, nullable=False, default=_expiration_12h)
  625. user = db.relationship(User)
  626. def is_expired(self):
  627. return self.expired < arrow.now()
  628. class AliasUsedOn(db.Model, ModelMixin):
  629. """Used to know where an alias is created"""
  630. __table_args__ = (
  631. db.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"),
  632. )
  633. alias_id = db.Column(db.ForeignKey(Alias.id, ondelete="cascade"), nullable=False)
  634. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  635. alias = db.relationship(Alias)
  636. hostname = db.Column(db.String(1024), nullable=False)
  637. class ApiKey(db.Model, ModelMixin):
  638. """used in browser extension to identify user"""
  639. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  640. code = db.Column(db.String(128), unique=True, nullable=False)
  641. name = db.Column(db.String(128), nullable=False)
  642. last_used = db.Column(ArrowType, default=None)
  643. times = db.Column(db.Integer, default=0, nullable=False)
  644. user = db.relationship(User)
  645. @classmethod
  646. def create(cls, user_id, name):
  647. # generate unique code
  648. found = False
  649. while not found:
  650. code = random_string(60)
  651. if not cls.get_by(code=code):
  652. found = True
  653. a = cls(user_id=user_id, code=code, name=name)
  654. db.session.add(a)
  655. return a
  656. class CustomDomain(db.Model, ModelMixin):
  657. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  658. domain = db.Column(db.String(128), unique=True, nullable=False)
  659. verified = db.Column(db.Boolean, nullable=False, default=False)
  660. dkim_verified = db.Column(
  661. db.Boolean, nullable=False, default=False, server_default="0"
  662. )
  663. spf_verified = db.Column(
  664. db.Boolean, nullable=False, default=False, server_default="0"
  665. )
  666. # an alias is created automatically the first time it receives an email
  667. catch_all = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
  668. user = db.relationship(User)
  669. def nb_alias(self):
  670. return Alias.filter_by(custom_domain_id=self.id).count()
  671. def __repr__(self):
  672. return f"<Custom Domain {self.domain}>"
  673. class LifetimeCoupon(db.Model, ModelMixin):
  674. code = db.Column(db.String(128), nullable=False, unique=True)
  675. nb_used = db.Column(db.Integer, nullable=False)
  676. class Directory(db.Model, ModelMixin):
  677. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  678. name = db.Column(db.String(128), unique=True, nullable=False)
  679. user = db.relationship(User)
  680. def nb_alias(self):
  681. return Alias.filter_by(directory_id=self.id).count()
  682. def __repr__(self):
  683. return f"<Directory {self.name}>"
  684. class Job(db.Model, ModelMixin):
  685. """Used to schedule one-time job in the future"""
  686. name = db.Column(db.String(128), nullable=False)
  687. payload = db.Column(db.JSON)
  688. # whether the job has been taken by the job runner
  689. taken = db.Column(db.Boolean, default=False, nullable=False)
  690. run_at = db.Column(ArrowType)
  691. def __repr__(self):
  692. return f"<Job {self.id} {self.name} {self.payload}>"
  693. class Mailbox(db.Model, ModelMixin):
  694. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  695. email = db.Column(db.String(256), unique=True, nullable=False)
  696. verified = db.Column(db.Boolean, default=False, nullable=False)
  697. # used when user wants to update mailbox email
  698. new_email = db.Column(db.String(256), unique=True)
  699. pgp_public_key = db.Column(db.Text, nullable=True)
  700. pgp_finger_print = db.Column(db.String(512), nullable=True)
  701. def nb_alias(self):
  702. return Alias.filter_by(mailbox_id=self.id).count()
  703. def __repr__(self):
  704. return f"<Mailbox {self.email}>"
  705. class AccountActivation(db.Model, ModelMixin):
  706. """contains code to activate the user account when they sign up on mobile"""
  707. user_id = db.Column(
  708. db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True
  709. )
  710. # the activation code is usually 6 digits
  711. code = db.Column(db.String(10), nullable=False)
  712. # nb tries decrements each time user enters wrong code
  713. tries = db.Column(db.Integer, default=3, nullable=False)
  714. __table_args__ = (
  715. CheckConstraint(tries >= 0, name="account_activation_tries_positive"),
  716. {},
  717. )
  718. class RefusedEmail(db.Model, ModelMixin):
  719. """Store emails that have been refused, i.e. bounced or classified as spams"""
  720. # Store the full report, including logs from Sending & Receiving MTA
  721. full_report_path = db.Column(db.String(128), unique=True, nullable=False)
  722. # The original email, to display to user
  723. path = db.Column(db.String(128), unique=True, nullable=False)
  724. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  725. # the email content will be deleted at this date
  726. delete_at = db.Column(ArrowType, nullable=False, default=_expiration_7d)
  727. # toggle this when email content (stored at full_report_path & path are deleted)
  728. deleted = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
  729. def get_url(self, expires_in=3600):
  730. return s3.get_url(self.path, expires_in)
  731. def __repr__(self):
  732. return f"<Refused Email {self.id} {self.path} {self.delete_at}>"