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, readonly=1),
75 'res_id': fields.integer('Related Document ID', select=1, readonly=1),
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 = 'Email Message'
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),
186 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
187 if not cr.fetchone():
188 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
190 def copy(self, cr, uid, id, default=None, context=None):
191 """Overridden to avoid duplicating fields that are unique to each email"""
194 default.update(message_id=False,original=False,headers=False)
195 return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
197 def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
198 email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
199 res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
201 """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
202 the next time :meth:`process_email_queue` is called explicitly.
204 :param string email_from: sender email address
205 :param list email_to: list of recipient addresses (to be joined with commas)
206 :param string subject: email subject (no pre-encoding/quoting necessary)
207 :param string body: email body, according to the ``subtype`` (by default, plaintext).
208 If html subtype is used, the message will be automatically converted
209 to plaintext and wrapped in multipart/alternative.
210 :param list email_cc: optional list of string values for CC header (to be joined with commas)
211 :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
212 :param string model: optional model name of the document this mail is related to (this will also
213 be used to generate a tracking id, used to match any response related to the
215 :param int res_id: optional resource identifier this mail is related to (this will also
216 be used to generate a tracking id, used to match any response related to the
218 :param string reply_to: optional value of Reply-To header
219 :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
220 must match the format of the ``body`` parameter. Default is 'plain',
221 making the content part of the mail "text/plain".
222 :param dict attachments: map of filename to filecontents, where filecontents is a string
223 containing the bytes of the attachment
224 :param dict headers: optional map of headers to set on the outgoing mail (may override the
225 other headers, including Subject, Reply-To, Message-Id, etc.)
226 :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
227 :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
228 successfully sent (default to False)
233 if attachments is None:
235 attachment_obj = self.pool.get('ir.attachment')
236 for param in (email_to, email_cc, email_bcc):
237 if param and not isinstance(param, list):
241 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
245 'body_text': body if subtype != 'html' else False,
246 'body_html': body if subtype == 'html' else False,
247 'email_from': email_from,
248 'email_to': email_to and ','.join(email_to) or '',
249 'email_cc': email_cc and ','.join(email_cc) or '',
250 'email_bcc': email_bcc and ','.join(email_bcc) or '',
251 'reply_to': reply_to,
252 'message_id': message_id,
253 'references': references,
255 'headers': headers, # serialize the dict on the fly
256 'mail_server_id': mail_server_id,
258 'auto_delete': auto_delete
260 email_msg_id = self.create(cr, uid, msg_vals, context)
262 for fname, fcontent in attachments.iteritems():
265 'datas_fname': fname,
267 'res_model': self._name,
268 'res_id': email_msg_id,
270 if context.has_key('default_type'):
271 del context['default_type']
272 attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
274 self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
277 def mark_outgoing(self, cr, uid, ids, context=None):
278 return self.write(cr, uid, ids, {'state':'outgoing'}, context)
280 def process_email_queue(self, cr, uid, ids=None, context=None):
281 """Send immediately queued messages, committing after each
282 message is sent - this is not transactional and should
283 not be called during another transaction!
285 :param list ids: optional list of emails ids to send. If passed
286 no search is performed, and these ids are used
288 :param dict context: if a 'filters' key is present in context,
289 this value will be used as an additional
290 filter to further restrict the outgoing
291 messages to send (by default all 'outgoing'
297 filters = [('state', '=', 'outgoing')]
298 if 'filters' in context:
299 filters.extend(context['filters'])
300 ids = self.search(cr, uid, filters, context=context)
303 # Force auto-commit - this is meant to be called by
304 # the scheduler, and we can't allow rolling back the status
305 # of previously sent emails!
306 res = self.send(cr, uid, ids, auto_commit=True, context=context)
308 _logger.exception("Failed processing mail queue")
311 def parse_message(self, message, save_original=False):
312 """Parses a string or email.message.Message representing an
313 RFC-2822 email, and returns a generic dict holding the
316 :param message: the message to parse
317 :type message: email.message.Message | string | unicode
318 :param bool save_original: whether the returned dict
319 should include an ``original`` entry with the base64
320 encoded source of the message.
322 :return: A dict with the following structure, where each
323 field may not be present if missing in original
326 { 'message-id': msg_id,
331 'headers' : { 'X-Mailer': mailer,
332 #.. all X- headers...
334 'subtype': msg_mime_subtype,
335 'body_text': plaintext_body
336 'body_html': html_body,
337 'attachments': [('file1', 'bytes'),
340 'original': source_of_email,
344 if isinstance(message, str):
345 msg_txt = email.message_from_string(message)
347 # Warning: message_from_string doesn't always work correctly on unicode,
348 # we must use utf-8 strings here :-(
349 if isinstance(message, unicode):
350 message = message.encode('utf-8')
351 msg_txt = email.message_from_string(message)
353 message_id = msg_txt.get('message-id', False)
357 # save original, we need to be able to read the original email sometimes
358 msg['original'] = message.as_string() if isinstance(message, Message) \
360 msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
363 # Very unusual situation, be we should be fault-tolerant here
364 message_id = time.time()
365 msg_txt['message-id'] = message_id
366 _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
368 fields = msg_txt.keys()
369 msg['id'] = message_id
370 msg['message-id'] = message_id
372 if 'Subject' in fields:
373 msg['subject'] = decode(msg_txt.get('Subject'))
375 if 'Content-Type' in fields:
376 msg['content-type'] = msg_txt.get('Content-Type')
379 msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
382 msg['to'] = decode(msg_txt.get('To'))
384 if 'Delivered-To' in fields:
385 msg['to'] = decode(msg_txt.get('Delivered-To'))
388 msg['cc'] = decode(msg_txt.get('CC'))
391 msg['cc'] = decode(msg_txt.get('Cc'))
393 if 'Reply-To' in fields:
394 msg['reply'] = decode(msg_txt.get('Reply-To'))
397 date_hdr = decode(msg_txt.get('Date'))
398 msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
400 if 'Content-Transfer-Encoding' in fields:
401 msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
403 if 'References' in fields:
404 msg['references'] = msg_txt.get('References')
406 if 'In-Reply-To' in fields:
407 msg['in-reply-to'] = msg_txt.get('In-Reply-To')
410 msg['subtype'] = 'plain'
411 for item in msg_txt.items():
412 if item[0].startswith('X-'):
413 msg['headers'].update({item[0]: item[1]})
414 if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
415 encoding = msg_txt.get_content_charset()
416 body = msg_txt.get_payload(decode=True)
417 if 'text/html' in msg.get('content-type', ''):
418 msg['body_html'] = body
419 msg['subtype'] = 'html'
420 body = tools.html2plaintext(body)
421 msg['body_text'] = tools.ustr(body, encoding)
424 if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
426 if 'multipart/alternative' in msg.get('content-type', ''):
427 msg['subtype'] = 'alternative'
429 msg['subtype'] = 'mixed'
430 for part in msg_txt.walk():
431 if part.get_content_maintype() == 'multipart':
434 encoding = part.get_content_charset()
435 filename = part.get_filename()
436 if part.get_content_maintype()=='text':
437 content = part.get_payload(decode=True)
439 attachments.append((filename, content))
440 content = tools.ustr(content, encoding)
441 if part.get_content_subtype() == 'html':
442 msg['body_html'] = content
443 msg['subtype'] = 'html' # html version prevails
444 body = tools.ustr(tools.html2plaintext(content))
445 elif part.get_content_subtype() == 'plain':
447 elif part.get_content_maintype() in ('application', 'image'):
449 attachments.append((filename,part.get_payload(decode=True)))
451 res = part.get_payload(decode=True)
452 body += tools.ustr(res, encoding)
454 msg['body_text'] = body
455 msg['attachments'] = attachments
457 # for backwards compatibility:
458 msg['body'] = msg['body_text']
459 msg['sub_type'] = msg['subtype'] or 'plain'
463 def send(self, cr, uid, ids, auto_commit=False, context=None):
464 """Sends the selected emails immediately, ignoring their current
465 state (mails that have already been sent should not be passed
466 unless they should actually be re-sent).
467 Emails successfully delivered are marked as 'sent', and those
468 that fail to be deliver are marked as 'exception', and the
469 corresponding error message is output in the server logs.
471 :param bool auto_commit: whether to force a commit of the message
472 status after sending each message (meant
473 only for processing by the scheduler),
474 should never be True during normal
475 transactions (default: False)
480 ir_mail_server = self.pool.get('ir.mail_server')
481 self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
482 for message in self.browse(cr, uid, ids, context=context):
485 for attach in message.attachment_ids:
486 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
488 body = message.body_html if message.subtype == 'html' else message.body_text
489 body_alternative = None
490 subtype_alternative = None
491 if message.subtype == 'html' and message.body_text:
492 # we have a plain text alternative prepared, pass it to
493 # build_message instead of letting it build one
494 body_alternative = message.body_text
495 subtype_alternative = 'plain'
497 msg = ir_mail_server.build_email(
498 email_from=message.email_from,
499 email_to=to_email(message.email_to),
500 subject=message.subject,
502 body_alternative=body_alternative,
503 email_cc=to_email(message.email_cc),
504 email_bcc=to_email(message.email_bcc),
505 reply_to=message.reply_to,
506 attachments=attachments, message_id=message.message_id,
507 references = message.references,
508 object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
509 subtype=message.subtype,
510 subtype_alternative=subtype_alternative,
511 headers=message.headers and ast.literal_eval(message.headers))
512 res = ir_mail_server.send_email(cr, uid, msg,
513 mail_server_id=message.mail_server_id.id,
516 message.write({'state':'sent', 'message_id': res})
518 message.write({'state':'exception'})
520 # if auto_delete=True then delete that sent messages as well as attachments
522 if message.state == 'sent' and message.auto_delete:
523 self.pool.get('ir.attachment').unlink(cr, uid,
524 [x.id for x in message.attachment_ids],
528 _logger.exception('failed sending mail.message %s', message.id)
529 message.write({'state':'exception'})
531 if auto_commit == True:
535 def cancel(self, cr, uid, ids, context=None):
536 self.write(cr, uid, ids, {'state':'cancel'}, context=context)
539 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: