models.py 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268
  1. import enum
  2. import random
  3. import uuid
  4. from email.utils import formataddr
  5. from typing import List
  6. import arrow
  7. import bcrypt
  8. from flask import url_for
  9. from flask_login import UserMixin
  10. from sqlalchemy import text, desc, CheckConstraint
  11. from sqlalchemy.exc import IntegrityError
  12. from sqlalchemy_utils import ArrowType
  13. from app import s3
  14. from app.config import (
  15. EMAIL_DOMAIN,
  16. MAX_NB_EMAIL_FREE_PLAN,
  17. URL,
  18. AVATAR_URL_EXPIRATION,
  19. JOB_ONBOARDING_1,
  20. JOB_ONBOARDING_2,
  21. JOB_ONBOARDING_3,
  22. JOB_ONBOARDING_4,
  23. LANDING_PAGE_URL,
  24. FIRST_ALIAS_DOMAIN,
  25. )
  26. from app.errors import AliasInTrashError
  27. from app.extensions import db
  28. from app.log import LOG
  29. from app.oauth_models import Scope
  30. from app.utils import convert_to_id, random_string, random_words, random_word
  31. class ModelMixin(object):
  32. id = db.Column(db.Integer, primary_key=True, autoincrement=True)
  33. created_at = db.Column(ArrowType, default=arrow.utcnow, nullable=False)
  34. updated_at = db.Column(ArrowType, default=None, onupdate=arrow.utcnow)
  35. _repr_hide = ["created_at", "updated_at"]
  36. @classmethod
  37. def query(cls):
  38. return db.session.query(cls)
  39. @classmethod
  40. def get(cls, id):
  41. return cls.query.get(id)
  42. @classmethod
  43. def get_by(cls, **kw):
  44. return cls.query.filter_by(**kw).first()
  45. @classmethod
  46. def filter_by(cls, **kw):
  47. return cls.query.filter_by(**kw)
  48. @classmethod
  49. def get_or_create(cls, **kw):
  50. r = cls.get_by(**kw)
  51. if not r:
  52. r = cls(**kw)
  53. db.session.add(r)
  54. return r
  55. @classmethod
  56. def create(cls, **kw):
  57. r = cls(**kw)
  58. db.session.add(r)
  59. return r
  60. def save(self):
  61. db.session.add(self)
  62. @classmethod
  63. def delete(cls, obj_id):
  64. cls.query.filter(cls.id == obj_id).delete()
  65. def __repr__(self):
  66. values = ", ".join(
  67. "%s=%r" % (n, getattr(self, n))
  68. for n in self.__table__.c.keys()
  69. if n not in self._repr_hide
  70. )
  71. return "%s(%s)" % (self.__class__.__name__, values)
  72. class File(db.Model, ModelMixin):
  73. path = db.Column(db.String(128), unique=True, nullable=False)
  74. user_id = db.Column(db.ForeignKey("users.id", ondelete="cascade"), nullable=True)
  75. def get_url(self, expires_in=3600):
  76. return s3.get_url(self.path, expires_in)
  77. class PlanEnum(enum.Enum):
  78. monthly = 2
  79. yearly = 3
  80. class AliasGeneratorEnum(enum.Enum):
  81. word = 1 # aliases are generated based on random words
  82. uuid = 2 # aliases are generated based on uuid
  83. @classmethod
  84. def has_value(cls, value: int) -> bool:
  85. return value in set(item.value for item in cls)
  86. class User(db.Model, ModelMixin, UserMixin):
  87. __tablename__ = "users"
  88. email = db.Column(db.String(256), unique=True, nullable=False)
  89. salt = db.Column(db.String(128), nullable=True)
  90. password = db.Column(db.String(128), nullable=True)
  91. name = db.Column(db.String(128), nullable=False)
  92. is_admin = db.Column(db.Boolean, nullable=False, default=False)
  93. alias_generator = db.Column(
  94. db.Integer,
  95. nullable=False,
  96. default=AliasGeneratorEnum.word.value,
  97. server_default=str(AliasGeneratorEnum.word.value),
  98. )
  99. notification = db.Column(
  100. db.Boolean, default=True, nullable=False, server_default="1"
  101. )
  102. activated = db.Column(db.Boolean, default=False, nullable=False)
  103. profile_picture_id = db.Column(db.ForeignKey(File.id), nullable=True)
  104. otp_secret = db.Column(db.String(16), nullable=True)
  105. enable_otp = db.Column(
  106. db.Boolean, nullable=False, default=False, server_default="0"
  107. )
  108. # Fields for WebAuthn
  109. fido_uuid = db.Column(db.String(), nullable=True, unique=True)
  110. fido_credential_id = db.Column(db.String(), nullable=True, unique=True)
  111. fido_pk = db.Column(db.String(), nullable=True, unique=True)
  112. fido_sign_count = db.Column(db.Integer(), nullable=True)
  113. # whether user can use Fido
  114. can_use_fido = db.Column(
  115. db.Boolean, default=False, nullable=False, server_default="0"
  116. )
  117. def fido_enabled(self) -> bool:
  118. if self.can_use_fido and self.fido_uuid is not None:
  119. return True
  120. return False
  121. # some users could have lifetime premium
  122. lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0")
  123. # user can use all premium features until this date
  124. trial_end = db.Column(
  125. ArrowType, default=lambda: arrow.now().shift(days=7, hours=1), nullable=True
  126. )
  127. # the mailbox used when create random alias
  128. # this field is nullable but in practice, it's always set
  129. # it cannot be set to non-nullable though
  130. # as this will create foreign key cycle between User and Mailbox
  131. default_mailbox_id = db.Column(
  132. db.ForeignKey("mailbox.id"), nullable=True, default=None
  133. )
  134. profile_picture = db.relationship(File, foreign_keys=[profile_picture_id])
  135. # Use the "via" format for sender address, i.e. "name@example.com via SimpleLogin"
  136. # If False, use the format "Name - name at example.com"
  137. use_via_format_for_sender = db.Column(
  138. db.Boolean, default=True, nullable=False, server_default="1"
  139. )
  140. referral_id = db.Column(
  141. db.ForeignKey("referral.id", ondelete="SET NULL"), nullable=True, default=None
  142. )
  143. referral = db.relationship("Referral", foreign_keys=[referral_id])
  144. # whether intro has been shown to user
  145. intro_shown = db.Column(
  146. db.Boolean, default=False, nullable=False, server_default="0"
  147. )
  148. @classmethod
  149. def create(cls, email, name, password=None, **kwargs):
  150. user: User = super(User, cls).create(email=email, name=name, **kwargs)
  151. if password:
  152. user.set_password(password)
  153. db.session.flush()
  154. mb = Mailbox.create(user_id=user.id, email=user.email, verified=True)
  155. db.session.flush()
  156. user.default_mailbox_id = mb.id
  157. # create a first alias mail to show user how to use when they login
  158. Alias.create_new(user, prefix="my-first-alias", mailbox_id=mb.id)
  159. db.session.flush()
  160. # Schedule onboarding emails
  161. Job.create(
  162. name=JOB_ONBOARDING_1,
  163. payload={"user_id": user.id},
  164. run_at=arrow.now().shift(days=1),
  165. )
  166. Job.create(
  167. name=JOB_ONBOARDING_2,
  168. payload={"user_id": user.id},
  169. run_at=arrow.now().shift(days=2),
  170. )
  171. Job.create(
  172. name=JOB_ONBOARDING_3,
  173. payload={"user_id": user.id},
  174. run_at=arrow.now().shift(days=3),
  175. )
  176. Job.create(
  177. name=JOB_ONBOARDING_4,
  178. payload={"user_id": user.id},
  179. run_at=arrow.now().shift(days=4),
  180. )
  181. db.session.flush()
  182. return user
  183. def _lifetime_or_active_subscription(self) -> bool:
  184. """True if user has lifetime licence or active subscription"""
  185. if self.lifetime:
  186. return True
  187. sub: Subscription = self.get_subscription()
  188. if sub:
  189. return True
  190. apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id)
  191. if apple_sub and apple_sub.is_valid():
  192. return True
  193. manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
  194. if manual_sub and manual_sub.end_at > arrow.now():
  195. return True
  196. return False
  197. def in_trial(self):
  198. """return True if user does not have lifetime licence or an active subscription AND is in trial period"""
  199. if self._lifetime_or_active_subscription():
  200. return False
  201. if self.trial_end and arrow.now() < self.trial_end:
  202. return True
  203. return False
  204. def should_show_upgrade_button(self):
  205. if self._lifetime_or_active_subscription():
  206. # user who has canceled can also re-subscribe
  207. sub: Subscription = self.get_subscription()
  208. if sub and sub.cancelled:
  209. return True
  210. return False
  211. return True
  212. def can_upgrade(self):
  213. """User who has lifetime licence or giveaway manual subscriptions can decide to upgrade to a paid plan"""
  214. sub: Subscription = self.get_subscription()
  215. # user who has canceled can also re-subscribe
  216. if sub and not sub.cancelled:
  217. return False
  218. apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id)
  219. if apple_sub and apple_sub.is_valid():
  220. return False
  221. manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
  222. # user who has giveaway premium can decide to upgrade
  223. if (
  224. manual_sub
  225. and manual_sub.end_at > arrow.now()
  226. and not manual_sub.is_giveaway
  227. ):
  228. return False
  229. return True
  230. def is_premium(self) -> bool:
  231. """
  232. user is premium if they:
  233. - have a lifetime deal or
  234. - in trial period or
  235. - active subscription
  236. """
  237. if self._lifetime_or_active_subscription():
  238. return True
  239. if self.trial_end and arrow.now() < self.trial_end:
  240. return True
  241. return False
  242. def can_create_new_alias(self) -> bool:
  243. if self.is_premium():
  244. return True
  245. return Alias.filter_by(user_id=self.id).count() < MAX_NB_EMAIL_FREE_PLAN
  246. def set_password(self, password):
  247. salt = bcrypt.gensalt()
  248. password_hash = bcrypt.hashpw(password.encode(), salt).decode()
  249. self.salt = salt.decode()
  250. self.password = password_hash
  251. def check_password(self, password) -> bool:
  252. if not self.password:
  253. return False
  254. password_hash = bcrypt.hashpw(password.encode(), self.salt.encode())
  255. return self.password.encode() == password_hash
  256. def profile_picture_url(self):
  257. if self.profile_picture_id:
  258. return self.profile_picture.get_url()
  259. else:
  260. return url_for("static", filename="default-avatar.png")
  261. def suggested_emails(self, website_name) -> (str, [str]):
  262. """return suggested email and other email choices """
  263. website_name = convert_to_id(website_name)
  264. all_aliases = [
  265. ge.email for ge in Alias.filter_by(user_id=self.id, enabled=True)
  266. ]
  267. if self.can_create_new_alias():
  268. suggested_alias = Alias.create_new(self, prefix=website_name).email
  269. else:
  270. # pick an email from the list of gen emails
  271. suggested_alias = random.choice(all_aliases)
  272. return (
  273. suggested_alias,
  274. list(set(all_aliases).difference({suggested_alias})),
  275. )
  276. def suggested_names(self) -> (str, [str]):
  277. """return suggested name and other name choices """
  278. other_name = convert_to_id(self.name)
  279. return self.name, [other_name, "Anonymous", "whoami"]
  280. def get_name_initial(self) -> str:
  281. names = self.name.split(" ")
  282. return "".join([n[0].upper() for n in names if n])
  283. def get_subscription(self) -> "Subscription":
  284. """return *active* subscription
  285. TODO: support user unsubscribe and re-subscribe
  286. """
  287. sub = Subscription.get_by(user_id=self.id)
  288. # TODO: sub is active only if sub.next_bill_date > now
  289. # due to a bug on next_bill_date, wait until next month (May 8)
  290. # when all next_bill_date are correctly updated to add this check
  291. if sub and sub.cancelled:
  292. # sub is active until the next billing_date + 1
  293. if sub.next_bill_date >= arrow.now().shift(days=-1).date():
  294. return sub
  295. # past subscription, user is considered not having a subscription = free plan
  296. else:
  297. return None
  298. else:
  299. return sub
  300. def verified_custom_domains(self):
  301. return CustomDomain.query.filter_by(user_id=self.id, verified=True).all()
  302. def mailboxes(self) -> List["Mailbox"]:
  303. """list of mailbox that user own"""
  304. mailboxes = []
  305. for mailbox in Mailbox.query.filter_by(user_id=self.id, verified=True):
  306. mailboxes.append(mailbox)
  307. return mailboxes
  308. def nb_directory(self):
  309. return Directory.query.filter_by(user_id=self.id).count()
  310. def __repr__(self):
  311. return f"<User {self.id} {self.name} {self.email}>"
  312. def _expiration_1h():
  313. return arrow.now().shift(hours=1)
  314. def _expiration_12h():
  315. return arrow.now().shift(hours=12)
  316. def _expiration_5m():
  317. return arrow.now().shift(minutes=5)
  318. def _expiration_7d():
  319. return arrow.now().shift(days=7)
  320. class ActivationCode(db.Model, ModelMixin):
  321. """For activate user account"""
  322. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  323. code = db.Column(db.String(128), unique=True, nullable=False)
  324. user = db.relationship(User)
  325. expired = db.Column(ArrowType, nullable=False, default=_expiration_1h)
  326. def is_expired(self):
  327. return self.expired < arrow.now()
  328. class ResetPasswordCode(db.Model, ModelMixin):
  329. """For resetting password"""
  330. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  331. code = db.Column(db.String(128), unique=True, nullable=False)
  332. user = db.relationship(User)
  333. expired = db.Column(ArrowType, nullable=False, default=_expiration_1h)
  334. def is_expired(self):
  335. return self.expired < arrow.now()
  336. class SocialAuth(db.Model, ModelMixin):
  337. """Store how user authenticates with social login"""
  338. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  339. # name of the social login used, could be facebook, google or github
  340. social = db.Column(db.String(128), nullable=False)
  341. __table_args__ = (db.UniqueConstraint("user_id", "social", name="uq_social_auth"),)
  342. # <<< OAUTH models >>>
  343. def generate_oauth_client_id(client_name) -> str:
  344. oauth_client_id = convert_to_id(client_name) + "-" + random_string()
  345. # check that the client does not exist yet
  346. if not Client.get_by(oauth_client_id=oauth_client_id):
  347. LOG.debug("generate oauth_client_id %s", oauth_client_id)
  348. return oauth_client_id
  349. # Rerun the function
  350. LOG.warning(
  351. "client_id %s already exists, generate a new client_id", oauth_client_id
  352. )
  353. return generate_oauth_client_id(client_name)
  354. class Client(db.Model, ModelMixin):
  355. oauth_client_id = db.Column(db.String(128), unique=True, nullable=False)
  356. oauth_client_secret = db.Column(db.String(128), nullable=False)
  357. name = db.Column(db.String(128), nullable=False)
  358. home_url = db.Column(db.String(1024))
  359. published = db.Column(db.Boolean, default=False, nullable=False)
  360. # user who created this client
  361. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  362. icon_id = db.Column(db.ForeignKey(File.id), nullable=True)
  363. icon = db.relationship(File)
  364. def nb_user(self):
  365. return ClientUser.filter_by(client_id=self.id).count()
  366. def get_scopes(self) -> [Scope]:
  367. # todo: client can choose which scopes they want to have access
  368. return [Scope.NAME, Scope.EMAIL, Scope.AVATAR_URL]
  369. @classmethod
  370. def create_new(cls, name, user_id) -> "Client":
  371. # generate a client-id
  372. oauth_client_id = generate_oauth_client_id(name)
  373. oauth_client_secret = random_string(40)
  374. client = Client.create(
  375. name=name,
  376. oauth_client_id=oauth_client_id,
  377. oauth_client_secret=oauth_client_secret,
  378. user_id=user_id,
  379. )
  380. return client
  381. def get_icon_url(self):
  382. if self.icon_id:
  383. return self.icon.get_url()
  384. else:
  385. return URL + "/static/default-icon.svg"
  386. def last_user_login(self) -> "ClientUser":
  387. client_user = (
  388. ClientUser.query.filter(ClientUser.client_id == self.id)
  389. .order_by(ClientUser.updated_at)
  390. .first()
  391. )
  392. if client_user:
  393. return client_user
  394. return None
  395. class RedirectUri(db.Model, ModelMixin):
  396. """Valid redirect uris for a client"""
  397. client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
  398. uri = db.Column(db.String(1024), nullable=False)
  399. client = db.relationship(Client, backref="redirect_uris")
  400. class AuthorizationCode(db.Model, ModelMixin):
  401. code = db.Column(db.String(128), unique=True, nullable=False)
  402. client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
  403. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  404. scope = db.Column(db.String(128))
  405. redirect_uri = db.Column(db.String(1024))
  406. # what is the input response_type, e.g. "code", "code,id_token", ...
  407. response_type = db.Column(db.String(128))
  408. user = db.relationship(User, lazy=False)
  409. client = db.relationship(Client, lazy=False)
  410. expired = db.Column(ArrowType, nullable=False, default=_expiration_5m)
  411. def is_expired(self):
  412. return self.expired < arrow.now()
  413. class OauthToken(db.Model, ModelMixin):
  414. access_token = db.Column(db.String(128), unique=True)
  415. client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
  416. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  417. scope = db.Column(db.String(128))
  418. redirect_uri = db.Column(db.String(1024))
  419. # what is the input response_type, e.g. "token", "token,id_token", ...
  420. response_type = db.Column(db.String(128))
  421. user = db.relationship(User)
  422. client = db.relationship(Client)
  423. expired = db.Column(ArrowType, nullable=False, default=_expiration_1h)
  424. def is_expired(self):
  425. return self.expired < arrow.now()
  426. def generate_email(
  427. scheme: int = AliasGeneratorEnum.word.value, in_hex: bool = False
  428. ) -> str:
  429. """generate an email address that does not exist before
  430. :param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated
  431. :type in_hex: bool, if the generate scheme is uuid, is hex favorable?
  432. """
  433. if scheme == AliasGeneratorEnum.uuid.value:
  434. name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
  435. random_email = name + "@" + EMAIL_DOMAIN
  436. else:
  437. random_email = random_words() + "@" + EMAIL_DOMAIN
  438. # check that the client does not exist yet
  439. if not Alias.get_by(email=random_email) and not DeletedAlias.get_by(
  440. email=random_email
  441. ):
  442. LOG.debug("generate email %s", random_email)
  443. return random_email
  444. # Rerun the function
  445. LOG.warning("email %s already exists, generate a new email", random_email)
  446. return generate_email(scheme=scheme, in_hex=in_hex)
  447. class Alias(db.Model, ModelMixin):
  448. """Alias"""
  449. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  450. email = db.Column(db.String(128), unique=True, nullable=False)
  451. # the name to use when user replies/sends from alias
  452. name = db.Column(db.String(128), nullable=True, default=None)
  453. enabled = db.Column(db.Boolean(), default=True, nullable=False)
  454. custom_domain_id = db.Column(
  455. db.ForeignKey("custom_domain.id", ondelete="cascade"), nullable=True
  456. )
  457. custom_domain = db.relationship("CustomDomain", foreign_keys=[custom_domain_id])
  458. # To know whether an alias is created "on the fly", i.e. via the custom domain catch-all feature
  459. automatic_creation = db.Column(
  460. db.Boolean, nullable=False, default=False, server_default="0"
  461. )
  462. # to know whether an alias belongs to a directory
  463. directory_id = db.Column(
  464. db.ForeignKey("directory.id", ondelete="cascade"), nullable=True
  465. )
  466. note = db.Column(db.Text, default=None, nullable=True)
  467. # an alias can be owned by another mailbox
  468. mailbox_id = db.Column(
  469. db.ForeignKey("mailbox.id", ondelete="cascade"), nullable=False
  470. )
  471. user = db.relationship(User)
  472. mailbox = db.relationship("Mailbox")
  473. @classmethod
  474. def create(cls, **kw):
  475. r = cls(**kw)
  476. # make sure alias is not in global trash, i.e. DeletedAlias table
  477. email = kw["email"]
  478. if DeletedAlias.get_by(email=email):
  479. raise AliasInTrashError
  480. db.session.add(r)
  481. return r
  482. @classmethod
  483. def create_new(cls, user, prefix, note=None, mailbox_id=None):
  484. if not prefix:
  485. raise Exception("alias prefix cannot be empty")
  486. # find the right suffix - avoid infinite loop by running this at max 1000 times
  487. for i in range(1000):
  488. suffix = random_word()
  489. email = f"{prefix}.{suffix}@{FIRST_ALIAS_DOMAIN}"
  490. if not cls.get_by(email=email) and not DeletedAlias.get_by(email=email):
  491. break
  492. return Alias.create(
  493. user_id=user.id,
  494. email=email,
  495. note=note,
  496. mailbox_id=mailbox_id or user.default_mailbox_id,
  497. )
  498. @classmethod
  499. def create_new_random(
  500. cls,
  501. user,
  502. scheme: int = AliasGeneratorEnum.word.value,
  503. in_hex: bool = False,
  504. note: str = None,
  505. ):
  506. """create a new random alias"""
  507. random_email = generate_email(scheme=scheme, in_hex=in_hex)
  508. return Alias.create(
  509. user_id=user.id,
  510. email=random_email,
  511. mailbox_id=user.default_mailbox_id,
  512. note=note,
  513. )
  514. def mailbox_email(self):
  515. if self.mailbox_id:
  516. return self.mailbox.email
  517. else:
  518. return self.user.email
  519. def __repr__(self):
  520. return f"<Alias {self.id} {self.email}>"
  521. class ClientUser(db.Model, ModelMixin):
  522. __table_args__ = (
  523. db.UniqueConstraint("user_id", "client_id", name="uq_client_user"),
  524. )
  525. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  526. client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
  527. # Null means client has access to user original email
  528. alias_id = db.Column(db.ForeignKey(Alias.id, ondelete="cascade"), nullable=True)
  529. # user can decide to send to client another name
  530. name = db.Column(
  531. db.String(128), nullable=True, default=None, server_default=text("NULL")
  532. )
  533. # user can decide to send to client a default avatar
  534. default_avatar = db.Column(
  535. db.Boolean, nullable=False, default=False, server_default="0"
  536. )
  537. alias = db.relationship(Alias, backref="client_users")
  538. user = db.relationship(User)
  539. client = db.relationship(Client)
  540. def get_email(self):
  541. return self.alias.email if self.alias_id else self.user.email
  542. def get_user_name(self):
  543. if self.name:
  544. return self.name
  545. else:
  546. return self.user.name
  547. def get_user_info(self) -> dict:
  548. """return user info according to client scope
  549. Return dict with key being scope name. For now all the fields are the same for all clients:
  550. {
  551. "client": "Demo",
  552. "email": "test-avk5l@mail-tester.com",
  553. "email_verified": true,
  554. "id": 1,
  555. "name": "Son GM",
  556. "avatar_url": "http://s3..."
  557. }
  558. """
  559. res = {
  560. "id": self.id,
  561. "client": self.client.name,
  562. "email_verified": True,
  563. "sub": str(self.id),
  564. }
  565. for scope in self.client.get_scopes():
  566. if scope == Scope.NAME:
  567. if self.name:
  568. res[Scope.NAME.value] = self.name
  569. else:
  570. res[Scope.NAME.value] = self.user.name
  571. elif scope == Scope.AVATAR_URL:
  572. if self.user.profile_picture_id:
  573. if self.default_avatar:
  574. res[Scope.AVATAR_URL.value] = URL + "/static/default-avatar.png"
  575. else:
  576. res[Scope.AVATAR_URL.value] = self.user.profile_picture.get_url(
  577. AVATAR_URL_EXPIRATION
  578. )
  579. else:
  580. res[Scope.AVATAR_URL.value] = None
  581. elif scope == Scope.EMAIL:
  582. # Use generated email
  583. if self.alias_id:
  584. LOG.debug(
  585. "Use gen email for user %s, client %s", self.user, self.client
  586. )
  587. res[Scope.EMAIL.value] = self.alias.email
  588. # Use user original email
  589. else:
  590. res[Scope.EMAIL.value] = self.user.email
  591. return res
  592. class Contact(db.Model, ModelMixin):
  593. """
  594. Store configuration of sender (website-email) and alias.
  595. """
  596. __table_args__ = (
  597. db.UniqueConstraint("alias_id", "website_email", name="uq_contact"),
  598. )
  599. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  600. alias_id = db.Column(db.ForeignKey(Alias.id, ondelete="cascade"), nullable=False)
  601. name = db.Column(
  602. db.String(512), nullable=True, default=None, server_default=text("NULL")
  603. )
  604. website_email = db.Column(db.String(512), nullable=False)
  605. # the email from header, e.g. AB CD <ab@cd.com>
  606. # nullable as this field is added after website_email
  607. website_from = db.Column(db.String(1024), nullable=True)
  608. # when user clicks on "reply", they will reply to this address.
  609. # This address allows to hide user personal email
  610. # this reply email is created every time a website sends an email to user
  611. # it has the prefix "reply+" to distinguish with other email
  612. reply_email = db.Column(db.String(512), nullable=False)
  613. # whether a contact is created via CC
  614. is_cc = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
  615. alias = db.relationship(Alias, backref="contacts")
  616. user = db.relationship(User)
  617. def website_send_to(self):
  618. """return the email address with name.
  619. to use when user wants to send an email from the alias
  620. Return
  621. "First Last | email at example.com" <ra+random_string@SL>
  622. """
  623. # Prefer using contact name if possible
  624. name = self.name
  625. # if no name, try to parse it from website_from
  626. if not name and self.website_from:
  627. try:
  628. from app.email_utils import parseaddr_unicode
  629. name, _ = parseaddr_unicode(self.website_from)
  630. except Exception:
  631. # Skip if website_from is wrongly formatted
  632. LOG.warning(
  633. "Cannot parse contact %s website_from %s", self, self.website_from
  634. )
  635. name = ""
  636. # remove all double quote
  637. if name:
  638. name = name.replace('"', "")
  639. if name:
  640. name = name + " | " + self.website_email.replace("@", " at ")
  641. else:
  642. name = self.website_email.replace("@", " at ")
  643. # cannot use formataddr here as this field is for email client, not for MTA
  644. return f'"{name}" <{self.reply_email}>'
  645. def new_addr(self):
  646. """
  647. Replace original email by reply_email. 2 possible formats:
  648. - first@example.com by SimpleLogin <reply_email> OR
  649. - First Last - first at example.com <reply_email>
  650. And return new address with RFC 2047 format
  651. `new_email` is a special reply address
  652. """
  653. user = self.user
  654. if user and user.use_via_format_for_sender:
  655. new_name = f"{self.website_email} via SimpleLogin"
  656. else:
  657. name = self.name or ""
  658. new_name = (
  659. name + (" - " if name else "") + self.website_email.replace("@", " at ")
  660. ).strip()
  661. new_addr = formataddr((new_name, self.reply_email)).strip()
  662. return new_addr.strip()
  663. def last_reply(self) -> "EmailLog":
  664. """return the most recent reply"""
  665. return (
  666. EmailLog.query.filter_by(contact_id=self.id, is_reply=True)
  667. .order_by(desc(EmailLog.created_at))
  668. .first()
  669. )
  670. def __repr__(self):
  671. return f"<Contact {self.id} {self.website_email} {self.alias_id}>"
  672. class EmailLog(db.Model, ModelMixin):
  673. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  674. contact_id = db.Column(
  675. db.ForeignKey(Contact.id, ondelete="cascade"), nullable=False
  676. )
  677. # whether this is a reply
  678. is_reply = db.Column(db.Boolean, nullable=False, default=False)
  679. # for ex if alias is disabled, this forwarding is blocked
  680. blocked = db.Column(db.Boolean, nullable=False, default=False)
  681. # can happen when user email service refuses the forwarded email
  682. # usually because the forwarded email is too spammy
  683. bounced = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
  684. # SpamAssassin result
  685. is_spam = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
  686. spam_status = db.Column(db.Text, nullable=True, default=None)
  687. # Point to the email that has been refused
  688. refused_email_id = db.Column(
  689. db.ForeignKey("refused_email.id", ondelete="SET NULL"), nullable=True
  690. )
  691. refused_email = db.relationship("RefusedEmail")
  692. forward = db.relationship(Contact)
  693. contact = db.relationship(Contact)
  694. def get_action(self) -> str:
  695. """return the action name: forward|reply|block|bounced"""
  696. if self.is_reply:
  697. return "reply"
  698. elif self.bounced:
  699. return "bounced"
  700. elif self.blocked:
  701. return "block"
  702. else:
  703. return "forward"
  704. class Subscription(db.Model, ModelMixin):
  705. # Come from Paddle
  706. cancel_url = db.Column(db.String(1024), nullable=False)
  707. update_url = db.Column(db.String(1024), nullable=False)
  708. subscription_id = db.Column(db.String(1024), nullable=False, unique=True)
  709. event_time = db.Column(ArrowType, nullable=False)
  710. next_bill_date = db.Column(db.Date, nullable=False)
  711. cancelled = db.Column(db.Boolean, nullable=False, default=False)
  712. plan = db.Column(db.Enum(PlanEnum), nullable=False)
  713. user_id = db.Column(
  714. db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True
  715. )
  716. user = db.relationship(User)
  717. def plan_name(self):
  718. if self.plan == PlanEnum.monthly:
  719. return "Monthly ($2.99/month)"
  720. else:
  721. return "Yearly ($29.99/year)"
  722. class ManualSubscription(db.Model, ModelMixin):
  723. """
  724. For users who use other forms of payment and therefore not pass by Paddle
  725. """
  726. user_id = db.Column(
  727. db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True
  728. )
  729. # an reminder is sent several days before the subscription ends
  730. end_at = db.Column(ArrowType, nullable=False)
  731. # for storing note about this subscription
  732. comment = db.Column(db.Text, nullable=True)
  733. # manual subscription are also used for Premium giveaways
  734. is_giveaway = db.Column(
  735. db.Boolean, default=False, nullable=False, server_default="0"
  736. )
  737. user = db.relationship(User)
  738. # https://help.apple.com/app-store-connect/#/dev58bda3212
  739. _APPLE_GRACE_PERIOD_DAYS = 16
  740. class AppleSubscription(db.Model, ModelMixin):
  741. """
  742. For users who have subscribed via Apple in-app payment
  743. """
  744. user_id = db.Column(
  745. db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True
  746. )
  747. expires_date = db.Column(ArrowType, nullable=False)
  748. # to avoid using "Restore Purchase" on another account
  749. original_transaction_id = db.Column(db.String(256), nullable=False, unique=True)
  750. receipt_data = db.Column(db.Text(), nullable=False)
  751. plan = db.Column(db.Enum(PlanEnum), nullable=False)
  752. user = db.relationship(User)
  753. def is_valid(self):
  754. # Todo: take into account grace period?
  755. return self.expires_date > arrow.now().shift(days=-_APPLE_GRACE_PERIOD_DAYS)
  756. class DeletedAlias(db.Model, ModelMixin):
  757. """Store all deleted alias to make sure they are NOT reused"""
  758. email = db.Column(db.String(256), unique=True, nullable=False)
  759. class EmailChange(db.Model, ModelMixin):
  760. """Used when user wants to update their email"""
  761. user_id = db.Column(
  762. db.ForeignKey(User.id, ondelete="cascade"),
  763. nullable=False,
  764. unique=True,
  765. index=True,
  766. )
  767. new_email = db.Column(db.String(256), unique=True, nullable=False)
  768. code = db.Column(db.String(128), unique=True, nullable=False)
  769. expired = db.Column(ArrowType, nullable=False, default=_expiration_12h)
  770. user = db.relationship(User)
  771. def is_expired(self):
  772. return self.expired < arrow.now()
  773. class AliasUsedOn(db.Model, ModelMixin):
  774. """Used to know where an alias is created"""
  775. __table_args__ = (
  776. db.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"),
  777. )
  778. alias_id = db.Column(db.ForeignKey(Alias.id, ondelete="cascade"), nullable=False)
  779. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  780. alias = db.relationship(Alias)
  781. hostname = db.Column(db.String(1024), nullable=False)
  782. class ApiKey(db.Model, ModelMixin):
  783. """used in browser extension to identify user"""
  784. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  785. code = db.Column(db.String(128), unique=True, nullable=False)
  786. name = db.Column(db.String(128), nullable=False)
  787. last_used = db.Column(ArrowType, default=None)
  788. times = db.Column(db.Integer, default=0, nullable=False)
  789. user = db.relationship(User)
  790. @classmethod
  791. def create(cls, user_id, name):
  792. # generate unique code
  793. found = False
  794. while not found:
  795. code = random_string(60)
  796. if not cls.get_by(code=code):
  797. found = True
  798. a = cls(user_id=user_id, code=code, name=name)
  799. db.session.add(a)
  800. return a
  801. class CustomDomain(db.Model, ModelMixin):
  802. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  803. domain = db.Column(db.String(128), unique=True, nullable=False)
  804. # default name to use when user replies/sends from alias
  805. name = db.Column(db.String(128), nullable=True, default=None)
  806. verified = db.Column(db.Boolean, nullable=False, default=False)
  807. dkim_verified = db.Column(
  808. db.Boolean, nullable=False, default=False, server_default="0"
  809. )
  810. spf_verified = db.Column(
  811. db.Boolean, nullable=False, default=False, server_default="0"
  812. )
  813. dmarc_verified = db.Column(
  814. db.Boolean, nullable=False, default=False, server_default="0"
  815. )
  816. # an alias is created automatically the first time it receives an email
  817. catch_all = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
  818. user = db.relationship(User)
  819. @classmethod
  820. def delete(cls, obj_id):
  821. # Put all aliases belonging to this domain to global trash
  822. try:
  823. for alias in Alias.query.filter_by(custom_domain_id=obj_id):
  824. DeletedAlias.create(email=alias.email)
  825. db.session.commit()
  826. except IntegrityError:
  827. LOG.error("Some aliases have been added before to DeletedAlias")
  828. db.session.rollback()
  829. cls.query.filter(cls.id == obj_id).delete()
  830. db.session.commit()
  831. def nb_alias(self):
  832. return Alias.filter_by(custom_domain_id=self.id).count()
  833. def __repr__(self):
  834. return f"<Custom Domain {self.domain}>"
  835. class LifetimeCoupon(db.Model, ModelMixin):
  836. code = db.Column(db.String(128), nullable=False, unique=True)
  837. nb_used = db.Column(db.Integer, nullable=False)
  838. class Directory(db.Model, ModelMixin):
  839. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  840. name = db.Column(db.String(128), unique=True, nullable=False)
  841. user = db.relationship(User)
  842. def nb_alias(self):
  843. return Alias.filter_by(directory_id=self.id).count()
  844. @classmethod
  845. def delete(cls, obj_id):
  846. # Put all aliases belonging to this directory to global trash
  847. try:
  848. for alias in Alias.query.filter_by(directory_id=obj_id):
  849. DeletedAlias.create(email=alias.email)
  850. db.session.commit()
  851. # this can happen when a previously deleted alias is re-created via catch-all or directory feature
  852. except IntegrityError:
  853. LOG.error("Some aliases have been added before to DeletedAlias")
  854. db.session.rollback()
  855. cls.query.filter(cls.id == obj_id).delete()
  856. db.session.commit()
  857. def __repr__(self):
  858. return f"<Directory {self.name}>"
  859. class Job(db.Model, ModelMixin):
  860. """Used to schedule one-time job in the future"""
  861. name = db.Column(db.String(128), nullable=False)
  862. payload = db.Column(db.JSON)
  863. # whether the job has been taken by the job runner
  864. taken = db.Column(db.Boolean, default=False, nullable=False)
  865. run_at = db.Column(ArrowType)
  866. def __repr__(self):
  867. return f"<Job {self.id} {self.name} {self.payload}>"
  868. class Mailbox(db.Model, ModelMixin):
  869. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  870. email = db.Column(db.String(256), nullable=False)
  871. verified = db.Column(db.Boolean, default=False, nullable=False)
  872. force_spf = db.Column(db.Boolean, default=True, server_default="1", nullable=False)
  873. # used when user wants to update mailbox email
  874. new_email = db.Column(db.String(256), unique=True)
  875. pgp_public_key = db.Column(db.Text, nullable=True)
  876. pgp_finger_print = db.Column(db.String(512), nullable=True)
  877. __table_args__ = (db.UniqueConstraint("user_id", "email", name="uq_mailbox_user"),)
  878. def nb_alias(self):
  879. return Alias.filter_by(mailbox_id=self.id).count()
  880. @classmethod
  881. def delete(cls, obj_id):
  882. # Put all aliases belonging to this mailbox to global trash
  883. try:
  884. for alias in Alias.query.filter_by(mailbox_id=obj_id):
  885. DeletedAlias.create(email=alias.email)
  886. db.session.commit()
  887. # this can happen when a previously deleted alias is re-created via catch-all or directory feature
  888. except IntegrityError:
  889. LOG.error("Some aliases have been added before to DeletedAlias")
  890. db.session.rollback()
  891. cls.query.filter(cls.id == obj_id).delete()
  892. db.session.commit()
  893. def __repr__(self):
  894. return f"<Mailbox {self.email}>"
  895. class AccountActivation(db.Model, ModelMixin):
  896. """contains code to activate the user account when they sign up on mobile"""
  897. user_id = db.Column(
  898. db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True
  899. )
  900. # the activation code is usually 6 digits
  901. code = db.Column(db.String(10), nullable=False)
  902. # nb tries decrements each time user enters wrong code
  903. tries = db.Column(db.Integer, default=3, nullable=False)
  904. __table_args__ = (
  905. CheckConstraint(tries >= 0, name="account_activation_tries_positive"),
  906. {},
  907. )
  908. class RefusedEmail(db.Model, ModelMixin):
  909. """Store emails that have been refused, i.e. bounced or classified as spams"""
  910. # Store the full report, including logs from Sending & Receiving MTA
  911. full_report_path = db.Column(db.String(128), unique=True, nullable=False)
  912. # The original email, to display to user
  913. path = db.Column(db.String(128), unique=True, nullable=True)
  914. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  915. # the email content will be deleted at this date
  916. delete_at = db.Column(ArrowType, nullable=False, default=_expiration_7d)
  917. # toggle this when email content (stored at full_report_path & path are deleted)
  918. deleted = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
  919. def get_url(self, expires_in=3600):
  920. if self.path:
  921. return s3.get_url(self.path, expires_in)
  922. else:
  923. return s3.get_url(self.full_report_path, expires_in)
  924. def __repr__(self):
  925. return f"<Refused Email {self.id} {self.path} {self.delete_at}>"
  926. class Referral(db.Model, ModelMixin):
  927. """Referral code so user can invite others"""
  928. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  929. name = db.Column(db.String(512), nullable=True, default=None)
  930. code = db.Column(db.String(128), unique=True, nullable=False)
  931. def nb_user(self):
  932. return User.filter_by(referral_id=self.id, activated=True).count()
  933. def link(self):
  934. return f"{LANDING_PAGE_URL}?slref={self.code}"
  935. class SentAlert(db.Model, ModelMixin):
  936. """keep track of alerts sent to user.
  937. User can receive an alert when there's abnormal activity on their aliases such as
  938. - reverse-alias not used by the owning mailbox
  939. - SPF fails when using the reverse-alias
  940. - bounced email
  941. - ...
  942. Different rate controls can then be implemented based on SentAlert:
  943. - only once alert: an alert type should be sent only once
  944. - max number of sent per 24H: an alert type should not be sent more than X times in 24h
  945. """
  946. user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
  947. to_email = db.Column(db.String(256), nullable=False)
  948. alert_type = db.Column(db.String(256), nullable=False)