1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2010-2011 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 _
38 _logger = logging.getLogger('mail')
40 def format_date_tz(date, tz=None):
43 format = tools.DEFAULT_SERVER_DATETIME_FORMAT
44 return tools.server_to_local_timestamp(date, format, format, tz)
46 def truncate_text(text):
47 lines = text and text.split('\n') or []
49 res = '\n\t'.join(lines[:3]) + '...'
51 res = '\n\t'.join(lines)
55 """Returns unicode() string conversion of the the given encoded smtp header text"""
57 text = decode_header(text.replace('\r', ''))
58 return ''.join([tools.ustr(x[0], x[1]) for x in text])
61 """Return a list of the email addresses found in ``text``"""
62 if not text: return []
63 return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
65 class mail_message_common(osv.osv_memory):
66 """Common abstract class for holding the main attributes of a
67 message object. It could be reused as parent model for any
68 database model or wizard screen that needs to hold a kind of
71 _name = 'mail.message.common'
74 'subject': fields.char('Subject', size=512, required=True),
75 'model': fields.char('Related Document model', size=128, select=1), # was readonly
76 'res_id': fields.integer('Related Document ID', select=1), # was readonly
77 'date': fields.datetime('Date'),
78 'email_from': fields.char('From', size=128, help='Message sender, taken from user preferences. If empty, this is not a mail but a message.'),
79 'email_to': fields.char('To', size=256, help='Message recipients'),
80 'email_cc': fields.char('Cc', size=256, help='Carbon copy message recipients'),
81 'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
82 'reply_to':fields.char('Reply-To', size=256, help='Preferred response address for the message'),
83 'headers': fields.text('Message headers', readonly=1,
84 help="Full message headers, e.g. SMTP session headers "
85 "(usually available on inbound messages only)"),
86 'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
87 'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
88 'subtype': fields.char('Message type', size=32, help="Type of message, usually 'html' or 'plain', used to "
89 "select plaintext or rich text contents accordingly", readonly=1),
90 'body_text': fields.text('Text contents', help="Plain-text version of the message"),
91 'body_html': fields.text('Rich-text contents', help="Rich-text/HTML version of the message"),
96 'date': (lambda *a: datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')),
99 class mail_message(osv.osv):
100 '''Model holding RFC2822 email messages, and providing facilities
101 to parse, queue and send new messages
103 Messages that do not have a value for the email_from column
104 are simple log messages (e.g. document state changes), while
105 actual e-mails have the email_from value set.
106 The ``display_text`` field will have a slightly different
107 presentation for real emails and for log messages.
110 _name = 'mail.message'
111 _inherit = 'mail.message.common'
112 _description = 'Generic Message (Email, Comment, Notification)'
115 # XXX to review - how to determine action to use?
116 def open_document(self, cr, uid, ids, context=None):
119 msg = self.browse(cr, uid, ids[0], context=context)
123 ir_act_window = self.pool.get('ir.actions.act_window')
124 action_ids = ir_act_window.search(cr, uid, [('res_model', '=', model)])
126 action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
128 'domain' : "[('id','=',%d)]"%(res_id),
134 # XXX to review - how to determine action to use?
135 def open_attachment(self, cr, uid, ids, context=None):
137 action_pool = self.pool.get('ir.actions.act_window')
138 message = self.browse(cr, uid, ids, context=context)[0]
139 att_ids = [x.id for x in message.attachment_ids]
140 action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')])
142 action_data = action_pool.read(cr, uid, action_ids[0], context=context)
144 'domain': [('id','in',att_ids)],
149 def _get_display_text(self, cr, uid, ids, name, arg, context=None):
152 tz = context.get('tz')
154 for message in self.browse(cr, uid, ids, context=context):
156 if message.email_from:
157 msg_txt += _('%s wrote on %s: \n Subject: %s \n\t') % (message.email_from or '/', format_date_tz(message.date, tz), message.subject)
158 if message.body_text:
159 msg_txt += truncate_text(message.body_text)
161 msg_txt = (message.user_id.name or '/') + _(' on ') + format_date_tz(message.date, tz) + ':\n\t'
162 msg_txt += (message.subject or '')
163 result[message.id] = msg_txt
167 'partner_id': fields.many2one('res.partner', 'Related partner'),
168 'user_id': fields.many2one('res.users', 'Related user', readonly=1),
169 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', 'Attachments'),
170 'display_text': fields.function(_get_display_text, method=True, type='text', size="512", string='Display Text'),
171 'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
172 'state': fields.selection([
173 ('outgoing', 'Outgoing'),
175 ('received', 'Received'),
176 ('exception', 'Delivery Failed'),
177 ('cancel', 'Cancelled'),
178 ], 'State', readonly=True),
179 'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it, to save space"),
180 'original': fields.binary('Original', help="Original version of the message, as it was sent on the network", readonly=1),
181 # note feature: add type (email, comment, notification) and need_action
182 'type': fields.selection([
184 ('comment', 'Comment'),
185 ('notification', 'System notification'),
186 ], 'Type', help="Message type: e-mail for e-mail message, notification for system message, comment for other messages such as user replies"),
187 'need_action_user_id': fields.many2one('res.users', 'Action by user', help="User requested to perform an action"),
195 #------------------------------------------------------
197 #------------------------------------------------------
199 def create(self, cr, uid, vals, context=None):
200 # temporary log directly created messages (to debug OpenSocial)
201 if not 'mail.thread' in context:
202 _logger.warning('Creating message without using mail.thread API')
203 _logger.warning('Message details: %s', str(vals))
204 msg_id = super(mail_message, self).create(cr, uid, vals, context)
207 #------------------------------------------------------
209 #------------------------------------------------------
212 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
213 if not cr.fetchone():
214 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
216 def copy(self, cr, uid, id, default=None, context=None):
217 """Overridden to avoid duplicating fields that are unique to each email"""
220 default.update(message_id=False,original=False,headers=False)
221 return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
223 def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
224 email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
225 res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
227 """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
228 the next time :meth:`process_email_queue` is called explicitly.
230 :param string email_from: sender email address
231 :param list email_to: list of recipient addresses (to be joined with commas)
232 :param string subject: email subject (no pre-encoding/quoting necessary)
233 :param string body: email body, according to the ``subtype`` (by default, plaintext).
234 If html subtype is used, the message will be automatically converted
235 to plaintext and wrapped in multipart/alternative.
236 :param list email_cc: optional list of string values for CC header (to be joined with commas)
237 :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
238 :param string model: optional model name of the document this mail is related to (this will also
239 be used to generate a tracking id, used to match any response related to the
241 :param int res_id: optional resource identifier this mail is related to (this will also
242 be used to generate a tracking id, used to match any response related to the
244 :param string reply_to: optional value of Reply-To header
245 :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
246 must match the format of the ``body`` parameter. Default is 'plain',
247 making the content part of the mail "text/plain".
248 :param dict attachments: map of filename to filecontents, where filecontents is a string
249 containing the bytes of the attachment
250 :param dict headers: optional map of headers to set on the outgoing mail (may override the
251 other headers, including Subject, Reply-To, Message-Id, etc.)
252 :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
253 :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
254 successfully sent (default to False)
259 if attachments is None:
261 attachment_obj = self.pool.get('ir.attachment')
262 for param in (email_to, email_cc, email_bcc):
263 if param and not isinstance(param, list):
267 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
271 'body_text': body if subtype != 'html' else False,
272 'body_html': body if subtype == 'html' else False,
273 'email_from': email_from,
274 'email_to': email_to and ','.join(email_to) or '',
275 'email_cc': email_cc and ','.join(email_cc) or '',
276 'email_bcc': email_bcc and ','.join(email_bcc) or '',
277 'reply_to': reply_to,
278 'message_id': message_id,
279 'references': references,
281 'headers': headers, # serialize the dict on the fly
282 'mail_server_id': mail_server_id,
284 'auto_delete': auto_delete
286 email_msg_id = self.create(cr, uid, msg_vals, context)
288 for fname, fcontent in attachments.iteritems():
291 'datas_fname': fname,
293 'res_model': self._name,
294 'res_id': email_msg_id,
296 if context.has_key('default_type'):
297 del context['default_type']
298 attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
300 self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
303 def mark_outgoing(self, cr, uid, ids, context=None):
304 return self.write(cr, uid, ids, {'state':'outgoing'}, context)
306 def process_email_queue(self, cr, uid, ids=None, context=None):
307 """Send immediately queued messages, committing after each
308 message is sent - this is not transactional and should
309 not be called during another transaction!
311 :param list ids: optional list of emails ids to send. If passed
312 no search is performed, and these ids are used
314 :param dict context: if a 'filters' key is present in context,
315 this value will be used as an additional
316 filter to further restrict the outgoing
317 messages to send (by default all 'outgoing'
323 filters = [('state', '=', 'outgoing')]
324 if 'filters' in context:
325 filters.extend(context['filters'])
326 ids = self.search(cr, uid, filters, context=context)
329 # Force auto-commit - this is meant to be called by
330 # the scheduler, and we can't allow rolling back the status
331 # of previously sent emails!
332 res = self.send(cr, uid, ids, auto_commit=True, context=context)
334 _logger.exception("Failed processing mail queue")
337 def parse_message(self, message, save_original=False):
338 """Parses a string or email.message.Message representing an
339 RFC-2822 email, and returns a generic dict holding the
342 :param message: the message to parse
343 :type message: email.message.Message | string | unicode
344 :param bool save_original: whether the returned dict
345 should include an ``original`` entry with the base64
346 encoded source of the message.
348 :return: A dict with the following structure, where each
349 field may not be present if missing in original
352 { 'message-id': msg_id,
357 'headers' : { 'X-Mailer': mailer,
358 #.. all X- headers...
360 'subtype': msg_mime_subtype,
361 'body_text': plaintext_body
362 'body_html': html_body,
363 'attachments': [('file1', 'bytes'),
366 'original': source_of_email,
370 if isinstance(message, str):
371 msg_txt = email.message_from_string(message)
373 # Warning: message_from_string doesn't always work correctly on unicode,
374 # we must use utf-8 strings here :-(
375 if isinstance(message, unicode):
376 message = message.encode('utf-8')
377 msg_txt = email.message_from_string(message)
379 message_id = msg_txt.get('message-id', False)
383 # save original, we need to be able to read the original email sometimes
384 msg['original'] = message.as_string() if isinstance(message, Message) \
386 msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
389 # Very unusual situation, be we should be fault-tolerant here
390 message_id = time.time()
391 msg_txt['message-id'] = message_id
392 _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
394 fields = msg_txt.keys()
395 msg['id'] = message_id
396 msg['message-id'] = message_id
398 if 'Subject' in fields:
399 msg['subject'] = decode(msg_txt.get('Subject'))
401 if 'Content-Type' in fields:
402 msg['content-type'] = msg_txt.get('Content-Type')
405 msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
408 msg['to'] = decode(msg_txt.get('To'))
410 if 'Delivered-To' in fields:
411 msg['to'] = decode(msg_txt.get('Delivered-To'))
414 msg['cc'] = decode(msg_txt.get('CC'))
417 msg['cc'] = decode(msg_txt.get('Cc'))
419 if 'Reply-To' in fields:
420 msg['reply'] = decode(msg_txt.get('Reply-To'))
423 date_hdr = decode(msg_txt.get('Date'))
424 msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
426 if 'Content-Transfer-Encoding' in fields:
427 msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
429 if 'References' in fields:
430 msg['references'] = msg_txt.get('References')
432 if 'In-Reply-To' in fields:
433 msg['in-reply-to'] = msg_txt.get('In-Reply-To')
436 msg['subtype'] = 'plain'
437 for item in msg_txt.items():
438 if item[0].startswith('X-'):
439 msg['headers'].update({item[0]: item[1]})
440 if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
441 encoding = msg_txt.get_content_charset()
442 body = msg_txt.get_payload(decode=True)
443 if 'text/html' in msg.get('content-type', ''):
444 msg['body_html'] = body
445 msg['subtype'] = 'html'
447 body = tools.html2plaintext(body)
448 msg['body_text'] = tools.ustr(body, encoding)
451 if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
453 if 'multipart/alternative' in msg.get('content-type', ''):
454 msg['subtype'] = 'alternative'
456 msg['subtype'] = 'mixed'
457 for part in msg_txt.walk():
458 if part.get_content_maintype() == 'multipart':
461 encoding = part.get_content_charset()
462 filename = part.get_filename()
463 if part.get_content_maintype()=='text':
464 content = part.get_payload(decode=True)
466 attachments.append((filename, content))
467 content = tools.ustr(content, encoding)
468 if part.get_content_subtype() == 'html':
469 msg['body_html'] = content
470 msg['subtype'] = 'html' # html version prevails
471 body = tools.ustr(tools.html2plaintext(content))
472 elif part.get_content_subtype() == 'plain':
474 elif part.get_content_maintype() in ('application', 'image'):
476 attachments.append((filename,part.get_payload(decode=True)))
478 res = part.get_payload(decode=True)
479 body += tools.ustr(res, encoding)
481 msg['body_text'] = body
482 msg['attachments'] = attachments
484 # for backwards compatibility:
485 msg['body'] = msg['body_text']
486 msg['sub_type'] = msg['subtype'] or 'plain'
490 def send(self, cr, uid, ids, auto_commit=False, context=None):
491 """Sends the selected emails immediately, ignoring their current
492 state (mails that have already been sent should not be passed
493 unless they should actually be re-sent).
494 Emails successfully delivered are marked as 'sent', and those
495 that fail to be deliver are marked as 'exception', and the
496 corresponding error message is output in the server logs.
498 :param bool auto_commit: whether to force a commit of the message
499 status after sending each message (meant
500 only for processing by the scheduler),
501 should never be True during normal
502 transactions (default: False)
507 ir_mail_server = self.pool.get('ir.mail_server')
508 self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
509 for message in self.browse(cr, uid, ids, context=context):
512 for attach in message.attachment_ids:
513 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
515 body = message.body_html if message.subtype == 'html' else message.body_text
516 body_alternative = None
517 subtype_alternative = None
518 if message.subtype == 'html' and message.body_text:
519 # we have a plain text alternative prepared, pass it to
520 # build_message instead of letting it build one
521 body_alternative = message.body_text
522 subtype_alternative = 'plain'
524 msg = ir_mail_server.build_email(
525 email_from=message.email_from,
526 email_to=to_email(message.email_to),
527 subject=message.subject,
529 body_alternative=body_alternative,
530 email_cc=to_email(message.email_cc),
531 email_bcc=to_email(message.email_bcc),
532 reply_to=message.reply_to,
533 attachments=attachments, message_id=message.message_id,
534 references = message.references,
535 object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
536 subtype=message.subtype,
537 subtype_alternative=subtype_alternative,
538 headers=message.headers and ast.literal_eval(message.headers))
539 res = ir_mail_server.send_email(cr, uid, msg,
540 mail_server_id=message.mail_server_id.id,
543 message.write({'state':'sent', 'message_id': res})
545 message.write({'state':'exception'})
547 # if auto_delete=True then delete that sent messages as well as attachments
549 if message.state == 'sent' and message.auto_delete:
550 self.pool.get('ir.attachment').unlink(cr, uid,
551 [x.id for x in message.attachment_ids],
555 _logger.exception('failed sending mail.message %s', message.id)
556 message.write({'state':'exception'})
558 if auto_commit == True:
562 def cancel(self, cr, uid, ids, context=None):
563 self.write(cr, uid, ids, {'state':'cancel'}, context=context)
566 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: