[MERGE] forward port of branch saas-3 up to f7a76cb
[odoo/odoo.git] / addons / mail / mail_mail.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2010-today OpenERP SA (<http://www.openerp.com>)
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>
19 #
20 ##############################################################################
21
22 import base64
23 import logging
24 from email.utils import formataddr
25 from urlparse import urljoin
26
27 from openerp import api, tools
28 from openerp import SUPERUSER_ID
29 from openerp.addons.base.ir.ir_mail_server import MailDeliveryException
30 from openerp.osv import fields, osv
31 from openerp.tools.safe_eval import safe_eval as eval
32 from openerp.tools.translate import _
33
34 _logger = logging.getLogger(__name__)
35
36
37 class mail_mail(osv.Model):
38     """ Model holding RFC2822 email messages to send. This model also provides
39         facilities to queue and send new email messages.  """
40     _name = 'mail.mail'
41     _description = 'Outgoing Mails'
42     _inherits = {'mail.message': 'mail_message_id'}
43     _order = 'id desc'
44     _rec_name = 'subject'
45
46     _columns = {
47         'mail_message_id': fields.many2one('mail.message', 'Message', required=True, ondelete='cascade', auto_join=True),
48         'state': fields.selection([
49             ('outgoing', 'Outgoing'),
50             ('sent', 'Sent'),
51             ('received', 'Received'),
52             ('exception', 'Delivery Failed'),
53             ('cancel', 'Cancelled'),
54         ], 'Status', readonly=True, copy=False),
55         'auto_delete': fields.boolean('Auto Delete',
56             help="Permanently delete this email after sending it, to save space"),
57         'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
58         'email_to': fields.text('To', help='Message recipients (emails)'),
59         'recipient_ids': fields.many2many('res.partner', string='To (Partners)'),
60         'email_cc': fields.char('Cc', help='Carbon copy message recipients'),
61         'body_html': fields.text('Rich-text Contents', help="Rich-text/HTML message"),
62         'headers': fields.text('Headers', copy=False),
63         # Auto-detected based on create() - if 'mail_message_id' was passed then this mail is a notification
64         # and during unlink() we will not cascade delete the parent and its attachments
65         'notification': fields.boolean('Is Notification',
66             help='Mail has been created to notify people of an existing mail.message'),
67     }
68
69     _defaults = {
70         'state': 'outgoing',
71     }
72
73     def default_get(self, cr, uid, fields, context=None):
74         # protection for `default_type` values leaking from menu action context (e.g. for invoices)
75         # To remove when automatic context propagation is removed in web client
76         if context and context.get('default_type') and context.get('default_type') not in self._fields['type'].selection:
77             context = dict(context, default_type=None)
78         return super(mail_mail, self).default_get(cr, uid, fields, context=context)
79
80     def create(self, cr, uid, values, context=None):
81         # notification field: if not set, set if mail comes from an existing mail.message
82         if 'notification' not in values and values.get('mail_message_id'):
83             values['notification'] = True
84         return super(mail_mail, self).create(cr, uid, values, context=context)
85
86     def unlink(self, cr, uid, ids, context=None):
87         # cascade-delete the parent message for all mails that are not created for a notification
88         ids_to_cascade = self.search(cr, uid, [('notification', '=', False), ('id', 'in', ids)])
89         parent_msg_ids = [m.mail_message_id.id for m in self.browse(cr, uid, ids_to_cascade, context=context)]
90         res = super(mail_mail, self).unlink(cr, uid, ids, context=context)
91         self.pool.get('mail.message').unlink(cr, uid, parent_msg_ids, context=context)
92         return res
93
94     def mark_outgoing(self, cr, uid, ids, context=None):
95         return self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
96
97     def cancel(self, cr, uid, ids, context=None):
98         return self.write(cr, uid, ids, {'state': 'cancel'}, context=context)
99
100     @api.cr_uid
101     def process_email_queue(self, cr, uid, ids=None, context=None):
102         """Send immediately queued messages, committing after each
103            message is sent - this is not transactional and should
104            not be called during another transaction!
105
106            :param list ids: optional list of emails ids to send. If passed
107                             no search is performed, and these ids are used
108                             instead.
109            :param dict context: if a 'filters' key is present in context,
110                                 this value will be used as an additional
111                                 filter to further restrict the outgoing
112                                 messages to send (by default all 'outgoing'
113                                 messages are sent).
114         """
115         if context is None:
116             context = {}
117         if not ids:
118             filters = [('state', '=', 'outgoing')]
119             if 'filters' in context:
120                 filters.extend(context['filters'])
121             ids = self.search(cr, uid, filters, context=context)
122         res = None
123         try:
124             # Force auto-commit - this is meant to be called by
125             # the scheduler, and we can't allow rolling back the status
126             # of previously sent emails!
127             res = self.send(cr, uid, ids, auto_commit=True, context=context)
128         except Exception:
129             _logger.exception("Failed processing mail queue")
130         return res
131
132     def _postprocess_sent_message(self, cr, uid, mail, context=None, mail_sent=True):
133         """Perform any post-processing necessary after sending ``mail``
134         successfully, including deleting it completely along with its
135         attachment if the ``auto_delete`` flag of the mail was set.
136         Overridden by subclasses for extra post-processing behaviors.
137
138         :param browse_record mail: the mail that was just sent
139         :return: True
140         """
141         if mail_sent and mail.auto_delete:
142             # done with SUPERUSER_ID to avoid giving large unlink access rights
143             self.unlink(cr, SUPERUSER_ID, [mail.id], context=context)
144         return True
145
146     #------------------------------------------------------
147     # mail_mail formatting, tools and send mechanism
148     #------------------------------------------------------
149
150     def _get_partner_access_link(self, cr, uid, mail, partner=None, context=None):
151         """Generate URLs for links in mails: partner has access (is user):
152         link to action_mail_redirect action that will redirect to doc or Inbox """
153         if context is None:
154             context = {}
155         if partner and partner.user_ids:
156             base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url')
157             mail_model = mail.model or 'mail.thread'
158             url = urljoin(base_url, self.pool[mail_model]._get_access_link(cr, uid, mail, partner, context=context))
159             return "<span class='oe_mail_footer_access'><small>%(access_msg)s <a style='color:inherit' href='%(portal_link)s'>%(portal_msg)s</a></small></span>" % {
160                 'access_msg': _('about') if mail.record_name else _('access'),
161                 'portal_link': url,
162                 'portal_msg': '%s %s' % (context.get('model_name', ''), mail.record_name) if mail.record_name else _('your messages'),
163             }
164         else:
165             return None
166
167     def send_get_mail_subject(self, cr, uid, mail, force=False, partner=None, context=None):
168         """If subject is void, set the subject as 'Re: <Resource>' or
169         'Re: <mail.parent_id.subject>'
170
171             :param boolean force: force the subject replacement
172         """
173         if (force or not mail.subject) and mail.record_name:
174             return 'Re: %s' % (mail.record_name)
175         elif (force or not mail.subject) and mail.parent_id and mail.parent_id.subject:
176             return 'Re: %s' % (mail.parent_id.subject)
177         return mail.subject
178
179     def send_get_mail_body(self, cr, uid, mail, partner=None, context=None):
180         """Return a specific ir_email body. The main purpose of this method
181         is to be inherited to add custom content depending on some module."""
182         body = mail.body_html
183
184         # generate access links for notifications or emails linked to a specific document with auto threading
185         link = None
186         if mail.notification or (mail.model and mail.res_id and not mail.no_auto_thread):
187             link = self._get_partner_access_link(cr, uid, mail, partner, context=context)
188         if link:
189             body = tools.append_content_to_html(body, link, plaintext=False, container_tag='div')
190         return body
191
192     def send_get_mail_to(self, cr, uid, mail, partner=None, context=None):
193         """Forge the email_to with the following heuristic:
194           - if 'partner', recipient specific (Partner Name <email>)
195           - else fallback on mail.email_to splitting """
196         if partner:
197             email_to = [formataddr((partner.name, partner.email))]
198         else:
199             email_to = tools.email_split(mail.email_to)
200         return email_to
201
202     def send_get_email_dict(self, cr, uid, mail, partner=None, context=None):
203         """Return a dictionary for specific email values, depending on a
204         partner, or generic to the whole recipients given by mail.email_to.
205
206             :param browse_record mail: mail.mail browse_record
207             :param browse_record partner: specific recipient partner
208         """
209         body = self.send_get_mail_body(cr, uid, mail, partner=partner, context=context)
210         body_alternative = tools.html2plaintext(body)
211         res = {
212             'body': body,
213             'body_alternative': body_alternative,
214             'subject': self.send_get_mail_subject(cr, uid, mail, partner=partner, context=context),
215             'email_to': self.send_get_mail_to(cr, uid, mail, partner=partner, context=context),
216         }
217         return res
218
219     def send(self, cr, uid, ids, auto_commit=False, raise_exception=False, context=None):
220         """ Sends the selected emails immediately, ignoring their current
221             state (mails that have already been sent should not be passed
222             unless they should actually be re-sent).
223             Emails successfully delivered are marked as 'sent', and those
224             that fail to be deliver are marked as 'exception', and the
225             corresponding error mail is output in the server logs.
226
227             :param bool auto_commit: whether to force a commit of the mail status
228                 after sending each mail (meant only for scheduler processing);
229                 should never be True during normal transactions (default: False)
230             :param bool raise_exception: whether to raise an exception if the
231                 email sending process has failed
232             :return: True
233         """
234         context = dict(context or {})
235         ir_mail_server = self.pool.get('ir.mail_server')
236         ir_attachment = self.pool['ir.attachment']
237         for mail in self.browse(cr, SUPERUSER_ID, ids, context=context):
238             try:
239                 # TDE note: remove me when model_id field is present on mail.message - done here to avoid doing it multiple times in the sub method
240                 if mail.model:
241                     model_id = self.pool['ir.model'].search(cr, SUPERUSER_ID, [('model', '=', mail.model)], context=context)[0]
242                     model = self.pool['ir.model'].browse(cr, SUPERUSER_ID, model_id, context=context)
243                 else:
244                     model = None
245                 if model:
246                     context['model_name'] = model.name
247
248                 # load attachment binary data with a separate read(), as prefetching all
249                 # `datas` (binary field) could bloat the browse cache, triggerring
250                 # soft/hard mem limits with temporary data.
251                 attachment_ids = [a.id for a in mail.attachment_ids]
252                 attachments = [(a['datas_fname'], base64.b64decode(a['datas']))
253                                  for a in ir_attachment.read(cr, SUPERUSER_ID, attachment_ids,
254                                                              ['datas_fname', 'datas'])]
255
256                 # specific behavior to customize the send email for notified partners
257                 email_list = []
258                 if mail.email_to:
259                     email_list.append(self.send_get_email_dict(cr, uid, mail, context=context))
260                 for partner in mail.recipient_ids:
261                     email_list.append(self.send_get_email_dict(cr, uid, mail, partner=partner, context=context))
262                 # headers
263                 headers = {}
264                 bounce_alias = self.pool['ir.config_parameter'].get_param(cr, uid, "mail.bounce.alias", context=context)
265                 catchall_domain = self.pool['ir.config_parameter'].get_param(cr, uid, "mail.catchall.domain", context=context)
266                 if bounce_alias and catchall_domain:
267                     if mail.model and mail.res_id:
268                         headers['Return-Path'] = '%s-%d-%s-%d@%s' % (bounce_alias, mail.id, mail.model, mail.res_id, catchall_domain)
269                     else:
270                         headers['Return-Path'] = '%s-%d@%s' % (bounce_alias, mail.id, catchall_domain)
271                 if mail.headers:
272                     try:
273                         headers.update(eval(mail.headers))
274                     except Exception:
275                         pass
276
277                 # Writing on the mail object may fail (e.g. lock on user) which
278                 # would trigger a rollback *after* actually sending the email.
279                 # To avoid sending twice the same email, provoke the failure earlier
280                 mail.write({'state': 'exception'})
281                 mail_sent = False
282
283                 # build an RFC2822 email.message.Message object and send it without queuing
284                 res = None
285                 for email in email_list:
286                     msg = ir_mail_server.build_email(
287                         email_from=mail.email_from,
288                         email_to=email.get('email_to'),
289                         subject=email.get('subject'),
290                         body=email.get('body'),
291                         body_alternative=email.get('body_alternative'),
292                         email_cc=tools.email_split(mail.email_cc),
293                         reply_to=mail.reply_to,
294                         attachments=attachments,
295                         message_id=mail.message_id,
296                         references=mail.references,
297                         object_id=mail.res_id and ('%s-%s' % (mail.res_id, mail.model)),
298                         subtype='html',
299                         subtype_alternative='plain',
300                         headers=headers)
301                     try:
302                         res = ir_mail_server.send_email(cr, uid, msg,
303                                                     mail_server_id=mail.mail_server_id.id,
304                                                     context=context)
305                     except AssertionError as error:
306                         if error.message == ir_mail_server.NO_VALID_RECIPIENT:
307                             # No valid recipient found for this particular
308                             # mail item -> ignore error to avoid blocking
309                             # delivery to next recipients, if any. If this is
310                             # the only recipient, the mail will show as failed.
311                             _logger.warning("Ignoring invalid recipients for mail.mail %s: %s",
312                                             mail.message_id, email.get('email_to'))
313                         else:
314                             raise
315                 if res:
316                     mail.write({'state': 'sent', 'message_id': res})
317                     mail_sent = True
318
319                 # /!\ can't use mail.state here, as mail.refresh() will cause an error
320                 # see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1
321                 if mail_sent:
322                     _logger.info('Mail with ID %r and Message-Id %r successfully sent', mail.id, mail.message_id)
323                 self._postprocess_sent_message(cr, uid, mail, context=context, mail_sent=mail_sent)
324             except MemoryError:
325                 # prevent catching transient MemoryErrors, bubble up to notify user or abort cron job
326                 # instead of marking the mail as failed
327                 _logger.exception('MemoryError while processing mail with ID %r and Msg-Id %r. '\
328                                       'Consider raising the --limit-memory-hard startup option',
329                                   mail.id, mail.message_id)
330                 raise
331             except Exception as e:
332                 _logger.exception('failed sending mail.mail %s', mail.id)
333                 mail.write({'state': 'exception'})
334                 self._postprocess_sent_message(cr, uid, mail, context=context, mail_sent=False)
335                 if raise_exception:
336                     if isinstance(e, AssertionError):
337                         # get the args of the original error, wrap into a value and throw a MailDeliveryException
338                         # that is an except_orm, with name and value as arguments
339                         value = '. '.join(e.args)
340                         raise MailDeliveryException(_("Mail Delivery Failed"), value)
341                     raise
342
343             if auto_commit is True:
344                 cr.commit()
345         return True