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(__name__)
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 def name_get(self, cr, uid, ids, context=None):
102 for message in self.browse(cr, uid, ids, context=context):
105 name = '%s: ' % (message.subject)
106 if message.body_text:
107 name = '%s%s ' % (name, message.body_text[0:20])
109 name = '%s(%s)' % (name, message.date)
110 res.append((message.id, name))
113 _name = 'mail.message.common'
114 _rec_name = 'subject'
116 'subject': fields.char('Subject', size=512),
117 'model': fields.char('Related Document Model', size=128, select=1),
118 'res_id': fields.integer('Related Document ID', select=1),
119 'record_name': fields.function(get_record_name, type='string', string='Message Record Name',
120 help="Name of the record, matching the result of the name_get."),
121 'date': fields.datetime('Date'),
122 'email_from': fields.char('From', size=128, help='Message sender, taken from user preferences.'),
123 'email_to': fields.char('To', size=256, help='Message recipients'),
124 'email_cc': fields.char('Cc', size=256, help='Carbon copy message recipients'),
125 'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
126 'reply_to':fields.char('Reply-To', size=256, help='Preferred response address for the message'),
127 'headers': fields.text('Message Headers', readonly=1,
128 help="Full message headers, e.g. SMTP session headers (usually available on inbound messages only)"),
129 'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
130 'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
131 'subtype': fields.char('Message Type', size=32, help="Type of message, usually 'html' or 'plain', used to "
132 "select plaintext or rich text contents accordingly", readonly=1),
133 'body_text': fields.text('Text Contents', help="Plain-text version of the message"),
134 'body_html': fields.text('Rich-text Contents', help="Rich-text/HTML version of the message"),
135 'body': fields.function(get_body, fnct_search = search_body, string='Message Content', type='text',
136 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."),
137 'parent_id': fields.many2one('mail.message', 'Parent Message', help="Parent message, used for displaying as threads with hierarchy",
138 select=True, ondelete='set null',),
139 'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
144 'date': (lambda *a: fields.datetime.now()),
147 class mail_message(osv.osv):
148 '''Model holding messages: system notification (replacing res.log
149 notifications), comments (for OpenSocial feature) and
150 RFC2822 email messages. This model also provides facilities to
151 parse, queue and send new email messages. Type of messages
152 are differentiated using the 'type' column.
154 The ``display_text`` field will have a slightly different
155 presentation for real emails and for log messages.
158 _name = 'mail.message'
159 _inherit = 'mail.message.common'
160 _description = 'Mail Message (email, comment, notification)'
163 # XXX to review - how to determine action to use?
164 def open_document(self, cr, uid, ids, context=None):
167 msg = self.browse(cr, uid, ids[0], context=context)
171 ir_act_window = self.pool.get('ir.actions.act_window')
172 action_ids = ir_act_window.search(cr, uid, [('res_model', '=', model)])
174 action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
176 'domain' : "[('id','=',%d)]"%(res_id),
182 # XXX to review - how to determine action to use?
183 def open_attachment(self, cr, uid, ids, context=None):
185 action_pool = self.pool.get('ir.actions.act_window')
186 message = self.browse(cr, uid, ids, context=context)[0]
187 att_ids = [x.id for x in message.attachment_ids]
188 action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')])
190 action_data = action_pool.read(cr, uid, action_ids[0], context=context)
192 'domain': [('id','in',att_ids)],
197 def _get_display_text(self, cr, uid, ids, name, arg, context=None):
200 tz = context.get('tz')
203 # Read message as UID 1 to allow viewing author even if from different company
204 for message in self.browse(cr, SUPERUSER_ID, ids):
206 if message.email_from:
207 msg_txt += _('%s wrote on %s: \n Subject: %s \n\t') % (message.email_from or '/', format_date_tz(message.date, tz), message.subject)
208 if message.body_text:
209 msg_txt += truncate_text(message.body_text)
211 msg_txt = (message.user_id.name or '/') + _(' on ') + format_date_tz(message.date, tz) + ':\n\t'
212 msg_txt += (message.subject or '')
213 result[message.id] = msg_txt
217 'type': fields.selection([
219 ('comment', 'Comment'),
220 ('notification', 'System notification'),
221 ], 'Type', help="Message type: email for email message, notification for system message, comment for other messages such as user replies"),
222 'partner_id': fields.many2one('res.partner', 'Related partner'),
223 'user_id': fields.many2one('res.users', 'Related User', readonly=1),
224 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', 'Attachments'),
225 'display_text': fields.function(_get_display_text, method=True, type='text', size="512", string='Display Text'),
226 'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
227 'state': fields.selection([
228 ('outgoing', 'Outgoing'),
230 ('received', 'Received'),
231 ('exception', 'Delivery Failed'),
232 ('cancel', 'Cancelled'),
233 ], 'Status', readonly=True),
234 'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it, to save space"),
235 'original': fields.binary('Original', help="Original version of the message, as it was sent on the network", readonly=1),
243 #------------------------------------------------------
245 #------------------------------------------------------
248 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
249 if not cr.fetchone():
250 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
252 def copy(self, cr, uid, id, default=None, context=None):
253 """Overridden to avoid duplicating fields that are unique to each email"""
256 default.update(message_id=False,original=False,headers=False)
257 return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
259 def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
260 email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
261 res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
263 """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
264 the next time :meth:`process_email_queue` is called explicitly.
266 :param string email_from: sender email address
267 :param list email_to: list of recipient addresses (to be joined with commas)
268 :param string subject: email subject (no pre-encoding/quoting necessary)
269 :param string body: email body, according to the ``subtype`` (by default, plaintext).
270 If html subtype is used, the message will be automatically converted
271 to plaintext and wrapped in multipart/alternative.
272 :param list email_cc: optional list of string values for CC header (to be joined with commas)
273 :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
274 :param string model: optional model name of the document this mail is related to (this will also
275 be used to generate a tracking id, used to match any response related to the
277 :param int res_id: optional resource identifier this mail is related to (this will also
278 be used to generate a tracking id, used to match any response related to the
280 :param string reply_to: optional value of Reply-To header
281 :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
282 must match the format of the ``body`` parameter. Default is 'plain',
283 making the content part of the mail "text/plain".
284 :param dict attachments: map of filename to filecontents, where filecontents is a string
285 containing the bytes of the attachment
286 :param dict headers: optional map of headers to set on the outgoing mail (may override the
287 other headers, including Subject, Reply-To, Message-Id, etc.)
288 :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
289 :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
290 successfully sent (default to False)
295 if attachments is None:
297 attachment_obj = self.pool.get('ir.attachment')
298 for param in (email_to, email_cc, email_bcc):
299 if param and not isinstance(param, list):
303 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
308 'body_text': body if subtype != 'html' else False,
309 'body_html': body if subtype == 'html' else False,
310 'email_from': email_from,
311 'email_to': email_to and ','.join(email_to) or '',
312 'email_cc': email_cc and ','.join(email_cc) or '',
313 'email_bcc': email_bcc and ','.join(email_bcc) or '',
314 'reply_to': reply_to,
315 'message_id': message_id,
316 'references': references,
318 'headers': headers, # serialize the dict on the fly
319 'mail_server_id': mail_server_id,
321 'auto_delete': auto_delete
323 email_msg_id = self.create(cr, uid, msg_vals, context)
325 for fname, fcontent in attachments.iteritems():
328 'datas_fname': fname,
329 'datas': fcontent and fcontent.encode('base64'),
330 'res_model': self._name,
331 'res_id': email_msg_id,
333 if context.has_key('default_type'):
334 del context['default_type']
335 attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
337 self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
340 def mark_outgoing(self, cr, uid, ids, context=None):
341 return self.write(cr, uid, ids, {'state':'outgoing'}, context)
343 def process_email_queue(self, cr, uid, ids=None, context=None):
344 """Send immediately queued messages, committing after each
345 message is sent - this is not transactional and should
346 not be called during another transaction!
348 :param list ids: optional list of emails ids to send. If passed
349 no search is performed, and these ids are used
351 :param dict context: if a 'filters' key is present in context,
352 this value will be used as an additional
353 filter to further restrict the outgoing
354 messages to send (by default all 'outgoing'
360 filters = [('state', '=', 'outgoing')]
361 if 'filters' in context:
362 filters.extend(context['filters'])
363 ids = self.search(cr, uid, filters, context=context)
366 # Force auto-commit - this is meant to be called by
367 # the scheduler, and we can't allow rolling back the status
368 # of previously sent emails!
369 res = self.send(cr, uid, ids, auto_commit=True, context=context)
371 _logger.exception("Failed processing mail queue")
374 def parse_message(self, message, save_original=False):
375 """Parses a string or email.message.Message representing an
376 RFC-2822 email, and returns a generic dict holding the
379 :param message: the message to parse
380 :type message: email.message.Message | string | unicode
381 :param bool save_original: whether the returned dict
382 should include an ``original`` entry with the base64
383 encoded source of the message.
385 :return: A dict with the following structure, where each
386 field may not be present if missing in original
389 { 'message-id': msg_id,
394 'headers' : { 'X-Mailer': mailer,
395 #.. all X- headers...
397 'subtype': msg_mime_subtype,
398 'body_text': plaintext_body
399 'body_html': html_body,
400 'attachments': [('file1', 'bytes'),
403 'original': source_of_email,
407 if isinstance(message, str):
408 msg_txt = email.message_from_string(message)
410 # Warning: message_from_string doesn't always work correctly on unicode,
411 # we must use utf-8 strings here :-(
412 if isinstance(message, unicode):
413 message = message.encode('utf-8')
414 msg_txt = email.message_from_string(message)
416 message_id = msg_txt.get('message-id', False)
420 # save original, we need to be able to read the original email sometimes
421 msg['original'] = message.as_string() if isinstance(message, Message) \
423 msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
426 # Very unusual situation, be we should be fault-tolerant here
427 message_id = time.time()
428 msg_txt['message-id'] = message_id
429 _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
431 fields = msg_txt.keys()
432 msg['id'] = message_id
433 msg['message-id'] = message_id
435 if 'Subject' in fields:
436 msg['subject'] = decode(msg_txt.get('Subject'))
438 if 'Content-Type' in fields:
439 msg['content-type'] = msg_txt.get('Content-Type')
442 msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
445 msg['to'] = decode(msg_txt.get('To'))
447 if 'Delivered-To' in fields:
448 msg['to'] = decode(msg_txt.get('Delivered-To'))
451 msg['cc'] = decode(msg_txt.get('CC'))
454 msg['cc'] = decode(msg_txt.get('Cc'))
456 if 'Reply-To' in fields:
457 msg['reply'] = decode(msg_txt.get('Reply-To'))
460 date_hdr = decode(msg_txt.get('Date'))
461 msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
463 if 'Content-Transfer-Encoding' in fields:
464 msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
466 if 'References' in fields:
467 msg['references'] = msg_txt.get('References')
469 if 'In-Reply-To' in fields:
470 msg['in-reply-to'] = msg_txt.get('In-Reply-To')
473 msg['subtype'] = 'plain'
474 for item in msg_txt.items():
475 if item[0].startswith('X-'):
476 msg['headers'].update({item[0]: item[1]})
477 if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
478 encoding = msg_txt.get_content_charset()
479 body = msg_txt.get_payload(decode=True)
480 if 'text/html' in msg.get('content-type', ''):
481 msg['body_html'] = body
482 msg['subtype'] = 'html'
484 body = tools.html2plaintext(body)
485 msg['body_text'] = tools.ustr(body, encoding)
488 if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
490 if 'multipart/alternative' in msg.get('content-type', ''):
491 msg['subtype'] = 'alternative'
493 msg['subtype'] = 'mixed'
494 for part in msg_txt.walk():
495 if part.get_content_maintype() == 'multipart':
498 encoding = part.get_content_charset()
499 filename = part.get_filename()
500 if part.get_content_maintype()=='text':
501 content = part.get_payload(decode=True)
503 attachments.append((filename, content))
504 content = tools.ustr(content, encoding)
505 if part.get_content_subtype() == 'html':
506 msg['body_html'] = content
507 msg['subtype'] = 'html' # html version prevails
508 body = tools.ustr(tools.html2plaintext(content))
509 body = body.replace(' ', '')
510 elif part.get_content_subtype() == 'plain':
512 elif part.get_content_maintype() in ('application', 'image'):
514 attachments.append((filename,part.get_payload(decode=True)))
516 res = part.get_payload(decode=True)
517 body += tools.ustr(res, encoding)
519 msg['body_text'] = body
520 msg['attachments'] = attachments
522 # for backwards compatibility:
523 msg['body'] = msg['body_text']
524 msg['sub_type'] = msg['subtype'] or 'plain'
527 def _postprocess_sent_message(self, cr, uid, message, context=None):
528 """Perform any post-processing necessary after sending ``message``
529 successfully, including deleting it completely along with its
530 attachment if the ``auto_delete`` flag of the message was set.
531 Overridden by subclasses for extra post-processing behaviors.
533 :param browse_record message: the message that was just sent
536 if message.auto_delete:
537 self.pool.get('ir.attachment').unlink(cr, uid,
538 [x.id for x in message.attachment_ids \
539 if x.res_model == self._name and \
540 x.res_id == message.id],
545 def send(self, cr, uid, ids, auto_commit=False, context=None):
546 """Sends the selected emails immediately, ignoring their current
547 state (mails that have already been sent should not be passed
548 unless they should actually be re-sent).
549 Emails successfully delivered are marked as 'sent', and those
550 that fail to be deliver are marked as 'exception', and the
551 corresponding error message is output in the server logs.
553 :param bool auto_commit: whether to force a commit of the message
554 status after sending each message (meant
555 only for processing by the scheduler),
556 should never be True during normal
557 transactions (default: False)
562 ir_mail_server = self.pool.get('ir.mail_server')
563 self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
564 for message in self.browse(cr, uid, ids, context=context):
567 for attach in message.attachment_ids:
568 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
570 body = message.body_html if message.subtype == 'html' else message.body_text
571 body_alternative = None
572 subtype_alternative = None
573 if message.subtype == 'html' and message.body_text:
574 # we have a plain text alternative prepared, pass it to
575 # build_message instead of letting it build one
576 body_alternative = message.body_text
577 subtype_alternative = 'plain'
579 msg = ir_mail_server.build_email(
580 email_from=message.email_from,
581 email_to=to_email(message.email_to),
582 subject=message.subject,
584 body_alternative=body_alternative,
585 email_cc=to_email(message.email_cc),
586 email_bcc=to_email(message.email_bcc),
587 reply_to=message.reply_to,
588 attachments=attachments, message_id=message.message_id,
589 references = message.references,
590 object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
591 subtype=message.subtype,
592 subtype_alternative=subtype_alternative,
593 headers=message.headers and ast.literal_eval(message.headers))
594 res = ir_mail_server.send_email(cr, uid, msg,
595 mail_server_id=message.mail_server_id.id,
598 message.write({'state':'sent', 'message_id': res})
600 message.write({'state':'exception'})
602 if message.state == 'sent':
603 self._postprocess_sent_message(cr, uid, message, context=context)
605 _logger.exception('failed sending mail.message %s', message.id)
606 message.write({'state':'exception'})
608 if auto_commit == True:
612 def cancel(self, cr, uid, ids, context=None):
613 self.write(cr, uid, ids, {'state':'cancel'}, context=context)
616 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: