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):
97 for message in self.browse(cr, uid, ids, context=context):
100 name = '%s: ' % (message.subject)
101 if message.body_text:
102 name = '%s%s ' % (name, message.body_text[0:20])
104 name = '%s(%s)' % (name, message.date)
105 res.append((message.id, name))
108 _name = 'mail.message.common'
109 _rec_name = 'subject'
111 'subject': fields.char('Subject', size=512),
112 'model': fields.char('Related Document Model', size=128, select=1),
113 'res_id': fields.integer('Related Document ID', select=1),
114 'record_name': fields.function(get_record_name, type='string',
115 string='Message Record Name',
116 help="Name get of the related document."),
117 'date': fields.datetime('Date'),
118 'email_from': fields.char('From', size=128, help='Message sender, taken from user preferences.'),
119 'email_to': fields.char('To', size=256, help='Message recipients'),
120 'email_cc': fields.char('Cc', size=256, help='Carbon copy message recipients'),
121 'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
122 'reply_to':fields.char('Reply-To', size=256, help='Preferred response address for the message'),
123 'headers': fields.text('Message Headers', readonly=1,
124 help="Full message headers, e.g. SMTP session headers (usually available on inbound messages only)"),
125 'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
126 'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
127 'content_subtype': fields.char('Message content subtype', size=32,
128 oldname="subtype", readonly=1,
129 help="Type of message, usually 'html' or 'plain', used to select "\
130 "plain-text or rich-text contents accordingly"),
131 'body_text': fields.text('Text Contents', help="Plain-text version of the message"),
132 'body_html': fields.text('Rich-text Contents', help="Rich-text/HTML version of the message"),
133 'body': fields.function(get_body, fnct_search = search_body, type='text',
134 string='Message Content', store=True,
135 help="Content of the message. This content equals the body_text field "\
136 "for plain-test messages, and body_html for rich-text/HTML "\
137 "messages. This allows having one field if we want to access "\
138 "the content matching the message content_subtype."),
139 'parent_id': fields.many2one('mail.message.common', 'Parent Message',
140 select=True, ondelete='set null',
141 help="Parent message, used for displaying as threads with hierarchy"),
145 'content_subtype': 'plain',
146 'date': (lambda *a: fields.datetime.now()),
149 class mail_message(osv.Model):
150 """Model holding messages: system notification (replacing res.log
151 notifications), comments (for OpenChatter feature) and
152 RFC2822 email messages. This model also provides facilities to
153 parse, queue and send new email messages. Type of messages
154 are differentiated using the 'type' column. """
156 _name = 'mail.message'
157 _inherit = 'mail.message.common'
158 _description = 'Mail Message (email, comment, notification)'
161 def open_document(self, cr, uid, ids, context=None):
162 """ Open the message related document. Note that only the document of
163 ids[0] will be opened.
164 TODO: how to determine the action to use ?
169 msg = self.browse(cr, uid, ids[0], context=context)
170 ir_act_window = self.pool.get('ir.actions.act_window')
171 action_ids = ir_act_window.search(cr, uid, [('res_model', '=', msg.model)], context=context)
173 action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
175 'domain' : "[('id', '=', %d)]" % (msg.res_id),
181 def open_attachment(self, cr, uid, ids, context=None):
182 """ Open the message related attachments.
183 TODO: how to determine the action to use ?
188 action_pool = self.pool.get('ir.actions.act_window')
189 messages = self.browse(cr, uid, ids, context=context)
190 att_ids = [x.id for message in messages for x in message.attachment_ids]
191 action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')], context=context)
193 action_data = action_pool.read(cr, uid, action_ids[0], context=context)
195 'domain': [('id', 'in', att_ids)],
201 'type': fields.selection([
203 ('comment', 'Comment'),
204 ('notification', 'System notification'),
206 help="Message type: email for email message, notification for system "\
207 "message, comment for other messages such as user replies"),
208 'partner_id': fields.many2one('res.partner', 'Related partner',
209 help="Deprecated field. Use partner_ids instead."),
210 'partner_ids': fields.many2many('res.partner',
211 'mail_message_destination_partner_rel',
212 'message_id', 'partner_id', 'Destination partners',
213 help="When sending emails through the social network composition wizard"\
214 "you may choose to send a copy of the mail to partners."),
215 'user_id': fields.many2one('res.users', 'Related User', readonly=1),
216 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', 'Attachments'),
217 'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
218 'state': fields.selection([
219 ('outgoing', 'Outgoing'),
221 ('received', 'Received'),
222 ('exception', 'Delivery Failed'),
223 ('cancel', 'Cancelled'),
224 ], 'Status', readonly=True),
225 'auto_delete': fields.boolean('Auto Delete',
226 help="Permanently delete this email after sending it, to save space"),
227 'original': fields.binary('Original', readonly=1,
228 help="Original version of the message, as it was sent on the network"),
229 'parent_id': fields.many2one('mail.message', 'Parent Message',
230 select=True, ondelete='set null',
231 help="Parent message, used for displaying as threads with hierarchy"),
232 'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
240 #------------------------------------------------------
242 #------------------------------------------------------
245 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
246 if not cr.fetchone():
247 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
249 def check(self, cr, uid, ids, mode, context=None, values=None):
250 """Restricts the access to a mail.message, according to referred model
255 if isinstance(ids, (int, long)):
257 cr.execute('SELECT DISTINCT model, res_id FROM mail_message WHERE id = ANY (%s)', (ids,))
258 for rmod, rid in cr.fetchall():
259 if not (rmod and rid):
261 res_ids.setdefault(rmod,set()).add(rid)
263 if 'res_model' in values and 'res_id' in values:
264 res_ids.setdefault(values['res_model'],set()).add(values['res_id'])
266 ima_obj = self.pool.get('ir.model.access')
267 for model, mids in res_ids.items():
268 # ignore mail messages that are not attached to a resource anymore when checking access rights
269 # (resource was deleted but message was not)
270 mids = self.pool.get(model).exists(cr, uid, mids)
271 ima_obj.check(cr, uid, model, mode)
272 self.pool.get(model).check_access_rule(cr, uid, mids, mode, context=context)
274 def create(self, cr, uid, values, context=None):
275 self.check(cr, uid, [], mode='create', context=context, values=values)
276 return super(mail_message, self).create(cr, uid, values, context)
278 def read(self, cr, uid, ids, fields_to_read=None, context=None, load='_classic_read'):
279 self.check(cr, uid, ids, 'read', context=context)
280 return super(mail_message, self).read(cr, uid, ids, fields_to_read, context, load)
282 def copy(self, cr, uid, id, default=None, context=None):
283 """Overridden to avoid duplicating fields that are unique to each email"""
286 self.check(cr, uid, [id], 'read', context=context)
287 default.update(message_id=False, original=False, headers=False)
288 return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
290 def write(self, cr, uid, ids, vals, context=None):
291 self.check(cr, uid, ids, 'write', context=context, values=vals)
292 return super(mail_message, self).write(cr, uid, ids, vals, context)
294 def unlink(self, cr, uid, ids, context=None):
295 self.check(cr, uid, ids, 'unlink', context=context)
296 return super(mail_message, self).unlink(cr, uid, ids, context)
298 def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, type='email',
299 email_cc=None, email_bcc=None, reply_to=False, partner_ids=None, attachments=None,
300 message_id=False, references=False, res_id=False, content_subtype='plain',
301 headers=None, mail_server_id=False, auto_delete=False, context=None):
302 """ Schedule sending a new email message, to be sent the next time the
303 mail scheduler runs, or the next time :meth:`process_email_queue` is
306 :param string email_from: sender email address
307 :param list email_to: list of recipient addresses (to be joined with commas)
308 :param string subject: email subject (no pre-encoding/quoting necessary)
309 :param string body: email body, according to the ``content_subtype``
310 (by default, plaintext). If html content_subtype is used, the
311 message will be automatically converted to plaintext and wrapped
312 in multipart/alternative.
313 :param list email_cc: optional list of string values for CC header
314 (to be joined with commas)
315 :param list email_bcc: optional list of string values for BCC header
316 (to be joined with commas)
317 :param string model: optional model name of the document this mail
318 is related to (this will also be used to generate a tracking id,
319 used to match any response related to the same document)
320 :param int res_id: optional resource identifier this mail is related
321 to (this will also be used to generate a tracking id, used to
322 match any response related to the same document)
323 :param string reply_to: optional value of Reply-To header
324 :param partner_ids: destination partner_ids
325 :param string content_subtype: optional mime content_subtype for
326 the text body (usually 'plain' or 'html'), must match the format
327 of the ``body`` parameter. Default is 'plain', making the content
328 part of the mail "text/plain".
329 :param dict attachments: map of filename to filecontents, where
330 filecontents is a string containing the bytes of the attachment
331 :param dict headers: optional map of headers to set on the outgoing
332 mail (may override the other headers, including Subject,
333 Reply-To, Message-Id, etc.)
334 :param int mail_server_id: optional id of the preferred outgoing
335 mail server for this mail
336 :param bool auto_delete: optional flag to turn on auto-deletion of
337 the message after it has been successfully sent (default to False)
341 if attachments is None:
343 if partner_ids is None:
345 attachment_obj = self.pool.get('ir.attachment')
346 for param in (email_to, email_cc, email_bcc):
347 if param and not isinstance(param, list):
351 'date': fields.datetime.now(),
356 'body_text': body if content_subtype != 'html' else False,
357 'body_html': body if content_subtype == 'html' else False,
358 'email_from': email_from,
359 'email_to': email_to and ','.join(email_to) or '',
360 'email_cc': email_cc and ','.join(email_cc) or '',
361 'email_bcc': email_bcc and ','.join(email_bcc) or '',
362 'partner_ids': partner_ids,
363 'reply_to': reply_to,
364 'message_id': message_id,
365 'references': references,
366 'content_subtype': content_subtype,
367 'headers': headers, # serialize the dict on the fly
368 'mail_server_id': mail_server_id,
370 'auto_delete': auto_delete
372 email_msg_id = self.create(cr, uid, msg_vals, context)
374 for fname, fcontent in attachments.iteritems():
377 'datas_fname': fname,
378 'datas': fcontent and fcontent.encode('base64'),
379 'res_model': self._name,
380 'res_id': email_msg_id,
382 if context.has_key('default_type'):
383 del context['default_type']
384 attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
386 self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
389 def mark_outgoing(self, cr, uid, ids, context=None):
390 return self.write(cr, uid, ids, {'state':'outgoing'}, context=context)
392 def cancel(self, cr, uid, ids, context=None):
393 return self.write(cr, uid, ids, {'state':'cancel'}, context=context)
395 def process_email_queue(self, cr, uid, ids=None, context=None):
396 """Send immediately queued messages, committing after each
397 message is sent - this is not transactional and should
398 not be called during another transaction!
400 :param list ids: optional list of emails ids to send. If passed
401 no search is performed, and these ids are used
403 :param dict context: if a 'filters' key is present in context,
404 this value will be used as an additional
405 filter to further restrict the outgoing
406 messages to send (by default all 'outgoing'
412 filters = ['&', ('state', '=', 'outgoing'), ('type', '=', 'email')]
413 if 'filters' in context:
414 filters.extend(context['filters'])
415 ids = self.search(cr, uid, filters, context=context)
418 # Force auto-commit - this is meant to be called by
419 # the scheduler, and we can't allow rolling back the status
420 # of previously sent emails!
421 res = self.send(cr, uid, ids, auto_commit=True, context=context)
423 _logger.exception("Failed processing mail queue")
426 def parse_message(self, message, save_original=False, context=None):
427 """Parses a string or email.message.Message representing an
428 RFC-2822 email, and returns a generic dict holding the
431 :param message: the message to parse
432 :type message: email.message.Message | string | unicode
433 :param bool save_original: whether the returned dict
434 should include an ``original`` entry with the base64
435 encoded source of the message.
437 :return: A dict with the following structure, where each
438 field may not be present if missing in original
441 { 'message-id': msg_id,
446 'headers' : { 'X-Mailer': mailer,
447 #.. all X- headers...
449 'content_subtype': msg_mime_subtype,
450 'body_text': plaintext_body
451 'body_html': html_body,
452 'attachments': [('file1', 'bytes'),
455 'original': source_of_email,
459 if isinstance(message, str):
460 msg_txt = email.message_from_string(message)
462 # Warning: message_from_string doesn't always work correctly on unicode,
463 # we must use utf-8 strings here :-(
464 if isinstance(message, unicode):
465 message = message.encode('utf-8')
466 msg_txt = email.message_from_string(message)
468 message_id = msg_txt.get('message-id', False)
472 # save original, we need to be able to read the original email sometimes
473 msg['original'] = message.as_string() if isinstance(message, Message) \
475 msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
478 # Very unusual situation, be we should be fault-tolerant here
479 message_id = time.time()
480 msg_txt['message-id'] = message_id
481 _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
483 msg_fields = msg_txt.keys()
484 msg['id'] = message_id
485 msg['message-id'] = message_id
487 if 'Subject' in msg_fields:
488 msg['subject'] = decode(msg_txt.get('Subject'))
490 if 'Content-Type' in msg_fields:
491 msg['content-type'] = msg_txt.get('Content-Type')
493 if 'From' in msg_fields:
494 msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
496 if 'To' in msg_fields:
497 msg['to'] = decode(msg_txt.get('To'))
499 if 'Delivered-To' in msg_fields:
500 msg['to'] = decode(msg_txt.get('Delivered-To'))
502 if 'CC' in msg_fields:
503 msg['cc'] = decode(msg_txt.get('CC'))
505 if 'Cc' in msg_fields:
506 msg['cc'] = decode(msg_txt.get('Cc'))
508 if 'Reply-To' in msg_fields:
509 msg['reply'] = decode(msg_txt.get('Reply-To'))
511 if 'Date' in msg_fields:
512 date_hdr = decode(msg_txt.get('Date'))
513 # convert from email timezone to server timezone
514 date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
515 date_server_datetime_str = date_server_datetime.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
516 msg['date'] = date_server_datetime_str
518 if 'Content-Transfer-Encoding' in msg_fields:
519 msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
521 if 'References' in msg_fields:
522 msg['references'] = msg_txt.get('References')
524 if 'In-Reply-To' in msg_fields:
525 msg['in-reply-to'] = msg_txt.get('In-Reply-To')
528 msg['content_subtype'] = 'plain'
529 for item in msg_txt.items():
530 if item[0].startswith('X-'):
531 msg['headers'].update({item[0]: item[1]})
532 if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
533 encoding = msg_txt.get_content_charset()
534 body = msg_txt.get_payload(decode=True)
535 if 'text/html' in msg.get('content-type', ''):
536 msg['body_html'] = body
537 msg['content_subtype'] = 'html'
539 body = tools.html2plaintext(body)
540 msg['body_text'] = tools.ustr(body, encoding)
543 if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
545 if 'multipart/alternative' in msg.get('content-type', ''):
546 msg['content_subtype'] = 'alternative'
548 msg['content_subtype'] = 'mixed'
549 for part in msg_txt.walk():
550 if part.get_content_maintype() == 'multipart':
553 encoding = part.get_content_charset()
554 filename = part.get_filename()
555 if part.get_content_maintype()=='text':
556 content = part.get_payload(decode=True)
558 attachments.append((filename, content))
559 content = tools.ustr(content, encoding)
560 if part.get_content_subtype() == 'html':
561 msg['body_html'] = content
562 msg['content_subtype'] = 'html' # html version prevails
563 body = tools.ustr(tools.html2plaintext(content))
564 body = body.replace(' ', '')
565 elif part.get_content_subtype() == 'plain':
567 elif part.get_content_maintype() in ('application', 'image'):
569 attachments.append((filename,part.get_payload(decode=True)))
571 res = part.get_payload(decode=True)
572 body += tools.ustr(res, encoding)
574 msg['body_text'] = body
575 msg['attachments'] = attachments
577 # for backwards compatibility:
578 msg['body'] = msg['body_text']
579 msg['sub_type'] = msg['content_subtype'] or 'plain'
582 def _postprocess_sent_message(self, cr, uid, message, context=None):
583 """Perform any post-processing necessary after sending ``message``
584 successfully, including deleting it completely along with its
585 attachment if the ``auto_delete`` flag of the message was set.
586 Overridden by subclasses for extra post-processing behaviors.
588 :param browse_record message: the message that was just sent
591 if message.auto_delete:
592 self.pool.get('ir.attachment').unlink(cr, uid,
593 [x.id for x in message.attachment_ids
594 if x.res_model == self._name and x.res_id == message.id],
599 def send(self, cr, uid, ids, auto_commit=False, context=None):
600 """Sends the selected emails immediately, ignoring their current
601 state (mails that have already been sent should not be passed
602 unless they should actually be re-sent).
603 Emails successfully delivered are marked as 'sent', and those
604 that fail to be deliver are marked as 'exception', and the
605 corresponding error message is output in the server logs.
607 :param bool auto_commit: whether to force a commit of the message
608 status after sending each message (meant
609 only for processing by the scheduler),
610 should never be True during normal
611 transactions (default: False)
614 ir_mail_server = self.pool.get('ir.mail_server')
615 self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
616 for message in self.browse(cr, uid, ids, context=context):
619 for attach in message.attachment_ids:
620 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
622 body = message.body_html if message.content_subtype == 'html' else message.body_text
623 body_alternative = None
624 content_subtype_alternative = None
625 if message.content_subtype == 'html' and message.body_text:
626 # we have a plain text alternative prepared, pass it to
627 # build_message instead of letting it build one
628 body_alternative = message.body_text
629 content_subtype_alternative = 'plain'
631 # handle destination_partners
632 partner_ids_email_to = ''
633 for partner in message.partner_ids:
634 partner_ids_email_to += '%s ' % (partner.email or '')
635 message_email_to = '%s %s' % (partner_ids_email_to, message.email_to or '')
637 # build an RFC2822 email.message.Message object and send it
639 msg = ir_mail_server.build_email(
640 email_from=message.email_from,
641 email_to=mail_tools_to_email(message_email_to),
642 subject=message.subject,
644 body_alternative=body_alternative,
645 email_cc=mail_tools_to_email(message.email_cc),
646 email_bcc=mail_tools_to_email(message.email_bcc),
647 reply_to=message.reply_to,
648 attachments=attachments, message_id=message.message_id,
649 references = message.references,
650 object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
651 subtype=message.content_subtype,
652 subtype_alternative=content_subtype_alternative,
653 headers=message.headers and ast.literal_eval(message.headers))
654 res = ir_mail_server.send_email(cr, uid, msg,
655 mail_server_id=message.mail_server_id.id,
658 message.write({'state':'sent', 'message_id': res, 'email_to': message_email_to})
660 message.write({'state':'exception', 'email_to': message_email_to})
662 if message.state == 'sent':
663 self._postprocess_sent_message(cr, uid, message, context=context)
665 _logger.exception('failed sending mail.message %s', message.id)
666 message.write({'state':'exception'})
668 if auto_commit == True:
674 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: