email_utils.py 18 KB

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