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
35 from osv import fields
36 from tools.translate import _
37 from openerp import SUPERUSER_ID
39 _logger = logging.getLogger('mail')
41 def format_date_tz(date, tz=None):
44 format = tools.DEFAULT_SERVER_DATETIME_FORMAT
45 return tools.server_to_local_timestamp(date, format, format, tz)
47 def truncate_text(text):
48 lines = text and text.split('\n') or []
50 res = '\n\t'.join(lines[:3]) + '...'
52 res = '\n\t'.join(lines)
56 """Returns unicode() string conversion of the the given encoded smtp header text"""
58 text = decode_header(text.replace('\r', ''))
59 return ''.join([tools.ustr(x[0], x[1]) for x in text])
62 """Return a list of the email addresses found in ``text``"""
63 if not text: return []
64 return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
66 class mail_message_common(osv.osv_memory):
67 """Common abstract class for holding the main attributes of a
68 message object. It could be reused as parent model for any
69 database model or wizard screen that needs to hold a kind of
72 def get_body(self, cr, uid, ids, name, arg, context=None):
75 result = dict.fromkeys(ids, '')
76 for message in self.browse(cr, uid, ids, context=context):
77 if message.subtype == 'html':
78 result[message.id] = message.body_html
80 result[message.id] = message.body_text
83 def search_body(self, cr, uid, obj, name, args, context=None):
85 - obj: mail.message object
87 - args: [('body', 'ilike', 'blah')]"""
88 return ['|', '&', ('subtype', '=', 'html'), ('body_html', args[0][1], args[0][2]), ('body_text', args[0][1], args[0][2])]
90 def get_record_name(self, cr, uid, ids, name, arg, context=None):
93 result = dict.fromkeys(ids, '')
94 for message in self.browse(cr, uid, ids, context=context):
95 if not message.model or not message.res_id:
97 result[message.id] = self.pool.get(message.model).name_get(cr, uid, [message.res_id], context=context)[0][1]
100 _name = 'mail.message.common'
101 _rec_name = 'subject'
103 'subject': fields.char('Subject', size=512, required=True),
104 'model': fields.char('Related Document model', size=128, select=1),
105 'res_id': fields.integer('Related Document ID', select=1),
106 'record_name': fields.function(get_record_name, type='string', string='Message record name',
107 help="Name of the record, matching the result of the name_get."),
108 'date': fields.datetime('Date'),
109 'email_from': fields.char('From', size=128, help='Message sender, taken from user preferences.'),
110 'email_to': fields.char('To', size=256, help='Message recipients'),
111 'email_cc': fields.char('Cc', size=256, help='Carbon copy message recipients'),
112 'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
113 'reply_to':fields.char('Reply-To', size=256, help='Preferred response address for the message'),
114 'headers': fields.text('Message headers', readonly=1,
115 help="Full message headers, e.g. SMTP session headers (usually available on inbound messages only)"),
116 'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
117 'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
118 'subtype': fields.char('Message type', size=32, help="Type of message, usually 'html' or 'plain', used to "
119 "select plaintext or rich text contents accordingly", readonly=1),
120 'body_text': fields.text('Text contents', help="Plain-text version of the message"),
121 'body_html': fields.text('Rich-text contents', help="Rich-text/HTML version of the message"),
122 'body': fields.function(get_body, fnct_search = search_body, string='Message content', type='text',
123 help="Content of the message. This content equals the body_text field for plain-test messages, and body_html for rich-text/HTML messages. This allows having one field if we want to access the content matching the message subtype."),
124 'parent_id': fields.many2one('mail.message', 'Parent message', help="Parent message, used for displaying as threads with hierarchy",
125 select=True, ondelete='set null',),
126 'child_ids': fields.one2many('mail.message', 'parent_id', 'Child messages'),
131 'date': (lambda *a: fields.datetime.now()),
134 class mail_message(osv.osv):
135 '''Model holding messages: system notification (replacing res.log
136 notifications), comments (for OpenSocial feature) and
137 RFC2822 email messages. This model also provides facilities to
138 parse, queue and send new email messages. Type of messages
139 are differentiated using the 'type' column.
141 The ``display_text`` field will have a slightly different
142 presentation for real emails and for log messages.
145 _name = 'mail.message'
146 _inherit = 'mail.message.common'
147 _description = 'Mail Message (email, comment, notification)'
150 # XXX to review - how to determine action to use?
151 def open_document(self, cr, uid, ids, context=None):
154 msg = self.browse(cr, uid, ids[0], context=context)
158 ir_act_window = self.pool.get('ir.actions.act_window')
159 action_ids = ir_act_window.search(cr, uid, [('res_model', '=', model)])
161 action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
163 'domain' : "[('id','=',%d)]"%(res_id),
169 # XXX to review - how to determine action to use?
170 def open_attachment(self, cr, uid, ids, context=None):
172 action_pool = self.pool.get('ir.actions.act_window')
173 message = self.browse(cr, uid, ids, context=context)[0]
174 att_ids = [x.id for x in message.attachment_ids]
175 action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')])
177 action_data = action_pool.read(cr, uid, action_ids[0], context=context)
179 'domain': [('id','in',att_ids)],
184 def _get_display_text(self, cr, uid, ids, name, arg, context=None):
187 tz = context.get('tz')
190 # Read message as UID 1 to allow viewing author even if from different company
191 for message in self.browse(cr, SUPERUSER_ID, ids):
193 if message.email_from:
194 msg_txt += _('%s wrote on %s: \n Subject: %s \n\t') % (message.email_from or '/', format_date_tz(message.date, tz), message.subject)
195 if message.body_text:
196 msg_txt += truncate_text(message.body_text)
198 msg_txt = (message.user_id.name or '/') + _(' on ') + format_date_tz(message.date, tz) + ':\n\t'
199 msg_txt += (message.subject or '')
200 result[message.id] = msg_txt
204 'type': fields.selection([
206 ('comment', 'Comment'),
207 ('notification', 'System notification'),
208 ], 'Type', help="Message type: e-mail for e-mail message, notification for system message, comment for other messages such as user replies"),
209 'partner_id': fields.many2one('res.partner', 'Related partner'),
210 'user_id': fields.many2one('res.users', 'Related user', readonly=1),
211 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', 'Attachments'),
212 'display_text': fields.function(_get_display_text, method=True, type='text', size="512", string='Display Text'),
213 'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
214 'state': fields.selection([
215 ('outgoing', 'Outgoing'),
217 ('received', 'Received'),
218 ('exception', 'Delivery Failed'),
219 ('cancel', 'Cancelled'),
220 ], 'State', readonly=True),
221 'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it, to save space"),
222 'original': fields.binary('Original', help="Original version of the message, as it was sent on the network", readonly=1),
230 #------------------------------------------------------
232 #------------------------------------------------------
235 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
236 if not cr.fetchone():
237 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
239 def copy(self, cr, uid, id, default=None, context=None):
240 """Overridden to avoid duplicating fields that are unique to each email"""
243 default.update(message_id=False,original=False,headers=False)
244 return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
246 def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
247 email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
248 res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
250 """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
251 the next time :meth:`process_email_queue` is called explicitly.
253 :param string email_from: sender email address
254 :param list email_to: list of recipient addresses (to be joined with commas)
255 :param string subject: email subject (no pre-encoding/quoting necessary)
256 :param string body: email body, according to the ``subtype`` (by default, plaintext).
257 If html subtype is used, the message will be automatically converted
258 to plaintext and wrapped in multipart/alternative.
259 :param list email_cc: optional list of string values for CC header (to be joined with commas)
260 :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
261 :param string model: optional model name of the document this mail is related to (this will also
262 be used to generate a tracking id, used to match any response related to the
264 :param int res_id: optional resource identifier this mail is related to (this will also
265 be used to generate a tracking id, used to match any response related to the
267 :param string reply_to: optional value of Reply-To header
268 :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
269 must match the format of the ``body`` parameter. Default is 'plain',
270 making the content part of the mail "text/plain".
271 :param dict attachments: map of filename to filecontents, where filecontents is a string
272 containing the bytes of the attachment
273 :param dict headers: optional map of headers to set on the outgoing mail (may override the
274 other headers, including Subject, Reply-To, Message-Id, etc.)
275 :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
276 :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
277 successfully sent (default to False)
282 if attachments is None:
284 attachment_obj = self.pool.get('ir.attachment')
285 for param in (email_to, email_cc, email_bcc):
286 if param and not isinstance(param, list):
290 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
295 'body_text': body if subtype != 'html' else False,
296 'body_html': body if subtype == 'html' else False,
297 'email_from': email_from,
298 'email_to': email_to and ','.join(email_to) or '',
299 'email_cc': email_cc and ','.join(email_cc) or '',
300 'email_bcc': email_bcc and ','.join(email_bcc) or '',
301 'reply_to': reply_to,
302 'message_id': message_id,
303 'references': references,
305 'headers': headers, # serialize the dict on the fly
306 'mail_server_id': mail_server_id,
308 'auto_delete': auto_delete
310 email_msg_id = self.create(cr, uid, msg_vals, context)
312 for fname, fcontent in attachments.iteritems():
315 'datas_fname': fname,
316 'datas': fcontent and fcontent.encode('base64'),
317 'res_model': self._name,
318 'res_id': email_msg_id,
320 if context.has_key('default_type'):
321 del context['default_type']
322 attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
324 self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
327 def mark_outgoing(self, cr, uid, ids, context=None):
328 return self.write(cr, uid, ids, {'state':'outgoing'}, context)
330 def process_email_queue(self, cr, uid, ids=None, context=None):
331 """Send immediately queued messages, committing after each
332 message is sent - this is not transactional and should
333 not be called during another transaction!
335 :param list ids: optional list of emails ids to send. If passed
336 no search is performed, and these ids are used
338 :param dict context: if a 'filters' key is present in context,
339 this value will be used as an additional
340 filter to further restrict the outgoing
341 messages to send (by default all 'outgoing'
347 filters = [('state', '=', 'outgoing')]
348 if 'filters' in context:
349 filters.extend(context['filters'])
350 ids = self.search(cr, uid, filters, context=context)
353 # Force auto-commit - this is meant to be called by
354 # the scheduler, and we can't allow rolling back the status
355 # of previously sent emails!
356 res = self.send(cr, uid, ids, auto_commit=True, context=context)
358 _logger.exception("Failed processing mail queue")
361 def parse_message(self, message, save_original=False):
362 """Parses a string or email.message.Message representing an
363 RFC-2822 email, and returns a generic dict holding the
366 :param message: the message to parse
367 :type message: email.message.Message | string | unicode
368 :param bool save_original: whether the returned dict
369 should include an ``original`` entry with the base64
370 encoded source of the message.
372 :return: A dict with the following structure, where each
373 field may not be present if missing in original
376 { 'message-id': msg_id,
381 'headers' : { 'X-Mailer': mailer,
382 #.. all X- headers...
384 'subtype': msg_mime_subtype,
385 'body_text': plaintext_body
386 'body_html': html_body,
387 'attachments': [('file1', 'bytes'),
390 'original': source_of_email,
394 if isinstance(message, str):
395 msg_txt = email.message_from_string(message)
397 # Warning: message_from_string doesn't always work correctly on unicode,
398 # we must use utf-8 strings here :-(
399 if isinstance(message, unicode):
400 message = message.encode('utf-8')
401 msg_txt = email.message_from_string(message)
403 message_id = msg_txt.get('message-id', False)
407 # save original, we need to be able to read the original email sometimes
408 msg['original'] = message.as_string() if isinstance(message, Message) \
410 msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
413 # Very unusual situation, be we should be fault-tolerant here
414 message_id = time.time()
415 msg_txt['message-id'] = message_id
416 _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
418 fields = msg_txt.keys()
419 msg['id'] = message_id
420 msg['message-id'] = message_id
422 if 'Subject' in fields:
423 msg['subject'] = decode(msg_txt.get('Subject'))
425 if 'Content-Type' in fields:
426 msg['content-type'] = msg_txt.get('Content-Type')
429 msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
432 msg['to'] = decode(msg_txt.get('To'))
434 if 'Delivered-To' in fields:
435 msg['to'] = decode(msg_txt.get('Delivered-To'))
438 msg['cc'] = decode(msg_txt.get('CC'))
441 msg['cc'] = decode(msg_txt.get('Cc'))
443 if 'Reply-To' in fields:
444 msg['reply'] = decode(msg_txt.get('Reply-To'))
447 date_hdr = decode(msg_txt.get('Date'))
448 msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
450 if 'Content-Transfer-Encoding' in fields:
451 msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
453 if 'References' in fields:
454 msg['references'] = msg_txt.get('References')
456 if 'In-Reply-To' in fields:
457 msg['in-reply-to'] = msg_txt.get('In-Reply-To')
460 msg['subtype'] = 'plain'
461 for item in msg_txt.items():
462 if item[0].startswith('X-'):
463 msg['headers'].update({item[0]: item[1]})
464 if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
465 encoding = msg_txt.get_content_charset()
466 body = msg_txt.get_payload(decode=True)
467 if 'text/html' in msg.get('content-type', ''):
468 msg['body_html'] = body
469 msg['subtype'] = 'html'
471 body = tools.html2plaintext(body)
472 msg['body_text'] = tools.ustr(body, encoding)
475 if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
477 if 'multipart/alternative' in msg.get('content-type', ''):
478 msg['subtype'] = 'alternative'
480 msg['subtype'] = 'mixed'
481 for part in msg_txt.walk():
482 if part.get_content_maintype() == 'multipart':
485 encoding = part.get_content_charset()
486 filename = part.get_filename()
487 if part.get_content_maintype()=='text':
488 content = part.get_payload(decode=True)
490 attachments.append((filename, content))
491 content = tools.ustr(content, encoding)
492 if part.get_content_subtype() == 'html':
493 msg['body_html'] = content
494 msg['subtype'] = 'html' # html version prevails
495 body = tools.ustr(tools.html2plaintext(content))
496 body = body.replace(' ', '')
497 elif part.get_content_subtype() == 'plain':
499 elif part.get_content_maintype() in ('application', 'image'):
501 attachments.append((filename,part.get_payload(decode=True)))
503 res = part.get_payload(decode=True)
504 body += tools.ustr(res, encoding)
506 msg['body_text'] = body
507 msg['attachments'] = attachments
509 # for backwards compatibility:
510 msg['body'] = msg['body_text']
511 msg['sub_type'] = msg['subtype'] or 'plain'
514 def _postprocess_sent_message(self, cr, uid, message, context=None):
515 """Perform any post-processing necessary after sending ``message``
516 successfully, including deleting it completely along with its
517 attachment if the ``auto_delete`` flag of the message was set.
518 Overridden by subclasses for extra post-processing behaviors.
520 :param browse_record message: the message that was just sent
525 if context.get('active_ids', False):
526 self.pool.get(context['active_model']).write(cr, uid, context['active_ids'], {'message_state':'read'}, context=context)
527 if message.auto_delete:
528 self.pool.get('ir.attachment').unlink(cr, uid,
529 [x.id for x in message.attachment_ids \
530 if x.res_model == self._name and \
531 x.res_id == message.id],
536 def send(self, cr, uid, ids, auto_commit=False, context=None):
537 """Sends the selected emails immediately, ignoring their current
538 state (mails that have already been sent should not be passed
539 unless they should actually be re-sent).
540 Emails successfully delivered are marked as 'sent', and those
541 that fail to be deliver are marked as 'exception', and the
542 corresponding error message is output in the server logs.
544 :param bool auto_commit: whether to force a commit of the message
545 status after sending each message (meant
546 only for processing by the scheduler),
547 should never be True during normal
548 transactions (default: False)
553 ir_mail_server = self.pool.get('ir.mail_server')
554 self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
555 for message in self.browse(cr, uid, ids, context=context):
558 for attach in message.attachment_ids:
559 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
561 body = message.body_html if message.subtype == 'html' else message.body_text
562 body_alternative = None
563 subtype_alternative = None
564 if message.subtype == 'html' and message.body_text:
565 # we have a plain text alternative prepared, pass it to
566 # build_message instead of letting it build one
567 body_alternative = message.body_text
568 subtype_alternative = 'plain'
570 msg = ir_mail_server.build_email(
571 email_from=message.email_from,
572 email_to=to_email(message.email_to),
573 subject=message.subject,
575 body_alternative=body_alternative,
576 email_cc=to_email(message.email_cc),
577 email_bcc=to_email(message.email_bcc),
578 reply_to=message.reply_to,
579 attachments=attachments, message_id=message.message_id,
580 references = message.references,
581 object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
582 subtype=message.subtype,
583 subtype_alternative=subtype_alternative,
584 headers=message.headers and ast.literal_eval(message.headers))
585 res = ir_mail_server.send_email(cr, uid, msg,
586 mail_server_id=message.mail_server_id.id,
589 message.write({'state':'sent', 'message_id': res})
591 message.write({'state':'exception'})
593 if message.state == 'sent':
594 self._postprocess_sent_message(cr, uid, message, context=context)
596 _logger.exception('failed sending mail.message %s', message.id)
597 message.write({'state':'exception'})
599 if auto_commit == True:
603 def cancel(self, cr, uid, ids, context=None):
604 self.write(cr, uid, ids, {'state':'cancel'}, context=context)
607 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: