1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2010-today OpenERP SA (<http://www.openerp.com>)
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
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
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/>
20 ##############################################################################
25 from urllib import urlencode
26 from urlparse import urljoin
28 from openerp import tools
29 from openerp import SUPERUSER_ID
30 from openerp.addons.base.ir.ir_mail_server import MailDeliveryException
31 from openerp.osv import fields, osv
32 from openerp.tools.translate import _
34 _logger = logging.getLogger(__name__)
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. """
41 _description = 'Outgoing Mails'
42 _inherits = {'mail.message': 'mail_message_id'}
46 'mail_message_id': fields.many2one('mail.message', 'Message', required=True, ondelete='cascade'),
47 'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
48 'state': fields.selection([
49 ('outgoing', 'Outgoing'),
51 ('received', 'Received'),
52 ('exception', 'Delivery Failed'),
53 ('cancel', 'Cancelled'),
54 ], 'Status', readonly=True),
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 # Auto-detected based on create() - if 'mail_message_id' was passed then this mail is a notification
63 # and during unlink() we will not cascade delete the parent and its attachments
64 'notification': fields.boolean('Is Notification',
65 help='Mail has been created to notify people of an existing mail.message')
68 def _get_default_from(self, cr, uid, context=None):
69 """ Kept for compatibility
70 TDE TODO: remove me in 8.0
72 return self.pool['mail.message']._get_default_from(cr, uid, context=context)
78 def default_get(self, cr, uid, fields, context=None):
79 # protection for `default_type` values leaking from menu action context (e.g. for invoices)
80 # To remove when automatic context propagation is removed in web client
81 if context and context.get('default_type') and context.get('default_type') not in self._all_columns['type'].column.selection:
82 context = dict(context, default_type=None)
83 return super(mail_mail, self).default_get(cr, uid, fields, context=context)
85 def _get_reply_to(self, cr, uid, values, context=None):
86 """ Return a specific reply_to: alias of the document through message_get_reply_to
87 or take the email_from
89 # if value specified: directly return it
90 if values.get('reply_to'):
91 return values.get('reply_to')
92 format_name = True # whether to use a 'Followers of Pigs <pigs@openerp.com' format
95 ir_config_parameter = self.pool.get("ir.config_parameter")
96 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
98 # model, res_id, email_from: comes from values OR related message
99 model, res_id, email_from = values.get('model'), values.get('res_id'), values.get('email_from')
100 if values.get('mail_message_id'):
101 message = self.pool.get('mail.message').browse(cr, uid, values.get('mail_message_id'), context=context)
103 email_reply_to = message.reply_to
106 model = message.model
108 res_id = message.res_id
110 email_from = message.email_from
112 # if model and res_id: try to use ``message_get_reply_to`` that returns the document alias
113 if not email_reply_to and model and res_id and hasattr(self.pool[model], 'message_get_reply_to'):
114 email_reply_to = self.pool[model].message_get_reply_to(cr, uid, [res_id], context=context)[0]
115 # no alias reply_to -> catchall alias
116 if not email_reply_to:
117 catchall_alias = ir_config_parameter.get_param(cr, uid, "mail.catchall.alias", context=context)
118 if catchall_domain and catchall_alias:
119 email_reply_to = '%s@%s' % (catchall_alias, catchall_domain)
121 # still no reply_to -> reply_to will be the email_from
122 if not email_reply_to and email_from:
123 email_reply_to = email_from
125 # format 'Document name <email_address>'
126 if email_reply_to and model and res_id and format_name:
127 emails = tools.email_split(email_reply_to)
129 email_reply_to = emails[0]
130 document_name = self.pool[model].name_get(cr, SUPERUSER_ID, [res_id], context=context)[0]
132 # sanitize document name
133 sanitized_doc_name = re.sub(r'[^\w+.]+', '-', document_name[1])
135 email_reply_to = _('"Followers of %s" <%s>') % (sanitized_doc_name, email_reply_to)
137 return email_reply_to
139 def create(self, cr, uid, values, context=None):
140 # notification field: if not set, set if mail comes from an existing mail.message
141 if 'notification' not in values and values.get('mail_message_id'):
142 values['notification'] = True
143 # reply_to: if not set, set with default values that require creation values
144 if not values.get('reply_to'):
145 values['reply_to'] = self._get_reply_to(cr, uid, values, context=context)
146 return super(mail_mail, self).create(cr, uid, values, context=context)
148 def unlink(self, cr, uid, ids, context=None):
149 # cascade-delete the parent message for all mails that are not created for a notification
150 ids_to_cascade = self.search(cr, uid, [('notification', '=', False), ('id', 'in', ids)])
151 parent_msg_ids = [m.mail_message_id.id for m in self.browse(cr, uid, ids_to_cascade, context=context)]
152 res = super(mail_mail, self).unlink(cr, uid, ids, context=context)
153 self.pool.get('mail.message').unlink(cr, uid, parent_msg_ids, context=context)
156 def mark_outgoing(self, cr, uid, ids, context=None):
157 return self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
159 def cancel(self, cr, uid, ids, context=None):
160 return self.write(cr, uid, ids, {'state': 'cancel'}, context=context)
162 def process_email_queue(self, cr, uid, ids=None, context=None):
163 """Send immediately queued messages, committing after each
164 message is sent - this is not transactional and should
165 not be called during another transaction!
167 :param list ids: optional list of emails ids to send. If passed
168 no search is performed, and these ids are used
170 :param dict context: if a 'filters' key is present in context,
171 this value will be used as an additional
172 filter to further restrict the outgoing
173 messages to send (by default all 'outgoing'
179 filters = ['&', ('state', '=', 'outgoing'), ('type', '=', 'email')]
180 if 'filters' in context:
181 filters.extend(context['filters'])
182 ids = self.search(cr, uid, filters, context=context)
185 # Force auto-commit - this is meant to be called by
186 # the scheduler, and we can't allow rolling back the status
187 # of previously sent emails!
188 res = self.send(cr, uid, ids, auto_commit=True, context=context)
190 _logger.exception("Failed processing mail queue")
193 def _postprocess_sent_message(self, cr, uid, mail, context=None):
194 """Perform any post-processing necessary after sending ``mail``
195 successfully, including deleting it completely along with its
196 attachment if the ``auto_delete`` flag of the mail was set.
197 Overridden by subclasses for extra post-processing behaviors.
199 :param browse_record mail: the mail that was just sent
203 # done with SUPERUSER_ID to avoid giving large unlink access rights
204 self.unlink(cr, SUPERUSER_ID, [mail.id], context=context)
207 #------------------------------------------------------
208 # mail_mail formatting, tools and send mechanism
209 #------------------------------------------------------
211 # TODO in 8.0(+): maybe factorize this to enable in modules link generation
212 # independently of mail_mail model
213 # TODO in 8.0(+): factorize doc name sanitized and 'Followers of ...' formatting
214 # because it begins to appear everywhere
216 def _get_partner_access_link(self, cr, uid, mail, partner=None, context=None):
217 """ Generate URLs for links in mails:
218 - partner is an user and has read access to the document: direct link to document with model, res_id
220 if partner and partner.user_ids:
221 base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url')
222 # the parameters to encode for the query and fragment part of url
223 query = {'db': cr.dbname}
225 'login': partner.user_ids[0].login,
226 'action': 'mail.action_mail_redirect',
228 if mail.notification:
230 'message_id': mail.mail_message_id.id,
232 url = urljoin(base_url, "?%s#%s" % (urlencode(query), urlencode(fragment)))
233 return _("""<small>Access your messages and documents <a style='color:inherit' href="%s">in OpenERP</a></small>""") % url
237 def send_get_mail_subject(self, cr, uid, mail, force=False, partner=None, context=None):
238 """ If subject is void and record_name defined: '<Author> posted on <Resource>'
240 :param boolean force: force the subject replacement
241 :param browse_record mail: mail.mail browse_record
242 :param browse_record partner: specific recipient partner
244 if (force or not mail.subject) and mail.record_name:
245 return 'Re: %s' % (mail.record_name)
246 elif (force or not mail.subject) and mail.parent_id and mail.parent_id.subject:
247 return 'Re: %s' % (mail.parent_id.subject)
250 def send_get_mail_body(self, cr, uid, mail, partner=None, context=None):
251 """ Return a specific ir_email body. The main purpose of this method
252 is to be inherited to add custom content depending on some module.
254 :param browse_record mail: mail.mail browse_record
255 :param browse_record partner: specific recipient partner
257 body = mail.body_html
260 link = self._get_partner_access_link(cr, uid, mail, partner, context=context)
262 body = tools.append_content_to_html(body, link, plaintext=False, container_tag='div')
265 def send_get_email_dict(self, cr, uid, mail, partner=None, context=None):
266 """ Return a dictionary for specific email values, depending on a
267 partner, or generic to the whole recipients given by mail.email_to.
269 :param browse_record mail: mail.mail browse_record
270 :param browse_record partner: specific recipient partner
272 body = self.send_get_mail_body(cr, uid, mail, partner=partner, context=context)
273 subject = self.send_get_mail_subject(cr, uid, mail, partner=partner, context=context)
274 body_alternative = tools.html2plaintext(body)
276 # generate email_to, heuristic:
277 # 1. if 'partner' is specified and there is a related document: Followers of 'Doc' <email>
278 # 2. if 'partner' is specified, but no related document: Partner Name <email>
279 # 3; fallback on mail.email_to that we split to have an email addresses list
280 if partner and mail.record_name:
281 sanitized_record_name = re.sub(r'[^\w+.]+', '-', mail.record_name)
282 email_to = [_('"Followers of %s" <%s>') % (sanitized_record_name, partner.email)]
284 email_to = ['%s <%s>' % (partner.name, partner.email)]
286 email_to = tools.email_split(mail.email_to)
290 'body_alternative': body_alternative,
292 'email_to': email_to,
295 def send(self, cr, uid, ids, auto_commit=False, raise_exception=False, context=None):
296 """ Sends the selected emails immediately, ignoring their current
297 state (mails that have already been sent should not be passed
298 unless they should actually be re-sent).
299 Emails successfully delivered are marked as 'sent', and those
300 that fail to be deliver are marked as 'exception', and the
301 corresponding error mail is output in the server logs.
303 :param bool auto_commit: whether to force a commit of the mail status
304 after sending each mail (meant only for scheduler processing);
305 should never be True during normal transactions (default: False)
306 :param bool raise_exception: whether to raise an exception if the
307 email sending process has failed
310 ir_mail_server = self.pool.get('ir.mail_server')
311 for mail in self.browse(cr, SUPERUSER_ID, ids, context=context):
315 for attach in mail.attachment_ids:
316 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
317 # specific behavior to customize the send email for notified partners
320 email_list.append(self.send_get_email_dict(cr, uid, mail, context=context))
321 for partner in mail.recipient_ids:
322 email_list.append(self.send_get_email_dict(cr, uid, mail, partner=partner, context=context))
324 # build an RFC2822 email.message.Message object and send it without queuing
325 for email in email_list:
326 msg = ir_mail_server.build_email(
327 email_from = mail.email_from,
328 email_to = email.get('email_to'),
329 subject = email.get('subject'),
330 body = email.get('body'),
331 body_alternative = email.get('body_alternative'),
332 email_cc = tools.email_split(mail.email_cc),
333 reply_to = mail.reply_to,
334 attachments = attachments,
335 message_id = mail.message_id,
336 references = mail.references,
337 object_id = mail.res_id and ('%s-%s' % (mail.res_id, mail.model)),
339 subtype_alternative = 'plain')
340 res = ir_mail_server.send_email(cr, uid, msg,
341 mail_server_id=mail.mail_server_id.id, context=context)
343 mail.write({'state': 'sent', 'message_id': res})
346 mail.write({'state': 'exception'})
349 # /!\ can't use mail.state here, as mail.refresh() will cause an error
350 # see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1
352 self._postprocess_sent_message(cr, uid, mail, context=context)
353 except Exception as e:
354 _logger.exception('failed sending mail.mail %s', mail.id)
355 mail.write({'state': 'exception'})
357 if isinstance(e, AssertionError):
358 # get the args of the original error, wrap into a value and throw a MailDeliveryException
359 # that is an except_orm, with name and value as arguments
360 value = '. '.join(e.args)
361 raise MailDeliveryException(_("Mail Delivery Failed"), value)
364 if auto_commit == True: