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 ##############################################################################
24 import dateutil.parser
30 from email.header import decode_header
31 from email.message import Message
33 from openerp import SUPERUSER_ID
35 from osv import fields
37 from tools import DEFAULT_SERVER_DATETIME_FORMAT
38 from tools.translate import _
41 _logger = logging.getLogger(__name__)
43 """ Some tools for parsing / creating email fields """
45 """Returns unicode() string conversion of the the given encoded smtp header text"""
47 text = decode_header(text.replace('\r', ''))
48 return ''.join([tools.ustr(x[0], x[1]) for x in text])
50 def mail_tools_to_email(text):
51 """Return a list of the email addresses found in ``text``"""
52 if not text: return []
53 return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
55 # TODO: remove that after cleaning
57 return mail_tools_to_email(text)
59 class mail_message_common(osv.TransientModel):
60 """ Common abstract class for holding the main attributes of a
61 message object. It could be reused as parent model for any
62 database model or wizard screen that needs to hold a kind of
64 All internal logic should be in another model while this
65 model holds the basics of a message. For example, a wizard for writing
66 emails should inherit from this class and not from mail.message."""
68 def get_body(self, cr, uid, ids, name, arg, context=None):
69 """ get correct body version: body_html for html messages, and
70 body_text for plain text messages
72 result = dict.fromkeys(ids, '')
73 for message in self.browse(cr, uid, ids, context=context):
74 if message.content_subtype == 'html':
75 result[message.id] = message.body_html
77 result[message.id] = message.body_text
80 def search_body(self, cr, uid, obj, name, args, context=None):
82 # - obj: mail.message object
84 # - args: [('body', 'ilike', 'blah')]
85 return ['|', '&', ('content_subtype', '=', 'html'), ('body_html', args[0][1], args[0][2]), ('body_text', args[0][1], args[0][2])]
87 def get_record_name(self, cr, uid, ids, name, arg, context=None):
88 result = dict.fromkeys(ids, '')
89 for message in self.browse(cr, uid, ids, context=context):
90 if not message.model or not message.res_id:
92 result[message.id] = self.pool.get(message.model).name_get(cr, uid, [message.res_id], context=context)[0][1]
95 def name_get(self, cr, uid, ids, context=None):
96 # name_get may receive int id instead of an id list
97 if isinstance(ids, (int, long)):
100 for message in self.browse(cr, uid, ids, context=context):
103 name = '%s: ' % (message.subject)
104 if message.body_text:
105 name = '%s%s ' % (name, message.body_text[0:20])
107 name = '%s(%s)' % (name, message.date)
108 res.append((message.id, name))
111 _name = 'mail.message.common'
112 _rec_name = 'subject'
114 'subject': fields.char('Subject', size=512),
115 'model': fields.char('Related Document Model', size=128, select=1),
116 'res_id': fields.integer('Related Document ID', select=1),
117 'record_name': fields.function(get_record_name, type='string',
118 string='Message Record Name',
119 help="Name get of the related document."),
120 'date': fields.datetime('Date'),
121 'email_from': fields.char('From', size=128, help='Message sender, taken from user preferences.'),
122 'email_to': fields.char('To', size=256, help='Message recipients'),
123 'email_cc': fields.char('Cc', size=256, help='Carbon copy message recipients'),
124 'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
125 'reply_to':fields.char('Reply-To', size=256, help='Preferred response address for the message'),
126 'headers': fields.text('Message Headers', readonly=1,
127 help="Full message headers, e.g. SMTP session headers (usually available on inbound messages only)"),
128 'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
129 'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
130 'content_subtype': fields.char('Message content subtype', size=32,
131 oldname="subtype", readonly=1,
132 help="Type of message, usually 'html' or 'plain', used to select "\
133 "plain-text or rich-text contents accordingly"),
134 'body_text': fields.text('Text Contents', help="Plain-text version of the message"),
135 'body_html': fields.html('Rich-text Contents', help="Rich-text/HTML version of the message"),
136 'body': fields.function(get_body, fnct_search = search_body, type='text',
137 string='Message Content', store=True,
138 help="Content of the message. This content equals the body_text field "\
139 "for plain-test messages, and body_html for rich-text/HTML "\
140 "messages. This allows having one field if we want to access "\
141 "the content matching the message content_subtype."),
142 'parent_id': fields.many2one('mail.message.common', 'Parent Message',
143 select=True, ondelete='set null',
144 help="Parent message, used for displaying as threads with hierarchy"),
148 'content_subtype': 'plain',
149 'date': (lambda *a: fields.datetime.now()),
152 class mail_message(osv.Model):
153 """Model holding messages: system notification (replacing res.log
154 notifications), comments (for OpenChatter feature) and
155 RFC2822 email messages. This model also provides facilities to
156 parse, queue and send new email messages. Type of messages
157 are differentiated using the 'type' column. """
159 _name = 'mail.message'
160 _inherit = 'mail.message.common'
161 _description = 'Mail Message (email, comment, notification)'
164 def open_document(self, cr, uid, ids, context=None):
165 """ Open the message related document. Note that only the document of
166 ids[0] will be opened.
167 TODO: how to determine the action to use ?
172 msg = self.browse(cr, uid, ids[0], context=context)
173 ir_act_window = self.pool.get('ir.actions.act_window')
174 action_ids = ir_act_window.search(cr, uid, [('res_model', '=', msg.model)], context=context)
176 action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
178 'domain' : "[('id', '=', %d)]" % (msg.res_id),
184 def open_attachment(self, cr, uid, ids, context=None):
185 """ Open the message related attachments.
186 TODO: how to determine the action to use ?
191 action_pool = self.pool.get('ir.actions.act_window')
192 messages = self.browse(cr, uid, ids, context=context)
193 att_ids = [x.id for message in messages for x in message.attachment_ids]
194 action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')], context=context)
196 action_data = action_pool.read(cr, uid, action_ids[0], context=context)
198 'domain': [('id', 'in', att_ids)],
204 'type': fields.selection([
206 ('comment', 'Comment'),
207 ('notification', 'System notification'),
209 help="Message type: email for email message, notification for system "\
210 "message, comment for other messages such as user replies"),
211 'partner_id': fields.many2one('res.partner', 'Related partner',
212 help="Deprecated field. Use partner_ids instead."),
213 'partner_ids': fields.many2many('res.partner',
214 'mail_message_res_partner_rel',
215 'message_id', 'partner_id', 'Destination partners',
216 help="When sending emails through the social network composition wizard"\
217 "you may choose to send a copy of the mail to partners."),
218 'user_id': fields.many2one('res.users', 'Related User', readonly=1),
219 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel',
220 'message_id', 'attachment_id', 'Attachments'),
221 'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
222 'state': fields.selection([
223 ('outgoing', 'Outgoing'),
225 ('received', 'Received'),
226 ('exception', 'Delivery Failed'),
227 ('cancel', 'Cancelled'),
228 ], 'Status', readonly=True),
229 'auto_delete': fields.boolean('Auto Delete',
230 help="Permanently delete this email after sending it, to save space"),
231 'original': fields.binary('Original', readonly=1,
232 help="Original version of the message, as it was sent on the network"),
233 'parent_id': fields.many2one('mail.message', 'Parent Message',
234 select=True, ondelete='set null',
235 help="Parent message, used for displaying as threads with hierarchy"),
236 'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
244 #------------------------------------------------------
246 #------------------------------------------------------
249 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
250 if not cr.fetchone():
251 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
253 def check(self, cr, uid, ids, mode, context=None, values=None):
254 """Restricts the access to a mail.message, according to referred model
259 if isinstance(ids, (int, long)):
261 cr.execute('SELECT DISTINCT model, res_id FROM mail_message WHERE id = ANY (%s)', (ids,))
262 for rmod, rid in cr.fetchall():
263 if not (rmod and rid):
265 res_ids.setdefault(rmod,set()).add(rid)
267 if 'res_model' in values and 'res_id' in values:
268 res_ids.setdefault(values['res_model'],set()).add(values['res_id'])
270 ima_obj = self.pool.get('ir.model.access')
271 for model, mids in res_ids.items():
272 # ignore mail messages that are not attached to a resource anymore when checking access rights
273 # (resource was deleted but message was not)
274 mids = self.pool.get(model).exists(cr, uid, mids)
275 ima_obj.check(cr, uid, model, mode)
276 self.pool.get(model).check_access_rule(cr, uid, mids, mode, context=context)
278 def create(self, cr, uid, values, context=None):
279 self.check(cr, uid, [], mode='create', context=context, values=values)
280 return super(mail_message, self).create(cr, uid, values, context)
282 def read(self, cr, uid, ids, fields_to_read=None, context=None, load='_classic_read'):
283 self.check(cr, uid, ids, 'read', context=context)
284 return super(mail_message, self).read(cr, uid, ids, fields_to_read, context, load)
286 def copy(self, cr, uid, id, default=None, context=None):
287 """Overridden to avoid duplicating fields that are unique to each email"""
290 self.check(cr, uid, [id], 'read', context=context)
291 default.update(message_id=False, original=False, headers=False)
292 return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
294 def write(self, cr, uid, ids, vals, context=None):
295 self.check(cr, uid, ids, 'write', context=context, values=vals)
296 return super(mail_message, self).write(cr, uid, ids, vals, context)
298 def unlink(self, cr, uid, ids, context=None):
299 self.check(cr, uid, ids, 'unlink', context=context)
300 return super(mail_message, self).unlink(cr, uid, ids, context)
302 def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, type='email',
303 email_cc=None, email_bcc=None, reply_to=False, partner_ids=None, attachments=None,
304 message_id=False, references=False, res_id=False, content_subtype='plain',
305 headers=None, mail_server_id=False, auto_delete=False, context=None):
306 """ Schedule sending a new email message, to be sent the next time the
307 mail scheduler runs, or the next time :meth:`process_email_queue` is
310 :param string email_from: sender email address
311 :param list email_to: list of recipient addresses (to be joined with commas)
312 :param string subject: email subject (no pre-encoding/quoting necessary)
313 :param string body: email body, according to the ``content_subtype``
314 (by default, plaintext). If html content_subtype is used, the
315 message will be automatically converted to plaintext and wrapped
316 in multipart/alternative.
317 :param list email_cc: optional list of string values for CC header
318 (to be joined with commas)
319 :param list email_bcc: optional list of string values for BCC header
320 (to be joined with commas)
321 :param string model: optional model name of the document this mail
322 is related to (this will also be used to generate a tracking id,
323 used to match any response related to the same document)
324 :param int res_id: optional resource identifier this mail is related
325 to (this will also be used to generate a tracking id, used to
326 match any response related to the same document)
327 :param string reply_to: optional value of Reply-To header
328 :param partner_ids: destination partner_ids
329 :param string content_subtype: optional mime content_subtype for
330 the text body (usually 'plain' or 'html'), must match the format
331 of the ``body`` parameter. Default is 'plain', making the content
332 part of the mail "text/plain".
333 :param dict attachments: map of filename to filecontents, where
334 filecontents is a string containing the bytes of the attachment
335 :param dict headers: optional map of headers to set on the outgoing
336 mail (may override the other headers, including Subject,
337 Reply-To, Message-Id, etc.)
338 :param int mail_server_id: optional id of the preferred outgoing
339 mail server for this mail
340 :param bool auto_delete: optional flag to turn on auto-deletion of
341 the message after it has been successfully sent (default to False)
345 if attachments is None:
347 if partner_ids is None:
349 attachment_obj = self.pool.get('ir.attachment')
350 for param in (email_to, email_cc, email_bcc):
351 if param and not isinstance(param, list):
355 'date': fields.datetime.now(),
360 'body_text': body if content_subtype != 'html' else False,
361 'body_html': body if content_subtype == 'html' else False,
362 'email_from': email_from,
363 'email_to': email_to and ','.join(email_to) or '',
364 'email_cc': email_cc and ','.join(email_cc) or '',
365 'email_bcc': email_bcc and ','.join(email_bcc) or '',
366 'partner_ids': partner_ids,
367 'reply_to': reply_to,
368 'message_id': message_id,
369 'references': references,
370 'content_subtype': content_subtype,
371 'headers': headers, # serialize the dict on the fly
372 'mail_server_id': mail_server_id,
374 'auto_delete': auto_delete
376 email_msg_id = self.create(cr, uid, msg_vals, context)
378 for attachment in attachments:
379 fname, fcontent = attachment
382 'datas_fname': fname,
383 'datas': fcontent and fcontent.encode('base64'),
384 'res_model': self._name,
385 'res_id': email_msg_id,
387 if context.has_key('default_type'):
388 del context['default_type']
389 attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
391 self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
394 def mark_outgoing(self, cr, uid, ids, context=None):
395 return self.write(cr, uid, ids, {'state':'outgoing'}, context=context)
397 def cancel(self, cr, uid, ids, context=None):
398 return self.write(cr, uid, ids, {'state':'cancel'}, context=context)
400 def process_email_queue(self, cr, uid, ids=None, context=None):
401 """Send immediately queued messages, committing after each
402 message is sent - this is not transactional and should
403 not be called during another transaction!
405 :param list ids: optional list of emails ids to send. If passed
406 no search is performed, and these ids are used
408 :param dict context: if a 'filters' key is present in context,
409 this value will be used as an additional
410 filter to further restrict the outgoing
411 messages to send (by default all 'outgoing'
417 filters = ['&', ('state', '=', 'outgoing'), ('type', '=', 'email')]
418 if 'filters' in context:
419 filters.extend(context['filters'])
420 ids = self.search(cr, uid, filters, context=context)
423 # Force auto-commit - this is meant to be called by
424 # the scheduler, and we can't allow rolling back the status
425 # of previously sent emails!
426 res = self.send(cr, uid, ids, auto_commit=True, context=context)
428 _logger.exception("Failed processing mail queue")
431 def parse_message(self, message, save_original=False, context=None):
432 """Parses a string or email.message.Message representing an
433 RFC-2822 email, and returns a generic dict holding the
436 :param message: the message to parse
437 :type message: email.message.Message | string | unicode
438 :param bool save_original: whether the returned dict
439 should include an ``original`` entry with the base64
440 encoded source of the message.
442 :return: A dict with the following structure, where each
443 field may not be present if missing in original
446 { 'message-id': msg_id,
451 'headers' : { 'X-Mailer': mailer,
452 #.. all X- headers...
454 'content_subtype': msg_mime_subtype,
455 'body_text': plaintext_body
456 'body_html': html_body,
457 'attachments': [('file1', 'bytes'),
460 'original': source_of_email,
464 if isinstance(message, str):
465 msg_txt = email.message_from_string(message)
467 # Warning: message_from_string doesn't always work correctly on unicode,
468 # we must use utf-8 strings here :-(
469 if isinstance(message, unicode):
470 message = message.encode('utf-8')
471 msg_txt = email.message_from_string(message)
473 message_id = msg_txt.get('message-id', False)
477 # save original, we need to be able to read the original email sometimes
478 msg['original'] = message.as_string() if isinstance(message, Message) \
480 msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
483 # Very unusual situation, be we should be fault-tolerant here
484 message_id = time.time()
485 msg_txt['message-id'] = message_id
486 _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
488 msg_fields = msg_txt.keys()
489 msg['id'] = message_id
490 msg['message-id'] = message_id
492 if 'Subject' in msg_fields:
493 msg['subject'] = decode(msg_txt.get('Subject'))
495 if 'Content-Type' in msg_fields:
496 msg['content-type'] = msg_txt.get('Content-Type')
498 if 'From' in msg_fields:
499 msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
501 if 'To' in msg_fields:
502 msg['to'] = decode(msg_txt.get('To'))
504 if 'Delivered-To' in msg_fields:
505 msg['to'] = decode(msg_txt.get('Delivered-To'))
507 if 'CC' in msg_fields:
508 msg['cc'] = decode(msg_txt.get('CC'))
510 if 'Cc' in msg_fields:
511 msg['cc'] = decode(msg_txt.get('Cc'))
513 if 'Reply-To' in msg_fields:
514 msg['reply'] = decode(msg_txt.get('Reply-To'))
516 if 'Date' in msg_fields:
517 date_hdr = decode(msg_txt.get('Date'))
518 # convert from email timezone to server timezone
519 date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
520 date_server_datetime_str = date_server_datetime.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
521 msg['date'] = date_server_datetime_str
523 if 'Content-Transfer-Encoding' in msg_fields:
524 msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
526 if 'References' in msg_fields:
527 msg['references'] = msg_txt.get('References')
529 if 'In-Reply-To' in msg_fields:
530 msg['in-reply-to'] = msg_txt.get('In-Reply-To')
533 msg['content_subtype'] = 'plain'
534 for item in msg_txt.items():
535 if item[0].startswith('X-'):
536 msg['headers'].update({item[0]: item[1]})
537 if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
538 encoding = msg_txt.get_content_charset()
539 body = msg_txt.get_payload(decode=True)
540 if 'text/html' in msg.get('content-type', ''):
541 msg['body_html'] = body
542 msg['content_subtype'] = 'html'
544 body = tools.html2plaintext(body)
545 msg['body_text'] = tools.ustr(body, encoding)
548 if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
550 if 'multipart/alternative' in msg.get('content-type', ''):
551 msg['content_subtype'] = 'alternative'
553 msg['content_subtype'] = 'mixed'
554 for part in msg_txt.walk():
555 if part.get_content_maintype() == 'multipart':
558 encoding = part.get_content_charset()
559 filename = part.get_filename()
560 if part.get_content_maintype()=='text':
561 content = part.get_payload(decode=True)
563 attachments.append((filename, content))
564 content = tools.ustr(content, encoding)
565 if part.get_content_subtype() == 'html':
566 msg['body_html'] = content
567 msg['content_subtype'] = 'html' # html version prevails
568 body = tools.ustr(tools.html2plaintext(content))
569 body = body.replace(' ', '')
570 elif part.get_content_subtype() == 'plain':
572 elif part.get_content_maintype() in ('application', 'image'):
574 attachments.append((filename,part.get_payload(decode=True)))
576 res = part.get_payload(decode=True)
577 body += tools.ustr(res, encoding)
579 msg['body_text'] = body
580 msg['attachments'] = attachments
582 # for backwards compatibility:
583 msg['body'] = msg['body_text']
584 msg['sub_type'] = msg['content_subtype'] or 'plain'
587 def _postprocess_sent_message(self, cr, uid, message, context=None):
588 """Perform any post-processing necessary after sending ``message``
589 successfully, including deleting it completely along with its
590 attachment if the ``auto_delete`` flag of the message was set.
591 Overridden by subclasses for extra post-processing behaviors.
593 :param browse_record message: the message that was just sent
596 if message.auto_delete:
597 self.pool.get('ir.attachment').unlink(cr, uid,
598 [x.id for x in message.attachment_ids
599 if x.res_model == self._name and x.res_id == message.id],
604 def send(self, cr, uid, ids, auto_commit=False, context=None):
605 """Sends the selected emails immediately, ignoring their current
606 state (mails that have already been sent should not be passed
607 unless they should actually be re-sent).
608 Emails successfully delivered are marked as 'sent', and those
609 that fail to be deliver are marked as 'exception', and the
610 corresponding error message is output in the server logs.
612 :param bool auto_commit: whether to force a commit of the message
613 status after sending each message (meant
614 only for processing by the scheduler),
615 should never be True during normal
616 transactions (default: False)
619 ir_mail_server = self.pool.get('ir.mail_server')
620 self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
621 for message in self.browse(cr, uid, ids, context=context):
624 for attach in message.attachment_ids:
625 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
627 body = message.body_html if message.content_subtype == 'html' else message.body_text
628 body_alternative = None
629 content_subtype_alternative = None
630 if message.content_subtype == 'html' and message.body_text:
631 # we have a plain text alternative prepared, pass it to
632 # build_message instead of letting it build one
633 body_alternative = message.body_text
634 content_subtype_alternative = 'plain'
636 # handle destination_partners
637 partner_ids_email_to = ''
638 for partner in message.partner_ids:
639 partner_ids_email_to += '%s ' % (partner.email or '')
640 message_email_to = '%s %s' % (partner_ids_email_to, message.email_to or '')
642 # build an RFC2822 email.message.Message object and send it
644 msg = ir_mail_server.build_email(
645 email_from=message.email_from,
646 email_to=mail_tools_to_email(message_email_to),
647 subject=message.subject,
649 body_alternative=body_alternative,
650 email_cc=mail_tools_to_email(message.email_cc),
651 email_bcc=mail_tools_to_email(message.email_bcc),
652 reply_to=message.reply_to,
653 attachments=attachments, message_id=message.message_id,
654 references = message.references,
655 object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
656 subtype=message.content_subtype,
657 subtype_alternative=content_subtype_alternative,
658 headers=message.headers and ast.literal_eval(message.headers))
659 res = ir_mail_server.send_email(cr, uid, msg,
660 mail_server_id=message.mail_server_id.id,
663 message.write({'state':'sent', 'message_id': res, 'email_to': message_email_to})
665 message.write({'state':'exception', 'email_to': message_email_to})
667 if message.state == 'sent':
668 self._postprocess_sent_message(cr, uid, message, context=context)
670 _logger.exception('failed sending mail.message %s', message.id)
671 message.write({'state':'exception'})
673 if auto_commit == True:
679 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: