email_utils.py 15 KB

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