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 ##############################################################################
27 from email.header import decode_header
31 from osv import fields
32 from tools.translate import _
33 from tools.safe_eval import literal_eval
35 _logger = logging.getLogger('mail')
37 def format_date_tz(date, tz=None):
40 format = tools.DEFAULT_SERVER_DATETIME_FORMAT
41 return tools.server_to_local_timestamp(date, format, format, tz)
43 def truncate_text(text):
44 lines = text and text.split('\n') or []
46 res = '\n\t'.join(lines[:3]) + '...'
48 res = '\n\t'.join(lines)
52 """Returns unicode() string conversion of the the given encoded smtp header text"""
54 text = decode_header(text.replace('\r', ''))
55 return ''.join([tools.ustr(x[0], x[1]) for x in text])
58 """Return a list of the email addresses found in ``text``"""
59 if not text: return []
60 return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
62 class mail_message_common(osv.osv_memory):
63 """Common abstract class for holding the main attributes of a
64 message object. It could be reused as parent model for any
65 database model or wizard screen that needs to hold a kind of
68 _name = 'mail.message.common'
71 'subject': fields.char('Subject', size=512, required=True),
72 'model': fields.char('Related Document model', size=128, select=1, readonly=1),
73 'res_id': fields.integer('Related Document ID', select=1, readonly=1),
74 'date': fields.datetime('Date'),
75 'email_from': fields.char('From', size=128, help='Message sender'),
76 'email_to': fields.char('To', size=256, help='Message recipients'),
77 'email_cc': fields.char('Cc', size=256, help='Carbon copy message recipients'),
78 'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
79 'reply_to':fields.char('Reply-To', size=256, help='Response address for the message'),
80 'headers': fields.text('Message headers', help="Full message headers, e.g. SMTP session headers", readonly=1),
81 'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
82 'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
83 'subtype': fields.char('Message type', size=32, help="Type of message, usually 'html' or 'plain', used to "
84 "select plaintext or rich text contents accordingly", readonly=1),
85 'body_text': fields.text('Text contents', help="Plain-text version of the message"),
86 'body_html': fields.text('Rich-text contents', help="Rich-text/HTML version of the message"),
87 'original': fields.text('Original', help="Original version of the message, before being imported by the system", readonly=1),
94 class mail_message(osv.osv):
95 '''Model holding RFC2822 email messages, and providing facilities
96 to parse, queue and send new messages
98 Messages that do not have a value for the email_from column
99 are simple log messages (e.g. document state changes), while
100 actual e-mails have the email_from value set.
101 The ``display_text`` field will have a slightly different
102 presentation for real emails and for log messages.
105 _name = 'mail.message'
106 _inherit = 'mail.message.common'
107 _description = 'Email Message'
110 # XXX to review - how to determine action to use?
111 def open_document(self, cr, uid, ids, context=None):
114 msg = self.browse(cr, uid, ids[0], context=context)
118 ir_act_window = self.pool.get('ir.actions.act_window')
119 action_ids = ir_act_window.search(cr, uid, [('res_model', '=', model)])
121 action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
123 'domain' : "[('id','=',%d)]"%(res_id),
129 # XXX to review - how to determine action to use?
130 def open_attachment(self, cr, uid, ids, context=None):
132 action_pool = self.pool.get('ir.actions.act_window')
133 message = self.browse(cr, uid, ids, context=context)[0]
134 att_ids = [x.id for x in message.attachment_ids]
135 action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')])
137 action_data = action_pool.read(cr, uid, action_ids[0], context=context)
139 'domain': [('id','in',att_ids)],
144 def _get_display_text(self, cr, uid, ids, name, arg, context=None):
147 tz = context.get('tz')
149 for message in self.browse(cr, uid, ids, context=context):
151 if message.email_from:
152 msg_txt += _('%s wrote on %s: \n Subject: %s \n\t') % (message.email_from or '/', format_date_tz(message.date, tz), message.subject)
154 msg_txt += truncate_text(message.body)
156 msg_txt = (message.user_id.name or '/') + _(' on ') + format_date_tz(message.date, tz) + ':\n\t'
157 msg_txt += message.subject
158 result[message.id] = msg_txt
162 'partner_id': fields.many2one('res.partner', 'Related partner'),
163 'user_id': fields.many2one('res.users', 'Related user', readonly=1),
164 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', 'Attachments'),
165 'display_text': fields.function(_get_display_text, method=True, type='text', size="512", string='Display Text'),
166 'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
167 'state': fields.selection([
168 ('outgoing', 'Outgoing'),
170 ('received', 'Received'),
171 ('exception', 'Exception'),
172 ('cancel', 'Cancelled'),
173 ], 'State', readonly=True),
174 'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it"),
178 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
179 if not cr.fetchone():
180 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
182 def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
183 email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
184 res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
186 """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
187 the next time :meth:`process_email_queue` is called explicitly.
189 :param string email_from: sender email address
190 :param list email_to: list of recipient addresses (to be joined with commas)
191 :param string subject: email subject (no pre-encoding/quoting necessary)
192 :param string body: email body, according to the ``subtype`` (by default, plaintext).
193 If html subtype is used, the message will be automatically converted
194 to plaintext and wrapped in multipart/alternative.
195 :param list email_cc: optional list of string values for CC header (to be joined with commas)
196 :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
197 :param string model: optional model name of the document this mail is related to (this will also
198 be used to generate a tracking id, used to match any response related to the
200 :param int res_id: optional resource identifier this mail is related to (this will also
201 be used to generate a tracking id, used to match any response related to the
203 :param string reply_to: optional value of Reply-To header
204 :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
205 must match the format of the ``body`` parameter. Default is 'plain',
206 making the content part of the mail "text/plain".
207 :param list attachments: list of (filename, filecontents) pairs, where filecontents is a string
208 containing the bytes of the attachment
209 :param dict headers: optional map of headers to set on the outgoing mail (may override the
210 other headers, including Subject, Reply-To, Message-Id, etc.)
211 :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
212 :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
213 successfully sent (default to False)
218 if attachments is None:
220 attachment_obj = self.pool.get('ir.attachment')
221 for param in (email_to, email_cc, email_bcc):
222 if param and not isinstance(param, list):
226 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
230 'body_text': body if subtype == 'plain' else False,
231 'body_html': body if subtype == 'html' else False,
232 'email_from': email_from,
233 'email_to': email_to and ','.join(email_to) or '',
234 'email_cc': email_cc and ','.join(email_cc) or '',
235 'email_bcc': email_bcc and ','.join(email_bcc) or '',
236 'reply_to': reply_to,
237 'message_id': message_id,
238 'references': references,
240 'headers': headers, # serialize the dict on the fly
241 'mail_server_id': mail_server_id,
243 'auto_delete': auto_delete
245 email_msg_id = self.create(cr, uid, msg_vals, context)
247 for fname, fcontent in attachments.items():
250 'datas_fname': fname,
252 'res_model': self._name,
253 'res_id': email_msg_id,
255 if context.has_key('default_type'):
256 del context['default_type']
257 attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
259 self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
262 def mark_outgoing(self, cr, uid, ids, context=None):
263 return self.write(cr, uid, ids, {'state':'outgoing'}, context)
265 def process_email_queue(self, cr, uid, ids=None, context=None):
266 """Send immediately queued messages, committing after each
267 message is sent - this is not transactional and should
268 not be called during another transaction!
270 :param list ids: optional list of emails ids to send. If passed
271 no search is performed, and these ids are used
273 :param dict context: if a 'filters' key is present in context,
274 this value will be used as an additional
275 filter to further restrict the outgoing
276 messages to send (by default all 'outgoing'
282 filters = [('state', '=', 'outgoing')]
283 if 'filters' in context:
284 filters.extend(context['filters'])
285 ids = self.search(cr, uid, filters, context=context)
288 # Force auto-commit - this is meant to be called by
289 # the scheduler, and we can't allow rolling back the status
290 # of previously sent emails!
291 res = self.send(cr, uid, ids, auto_commit=True, context=context)
293 _logger.exception("Failed processing mail queue")
296 def parse_message(self, message):
297 """Parses a string or email.message.Message representing an
298 RFC-2822 email, and returns a generic dict holding the
301 :param message: the message to parse
302 :type message: email.message.Message | string | unicode
304 :return: A dict with the following structure, where each
305 field may not be present if missing in original
308 { 'message-id': msg_id,
313 'headers' : { 'X-Mailer': mailer,
314 #.. all X- headers...
316 'subtype': msg_mime_subtype,
317 'body_text': plaintext_body
318 'body_html': html_body,
319 'attachments': { 'file1': 'bytes',
322 'original': source_of_email,
326 if isinstance(message, str):
327 msg_txt = email.message_from_string(message)
329 # Warning: message_from_string doesn't always work correctly on unicode,
330 # we must use utf-8 strings here :-(
331 if isinstance(message, unicode):
332 message = message.encode('utf-8')
333 msg_txt = email.message_from_string(message)
335 message_id = msg_txt.get('message-id', False)
338 # save original, we need to be able to read the original email sometimes
339 msg['original'] = message
342 # Very unusual situation, be we should be fault-tolerant here
343 message_id = time.time()
344 msg_txt['message-id'] = message_id
345 _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
347 fields = msg_txt.keys()
348 msg['id'] = message_id
349 msg['message-id'] = message_id
351 if 'Subject' in fields:
352 msg['subject'] = decode(msg_txt.get('Subject'))
354 if 'Content-Type' in fields:
355 msg['content-type'] = msg_txt.get('Content-Type')
358 msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
360 if 'Delivered-To' in fields:
361 msg['to'] = decode(msg_txt.get('Delivered-To'))
364 msg['cc'] = decode(msg_txt.get('CC'))
366 if 'Reply-To' in fields:
367 msg['reply'] = decode(msg_txt.get('Reply-To'))
370 msg['date'] = decode(msg_txt.get('Date'))
372 if 'Content-Transfer-Encoding' in fields:
373 msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
375 if 'References' in fields:
376 msg['references'] = msg_txt.get('References')
378 if 'In-Reply-To' in fields:
379 msg['in-reply-to'] = msg_txt.get('In-Reply-To')
382 for item in msg_txt.items():
383 if item[0].startswith('X-'):
384 msg['headers'].update({item[0]: item[1]})
385 if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
386 encoding = msg_txt.get_content_charset()
387 body = msg_txt.get_payload(decode=True)
388 if 'text/html' in msg.get('content-type', ''):
389 msg['body_html'] = body
390 msg['subtype'] = 'html'
391 body = tools.html2plaintext(body)
393 msg['subtype'] = 'plain'
394 msg['body_text'] = tools.ustr(body, encoding)
397 if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
399 if 'multipart/alternative' in msg.get('content-type', ''):
400 msg['subtype'] = 'alternative'
402 msg['subtype'] = 'mixed'
403 for part in msg_txt.walk():
404 if part.get_content_maintype() == 'multipart':
407 encoding = part.get_content_charset()
408 filename = part.get_filename()
409 if part.get_content_maintype()=='text':
410 content = part.get_payload(decode=True)
412 attachments[filename] = content
413 content = tools.ustr(content, encoding)
414 if part.get_content_subtype() == 'html':
415 msg['body_html'] = content
416 body = tools.ustr(tools.html2plaintext(content))
417 elif part.get_content_subtype() == 'plain':
419 elif part.get_content_maintype() in ('application', 'image'):
421 attachments[filename] = part.get_payload(decode=True)
423 res = part.get_payload(decode=True)
424 body += tools.ustr(res, encoding)
426 msg['body_text'] = body
427 msg['attachments'] = attachments
430 def send(self, cr, uid, ids, auto_commit=False, context=None):
431 """Sends the selected emails immediately, ignoring their current
432 state (mails that have already been sent should not be passed
433 unless they should actually be re-sent).
434 Emails successfully delivered are marked as 'sent', and those
435 that fail to be deliver are marked as 'exception', and the
436 corresponding error message is output in the server logs.
438 :param bool auto_commit: whether to force a commit of the message
439 status after sending each message (meant
440 only for processing by the scheduler),
441 should never be True during normal
442 transactions (default: False)
447 ir_mail_server = self.pool.get('ir.mail_server')
448 self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
449 for message in self.browse(cr, uid, ids, context=context):
452 for attach in message.attachment_ids:
453 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
454 msg = ir_mail_server.build_email(
455 email_from=message.email_from,
456 email_to=to_email(message.email_to),
457 subject=message.subject,
458 body=message.body_html if message.subtype == 'html' else message.body_text,
459 email_cc=to_email(message.email_cc),
460 email_bcc=to_email(message.email_bcc),
461 reply_to=message.reply_to,
462 attachments=attachments, message_id=message.message_id,
463 references = message.references,
464 object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
465 subtype=message.subtype,
466 headers=message.headers and literal_eval(message.headers))
467 res = ir_mail_server.send_email(cr, uid, msg,
468 mail_server_id=message.mail_server_id.id,
471 message.write({'state':'sent', 'message_id': res})
473 message.write({'state':'exception'})
475 # if auto_delete=True then delete that sent messages as well as attachments
477 if message.state == 'sent' and message.auto_delete:
478 self.pool.get('ir.attachment').unlink(cr, uid,
479 [x.id for x in message.attachment_ids],
483 _logger.exception('failed sending mail.message %s', message.id)
484 message.write({'state':'exception'})
486 if auto_commit == True:
490 def cancel(self, cr, uid, ids, context=None):
491 self.write(cr, uid, ids, {'state':'cancel'}, context=context)
494 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: