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
29 from email.header import decode_header
30 from email.message import Message
34 from osv import fields
35 from tools.translate import _
37 _logger = logging.getLogger('mail')
39 def format_date_tz(date, tz=None):
42 format = tools.DEFAULT_SERVER_DATETIME_FORMAT
43 return tools.server_to_local_timestamp(date, format, format, tz)
45 def truncate_text(text):
46 lines = text and text.split('\n') or []
48 res = '\n\t'.join(lines[:3]) + '...'
50 res = '\n\t'.join(lines)
54 """Returns unicode() string conversion of the the given encoded smtp header text"""
56 text = decode_header(text.replace('\r', ''))
57 return ''.join([tools.ustr(x[0], x[1]) for x in text])
60 """Return a list of the email addresses found in ``text``"""
61 if not text: return []
62 return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
64 class mail_message_common(osv.osv_memory):
65 """Common abstract class for holding the main attributes of a
66 message object. It could be reused as parent model for any
67 database model or wizard screen that needs to hold a kind of
70 _name = 'mail.message.common'
73 'subject': fields.char('Subject', size=512, required=True),
74 'model': fields.char('Related Document model', size=128, select=1), # was rfeadonly
75 'res_id': fields.integer('Related Document ID', select=1), # was rfeadonly
76 'date': fields.datetime('Date'),
77 'email_from': fields.char('From', size=128, help='Message sender, taken from user preferences. If empty, this is not a mail but a message.'),
78 'email_to': fields.char('To', size=256, help='Message recipients'),
79 'email_cc': fields.char('Cc', size=256, help='Carbon copy message recipients'),
80 'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
81 'reply_to':fields.char('Reply-To', size=256, help='Preferred response address for the message'),
82 'headers': fields.text('Message headers', readonly=1,
83 help="Full message headers, e.g. SMTP session headers "
84 "(usually available on inbound messages only)"),
85 'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
86 'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
87 'subtype': fields.char('Message type', size=32, help="Type of message, usually 'html' or 'plain', used to "
88 "select plaintext or rich text contents accordingly", readonly=1),
89 'body_text': fields.text('Text contents', help="Plain-text version of the message"),
90 'body_html': fields.text('Rich-text contents', help="Rich-text/HTML version of the message"),
97 class mail_message(osv.osv):
98 '''Model holding RFC2822 email messages, and providing facilities
99 to parse, queue and send new messages
101 Messages that do not have a value for the email_from column
102 are simple log messages (e.g. document state changes), while
103 actual e-mails have the email_from value set.
104 The ``display_text`` field will have a slightly different
105 presentation for real emails and for log messages.
108 _name = 'mail.message'
109 _inherit = 'mail.message.common'
110 _description = 'Generic Message (Email, Comment, Notification)'
113 # XXX to review - how to determine action to use?
114 def open_document(self, cr, uid, ids, context=None):
117 msg = self.browse(cr, uid, ids[0], context=context)
121 ir_act_window = self.pool.get('ir.actions.act_window')
122 action_ids = ir_act_window.search(cr, uid, [('res_model', '=', model)])
124 action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
126 'domain' : "[('id','=',%d)]"%(res_id),
132 # XXX to review - how to determine action to use?
133 def open_attachment(self, cr, uid, ids, context=None):
135 action_pool = self.pool.get('ir.actions.act_window')
136 message = self.browse(cr, uid, ids, context=context)[0]
137 att_ids = [x.id for x in message.attachment_ids]
138 action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')])
140 action_data = action_pool.read(cr, uid, action_ids[0], context=context)
142 'domain': [('id','in',att_ids)],
147 def _get_display_text(self, cr, uid, ids, name, arg, context=None):
150 tz = context.get('tz')
152 for message in self.browse(cr, uid, ids, context=context):
154 if message.email_from:
155 msg_txt += _('%s wrote on %s: \n Subject: %s \n\t') % (message.email_from or '/', format_date_tz(message.date, tz), message.subject)
156 if message.body_text:
157 msg_txt += truncate_text(message.body_text)
159 msg_txt = (message.user_id.name or '/') + _(' on ') + format_date_tz(message.date, tz) + ':\n\t'
160 msg_txt += message.subject
161 result[message.id] = msg_txt
165 'partner_id': fields.many2one('res.partner', 'Related partner'),
166 'user_id': fields.many2one('res.users', 'Related user', readonly=1),
167 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', 'Attachments'),
168 'display_text': fields.function(_get_display_text, method=True, type='text', size="512", string='Display Text'),
169 'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
170 'state': fields.selection([
171 ('outgoing', 'Outgoing'),
173 ('received', 'Received'),
174 ('exception', 'Delivery Failed'),
175 ('cancel', 'Cancelled'),
176 ], 'State', readonly=True),
177 'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it, to save space"),
178 'original': fields.binary('Original', help="Original version of the message, as it was sent on the network", readonly=1),
179 # note feature: add type (email, comment, notification) and need_action
180 'type': fields.selection([
182 ('comment', 'Comment'),
183 ('notification', 'Notification'),
184 ], 'Type', help="Message type: e-mail for e-mail message, notification for system message, comment for other messages such as user replies"),
185 'need_action': fields.boolean('Need action', help="Asks the user to perform an action"),
190 'type': 'notification',
193 #------------------------------------------------------
195 #------------------------------------------------------
197 def create(self, cr, uid, vals, context=None):
198 return super(mail_message, self).create(cr, uid, vals, context)
200 def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
201 if not context or not context.has_key('filter_search'):
202 return super(mail_message, self).search(cr, uid, args, offset=offset, limit=limit, order=order, context=context, count=count)
205 sub_obj = self.pool.get('mail.subscription')
206 sub_ids = sub_obj.search(cr, uid, [('user_id', '=', uid)])
207 subs = sub_obj.browse(cr, uid, sub_ids)
209 # stock tweets to find
210 res_model_ids_dict = {}
211 res_model_all_list = []
213 # check all subscriptions
215 if sub.res_model and sub.res_id == 0 and sub.res_domain == False:
217 if sub.res_model not in res_model_all_list:
218 res_model_all_list.append(sub.res_model)
219 elif sub.res_model and sub.res_id:
221 if res_model_ids_dict.has_key(sub.res_model):
222 res_model_ids_dict[sub.res_model].append(sub.res_id)
224 res_model_ids_dict[sub.res_model] = [sub.res_id]
225 elif sub.res_model and sub.res_domain:
227 res_obj = self.pool.get(sub.res_model)
229 #res_ids = res_obj.search(cr, uid, [('id', 'in', [1,2])])
230 res_ids = res_obj.search(cr, uid, eval(sub.res_domain))
231 if res_model_ids_dict.has_key(sub.res_model):
232 res_model_ids_dict[sub.res_model] += res_ids
234 res_model_ids_dict[sub.res_model] = res_ids
240 # add fully-followed domains
242 args.append(['model', 'in', res_model_all_list])
244 # add partially-followed domains
245 for x in range(0, len(res_model_ids_dict.keys())-1):
248 for res_model in res_model_ids_dict.keys():
249 if res_model not in res_model_all_list:
251 args.append(['model', '=', res_model])
252 args.append(['res_id', 'in', res_model_ids_dict[res_model]])
254 if context and context.has_key('filter_search'):
259 return super(mail_message, self).search(cr, uid, args, offset=offset, limit=limit,order=order, context=context, count=count)
261 #------------------------------------------------------
263 #------------------------------------------------------
266 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
267 if not cr.fetchone():
268 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
270 def copy(self, cr, uid, id, default=None, context=None):
271 """Overridden to avoid duplicating fields that are unique to each email"""
274 default.update(message_id=False,original=False,headers=False)
275 return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
277 def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
278 email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
279 res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
281 """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
282 the next time :meth:`process_email_queue` is called explicitly.
284 :param string email_from: sender email address
285 :param list email_to: list of recipient addresses (to be joined with commas)
286 :param string subject: email subject (no pre-encoding/quoting necessary)
287 :param string body: email body, according to the ``subtype`` (by default, plaintext).
288 If html subtype is used, the message will be automatically converted
289 to plaintext and wrapped in multipart/alternative.
290 :param list email_cc: optional list of string values for CC header (to be joined with commas)
291 :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
292 :param string model: optional model name of the document this mail is related to (this will also
293 be used to generate a tracking id, used to match any response related to the
295 :param int res_id: optional resource identifier this mail is related to (this will also
296 be used to generate a tracking id, used to match any response related to the
298 :param string reply_to: optional value of Reply-To header
299 :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
300 must match the format of the ``body`` parameter. Default is 'plain',
301 making the content part of the mail "text/plain".
302 :param dict attachments: map of filename to filecontents, where filecontents is a string
303 containing the bytes of the attachment
304 :param dict headers: optional map of headers to set on the outgoing mail (may override the
305 other headers, including Subject, Reply-To, Message-Id, etc.)
306 :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
307 :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
308 successfully sent (default to False)
313 if attachments is None:
315 attachment_obj = self.pool.get('ir.attachment')
316 for param in (email_to, email_cc, email_bcc):
317 if param and not isinstance(param, list):
321 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
325 'body_text': body if subtype != 'html' else False,
326 'body_html': body if subtype == 'html' else False,
327 'email_from': email_from,
328 'email_to': email_to and ','.join(email_to) or '',
329 'email_cc': email_cc and ','.join(email_cc) or '',
330 'email_bcc': email_bcc and ','.join(email_bcc) or '',
331 'reply_to': reply_to,
332 'message_id': message_id,
333 'references': references,
335 'headers': headers, # serialize the dict on the fly
336 'mail_server_id': mail_server_id,
338 'auto_delete': auto_delete
340 email_msg_id = self.create(cr, uid, msg_vals, context)
342 for fname, fcontent in attachments.iteritems():
345 'datas_fname': fname,
347 'res_model': self._name,
348 'res_id': email_msg_id,
350 if context.has_key('default_type'):
351 del context['default_type']
352 attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
354 self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
357 def mark_outgoing(self, cr, uid, ids, context=None):
358 return self.write(cr, uid, ids, {'state':'outgoing'}, context)
360 def process_email_queue(self, cr, uid, ids=None, context=None):
361 """Send immediately queued messages, committing after each
362 message is sent - this is not transactional and should
363 not be called during another transaction!
365 :param list ids: optional list of emails ids to send. If passed
366 no search is performed, and these ids are used
368 :param dict context: if a 'filters' key is present in context,
369 this value will be used as an additional
370 filter to further restrict the outgoing
371 messages to send (by default all 'outgoing'
377 filters = [('state', '=', 'outgoing')]
378 if 'filters' in context:
379 filters.extend(context['filters'])
380 ids = self.search(cr, uid, filters, context=context)
383 # Force auto-commit - this is meant to be called by
384 # the scheduler, and we can't allow rolling back the status
385 # of previously sent emails!
386 res = self.send(cr, uid, ids, auto_commit=True, context=context)
388 _logger.exception("Failed processing mail queue")
391 def parse_message(self, message, save_original=False):
392 """Parses a string or email.message.Message representing an
393 RFC-2822 email, and returns a generic dict holding the
396 :param message: the message to parse
397 :type message: email.message.Message | string | unicode
398 :param bool save_original: whether the returned dict
399 should include an ``original`` entry with the base64
400 encoded source of the message.
402 :return: A dict with the following structure, where each
403 field may not be present if missing in original
406 { 'message-id': msg_id,
411 'headers' : { 'X-Mailer': mailer,
412 #.. all X- headers...
414 'subtype': msg_mime_subtype,
415 'body_text': plaintext_body
416 'body_html': html_body,
417 'attachments': [('file1', 'bytes'),
420 'original': source_of_email,
424 if isinstance(message, str):
425 msg_txt = email.message_from_string(message)
427 # Warning: message_from_string doesn't always work correctly on unicode,
428 # we must use utf-8 strings here :-(
429 if isinstance(message, unicode):
430 message = message.encode('utf-8')
431 msg_txt = email.message_from_string(message)
433 message_id = msg_txt.get('message-id', False)
437 # save original, we need to be able to read the original email sometimes
438 msg['original'] = message.as_string() if isinstance(message, Message) \
440 msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
443 # Very unusual situation, be we should be fault-tolerant here
444 message_id = time.time()
445 msg_txt['message-id'] = message_id
446 _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
448 fields = msg_txt.keys()
449 msg['id'] = message_id
450 msg['message-id'] = message_id
452 if 'Subject' in fields:
453 msg['subject'] = decode(msg_txt.get('Subject'))
455 if 'Content-Type' in fields:
456 msg['content-type'] = msg_txt.get('Content-Type')
459 msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
462 msg['to'] = decode(msg_txt.get('To'))
464 if 'Delivered-To' in fields:
465 msg['to'] = decode(msg_txt.get('Delivered-To'))
468 msg['cc'] = decode(msg_txt.get('CC'))
471 msg['cc'] = decode(msg_txt.get('Cc'))
473 if 'Reply-To' in fields:
474 msg['reply'] = decode(msg_txt.get('Reply-To'))
477 date_hdr = decode(msg_txt.get('Date'))
478 msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
480 if 'Content-Transfer-Encoding' in fields:
481 msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
483 if 'References' in fields:
484 msg['references'] = msg_txt.get('References')
486 if 'In-Reply-To' in fields:
487 msg['in-reply-to'] = msg_txt.get('In-Reply-To')
490 msg['subtype'] = 'plain'
491 for item in msg_txt.items():
492 if item[0].startswith('X-'):
493 msg['headers'].update({item[0]: item[1]})
494 if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
495 encoding = msg_txt.get_content_charset()
496 body = msg_txt.get_payload(decode=True)
497 if 'text/html' in msg.get('content-type', ''):
498 msg['body_html'] = body
499 msg['subtype'] = 'html'
500 body = tools.html2plaintext(body)
501 msg['body_text'] = tools.ustr(body, encoding)
504 if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
506 if 'multipart/alternative' in msg.get('content-type', ''):
507 msg['subtype'] = 'alternative'
509 msg['subtype'] = 'mixed'
510 for part in msg_txt.walk():
511 if part.get_content_maintype() == 'multipart':
514 encoding = part.get_content_charset()
515 filename = part.get_filename()
516 if part.get_content_maintype()=='text':
517 content = part.get_payload(decode=True)
519 attachments.append((filename, content))
520 content = tools.ustr(content, encoding)
521 if part.get_content_subtype() == 'html':
522 msg['body_html'] = content
523 msg['subtype'] = 'html' # html version prevails
524 body = tools.ustr(tools.html2plaintext(content))
525 elif part.get_content_subtype() == 'plain':
527 elif part.get_content_maintype() in ('application', 'image'):
529 attachments.append((filename,part.get_payload(decode=True)))
531 res = part.get_payload(decode=True)
532 body += tools.ustr(res, encoding)
534 msg['body_text'] = body
535 msg['attachments'] = attachments
537 # for backwards compatibility:
538 msg['body'] = msg['body_text']
539 msg['sub_type'] = msg['subtype'] or 'plain'
543 def send(self, cr, uid, ids, auto_commit=False, context=None):
544 """Sends the selected emails immediately, ignoring their current
545 state (mails that have already been sent should not be passed
546 unless they should actually be re-sent).
547 Emails successfully delivered are marked as 'sent', and those
548 that fail to be deliver are marked as 'exception', and the
549 corresponding error message is output in the server logs.
551 :param bool auto_commit: whether to force a commit of the message
552 status after sending each message (meant
553 only for processing by the scheduler),
554 should never be True during normal
555 transactions (default: False)
560 ir_mail_server = self.pool.get('ir.mail_server')
561 self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
562 for message in self.browse(cr, uid, ids, context=context):
565 for attach in message.attachment_ids:
566 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
568 body = message.body_html if message.subtype == 'html' else message.body_text
569 body_alternative = None
570 subtype_alternative = None
571 if message.subtype == 'html' and message.body_text:
572 # we have a plain text alternative prepared, pass it to
573 # build_message instead of letting it build one
574 body_alternative = message.body_text
575 subtype_alternative = 'plain'
577 msg = ir_mail_server.build_email(
578 email_from=message.email_from,
579 email_to=to_email(message.email_to),
580 subject=message.subject,
582 body_alternative=body_alternative,
583 email_cc=to_email(message.email_cc),
584 email_bcc=to_email(message.email_bcc),
585 reply_to=message.reply_to,
586 attachments=attachments, message_id=message.message_id,
587 references = message.references,
588 object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
589 subtype=message.subtype,
590 subtype_alternative=subtype_alternative,
591 headers=message.headers and ast.literal_eval(message.headers))
592 res = ir_mail_server.send_email(cr, uid, msg,
593 mail_server_id=message.mail_server_id.id,
596 message.write({'state':'sent', 'message_id': res})
598 message.write({'state':'exception'})
600 # if auto_delete=True then delete that sent messages as well as attachments
602 if message.state == 'sent' and message.auto_delete:
603 self.pool.get('ir.attachment').unlink(cr, uid,
604 [x.id for x in message.attachment_ids],
608 _logger.exception('failed sending mail.message %s', message.id)
609 message.write({'state':'exception'})
611 if auto_commit == True:
615 def cancel(self, cr, uid, ids, context=None):
616 self.write(cr, uid, ids, {'state':'cancel'}, context=context)
619 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: