email_handler.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. """
  2. Handle the email *forward* and *reply*. phase. There are 3 actors:
  3. - website: who sends emails to alias@sl.co address
  4. - SL email handler (this script)
  5. - user personal email: to be protected. Should never leak to website.
  6. This script makes sure that in the forward phase, the email that is forwarded to user personal email has the following
  7. envelope and header fields:
  8. Envelope:
  9. mail from: @website
  10. rcpt to: @personal_email
  11. Header:
  12. From: @website
  13. To: alias@sl.co # so user knows this email is sent to alias
  14. Reply-to: special@sl.co # magic HERE
  15. And in the reply phase:
  16. Envelope:
  17. mail from: @website
  18. rcpt to: @website
  19. Header:
  20. From: alias@sl.co # so for website the email comes from alias. magic HERE
  21. To: @website
  22. The special@sl.co allows to hide user personal email when user clicks "Reply" to the forwarded email.
  23. It should contain the following info:
  24. - alias
  25. - @website
  26. """
  27. import time
  28. from email.message import Message
  29. from email.parser import Parser
  30. from email.policy import SMTPUTF8
  31. from smtplib import SMTP
  32. from aiosmtpd.controller import Controller
  33. from app.config import (
  34. EMAIL_DOMAIN,
  35. POSTFIX_SERVER,
  36. URL,
  37. ALIAS_DOMAINS,
  38. ADMIN_EMAIL,
  39. SUPPORT_EMAIL,
  40. )
  41. from app.email_utils import (
  42. get_email_name,
  43. get_email_part,
  44. send_email,
  45. add_dkim_signature,
  46. get_email_domain_part,
  47. add_or_replace_header,
  48. delete_header,
  49. send_cannot_create_directory_alias,
  50. send_cannot_create_domain_alias,
  51. email_belongs_to_alias_domains,
  52. render,
  53. )
  54. from app.extensions import db
  55. from app.log import LOG
  56. from app.models import (
  57. GenEmail,
  58. ForwardEmail,
  59. ForwardEmailLog,
  60. CustomDomain,
  61. Directory,
  62. User,
  63. DeletedAlias,
  64. )
  65. from app.utils import random_string
  66. from server import create_app
  67. # fix the database connection leak issue
  68. # use this method instead of create_app
  69. def new_app():
  70. app = create_app()
  71. @app.teardown_appcontext
  72. def shutdown_session(response_or_exc):
  73. # same as shutdown_session() in flask-sqlalchemy but this is not enough
  74. db.session.remove()
  75. # dispose the engine too
  76. db.engine.dispose()
  77. return app
  78. class MailHandler:
  79. async def handle_DATA(self, server, session, envelope):
  80. LOG.debug(">>> New message <<<")
  81. LOG.debug("Mail from %s", envelope.mail_from)
  82. LOG.debug("Rcpt to %s", envelope.rcpt_tos)
  83. message_data = envelope.content.decode("utf8", errors="replace")
  84. # Only when debug
  85. # LOG.debug("Message data:\n")
  86. # LOG.debug(message_data)
  87. # host IP, setup via Docker network
  88. smtp = SMTP(POSTFIX_SERVER, 25)
  89. msg = Parser(policy=SMTPUTF8).parsestr(message_data)
  90. for rcpt_to in envelope.rcpt_tos:
  91. # Reply case
  92. # reply+ or ra+ (reverse-alias) prefix
  93. if rcpt_to.startswith("reply+") or rcpt_to.startswith("ra+"):
  94. LOG.debug("Reply phase")
  95. app = new_app()
  96. with app.app_context():
  97. return self.handle_reply(envelope, smtp, msg, rcpt_to)
  98. else: # Forward case
  99. LOG.debug("Forward phase")
  100. app = new_app()
  101. with app.app_context():
  102. return self.handle_forward(envelope, smtp, msg, rcpt_to)
  103. def handle_forward(self, envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> str:
  104. """return *status_code message*"""
  105. alias = rcpt_to.lower() # alias@SL
  106. gen_email = GenEmail.get_by(email=alias)
  107. if not gen_email:
  108. LOG.d(
  109. "alias %s not exist. Try to see if it can be created on the fly", alias
  110. )
  111. # try to see if alias could be created on-the-fly
  112. on_the_fly = False
  113. # check if alias belongs to a directory, ie having directory/anything@EMAIL_DOMAIN format
  114. if email_belongs_to_alias_domains(alias):
  115. if "/" in alias or "+" in alias or "#" in alias:
  116. if "/" in alias:
  117. sep = "/"
  118. elif "+" in alias:
  119. sep = "+"
  120. else:
  121. sep = "#"
  122. directory_name = alias[: alias.find(sep)]
  123. LOG.d("directory_name %s", directory_name)
  124. directory = Directory.get_by(name=directory_name)
  125. # Only premium user can use the directory feature
  126. if directory:
  127. dir_user: User = directory.user
  128. if dir_user.can_create_new_alias():
  129. # if alias has been deleted before, do not auto-create it
  130. if DeletedAlias.get_by(
  131. email=alias, user_id=directory.user_id
  132. ):
  133. LOG.error(
  134. "Alias %s has been deleted before, cannot auto-create using directory %s, user %s",
  135. alias,
  136. directory_name,
  137. dir_user,
  138. )
  139. else:
  140. LOG.d(
  141. "create alias %s for directory %s", alias, directory
  142. )
  143. on_the_fly = True
  144. gen_email = GenEmail.create(
  145. email=alias,
  146. user_id=directory.user_id,
  147. directory_id=directory.id,
  148. )
  149. db.session.commit()
  150. else:
  151. LOG.error(
  152. "User %s is not premium anymore and cannot create alias with directory",
  153. dir_user,
  154. )
  155. send_cannot_create_directory_alias(
  156. dir_user, alias, directory_name
  157. )
  158. # try to create alias on-the-fly with custom-domain catch-all feature
  159. # check if alias is custom-domain alias and if the custom-domain has catch-all enabled
  160. if not on_the_fly:
  161. alias_domain = get_email_domain_part(alias)
  162. custom_domain = CustomDomain.get_by(domain=alias_domain)
  163. # Only premium user can continue using the catch-all feature
  164. if custom_domain and custom_domain.catch_all:
  165. domain_user: User = custom_domain.user
  166. if domain_user.can_create_new_alias():
  167. # if alias has been deleted before, do not auto-create it
  168. if DeletedAlias.get_by(
  169. email=alias, user_id=custom_domain.user_id
  170. ):
  171. LOG.error(
  172. "Alias %s has been deleted before, cannot auto-create using domain catch-all %s, user %s",
  173. alias,
  174. custom_domain,
  175. domain_user,
  176. )
  177. else:
  178. LOG.d("create alias %s for domain %s", alias, custom_domain)
  179. on_the_fly = True
  180. gen_email = GenEmail.create(
  181. email=alias,
  182. user_id=custom_domain.user_id,
  183. custom_domain_id=custom_domain.id,
  184. automatic_creation=True,
  185. )
  186. db.session.commit()
  187. else:
  188. LOG.error(
  189. "User %s is not premium anymore and cannot create alias with domain %s",
  190. domain_user,
  191. alias_domain,
  192. )
  193. send_cannot_create_domain_alias(
  194. domain_user, alias, alias_domain
  195. )
  196. if not on_the_fly:
  197. LOG.d("alias %s cannot be created on-the-fly, return 510", alias)
  198. return "510 Email not exist"
  199. if gen_email.mailbox_id:
  200. mailbox_email = gen_email.mailbox.email
  201. else:
  202. mailbox_email = gen_email.user.email
  203. website_email = get_email_part(msg["From"])
  204. forward_email = ForwardEmail.get_by(
  205. gen_email_id=gen_email.id, website_email=website_email
  206. )
  207. if forward_email:
  208. # update the From header if needed
  209. if forward_email.website_from != msg["From"]:
  210. LOG.d("Update From header for %s", forward_email)
  211. forward_email.website_from = msg["From"]
  212. db.session.commit()
  213. else:
  214. LOG.debug(
  215. "create forward email for alias %s and website email %s",
  216. alias,
  217. website_email,
  218. )
  219. # generate a reply_email, make sure it is unique
  220. # not use while to avoid infinite loop
  221. for _ in range(1000):
  222. reply_email = f"reply+{random_string(30)}@{EMAIL_DOMAIN}"
  223. if not ForwardEmail.get_by(reply_email=reply_email):
  224. break
  225. forward_email = ForwardEmail.create(
  226. gen_email_id=gen_email.id,
  227. website_email=website_email,
  228. website_from=msg["From"],
  229. reply_email=reply_email,
  230. )
  231. db.session.commit()
  232. forward_log = ForwardEmailLog.create(forward_id=forward_email.id)
  233. if gen_email.enabled:
  234. # add custom header
  235. add_or_replace_header(msg, "X-SimpleLogin-Type", "Forward")
  236. # remove reply-to header if present
  237. delete_header(msg, "Reply-To")
  238. # change the from header so the sender comes from @SL
  239. # so it can pass DMARC check
  240. # replace the email part in from: header
  241. from_header = (
  242. get_email_name(msg["From"])
  243. + ("" if get_email_name(msg["From"]) == "" else " - ")
  244. + website_email.replace("@", " at ")
  245. + f" <{forward_email.reply_email}>"
  246. )
  247. msg.replace_header("From", from_header)
  248. LOG.d("new from header:%s", from_header)
  249. # add List-Unsubscribe header
  250. unsubscribe_link = f"{URL}/dashboard/unsubscribe/{gen_email.id}"
  251. add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>")
  252. add_or_replace_header(
  253. msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click"
  254. )
  255. add_dkim_signature(msg, EMAIL_DOMAIN)
  256. LOG.d(
  257. "Forward mail from %s to %s, mail_options %s, rcpt_options %s ",
  258. website_email,
  259. mailbox_email,
  260. envelope.mail_options,
  261. envelope.rcpt_options,
  262. )
  263. # smtp.send_message has UnicodeEncodeErroremail issue
  264. # encode message raw directly instead
  265. msg_raw = msg.as_string().encode()
  266. smtp.sendmail(
  267. forward_email.reply_email,
  268. mailbox_email,
  269. msg_raw,
  270. envelope.mail_options,
  271. envelope.rcpt_options,
  272. )
  273. else:
  274. LOG.d("%s is disabled, do not forward", gen_email)
  275. forward_log.blocked = True
  276. db.session.commit()
  277. return "250 Message accepted for delivery"
  278. def handle_reply(self, envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> str:
  279. reply_email = rcpt_to.lower()
  280. # reply_email must end with EMAIL_DOMAIN
  281. if not reply_email.endswith(EMAIL_DOMAIN):
  282. LOG.warning(f"Reply email {reply_email} has wrong domain")
  283. return "550 wrong reply email"
  284. forward_email = ForwardEmail.get_by(reply_email=reply_email)
  285. if not forward_email:
  286. LOG.warning(f"No such forward-email with {reply_email} as reply-email")
  287. return "550 wrong reply email"
  288. alias: str = forward_email.gen_email.email
  289. alias_domain = alias[alias.find("@") + 1 :]
  290. # alias must end with one of the ALIAS_DOMAINS or custom-domain
  291. if not email_belongs_to_alias_domains(alias):
  292. if not CustomDomain.get_by(domain=alias_domain):
  293. return "550 alias unknown by SimpleLogin"
  294. gen_email = forward_email.gen_email
  295. if gen_email.mailbox_id:
  296. mailbox_email = gen_email.mailbox.email
  297. else:
  298. mailbox_email = gen_email.user.email
  299. # bounce email initiated by Postfix
  300. # can happen in case emails cannot be delivered to user-email
  301. # in this case Postfix will try to send a bounce report to original sender, which is
  302. # the "reply email"
  303. if envelope.mail_from == "<>":
  304. LOG.error(
  305. "Bounce when sending to alias %s, user %s, from header: %s",
  306. alias,
  307. gen_email.user,
  308. msg["From"],
  309. )
  310. # send the bounce email payload to admin
  311. msg.replace_header("From", SUPPORT_EMAIL)
  312. msg.replace_header("To", ADMIN_EMAIL)
  313. add_dkim_signature(msg, get_email_domain_part(SUPPORT_EMAIL))
  314. smtp.sendmail(
  315. SUPPORT_EMAIL,
  316. ADMIN_EMAIL,
  317. msg.as_string().encode(),
  318. envelope.mail_options,
  319. envelope.rcpt_options,
  320. )
  321. return "550 ignored"
  322. # only mailbox can send email to the reply-email
  323. if envelope.mail_from.lower() != mailbox_email.lower():
  324. LOG.warning(
  325. f"Reply email can only be used by user email. Actual mail_from: %s. msg from header: %s, User email %s. reply_email %s",
  326. envelope.mail_from,
  327. msg["From"],
  328. mailbox_email,
  329. reply_email,
  330. )
  331. user = gen_email.user
  332. send_email(
  333. mailbox_email,
  334. f"Reply from your alias {alias} only works from your mailbox",
  335. render(
  336. "transactional/reply-must-use-personal-email.txt",
  337. name=user.name,
  338. alias=alias,
  339. sender=envelope.mail_from,
  340. mailbox_email=mailbox_email,
  341. ),
  342. render(
  343. "transactional/reply-must-use-personal-email.html",
  344. name=user.name,
  345. alias=alias,
  346. sender=envelope.mail_from,
  347. mailbox_email=mailbox_email,
  348. ),
  349. )
  350. # Notify sender that they cannot send emails to this address
  351. send_email(
  352. envelope.mail_from,
  353. f"Your email ({envelope.mail_from}) is not allowed to send emails to {reply_email}",
  354. render(
  355. "transactional/send-from-alias-from-unknown-sender.txt",
  356. sender=envelope.mail_from,
  357. reply_email=reply_email,
  358. ),
  359. "",
  360. )
  361. return "550 ignored"
  362. delete_header(msg, "DKIM-Signature")
  363. # the email comes from alias
  364. msg.replace_header("From", alias)
  365. # some email providers like ProtonMail adds automatically the Reply-To field
  366. # make sure to delete it
  367. delete_header(msg, "Reply-To")
  368. msg.replace_header("To", forward_email.website_email)
  369. # add List-Unsubscribe header
  370. unsubscribe_link = f"{URL}/dashboard/unsubscribe/{forward_email.gen_email_id}"
  371. add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>")
  372. add_or_replace_header(
  373. msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click"
  374. )
  375. # Received-SPF is injected by postfix-policyd-spf-python can reveal user original email
  376. delete_header(msg, "Received-SPF")
  377. LOG.d(
  378. "send email from %s to %s, mail_options:%s,rcpt_options:%s",
  379. alias,
  380. forward_email.website_email,
  381. envelope.mail_options,
  382. envelope.rcpt_options,
  383. )
  384. if alias_domain in ALIAS_DOMAINS:
  385. add_dkim_signature(msg, alias_domain)
  386. # add DKIM-Signature for custom-domain alias
  387. else:
  388. custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain)
  389. if custom_domain.dkim_verified:
  390. add_dkim_signature(msg, alias_domain)
  391. msg_raw = msg.as_string().encode()
  392. smtp.sendmail(
  393. alias,
  394. forward_email.website_email,
  395. msg_raw,
  396. envelope.mail_options,
  397. envelope.rcpt_options,
  398. )
  399. ForwardEmailLog.create(forward_id=forward_email.id, is_reply=True)
  400. db.session.commit()
  401. return "250 Message accepted for delivery"
  402. if __name__ == "__main__":
  403. controller = Controller(MailHandler(), hostname="0.0.0.0", port=20381)
  404. controller.start()
  405. LOG.d("Start mail controller %s %s", controller.hostname, controller.port)
  406. while True:
  407. time.sleep(2)