email_utils.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import os
  2. from email.header import decode_header
  3. from email.message import Message
  4. from email.mime.base import MIMEBase
  5. from email.mime.multipart import MIMEMultipart
  6. from email.mime.text import MIMEText
  7. from email.utils import make_msgid, formatdate, parseaddr
  8. from smtplib import SMTP
  9. from typing import Optional
  10. import arrow
  11. import dkim
  12. from jinja2 import Environment, FileSystemLoader
  13. from app.config import (
  14. SUPPORT_EMAIL,
  15. ROOT_DIR,
  16. POSTFIX_SERVER,
  17. NOT_SEND_EMAIL,
  18. DKIM_SELECTOR,
  19. DKIM_PRIVATE_KEY,
  20. DKIM_HEADERS,
  21. ALIAS_DOMAINS,
  22. SUPPORT_NAME,
  23. POSTFIX_SUBMISSION_TLS,
  24. MAX_NB_EMAIL_FREE_PLAN,
  25. DISPOSABLE_EMAIL_DOMAINS,
  26. MAX_ALERT_24H,
  27. )
  28. from app.dns_utils import get_mx_domains
  29. from app.extensions import db
  30. from app.log import LOG
  31. from app.models import Mailbox, User, SentAlert
  32. def render(template_name, **kwargs) -> str:
  33. templates_dir = os.path.join(ROOT_DIR, "templates", "emails")
  34. env = Environment(loader=FileSystemLoader(templates_dir))
  35. template = env.get_template(template_name)
  36. return template.render(MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN, **kwargs)
  37. def send_welcome_email(user):
  38. send_email(
  39. user.email,
  40. f"Welcome to SimpleLogin {user.name}",
  41. render("com/welcome.txt", name=user.name, user=user),
  42. render("com/welcome.html", name=user.name, user=user),
  43. )
  44. def send_trial_end_soon_email(user):
  45. send_email(
  46. user.email,
  47. f"Your trial will end soon {user.name}",
  48. render("transactional/trial-end.txt", name=user.name, user=user),
  49. render("transactional/trial-end.html", name=user.name, user=user),
  50. )
  51. def send_activation_email(email, name, activation_link):
  52. send_email(
  53. email,
  54. f"Just one more step to join SimpleLogin {name}",
  55. render(
  56. "transactional/activation.txt",
  57. name=name,
  58. activation_link=activation_link,
  59. email=email,
  60. ),
  61. render(
  62. "transactional/activation.html",
  63. name=name,
  64. activation_link=activation_link,
  65. email=email,
  66. ),
  67. )
  68. def send_reset_password_email(email, name, reset_password_link):
  69. send_email(
  70. email,
  71. f"Reset your password on SimpleLogin",
  72. render(
  73. "transactional/reset-password.txt",
  74. name=name,
  75. reset_password_link=reset_password_link,
  76. ),
  77. render(
  78. "transactional/reset-password.html",
  79. name=name,
  80. reset_password_link=reset_password_link,
  81. ),
  82. )
  83. def send_change_email(new_email, current_email, name, link):
  84. send_email(
  85. new_email,
  86. f"Confirm email update on SimpleLogin",
  87. render(
  88. "transactional/change-email.txt",
  89. name=name,
  90. link=link,
  91. new_email=new_email,
  92. current_email=current_email,
  93. ),
  94. render(
  95. "transactional/change-email.html",
  96. name=name,
  97. link=link,
  98. new_email=new_email,
  99. current_email=current_email,
  100. ),
  101. )
  102. def send_new_app_email(email, name):
  103. send_email(
  104. email,
  105. f"Any question/feedback for SimpleLogin {name}?",
  106. render("com/new-app.txt", name=name),
  107. render("com/new-app.html", name=name),
  108. )
  109. def send_test_email_alias(email, name):
  110. send_email(
  111. email,
  112. f"This email is sent to {email}",
  113. render("transactional/test-email.txt", name=name, alias=email),
  114. render("transactional/test-email.html", name=name, alias=email),
  115. )
  116. def send_cannot_create_directory_alias(user, alias, directory):
  117. """when user cancels their subscription, they cannot create alias on the fly.
  118. If this happens, send them an email to notify
  119. """
  120. send_email(
  121. user.email,
  122. f"Alias {alias} cannot be created",
  123. render(
  124. "transactional/cannot-create-alias-directory.txt",
  125. name=user.name,
  126. alias=alias,
  127. directory=directory,
  128. ),
  129. render(
  130. "transactional/cannot-create-alias-directory.html",
  131. name=user.name,
  132. alias=alias,
  133. directory=directory,
  134. ),
  135. )
  136. def send_cannot_create_domain_alias(user, alias, domain):
  137. """when user cancels their subscription, they cannot create alias on the fly with custom domain.
  138. If this happens, send them an email to notify
  139. """
  140. send_email(
  141. user.email,
  142. f"Alias {alias} cannot be created",
  143. render(
  144. "transactional/cannot-create-alias-domain.txt",
  145. name=user.name,
  146. alias=alias,
  147. domain=domain,
  148. ),
  149. render(
  150. "transactional/cannot-create-alias-domain.html",
  151. name=user.name,
  152. alias=alias,
  153. domain=domain,
  154. ),
  155. )
  156. def send_email(
  157. to_email, subject, plaintext, html=None, bounced_email: Optional[Message] = None
  158. ):
  159. if NOT_SEND_EMAIL:
  160. LOG.d(
  161. "send email with subject %s to %s, plaintext: %s",
  162. subject,
  163. to_email,
  164. plaintext,
  165. )
  166. return
  167. LOG.d("send email to %s, subject %s", to_email, subject)
  168. if POSTFIX_SUBMISSION_TLS:
  169. smtp = SMTP(POSTFIX_SERVER, 587)
  170. smtp.starttls()
  171. else:
  172. smtp = SMTP(POSTFIX_SERVER, 25)
  173. if bounced_email:
  174. msg = MIMEMultipart("mixed")
  175. # add email main body
  176. body = MIMEMultipart("alternative")
  177. body.attach(MIMEText(plaintext, "text"))
  178. if html:
  179. body.attach(MIMEText(html, "html"))
  180. msg.attach(body)
  181. # add attachment
  182. rfcmessage = MIMEBase("message", "rfc822")
  183. rfcmessage.attach(bounced_email)
  184. msg.attach(rfcmessage)
  185. else:
  186. msg = MIMEMultipart("alternative")
  187. msg.attach(MIMEText(plaintext, "text"))
  188. if html:
  189. msg.attach(MIMEText(html, "html"))
  190. msg["Subject"] = subject
  191. msg["From"] = f"{SUPPORT_NAME} <{SUPPORT_EMAIL}>"
  192. msg["To"] = to_email
  193. msg_id_header = make_msgid()
  194. msg["Message-ID"] = msg_id_header
  195. date_header = formatdate()
  196. msg["Date"] = date_header
  197. # add DKIM
  198. email_domain = SUPPORT_EMAIL[SUPPORT_EMAIL.find("@") + 1 :]
  199. add_dkim_signature(msg, email_domain)
  200. msg_raw = msg.as_bytes()
  201. smtp.sendmail(SUPPORT_EMAIL, to_email, msg_raw)
  202. def send_email_with_rate_control(
  203. user: User,
  204. alert_type: str,
  205. to_email: str,
  206. subject,
  207. plaintext,
  208. html=None,
  209. bounced_email: Optional[Message] = None,
  210. max_alert_24h=MAX_ALERT_24H,
  211. ) -> bool:
  212. """Same as send_email with rate control over alert_type.
  213. For now no more than _MAX_ALERT_24h alert can be sent in the last 24h
  214. Return true if the email is sent, otherwise False
  215. """
  216. to_email = to_email.lower().strip()
  217. one_day_ago = arrow.now().shift(days=-1)
  218. nb_alert = (
  219. SentAlert.query.filter_by(alert_type=alert_type, to_email=to_email)
  220. .filter(SentAlert.created_at > one_day_ago)
  221. .count()
  222. )
  223. if nb_alert > max_alert_24h:
  224. LOG.error(
  225. "%s emails were sent to %s in the last 24h, alert type %s",
  226. nb_alert,
  227. to_email,
  228. alert_type,
  229. )
  230. return False
  231. SentAlert.create(user_id=user.id, alert_type=alert_type, to_email=to_email)
  232. db.session.commit()
  233. send_email(to_email, subject, plaintext, html, bounced_email)
  234. return True
  235. def get_email_local_part(address):
  236. """
  237. Get the local part from email
  238. ab@cd.com -> ab
  239. """
  240. return address[: address.find("@")]
  241. def get_email_domain_part(address):
  242. """
  243. Get the domain part from email
  244. ab@cd.com -> cd.com
  245. """
  246. return address[address.find("@") + 1 :].strip().lower()
  247. def add_dkim_signature(msg: Message, email_domain: str):
  248. delete_header(msg, "DKIM-Signature")
  249. # Specify headers in "byte" form
  250. # Generate message signature
  251. sig = dkim.sign(
  252. msg.as_bytes(),
  253. DKIM_SELECTOR,
  254. email_domain.encode(),
  255. DKIM_PRIVATE_KEY.encode(),
  256. include_headers=DKIM_HEADERS,
  257. )
  258. sig = sig.decode()
  259. # remove linebreaks from sig
  260. sig = sig.replace("\n", " ").replace("\r", "")
  261. msg["DKIM-Signature"] = sig[len("DKIM-Signature: ") :]
  262. def add_or_replace_header(msg: Message, header: str, value: str):
  263. """
  264. Remove all occurrences of `header` and add `header` with `value`.
  265. """
  266. delete_header(msg, header)
  267. msg[header] = value
  268. def delete_header(msg: Message, header: str):
  269. """a header can appear several times in message."""
  270. # inspired from https://stackoverflow.com/a/47903323/1428034
  271. for i in reversed(range(len(msg._headers))):
  272. header_name = msg._headers[i][0].lower()
  273. if header_name == header.lower():
  274. del msg._headers[i]
  275. def delete_all_headers_except(msg: Message, headers: [str]):
  276. headers = [h.lower() for h in headers]
  277. for i in reversed(range(len(msg._headers))):
  278. header_name = msg._headers[i][0].lower()
  279. if header_name not in headers:
  280. del msg._headers[i]
  281. def email_belongs_to_alias_domains(address: str) -> bool:
  282. """return True if an email ends with one of the alias domains provided by SimpleLogin"""
  283. for domain in ALIAS_DOMAINS:
  284. if address.endswith("@" + domain):
  285. return True
  286. return False
  287. def can_be_used_as_personal_email(email: str) -> bool:
  288. """return True if an email can be used as a personal email. Currently the only condition is email domain is not
  289. - one of ALIAS_DOMAINS
  290. - one of custom domains
  291. """
  292. domain = get_email_domain_part(email)
  293. if not domain:
  294. return False
  295. if domain in ALIAS_DOMAINS:
  296. return False
  297. from app.models import CustomDomain
  298. if CustomDomain.get_by(domain=domain, verified=True):
  299. return False
  300. if is_disposable_domain(domain):
  301. LOG.d("Domain %s is disposable", domain)
  302. return False
  303. # check if email MX domain is disposable
  304. mx_domains = get_mx_domain_list(domain)
  305. # if no MX record, email is not valid
  306. if not mx_domains:
  307. return False
  308. for mx_domain in mx_domains:
  309. if is_disposable_domain(mx_domain):
  310. LOG.d("MX Domain %s %s is disposable", mx_domain, domain)
  311. return False
  312. return True
  313. def is_disposable_domain(domain):
  314. for d in DISPOSABLE_EMAIL_DOMAINS:
  315. if domain == d:
  316. return True
  317. # subdomain
  318. if domain.endswith("." + d):
  319. return True
  320. return False
  321. def get_mx_domain_list(domain) -> [str]:
  322. """return list of MX domains for a given email.
  323. domain name ends *without* a dot (".") at the end.
  324. """
  325. priority_domains = get_mx_domains(domain)
  326. return [d[:-1] for _, d in priority_domains]
  327. def email_already_used(email: str) -> bool:
  328. """test if an email can be used when:
  329. - user signs up
  330. - add a new mailbox
  331. """
  332. if User.get_by(email=email):
  333. return True
  334. if Mailbox.get_by(email=email):
  335. return True
  336. return False
  337. def mailbox_already_used(email: str, user) -> bool:
  338. if Mailbox.get_by(email=email, user_id=user.id):
  339. return True
  340. # support the case user wants to re-add their real email as mailbox
  341. # can happen when user changes their root email and wants to add this new email as mailbox
  342. if email == user.email:
  343. return False
  344. return False
  345. def get_orig_message_from_bounce(msg: Message) -> Message:
  346. """parse the original email from Bounce"""
  347. i = 0
  348. for part in msg.walk():
  349. i += 1
  350. # the original message is the 4th part
  351. # 1st part is the root part, multipart/report
  352. # 2nd is text/plain, Postfix log
  353. # ...
  354. # 7th is original message
  355. if i == 7:
  356. return part
  357. def get_orig_message_from_spamassassin_report(msg: Message) -> Message:
  358. """parse the original email from Spamassassin report"""
  359. i = 0
  360. for part in msg.walk():
  361. i += 1
  362. # the original message is the 4th part
  363. # 1st part is the root part, multipart/report
  364. # 2nd is text/plain, SpamAssassin part
  365. # 3rd is the original message in message/rfc822 content type
  366. # 4th is original message
  367. if i == 4:
  368. return part
  369. def get_addrs_from_header(msg: Message, header) -> [str]:
  370. """Get all addresses contained in `header`
  371. Used for To or CC header.
  372. """
  373. ret = []
  374. header_content = msg.get_all(header)
  375. if not header_content:
  376. return ret
  377. for addrs in header_content:
  378. for addr in addrs.split(","):
  379. ret.append(addr.strip())
  380. # do not return empty string
  381. return [r for r in ret if r]
  382. def get_spam_info(msg: Message) -> (bool, str):
  383. """parse SpamAssassin header to detect whether a message is classified as spam.
  384. Return (is spam, spam status detail)
  385. The header format is
  386. ```X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
  387. DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
  388. URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2```
  389. """
  390. spamassassin_status = msg["X-Spam-Status"]
  391. if not spamassassin_status:
  392. return False, ""
  393. # yes or no
  394. spamassassin_answer = spamassassin_status[: spamassassin_status.find(",")]
  395. return spamassassin_answer.lower() == "yes", spamassassin_status
  396. def parseaddr_unicode(addr) -> (str, str):
  397. """Like parseaddr but return name in unicode instead of in RFC 2047 format
  398. '=?UTF-8?B?TmjGoW4gTmd1eeG7hW4=?= <abcd@gmail.com>' -> ('Nhơn Nguyễn', "abcd@gmail.com")
  399. """
  400. name, email = parseaddr(addr)
  401. email = email.strip().lower()
  402. if name:
  403. name = name.strip()
  404. decoded_string, charset = decode_header(name)[0]
  405. if charset is not None:
  406. name = decoded_string.decode(charset)
  407. else:
  408. name = decoded_string
  409. return name, email