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 readonly
75 'res_id': fields.integer('Related Document ID', select=1), # was readonly
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 msg_id = super(mail_message, self).create(cr, uid, vals, context)
199 # push the message to suscribed users
200 subscription_obj = self.pool.get('mail.subscription')
201 notification_obj = self.pool.get('mail.notification')
202 sub_ids = subscription_obj.search(cr, uid, ['&', ('res_model', '=', vals['model']), ('user_id', '=', uid)], context=context)
203 subs = subscription_obj.browse(cr, uid, sub_ids, context=context)
205 notification_obj.create(cr, uid, {'user_id': sub.user_id, 'message_id': msg_id}, context=context)
208 def get_pushed_messages(self, cr, uid, context=None):
209 """Wall: get messages to display"""
210 notification_obj = self.pool.get('mail.notification')
211 notification_ids = notification_obj.search(cr, uid, [('user_id', '=', uid)], context=context)
212 notifications = notification_obj.browse(cr, uid, notification_ids, context=context)
214 # TODO / REMARK: classify based on res_model / res_id to have a 1_level hierarchy ?
219 #------------------------------------------------------
221 #------------------------------------------------------
223 #def tmp_backup(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
224 #if not context or not context.has_key('filter_search'):
225 #return super(mail_message, self).search(cr, uid, args, offset=offset, limit=limit, order=order, context=context, count=count)
228 #sub_obj = self.pool.get('mail.subscription')
229 #sub_ids = sub_obj.search(cr, uid, [('user_id', '=', uid)])
230 #subs = sub_obj.browse(cr, uid, sub_ids)
232 ## stock tweets to find
233 #res_model_ids_dict = {}
234 #res_model_all_list = []
236 ## check all subscriptions
238 #if sub.res_model and sub.res_id == 0 and sub.res_domain == False:
240 #if sub.res_model not in res_model_all_list:
241 #res_model_all_list.append(sub.res_model)
242 #elif sub.res_model and sub.res_id:
244 #if res_model_ids_dict.has_key(sub.res_model):
245 #res_model_ids_dict[sub.res_model].append(sub.res_id)
247 #res_model_ids_dict[sub.res_model] = [sub.res_id]
248 #elif sub.res_model and sub.res_domain:
250 #res_obj = self.pool.get(sub.res_model)
251 #print sub.res_domain
252 ##res_ids = res_obj.search(cr, uid, [('id', 'in', [1,2])])
253 #res_ids = res_obj.search(cr, uid, eval(sub.res_domain))
254 #if res_model_ids_dict.has_key(sub.res_model):
255 #res_model_ids_dict[sub.res_model] += res_ids
257 #res_model_ids_dict[sub.res_model] = res_ids
263 ## add fully-followed domains
265 #args.append(['model', 'in', res_model_all_list])
267 ## add partially-followed domains
268 #for x in range(0, len(res_model_ids_dict.keys())-1):
271 #for res_model in res_model_ids_dict.keys():
272 #if res_model not in res_model_all_list:
274 #args.append(['model', '=', res_model])
275 #args.append(['res_id', 'in', res_model_ids_dict[res_model]])
277 #if context and context.has_key('filter_search'):
282 #return super(mail_message, self).search(cr, uid, args, offset=offset, limit=limit,order=order, context=context, count=count)
284 #------------------------------------------------------
286 #------------------------------------------------------
289 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
290 if not cr.fetchone():
291 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
293 def copy(self, cr, uid, id, default=None, context=None):
294 """Overridden to avoid duplicating fields that are unique to each email"""
297 default.update(message_id=False,original=False,headers=False)
298 return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
300 def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
301 email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
302 res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
304 """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
305 the next time :meth:`process_email_queue` is called explicitly.
307 :param string email_from: sender email address
308 :param list email_to: list of recipient addresses (to be joined with commas)
309 :param string subject: email subject (no pre-encoding/quoting necessary)
310 :param string body: email body, according to the ``subtype`` (by default, plaintext).
311 If html subtype is used, the message will be automatically converted
312 to plaintext and wrapped in multipart/alternative.
313 :param list email_cc: optional list of string values for CC header (to be joined with commas)
314 :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
315 :param string model: optional model name of the document this mail is related to (this will also
316 be used to generate a tracking id, used to match any response related to the
318 :param int res_id: optional resource identifier this mail is related to (this will also
319 be used to generate a tracking id, used to match any response related to the
321 :param string reply_to: optional value of Reply-To header
322 :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
323 must match the format of the ``body`` parameter. Default is 'plain',
324 making the content part of the mail "text/plain".
325 :param dict attachments: map of filename to filecontents, where filecontents is a string
326 containing the bytes of the attachment
327 :param dict headers: optional map of headers to set on the outgoing mail (may override the
328 other headers, including Subject, Reply-To, Message-Id, etc.)
329 :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
330 :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
331 successfully sent (default to False)
336 if attachments is None:
338 attachment_obj = self.pool.get('ir.attachment')
339 for param in (email_to, email_cc, email_bcc):
340 if param and not isinstance(param, list):
344 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
348 'body_text': body if subtype != 'html' else False,
349 'body_html': body if subtype == 'html' else False,
350 'email_from': email_from,
351 'email_to': email_to and ','.join(email_to) or '',
352 'email_cc': email_cc and ','.join(email_cc) or '',
353 'email_bcc': email_bcc and ','.join(email_bcc) or '',
354 'reply_to': reply_to,
355 'message_id': message_id,
356 'references': references,
358 'headers': headers, # serialize the dict on the fly
359 'mail_server_id': mail_server_id,
361 'auto_delete': auto_delete
363 email_msg_id = self.create(cr, uid, msg_vals, context)
365 for fname, fcontent in attachments.iteritems():
368 'datas_fname': fname,
370 'res_model': self._name,
371 'res_id': email_msg_id,
373 if context.has_key('default_type'):
374 del context['default_type']
375 attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
377 self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
380 def mark_outgoing(self, cr, uid, ids, context=None):
381 return self.write(cr, uid, ids, {'state':'outgoing'}, context)
383 def process_email_queue(self, cr, uid, ids=None, context=None):
384 """Send immediately queued messages, committing after each
385 message is sent - this is not transactional and should
386 not be called during another transaction!
388 :param list ids: optional list of emails ids to send. If passed
389 no search is performed, and these ids are used
391 :param dict context: if a 'filters' key is present in context,
392 this value will be used as an additional
393 filter to further restrict the outgoing
394 messages to send (by default all 'outgoing'
400 filters = [('state', '=', 'outgoing')]
401 if 'filters' in context:
402 filters.extend(context['filters'])
403 ids = self.search(cr, uid, filters, context=context)
406 # Force auto-commit - this is meant to be called by
407 # the scheduler, and we can't allow rolling back the status
408 # of previously sent emails!
409 res = self.send(cr, uid, ids, auto_commit=True, context=context)
411 _logger.exception("Failed processing mail queue")
414 def parse_message(self, message, save_original=False):
415 """Parses a string or email.message.Message representing an
416 RFC-2822 email, and returns a generic dict holding the
419 :param message: the message to parse
420 :type message: email.message.Message | string | unicode
421 :param bool save_original: whether the returned dict
422 should include an ``original`` entry with the base64
423 encoded source of the message.
425 :return: A dict with the following structure, where each
426 field may not be present if missing in original
429 { 'message-id': msg_id,
434 'headers' : { 'X-Mailer': mailer,
435 #.. all X- headers...
437 'subtype': msg_mime_subtype,
438 'body_text': plaintext_body
439 'body_html': html_body,
440 'attachments': [('file1', 'bytes'),
443 'original': source_of_email,
447 if isinstance(message, str):
448 msg_txt = email.message_from_string(message)
450 # Warning: message_from_string doesn't always work correctly on unicode,
451 # we must use utf-8 strings here :-(
452 if isinstance(message, unicode):
453 message = message.encode('utf-8')
454 msg_txt = email.message_from_string(message)
456 message_id = msg_txt.get('message-id', False)
460 # save original, we need to be able to read the original email sometimes
461 msg['original'] = message.as_string() if isinstance(message, Message) \
463 msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
466 # Very unusual situation, be we should be fault-tolerant here
467 message_id = time.time()
468 msg_txt['message-id'] = message_id
469 _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
471 fields = msg_txt.keys()
472 msg['id'] = message_id
473 msg['message-id'] = message_id
475 if 'Subject' in fields:
476 msg['subject'] = decode(msg_txt.get('Subject'))
478 if 'Content-Type' in fields:
479 msg['content-type'] = msg_txt.get('Content-Type')
482 msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
485 msg['to'] = decode(msg_txt.get('To'))
487 if 'Delivered-To' in fields:
488 msg['to'] = decode(msg_txt.get('Delivered-To'))
491 msg['cc'] = decode(msg_txt.get('CC'))
494 msg['cc'] = decode(msg_txt.get('Cc'))
496 if 'Reply-To' in fields:
497 msg['reply'] = decode(msg_txt.get('Reply-To'))
500 date_hdr = decode(msg_txt.get('Date'))
501 msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
503 if 'Content-Transfer-Encoding' in fields:
504 msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
506 if 'References' in fields:
507 msg['references'] = msg_txt.get('References')
509 if 'In-Reply-To' in fields:
510 msg['in-reply-to'] = msg_txt.get('In-Reply-To')
513 msg['subtype'] = 'plain'
514 for item in msg_txt.items():
515 if item[0].startswith('X-'):
516 msg['headers'].update({item[0]: item[1]})
517 if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
518 encoding = msg_txt.get_content_charset()
519 body = msg_txt.get_payload(decode=True)
520 if 'text/html' in msg.get('content-type', ''):
521 msg['body_html'] = body
522 msg['subtype'] = 'html'
523 body = tools.html2plaintext(body)
524 msg['body_text'] = tools.ustr(body, encoding)
527 if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
529 if 'multipart/alternative' in msg.get('content-type', ''):
530 msg['subtype'] = 'alternative'
532 msg['subtype'] = 'mixed'
533 for part in msg_txt.walk():
534 if part.get_content_maintype() == 'multipart':
537 encoding = part.get_content_charset()
538 filename = part.get_filename()
539 if part.get_content_maintype()=='text':
540 content = part.get_payload(decode=True)
542 attachments.append((filename, content))
543 content = tools.ustr(content, encoding)
544 if part.get_content_subtype() == 'html':
545 msg['body_html'] = content
546 msg['subtype'] = 'html' # html version prevails
547 body = tools.ustr(tools.html2plaintext(content))
548 elif part.get_content_subtype() == 'plain':
550 elif part.get_content_maintype() in ('application', 'image'):
552 attachments.append((filename,part.get_payload(decode=True)))
554 res = part.get_payload(decode=True)
555 body += tools.ustr(res, encoding)
557 msg['body_text'] = body
558 msg['attachments'] = attachments
560 # for backwards compatibility:
561 msg['body'] = msg['body_text']
562 msg['sub_type'] = msg['subtype'] or 'plain'
566 def send(self, cr, uid, ids, auto_commit=False, context=None):
567 """Sends the selected emails immediately, ignoring their current
568 state (mails that have already been sent should not be passed
569 unless they should actually be re-sent).
570 Emails successfully delivered are marked as 'sent', and those
571 that fail to be deliver are marked as 'exception', and the
572 corresponding error message is output in the server logs.
574 :param bool auto_commit: whether to force a commit of the message
575 status after sending each message (meant
576 only for processing by the scheduler),
577 should never be True during normal
578 transactions (default: False)
583 ir_mail_server = self.pool.get('ir.mail_server')
584 self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
585 for message in self.browse(cr, uid, ids, context=context):
588 for attach in message.attachment_ids:
589 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
591 body = message.body_html if message.subtype == 'html' else message.body_text
592 body_alternative = None
593 subtype_alternative = None
594 if message.subtype == 'html' and message.body_text:
595 # we have a plain text alternative prepared, pass it to
596 # build_message instead of letting it build one
597 body_alternative = message.body_text
598 subtype_alternative = 'plain'
600 msg = ir_mail_server.build_email(
601 email_from=message.email_from,
602 email_to=to_email(message.email_to),
603 subject=message.subject,
605 body_alternative=body_alternative,
606 email_cc=to_email(message.email_cc),
607 email_bcc=to_email(message.email_bcc),
608 reply_to=message.reply_to,
609 attachments=attachments, message_id=message.message_id,
610 references = message.references,
611 object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
612 subtype=message.subtype,
613 subtype_alternative=subtype_alternative,
614 headers=message.headers and ast.literal_eval(message.headers))
615 res = ir_mail_server.send_email(cr, uid, msg,
616 mail_server_id=message.mail_server_id.id,
619 message.write({'state':'sent', 'message_id': res})
621 message.write({'state':'exception'})
623 # if auto_delete=True then delete that sent messages as well as attachments
625 if message.state == 'sent' and message.auto_delete:
626 self.pool.get('ir.attachment').unlink(cr, uid,
627 [x.id for x in message.attachment_ids],
631 _logger.exception('failed sending mail.message %s', message.id)
632 message.write({'state':'exception'})
634 if auto_commit == True:
638 def cancel(self, cr, uid, ids, context=None):
639 self.write(cr, uid, ids, {'state':'cancel'}, context=context)
642 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: