You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

600 lines
23 KiB

  1. ##############################################################################
  2. #
  3. # Author: Avoin.Systems
  4. # Copyright 2018 Avoin.Systems
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as
  8. # published by the Free Software Foundation, either version 3 of the
  9. # License, or (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. #
  19. ##############################################################################
  20. import base64
  21. import binascii
  22. import logging
  23. import random
  24. import re
  25. import string
  26. from email.message import Message
  27. from email.utils import formataddr, parseaddr
  28. from Crypto.Cipher import AES
  29. from odoo import api, fields, models, tools
  30. from odoo.tools import frozendict
  31. from odoo.addons.base.models.ir_mail_server import encode_rfc2822_address_header
  32. _logger = logging.getLogger(__name__)
  33. MESSAGE_PREFIX = "msg-"
  34. def random_string(length):
  35. return "".join(
  36. random.choice(string.ascii_lowercase + string.digits) for _ in range(length)
  37. )
  38. def get_key(env):
  39. return env["ir.config_parameter"].get_param("database.secret", "noneedtobestrong")[
  40. :16
  41. ]
  42. def get_cipher(env):
  43. return AES.new(
  44. get_key(env).encode("utf-8"), mode=AES.MODE_CBC, iv=b"veryverysecret81"
  45. )
  46. def encode_msg_id(msg_id, env):
  47. id_padded = "%016d" % msg_id
  48. encrypted = get_cipher(env).encrypt(id_padded.encode("utf-8"))
  49. return base64.b32encode(encrypted).decode("utf-8")
  50. # Remove in Odoo 14
  51. def encode_msg_id_legacy(msg_id, env):
  52. id_padded = "%016d" % msg_id
  53. encrypted = get_cipher(env).encrypt(id_padded.encode("utf-8"))
  54. return base64.urlsafe_b64encode(encrypted).decode("utf-8")
  55. def decode_msg_id(encoded_encrypted_id, env):
  56. try:
  57. # Some email clients don't respect the original Reply-To address case
  58. # and might make them lowercase. Make the encoded ID uppercase.
  59. encrypted = base64.b32decode(encoded_encrypted_id.encode("utf-8").upper())
  60. except binascii.Error:
  61. # Fall back to base64, which was used by the previous versions.
  62. # This can be removed in Odoo 14.
  63. try:
  64. encrypted = base64.urlsafe_b64decode(encoded_encrypted_id.encode("utf-8"))
  65. except binascii.Error:
  66. _logger.error(
  67. "Unable to decode the message ID. The input value "
  68. "is invalid and cannot be decoded. "
  69. "Encoded value: {}".format(encoded_encrypted_id)
  70. )
  71. raise
  72. try:
  73. id_str = get_cipher(env).decrypt(encrypted).decode("utf-8")
  74. except UnicodeDecodeError:
  75. _logger.error(
  76. "Unable to decrypt the message ID. The input value "
  77. "probably wasn't encrypted with the same key. Encoded "
  78. "value: {}".format(encoded_encrypted_id)
  79. )
  80. raise
  81. return int(id_str)
  82. class MailServer(models.Model):
  83. _inherit = "ir.mail_server"
  84. reply_to_method = fields.Selection(
  85. [("default", "Odoo Default"), ("alias", "Alias"), ("msg_id", "Message ID")],
  86. "Reply-To Method",
  87. default="default",
  88. help="Odoo Default: Don't add any unique identifiers into the\n"
  89. "Reply-To address.\n"
  90. "\n"
  91. "Alias: Find or generate an email alias for the Reply-To field of\n "
  92. "every outgoing message so the responses will be automatically \n"
  93. "routed to the correct thread even if the email client (Yes, \n"
  94. "I'm looking at you, Microsoft Outlook) decides to drop the \n"
  95. "References, In-Reply-To and Message-ID fields.\n\n"
  96. "The alias will then be used to generate a RFC 5233 sub-address\n"
  97. "using the Force From Address field as a base, eg.\n"
  98. "odoo@mycompany.fi would become odoo+adf9bacd98732@mycompany.fi\n"
  99. "\n"
  100. "Note that this method has a flaw: if the headers have dropped\n"
  101. "and Odoo can't connect the reply to any message in the thread,\n"
  102. "it will automatically connect it to the first message in the \n"
  103. "thread which often is an internal note and the reply will also\n"
  104. "be marked as an internal note even when it should be a comment."
  105. "\n\n"
  106. "Message ID: Include a prefix and the message ID in encrypted\n"
  107. "and base32 encoded format in the Reply-To\n"
  108. "address to that Odoo will be able to directly connect the\n"
  109. "reply to the original message. Note that in this mode the\n"
  110. "Reply-To address has a priority over References and\n"
  111. "In-Reply-To headers.",
  112. )
  113. force_email_reply_to = fields.Char(
  114. "Force Reply-To Address",
  115. )
  116. force_email_reply_to_name = fields.Char(
  117. "Force Reply-To Name",
  118. )
  119. force_email_reply_to_domain = fields.Char(
  120. "Force Reply-To Domain",
  121. )
  122. force_email_from = fields.Char(
  123. "Force From Address",
  124. )
  125. force_email_sender = fields.Char(
  126. "Force Sender Address",
  127. )
  128. prioritize_reply_to_over_msgid = fields.Boolean(
  129. "Prioritize Reply-To Over Email Headers",
  130. default=True,
  131. help="If this field is selected, the unique Reply-To address "
  132. "generated by the Message ID method will be prioritized "
  133. "over the email headers (default Odoo behavior) in incoming "
  134. "emails. This is recommended when the Reply-To method is set to "
  135. "Message ID.",
  136. )
  137. headers_example = fields.Text(
  138. "Example Headers",
  139. compute="_compute_headers_example",
  140. store=False,
  141. )
  142. # TODO Implement field input validators
  143. def _get_reply_to_address(self, alias, original_from_name):
  144. self.ensure_one()
  145. force_email_from = encode_rfc2822_address_header(self.force_email_from)
  146. # Split the From address
  147. from_address = force_email_from.split("@")
  148. reply_to_addr = "{alias}@{domain}".format(
  149. alias=alias if alias else from_address[0],
  150. domain=self.force_email_reply_to_domain or from_address[1],
  151. )
  152. if self.force_email_reply_to_name:
  153. reply_to = formataddr((self.force_email_reply_to_name, reply_to_addr))
  154. elif original_from_name:
  155. reply_to = formataddr((original_from_name, reply_to_addr))
  156. else:
  157. reply_to = reply_to_addr
  158. return encode_rfc2822_address_header(reply_to)
  159. @api.depends(
  160. "force_email_sender",
  161. "force_email_reply_to",
  162. "force_email_reply_to_domain",
  163. "force_email_from",
  164. "force_email_reply_to_name",
  165. "reply_to_method",
  166. )
  167. def _compute_headers_example(self):
  168. for server in self:
  169. example = []
  170. if server.force_email_sender:
  171. example.append("Sender: {}".format(server.force_email_sender))
  172. if server.force_email_reply_to:
  173. example.append("Reply-To: {}".format(server.force_email_reply_to))
  174. elif server.force_email_from and server.reply_to_method != "default":
  175. reply_to_pair = server.force_email_from.split("@")
  176. if server.reply_to_method == "alias":
  177. token = "{}+1d278g1082bca"
  178. elif server.reply_to_method == "msg_id":
  179. token = "{}+" + MESSAGE_PREFIX + "p2IxKkfEKugl16juheTT0g=="
  180. else:
  181. token = "INVALID"
  182. _logger.error(
  183. "Invalid reply_to_method found: " + server.reply_to_method
  184. )
  185. # noinspection PyProtectedMember
  186. reply_to = server._get_reply_to_address(
  187. token.format(reply_to_pair[0]), "Original From Person"
  188. )
  189. example.append("Reply-To: {}".format(reply_to))
  190. else:
  191. example.append("Reply-To: Odoo default")
  192. if server.force_email_from:
  193. example.append(
  194. "From: {}".format(
  195. formataddr(("Original From Person", server.force_email_from))
  196. )
  197. )
  198. else:
  199. example.append("From: Odoo default")
  200. server.headers_example = "\n".join(example)
  201. @api.model
  202. def send_email(
  203. self,
  204. message,
  205. mail_server_id=None,
  206. smtp_server=None,
  207. smtp_port=None,
  208. smtp_user=None,
  209. smtp_password=None,
  210. smtp_encryption=None,
  211. smtp_debug=False,
  212. smtp_session=None,
  213. ):
  214. # Get SMTP Server Details from Mail Server
  215. mail_server = None
  216. if mail_server_id:
  217. mail_server = self.sudo().browse(mail_server_id)
  218. elif not smtp_server:
  219. mail_server = self.sudo().search([], order="sequence", limit=1)
  220. # Note that Odoo already has the ability to use a fixed From address
  221. # by settings "email_from" in the Odoo settings. This is however a
  222. # secondary option and here email_from always overrides that.
  223. if mail_server.force_email_from:
  224. original_from_name = parseaddr(message["From"])[0]
  225. force_email_from = encode_rfc2822_address_header(
  226. mail_server.force_email_from
  227. )
  228. del message["From"]
  229. message["From"] = formataddr((original_from_name, force_email_from))
  230. if mail_server.reply_to_method == "alias":
  231. # Find or create an email alias
  232. alias = self.find_or_create_alias(force_email_from.split("@"))
  233. # noinspection PyProtectedMember
  234. reply_to = mail_server._get_reply_to_address(
  235. alias,
  236. original_from_name,
  237. )
  238. del message["Reply-To"]
  239. message["Reply-To"] = reply_to
  240. elif mail_server.reply_to_method == "msg_id":
  241. odoo_msg_id = message.get("Message-Id")
  242. if odoo_msg_id:
  243. # The message_id isn't unique. Prefer the one that has a
  244. # model set and only pick the first record. Odoo does
  245. # almost the same thing in mail.thread.message_route().
  246. odoo_msg = (
  247. self.sudo()
  248. .env["mail.message"]
  249. .search(
  250. [("message_id", "=", odoo_msg_id)], order="model", limit=1
  251. )
  252. )
  253. encrypted_id = encode_msg_id(odoo_msg.id, self.env)
  254. # noinspection PyProtectedMember
  255. reply_to = mail_server._get_reply_to_address(
  256. "{}+{}{}".format(
  257. force_email_from.split("@")[0], MESSAGE_PREFIX, encrypted_id
  258. ),
  259. original_from_name,
  260. )
  261. _logger.info(
  262. 'Generated a new reply-to address "{}" for '
  263. 'Message-Id "{}".'.format(reply_to, odoo_msg_id)
  264. )
  265. del message["Reply-To"]
  266. message["Reply-To"] = reply_to
  267. else:
  268. _logger.warning(
  269. "Couldn't get Message-Id from the message {}. The "
  270. "reply might not find its way to the correct thread.".format(
  271. message.as_string()
  272. )
  273. )
  274. if mail_server.force_email_reply_to:
  275. del message["Reply-To"]
  276. message["Reply-To"] = encode_rfc2822_address_header(
  277. mail_server.force_email_reply_to
  278. )
  279. if mail_server.force_email_sender:
  280. del message["Sender"]
  281. message["Sender"] = encode_rfc2822_address_header(
  282. mail_server.force_email_sender
  283. )
  284. return super(MailServer, self).send_email(
  285. message,
  286. mail_server_id,
  287. smtp_server,
  288. smtp_port,
  289. smtp_user,
  290. smtp_password,
  291. smtp_encryption,
  292. smtp_debug,
  293. smtp_session,
  294. )
  295. def find_or_create_alias(self, from_address):
  296. record_id, record_model_name = self.resolve_record()
  297. if not record_id or not record_model_name:
  298. # Can't create an alias if we don't know the related record
  299. return False
  300. if record_model_name not in self.env:
  301. _logger.error(
  302. "Unable to find or create an alias for outgoing "
  303. "email: invalid_model name {}.".format(record_model_name)
  304. )
  305. return False
  306. # Find an alias
  307. alias_model_id = (
  308. self.env["ir.model"].search([("model", "=", record_model_name)]).id
  309. )
  310. # noinspection PyPep8Naming
  311. Alias = self.env["mail.alias"]
  312. existing_aliases = Alias.search(
  313. [
  314. ("alias_model_id", "=", alias_model_id),
  315. (
  316. "alias_name",
  317. "like",
  318. "{from_address}+".format(from_address=from_address[0]),
  319. ),
  320. ("alias_force_thread_id", "=", record_id),
  321. ("alias_contact", "=", "everyone"), # TODO: check from record
  322. ]
  323. )
  324. if existing_aliases:
  325. return existing_aliases[0].alias_name
  326. # Create a new alias
  327. alias = Alias.create(
  328. {
  329. "alias_model_id": alias_model_id,
  330. "alias_name": "{from_address}+{random_string}".format(
  331. from_address=from_address[0], random_string=random_string(8)
  332. ),
  333. "alias_force_thread_id": record_id,
  334. "alias_contact": "everyone",
  335. }
  336. )
  337. return alias.alias_name
  338. def resolve_record(self):
  339. ctx = self.env.context
  340. # Don't ever use active_id or active_model from the context here.
  341. # It might not be the one that you expect. Go ahead and try, open
  342. # a sales order, go to the related purchase order and send the PO.
  343. record_id = ctx.get("default_res_id")
  344. record_model_name = ctx.get("default_model")
  345. # If incoming_routes isn't enough, we can use ctx['incoming_to'] to
  346. # find a alias directly without active_id and active_model_name.
  347. routes = ctx.get("incoming_routes", [])
  348. if (not record_id or not record_model_name) and routes and len(routes) > 0:
  349. route = routes[0]
  350. record_model_name = route[0]
  351. record_id = route[1]
  352. return record_id, record_model_name
  353. @api.model
  354. def encrypt_message_id(self, message_id):
  355. """
  356. A helper encryption method for debugging mail delivery issues.
  357. :param message_id: The id of the `mail.message`
  358. :return: The id of the `mail.message` encrypted and base64 encoded
  359. """
  360. return encode_msg_id(message_id, self.env)
  361. @api.model
  362. def decrypt_message_id(self, encrypted_id):
  363. """
  364. A helper decryption method for debugging mail delivery issues.
  365. :param encrypted_id: The encrypted and base64 encoded id of
  366. the `mail.message` to be decrypted
  367. :return: The id of the `mail.message`
  368. """
  369. return decode_msg_id(encrypted_id, self.env)
  370. class MailThread(models.AbstractModel):
  371. _inherit = "mail.thread"
  372. """
  373. The process for incoming emails goes something like this:
  374. 1. message_process (processing the incoming message)
  375. 2. message_parse (parsing the email message)
  376. 3. message_route (decides how to route the email)
  377. 4. message_route_process (executes the route)
  378. 5. message_post (posts the message to a thread)
  379. """
  380. @api.model
  381. def message_parse(self, message, save_original=False):
  382. email_to = tools.decode_message_header(message, "To")
  383. email_to_localpart = (tools.email_split(email_to) or [""])[0].split("@", 1)[0]
  384. config_params = self.env["ir.config_parameter"].sudo()
  385. # Check if the To part contains the prefix and a base32/64 encoded string
  386. # Remove the "24," part when migrating to Odoo 14.
  387. prefix_in_to = email_to_localpart and re.search(
  388. r".*" + MESSAGE_PREFIX + "(?P<odoo_id>.{24,32}$)", email_to_localpart
  389. )
  390. prioritize_replyto_over_headers = config_params.get_param(
  391. "email_headers.prioritize_replyto_over_headers", "True"
  392. )
  393. prioritize_replyto_over_headers = (
  394. True if prioritize_replyto_over_headers != "False" else False
  395. )
  396. # If the msg prefix part is found in the To part, find the parent
  397. # message and inject the Message-Id to the In-Reply-To part and
  398. # remove References because it by default takes priority over
  399. # In-Reply-To. We want the unique Reply-To address have the priority.
  400. if prefix_in_to and prioritize_replyto_over_headers:
  401. message_id_encrypted = prefix_in_to.group("odoo_id")
  402. try:
  403. message_id = decode_msg_id(message_id_encrypted, self.env)
  404. parent_id = self.env["mail.message"].browse(message_id)
  405. if parent_id:
  406. # See unit test test_reply_to_method_msg_id_priority
  407. del message["References"]
  408. del message["In-Reply-To"]
  409. message["In-Reply-To"] = parent_id.message_id
  410. else:
  411. _logger.warning(
  412. "Received an invalid mail.message database id in incoming "
  413. "email sent to {}. The email type (comment, note) might "
  414. "be wrong.".format(email_to)
  415. )
  416. except UnicodeDecodeError:
  417. _logger.warning(
  418. "Unique Reply-To address of an incoming email couldn't be "
  419. "decrypted. Falling back to default Odoo behavior."
  420. )
  421. res = super(MailThread, self).message_parse(message, save_original)
  422. strip_message_id = config_params.get_param(
  423. "email_headers.strip_mail_message_ids", "True"
  424. )
  425. strip_message_id = True if strip_message_id != "False" else False
  426. if not strip_message_id == "True":
  427. return res
  428. # When Odoo compares message_id to the one stored in the database when determining
  429. # whether or not the incoming message is a reply to another one, the message_id search
  430. # parameter is stripped before the search. But Odoo does not do anything of the sort when
  431. # a message is created, meaning if some email software (for example Outlook,
  432. # for no particular reason) includes anything strippable at the start of the Message-Id,
  433. # any replies to that message in the future will not find their way correctly, as the
  434. # search yields nothing.
  435. #
  436. # Example of what happened before. The first one is the original Message-Id, and thus also
  437. # the ID that gets stored on the mail.message as the `message_id`
  438. # '\r\n <AM6PR05MB4933DE6BCAD68A037185EBCFFBAF0@AM6PR05MB4933.eurprd05.prod.outlook.com>'
  439. # But when trying to find this message, Odoo takes the above message_id and strips it,
  440. # which results in:
  441. # '<AM6PR05MB4933DE6BCAD68A037185EBCFFBAF0@AM6PR05MB4933.eurprd05.prod.outlook.com>'
  442. # And then the search is done for an exact match, which will fail.
  443. #
  444. # Odoo doesn't, so we must strip the message_ids before they are stored in the database
  445. mail_message_id = res.get("message_id", "")
  446. if mail_message_id:
  447. mail_message_id = mail_message_id.strip()
  448. res["message_id"] = mail_message_id
  449. return res
  450. @api.model
  451. def message_route_process(self, message, message_dict, routes):
  452. ctx = self.env.context.copy()
  453. ctx["incoming_routes"] = routes
  454. ctx["incoming_to"] = message_dict.get("to")
  455. self.env.context = frozendict(ctx)
  456. return super(MailThread, self).message_route_process(
  457. message, message_dict, routes
  458. )
  459. @api.model
  460. def message_route(
  461. self, message, message_dict, model=None, thread_id=None, custom_values=None
  462. ):
  463. # NOTE! If you're going to backport this module to Odoo 11 or Odoo 10,
  464. # you will have to create the mail_bounce_catchall email template
  465. # because it was introduced only in Odoo 12.
  466. if not isinstance(message, Message):
  467. raise TypeError("message must be an " "email.message.Message at this point")
  468. try:
  469. route = super(MailThread, self).message_route(
  470. message, message_dict, model, thread_id, custom_values
  471. )
  472. except ValueError:
  473. # If the headers that connect the incoming message to a thread in
  474. # Odoo have disappeared at some point and the message was sent to
  475. # the catchall address (with a sub-addressing suffix), we will
  476. # skip the default catchall check and perform it here for
  477. # mail.catchall.alias.custom. We do this because the alias check
  478. # if done AFTER the catchall check by default and it may cause
  479. # Odoo to send a bounce message to the sender who sent the email to
  480. # the correct thread-specific address.
  481. catchall_alias = (
  482. self.env["ir.config_parameter"]
  483. .sudo()
  484. .get_param("mail.catchall.alias.custom")
  485. )
  486. email_to = tools.decode_message_header(message, "To")
  487. email_to_localpart = (
  488. (tools.email_split(email_to) or [""])[0].split("@", 1)[0].lower()
  489. )
  490. message_id = message.get("Message-Id")
  491. email_from = tools.decode_message_header(message, "From")
  492. # check it does not directly contact catchall
  493. if catchall_alias and catchall_alias in email_to_localpart:
  494. _logger.info(
  495. "Routing mail from %s to %s with Message-Id %s: "
  496. "direct write to catchall, bounce",
  497. email_from,
  498. email_to,
  499. message_id,
  500. )
  501. body = self.env.ref("mail.mail_bounce_catchall").render(
  502. {"message": message}, engine="ir.qweb"
  503. )
  504. self._routing_create_bounce_email(
  505. email_from, body, message, reply_to=self.env.user.company_id.email
  506. )
  507. return []
  508. else:
  509. raise
  510. return route