summaryrefslogtreecommitdiff
path: root/addons/mail/models/mail_mail.py
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/models/mail_mail.py
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/models/mail_mail.py')
-rw-r--r--addons/mail/models/mail_mail.py446
1 files changed, 446 insertions, 0 deletions
diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py
new file mode 100644
index 00000000..672476cf
--- /dev/null
+++ b/addons/mail/models/mail_mail.py
@@ -0,0 +1,446 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import ast
+import base64
+import datetime
+import logging
+import psycopg2
+import smtplib
+import threading
+import re
+
+from collections import defaultdict
+
+from odoo import _, api, fields, models
+from odoo import tools
+from odoo.addons.base.models.ir_mail_server import MailDeliveryException
+
+_logger = logging.getLogger(__name__)
+
+
+class MailMail(models.Model):
+ """ Model holding RFC2822 email messages to send. This model also provides
+ facilities to queue and send new email messages. """
+ _name = 'mail.mail'
+ _description = 'Outgoing Mails'
+ _inherits = {'mail.message': 'mail_message_id'}
+ _order = 'id desc'
+ _rec_name = 'subject'
+
+ # content
+ mail_message_id = fields.Many2one('mail.message', 'Message', required=True, ondelete='cascade', index=True,
+ auto_join=True)
+ body_html = fields.Text('Rich-text Contents', help="Rich-text/HTML message")
+ references = fields.Text('References', help='Message references, such as identifiers of previous messages',
+ readonly=1)
+ headers = fields.Text('Headers', copy=False)
+ # Auto-detected based on create() - if 'mail_message_id' was passed then this mail is a notification
+ # and during unlink() we will not cascade delete the parent and its attachments
+ notification = fields.Boolean('Is Notification',
+ help='Mail has been created to notify people of an existing mail.message')
+ # recipients: include inactive partners (they may have been archived after
+ # the message was sent, but they should remain visible in the relation)
+ email_to = fields.Text('To', help='Message recipients (emails)')
+ email_cc = fields.Char('Cc', help='Carbon copy message recipients')
+ recipient_ids = fields.Many2many('res.partner', string='To (Partners)',
+ context={'active_test': False})
+ # process
+ state = fields.Selection([
+ ('outgoing', 'Outgoing'),
+ ('sent', 'Sent'),
+ ('received', 'Received'),
+ ('exception', 'Delivery Failed'),
+ ('cancel', 'Cancelled'),
+ ], 'Status', readonly=True, copy=False, default='outgoing')
+ auto_delete = fields.Boolean(
+ 'Auto Delete',
+ help="This option permanently removes any track of email after it's been sent, including from the Technical menu in the Settings, in order to preserve storage space of your Odoo database.")
+ failure_reason = fields.Text(
+ 'Failure Reason', readonly=1,
+ help="Failure reason. This is usually the exception thrown by the email server, stored to ease the debugging of mailing issues.")
+ scheduled_date = fields.Char('Scheduled Send Date',
+ help="If set, the queue manager will send the email after the date. If not set, the email will be send as soon as possible.")
+
+ @api.model_create_multi
+ def create(self, values_list):
+ # notification field: if not set, set if mail comes from an existing mail.message
+ for values in values_list:
+ if 'notification' not in values and values.get('mail_message_id'):
+ values['notification'] = True
+
+ new_mails = super(MailMail, self).create(values_list)
+
+ new_mails_w_attach = self
+ for mail, values in zip(new_mails, values_list):
+ if values.get('attachment_ids'):
+ new_mails_w_attach += mail
+ if new_mails_w_attach:
+ new_mails_w_attach.mapped('attachment_ids').check(mode='read')
+
+ return new_mails
+
+ def write(self, vals):
+ res = super(MailMail, self).write(vals)
+ if vals.get('attachment_ids'):
+ for mail in self:
+ mail.attachment_ids.check(mode='read')
+ return res
+
+ def unlink(self):
+ # cascade-delete the parent message for all mails that are not created for a notification
+ mail_msg_cascade_ids = [mail.mail_message_id.id for mail in self if not mail.notification]
+ res = super(MailMail, self).unlink()
+ if mail_msg_cascade_ids:
+ self.env['mail.message'].browse(mail_msg_cascade_ids).unlink()
+ return res
+
+ @api.model
+ def default_get(self, fields):
+ # protection for `default_type` values leaking from menu action context (e.g. for invoices)
+ # To remove when automatic context propagation is removed in web client
+ if self._context.get('default_type') not in type(self).message_type.base_field.selection:
+ self = self.with_context(dict(self._context, default_type=None))
+ return super(MailMail, self).default_get(fields)
+
+ def mark_outgoing(self):
+ return self.write({'state': 'outgoing'})
+
+ def cancel(self):
+ return self.write({'state': 'cancel'})
+
+ @api.model
+ def process_email_queue(self, ids=None):
+ """Send immediately queued messages, committing after each
+ message is sent - this is not transactional and should
+ not be called during another transaction!
+
+ :param list ids: optional list of emails ids to send. If passed
+ no search is performed, and these ids are used
+ instead.
+ :param dict context: if a 'filters' key is present in context,
+ this value will be used as an additional
+ filter to further restrict the outgoing
+ messages to send (by default all 'outgoing'
+ messages are sent).
+ """
+ filters = ['&',
+ ('state', '=', 'outgoing'),
+ '|',
+ ('scheduled_date', '<', datetime.datetime.now()),
+ ('scheduled_date', '=', False)]
+ if 'filters' in self._context:
+ filters.extend(self._context['filters'])
+ # TODO: make limit configurable
+ filtered_ids = self.search(filters, limit=10000).ids
+ if not ids:
+ ids = filtered_ids
+ else:
+ ids = list(set(filtered_ids) & set(ids))
+ ids.sort()
+
+ res = None
+ try:
+ # auto-commit except in testing mode
+ auto_commit = not getattr(threading.currentThread(), 'testing', False)
+ res = self.browse(ids).send(auto_commit=auto_commit)
+ except Exception:
+ _logger.exception("Failed processing mail queue")
+ return res
+
+ def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None):
+ """Perform any post-processing necessary after sending ``mail``
+ successfully, including deleting it completely along with its
+ attachment if the ``auto_delete`` flag of the mail was set.
+ Overridden by subclasses for extra post-processing behaviors.
+
+ :return: True
+ """
+ notif_mails_ids = [mail.id for mail in self if mail.notification]
+ if notif_mails_ids:
+ notifications = self.env['mail.notification'].search([
+ ('notification_type', '=', 'email'),
+ ('mail_id', 'in', notif_mails_ids),
+ ('notification_status', 'not in', ('sent', 'canceled'))
+ ])
+ if notifications:
+ # find all notification linked to a failure
+ failed = self.env['mail.notification']
+ if failure_type:
+ failed = notifications.filtered(lambda notif: notif.res_partner_id not in success_pids)
+ (notifications - failed).sudo().write({
+ 'notification_status': 'sent',
+ 'failure_type': '',
+ 'failure_reason': '',
+ })
+ if failed:
+ failed.sudo().write({
+ 'notification_status': 'exception',
+ 'failure_type': failure_type,
+ 'failure_reason': failure_reason,
+ })
+ messages = notifications.mapped('mail_message_id').filtered(lambda m: m.is_thread_message())
+ # TDE TODO: could be great to notify message-based, not notifications-based, to lessen number of notifs
+ messages._notify_message_notification_update() # notify user that we have a failure
+ if not failure_type or failure_type == 'RECIPIENT': # if we have another error, we want to keep the mail.
+ mail_to_delete_ids = [mail.id for mail in self if mail.auto_delete]
+ self.browse(mail_to_delete_ids).sudo().unlink()
+ return True
+
+ # ------------------------------------------------------
+ # mail_mail formatting, tools and send mechanism
+ # ------------------------------------------------------
+
+ def _send_prepare_body(self):
+ """Return a specific ir_email body. The main purpose of this method
+ is to be inherited to add custom content depending on some module."""
+ self.ensure_one()
+ return self.body_html or ''
+
+ def _send_prepare_values(self, partner=None):
+ """Return a dictionary for specific email values, depending on a
+ partner, or generic to the whole recipients given by mail.email_to.
+
+ :param Model partner: specific recipient partner
+ """
+ self.ensure_one()
+ body = self._send_prepare_body()
+ body_alternative = tools.html2plaintext(body)
+ if partner:
+ email_to = [tools.formataddr((partner.name or 'False', partner.email or 'False'))]
+ else:
+ email_to = tools.email_split_and_format(self.email_to)
+ res = {
+ 'body': body,
+ 'body_alternative': body_alternative,
+ 'email_to': email_to,
+ }
+ return res
+
+ def _split_by_server(self):
+ """Returns an iterator of pairs `(mail_server_id, record_ids)` for current recordset.
+
+ The same `mail_server_id` may repeat in order to limit batch size according to
+ the `mail.session.batch.size` system parameter.
+ """
+ groups = defaultdict(list)
+ # Turn prefetch OFF to avoid MemoryError on very large mail queues, we only care
+ # about the mail server ids in this case.
+ for mail in self.with_context(prefetch_fields=False):
+ groups[mail.mail_server_id.id].append(mail.id)
+ sys_params = self.env['ir.config_parameter'].sudo()
+ batch_size = int(sys_params.get_param('mail.session.batch.size', 1000))
+ for server_id, record_ids in groups.items():
+ for mail_batch in tools.split_every(batch_size, record_ids):
+ yield server_id, mail_batch
+
+ def send(self, auto_commit=False, raise_exception=False):
+ """ Sends the selected emails immediately, ignoring their current
+ state (mails that have already been sent should not be passed
+ unless they should actually be re-sent).
+ Emails successfully delivered are marked as 'sent', and those
+ that fail to be deliver are marked as 'exception', and the
+ corresponding error mail is output in the server logs.
+
+ :param bool auto_commit: whether to force a commit of the mail status
+ after sending each mail (meant only for scheduler processing);
+ should never be True during normal transactions (default: False)
+ :param bool raise_exception: whether to raise an exception if the
+ email sending process has failed
+ :return: True
+ """
+ for server_id, batch_ids in self._split_by_server():
+ smtp_session = None
+ try:
+ smtp_session = self.env['ir.mail_server'].connect(mail_server_id=server_id)
+ except Exception as exc:
+ if raise_exception:
+ # To be consistent and backward compatible with mail_mail.send() raised
+ # exceptions, it is encapsulated into an Odoo MailDeliveryException
+ raise MailDeliveryException(_('Unable to connect to SMTP Server'), exc)
+ else:
+ batch = self.browse(batch_ids)
+ batch.write({'state': 'exception', 'failure_reason': exc})
+ batch._postprocess_sent_message(success_pids=[], failure_type="SMTP")
+ else:
+ self.browse(batch_ids)._send(
+ auto_commit=auto_commit,
+ raise_exception=raise_exception,
+ smtp_session=smtp_session)
+ _logger.info(
+ 'Sent batch %s emails via mail server ID #%s',
+ len(batch_ids), server_id)
+ finally:
+ if smtp_session:
+ smtp_session.quit()
+
+ def _send(self, auto_commit=False, raise_exception=False, smtp_session=None):
+ IrMailServer = self.env['ir.mail_server']
+ IrAttachment = self.env['ir.attachment']
+ for mail_id in self.ids:
+ success_pids = []
+ failure_type = None
+ processing_pid = None
+ mail = None
+ try:
+ mail = self.browse(mail_id)
+ if mail.state != 'outgoing':
+ if mail.state != 'exception' and mail.auto_delete:
+ mail.sudo().unlink()
+ continue
+
+ # remove attachments if user send the link with the access_token
+ body = mail.body_html or ''
+ attachments = mail.attachment_ids
+ for link in re.findall(r'/web/(?:content|image)/([0-9]+)', body):
+ attachments = attachments - IrAttachment.browse(int(link))
+
+ # load attachment binary data with a separate read(), as prefetching all
+ # `datas` (binary field) could bloat the browse cache, triggerring
+ # soft/hard mem limits with temporary data.
+ attachments = [(a['name'], base64.b64decode(a['datas']), a['mimetype'])
+ for a in attachments.sudo().read(['name', 'datas', 'mimetype']) if
+ a['datas'] is not False]
+
+ # specific behavior to customize the send email for notified partners
+ email_list = []
+ if mail.email_to:
+ email_list.append(mail._send_prepare_values())
+ for partner in mail.recipient_ids:
+ values = mail._send_prepare_values(partner=partner)
+ values['partner_id'] = partner
+ email_list.append(values)
+
+ # headers
+ headers = {}
+ ICP = self.env['ir.config_parameter'].sudo()
+ bounce_alias = ICP.get_param("mail.bounce.alias")
+ bounce_alias_static = tools.str2bool(ICP.get_param("mail.bounce.alias.static", "False"))
+ catchall_domain = ICP.get_param("mail.catchall.domain")
+ if bounce_alias and catchall_domain:
+ if bounce_alias_static:
+ headers['Return-Path'] = '%s@%s' % (bounce_alias, catchall_domain)
+ elif mail.mail_message_id.is_thread_message():
+ headers['Return-Path'] = '%s+%d-%s-%d@%s' % (
+ bounce_alias, mail.id, mail.model, mail.res_id, catchall_domain)
+ else:
+ headers['Return-Path'] = '%s+%d@%s' % (bounce_alias, mail.id, catchall_domain)
+ if mail.headers:
+ try:
+ headers.update(ast.literal_eval(mail.headers))
+ except Exception:
+ pass
+
+ # Writing on the mail object may fail (e.g. lock on user) which
+ # would trigger a rollback *after* actually sending the email.
+ # To avoid sending twice the same email, provoke the failure earlier
+ mail.write({
+ 'state': 'exception',
+ 'failure_reason': _(
+ 'Error without exception. Probably due do sending an email without computed recipients.'),
+ })
+ # Update notification in a transient exception state to avoid concurrent
+ # update in case an email bounces while sending all emails related to current
+ # mail record.
+ notifs = self.env['mail.notification'].search([
+ ('notification_type', '=', 'email'),
+ ('mail_id', 'in', mail.ids),
+ ('notification_status', 'not in', ('sent', 'canceled'))
+ ])
+ if notifs:
+ notif_msg = _(
+ 'Error without exception. Probably due do concurrent access update of notification records. Please see with an administrator.')
+ notifs.sudo().write({
+ 'notification_status': 'exception',
+ 'failure_type': 'UNKNOWN',
+ 'failure_reason': notif_msg,
+ })
+ # `test_mail_bounce_during_send`, force immediate update to obtain the lock.
+ # see rev. 56596e5240ef920df14d99087451ce6f06ac6d36
+ notifs.flush(fnames=['notification_status', 'failure_type', 'failure_reason'], records=notifs)
+
+ # build an RFC2822 email.message.Message object and send it without queuing
+ res = None
+ for email in email_list:
+ # custom static email @stephan
+ custom_reply_to = mail.reply_to
+ if not mail.model:
+ custom_reply_to = mail.model
+ elif "sale.order" in mail.model:
+ custom_reply_to = 'sales2@indoteknik.com'
+ elif "purchase.order" in mail.model:
+ custom_reply_to = 'purchase@indoteknik.co.id'
+ else:
+ custom_reply_to = 'sales@indoteknik.com'
+ msg = IrMailServer.build_email(
+ email_from=mail.email_from,
+ email_to=email.get('email_to'),
+ subject=mail.subject,
+ body=email.get('body'),
+ body_alternative=email.get('body_alternative'),
+ email_cc=tools.email_split(mail.email_cc),
+ reply_to=custom_reply_to,
+ attachments=attachments,
+ message_id=mail.message_id,
+ references=mail.references,
+ object_id=mail.res_id and ('%s-%s' % (mail.res_id, mail.model)),
+ subtype='html',
+ subtype_alternative='plain',
+ headers=headers)
+ processing_pid = email.pop("partner_id", None)
+ try:
+ res = IrMailServer.send_email(
+ msg, mail_server_id=mail.mail_server_id.id, smtp_session=smtp_session)
+ if processing_pid:
+ success_pids.append(processing_pid)
+ processing_pid = None
+ except AssertionError as error:
+ if str(error) == IrMailServer.NO_VALID_RECIPIENT:
+ failure_type = "RECIPIENT"
+ # No valid recipient found for this particular
+ # mail item -> ignore error to avoid blocking
+ # delivery to next recipients, if any. If this is
+ # the only recipient, the mail will show as failed.
+ _logger.info("Ignoring invalid recipients for mail.mail %s: %s",
+ mail.message_id, email.get('email_to'))
+ else:
+ raise
+ if res: # mail has been sent at least once, no major exception occured
+ mail.write({'state': 'sent', 'message_id': res, 'failure_reason': False})
+ _logger.info('Mail with ID %r and Message-Id %r successfully sent', mail.id, mail.message_id)
+ # /!\ can't use mail.state here, as mail.refresh() will cause an error
+ # see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1
+ mail._postprocess_sent_message(success_pids=success_pids, failure_type=failure_type)
+ except MemoryError:
+ # prevent catching transient MemoryErrors, bubble up to notify user or abort cron job
+ # instead of marking the mail as failed
+ _logger.exception(
+ 'MemoryError while processing mail with ID %r and Msg-Id %r. Consider raising the --limit-memory-hard startup option',
+ mail.id, mail.message_id)
+ # mail status will stay on ongoing since transaction will be rollback
+ raise
+ except (psycopg2.Error, smtplib.SMTPServerDisconnected):
+ # If an error with the database or SMTP session occurs, chances are that the cursor
+ # or SMTP session are unusable, causing further errors when trying to save the state.
+ _logger.exception(
+ 'Exception while processing mail with ID %r and Msg-Id %r.',
+ mail.id, mail.message_id)
+ raise
+ except Exception as e:
+ failure_reason = tools.ustr(e)
+ _logger.exception('failed sending mail (id: %s) due to %s', mail.id, failure_reason)
+ mail.write({'state': 'exception', 'failure_reason': failure_reason})
+ mail._postprocess_sent_message(success_pids=success_pids, failure_reason=failure_reason,
+ failure_type='UNKNOWN')
+ if raise_exception:
+ if isinstance(e, (AssertionError, UnicodeEncodeError)):
+ if isinstance(e, UnicodeEncodeError):
+ value = "Invalid text: %s" % e.object
+ else:
+ value = '. '.join(e.args)
+ raise MailDeliveryException(value)
+ raise
+
+ if auto_commit is True:
+ self._cr.commit()
+ return True