dfd2fe00b929b2411ab09dfa69b89f3021d33864
[odoo/odoo.git] / addons / mail / mail_message.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2010-2011 OpenERP SA (<http://www.openerp.com>)
6 #
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
11 #
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
16 #
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/>
19 #
20 ##############################################################################
21
22 import ast
23 import base64
24 import dateutil.parser
25 import email
26 import logging
27 import re
28 import time
29 from email.header import decode_header
30 from email.message import Message
31
32 import tools
33 from osv import osv
34 from osv import fields
35 from tools.translate import _
36
37 _logger = logging.getLogger('mail')
38
39 def format_date_tz(date, tz=None):
40     if not date:
41         return 'n/a'
42     format = tools.DEFAULT_SERVER_DATETIME_FORMAT
43     return tools.server_to_local_timestamp(date, format, format, tz)
44
45 def truncate_text(text):
46     lines = text and text.split('\n') or []
47     if len(lines) > 3:
48         res = '\n\t'.join(lines[:3]) + '...'
49     else:
50         res = '\n\t'.join(lines)
51     return res
52
53 def decode(text):
54     """Returns unicode() string conversion of the the given encoded smtp header text"""
55     if text:
56         text = decode_header(text.replace('\r', ''))
57         return ''.join([tools.ustr(x[0], x[1]) for x in text])
58
59 def to_email(text):
60     """Return a list of the email addresses found in ``text``"""
61     if not text: return []
62     return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
63
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
68        message"""
69
70     _name = 'mail.message.common'
71     _rec_name = 'subject'
72     _columns = {
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"),
91     }
92
93     _defaults = {
94         'subtype': 'plain'
95     }
96
97 class mail_message(osv.osv):
98     '''Model holding RFC2822 email messages, and providing facilities
99        to parse, queue and send new messages
100
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.
106        '''
107
108     _name = 'mail.message'
109     _inherit = 'mail.message.common'
110     _description = 'Generic Message (Email, Comment, Notification)'
111     _order = 'date desc'
112
113     # XXX to review - how to determine action to use?
114     def open_document(self, cr, uid, ids, context=None):
115         action_data = False
116         if ids:
117             msg = self.browse(cr, uid, ids[0], context=context)
118             model = msg.model
119             res_id = msg.res_id
120
121             ir_act_window = self.pool.get('ir.actions.act_window')
122             action_ids = ir_act_window.search(cr, uid, [('res_model', '=', model)])
123             if action_ids:
124                 action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
125                 action_data.update({
126                     'domain' : "[('id','=',%d)]"%(res_id),
127                     'nodestroy': True,
128                     'context': {}
129                     })
130         return action_data
131
132     # XXX to review - how to determine action to use?
133     def open_attachment(self, cr, uid, ids, context=None):
134         action_data = False
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')])
139         if action_ids:
140             action_data = action_pool.read(cr, uid, action_ids[0], context=context)
141             action_data.update({
142                 'domain': [('id','in',att_ids)],
143                 'nodestroy': True
144                 })
145         return action_data
146
147     def _get_display_text(self, cr, uid, ids, name, arg, context=None):
148         if context is None:
149             context = {}
150         tz = context.get('tz')
151         result = {}
152         for message in self.browse(cr, uid, ids, context=context):
153             msg_txt = ''
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)
158             else:
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
162         return result
163
164     _columns = {
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'),
172                         ('sent', 'Sent'),
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([
181                         ('email', 'e-mail'),
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"),
186     }
187
188     _defaults = {
189         'state': 'received',
190         'type': 'notification',
191     }
192
193     #------------------------------------------------------
194     # Generic api
195     #------------------------------------------------------
196     
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)
204         for sub in subs:
205             notification_obj.create(cr, uid, {'user_id': sub.user_id, 'message_id': msg_id}, context=context)
206         return msg_id
207     
208     #------------------------------------------------------
209     # Note specific api
210     #------------------------------------------------------
211     
212     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
213         if not context or not context.has_key('filter_search'):
214             return super(mail_message, self).search(cr, uid, args, offset=offset, limit=limit, order=order, context=context, count=count)
215         
216         # get subscriptions
217         sub_obj = self.pool.get('mail.subscription')
218         sub_ids = sub_obj.search(cr, uid, [('user_id', '=', uid)])
219         subs = sub_obj.browse(cr, uid, sub_ids)
220         
221         # stock tweets to find
222         res_model_ids_dict = {}
223         res_model_all_list = []
224         
225         # check all subscriptions
226         for sub in subs:
227             if sub.res_model and sub.res_id == 0 and sub.res_domain == False:
228                 print "s-1"
229                 if sub.res_model not in res_model_all_list:
230                     res_model_all_list.append(sub.res_model)
231             elif sub.res_model and sub.res_id:
232                 print "s-2"
233                 if res_model_ids_dict.has_key(sub.res_model):
234                     res_model_ids_dict[sub.res_model].append(sub.res_id)
235                 else:
236                     res_model_ids_dict[sub.res_model] = [sub.res_id]
237             elif sub.res_model and sub.res_domain:
238                 print "s-3"
239                 res_obj = self.pool.get(sub.res_model)
240                 print sub.res_domain
241                 #res_ids = res_obj.search(cr, uid, [('id', 'in', [1,2])])
242                 res_ids = res_obj.search(cr, uid, eval(sub.res_domain))
243                 if res_model_ids_dict.has_key(sub.res_model):
244                     res_model_ids_dict[sub.res_model] += res_ids
245                 else:
246                     res_model_ids_dict[sub.res_model] = res_ids
247                 print 'cacaprout'
248             else:
249                 print 'erreur !!!'
250                 print sub
251         
252         # add fully-followed domains
253         args.append('|')
254         args.append(['model', 'in', res_model_all_list])
255         
256         # add partially-followed domains
257         for x in range(0, len(res_model_ids_dict.keys())-1):
258             args.append('|')
259         
260         for res_model in res_model_ids_dict.keys():
261             if res_model not in res_model_all_list:
262                 args.append('&')
263                 args.append(['model', '=', res_model])
264                 args.append(['res_id', 'in', res_model_ids_dict[res_model]])
265         
266         if context and context.has_key('filter_search'):
267             pass
268         else:
269             args = []
270         print args
271         return super(mail_message, self).search(cr, uid, args, offset=offset, limit=limit,order=order, context=context, count=count)
272     
273     #------------------------------------------------------
274     # E-Mail api
275     #------------------------------------------------------
276     
277     def init(self, cr):
278         cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
279         if not cr.fetchone():
280             cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
281
282     def copy(self, cr, uid, id, default=None, context=None):
283         """Overridden to avoid duplicating fields that are unique to each email"""
284         if default is None:
285             default = {}
286         default.update(message_id=False,original=False,headers=False)
287         return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
288
289     def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
290                              email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
291                              res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
292                              context=None):
293         """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
294            the next time :meth:`process_email_queue` is called explicitly.
295
296            :param string email_from: sender email address
297            :param list email_to: list of recipient addresses (to be joined with commas) 
298            :param string subject: email subject (no pre-encoding/quoting necessary)
299            :param string body: email body, according to the ``subtype`` (by default, plaintext).
300                                If html subtype is used, the message will be automatically converted
301                                to plaintext and wrapped in multipart/alternative.
302            :param list email_cc: optional list of string values for CC header (to be joined with commas)
303            :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
304            :param string model: optional model name of the document this mail is related to (this will also
305                                 be used to generate a tracking id, used to match any response related to the
306                                 same document)
307            :param int res_id: optional resource identifier this mail is related to (this will also
308                               be used to generate a tracking id, used to match any response related to the
309                               same document)
310            :param string reply_to: optional value of Reply-To header
311            :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
312                                   must match the format of the ``body`` parameter. Default is 'plain',
313                                   making the content part of the mail "text/plain".
314            :param dict attachments: map of filename to filecontents, where filecontents is a string
315                                     containing the bytes of the attachment
316            :param dict headers: optional map of headers to set on the outgoing mail (may override the
317                                 other headers, including Subject, Reply-To, Message-Id, etc.)
318            :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
319            :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
320                                     successfully sent (default to False)
321
322         """
323         if context is None:
324             context = {}
325         if attachments is None:
326             attachments = {}
327         attachment_obj = self.pool.get('ir.attachment')
328         for param in (email_to, email_cc, email_bcc):
329             if param and not isinstance(param, list):
330                 param = [param]
331         msg_vals = {
332                 'subject': subject,
333                 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
334                 'user_id': uid,
335                 'model': model,
336                 'res_id': res_id,
337                 'body_text': body if subtype != 'html' else False,
338                 'body_html': body if subtype == 'html' else False,
339                 'email_from': email_from,
340                 'email_to': email_to and ','.join(email_to) or '',
341                 'email_cc': email_cc and ','.join(email_cc) or '',
342                 'email_bcc': email_bcc and ','.join(email_bcc) or '',
343                 'reply_to': reply_to,
344                 'message_id': message_id,
345                 'references': references,
346                 'subtype': subtype,
347                 'headers': headers, # serialize the dict on the fly
348                 'mail_server_id': mail_server_id,
349                 'state': 'outgoing',
350                 'auto_delete': auto_delete
351             }
352         email_msg_id = self.create(cr, uid, msg_vals, context)
353         attachment_ids = []
354         for fname, fcontent in attachments.iteritems():
355             attachment_data = {
356                     'name': fname,
357                     'datas_fname': fname,
358                     'datas': fcontent,
359                     'res_model': self._name,
360                     'res_id': email_msg_id,
361             }
362             if context.has_key('default_type'):
363                 del context['default_type']
364             attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
365         if attachment_ids:
366             self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
367         return email_msg_id
368
369     def mark_outgoing(self, cr, uid, ids, context=None):
370         return self.write(cr, uid, ids, {'state':'outgoing'}, context)
371
372     def process_email_queue(self, cr, uid, ids=None, context=None):
373         """Send immediately queued messages, committing after each
374            message is sent - this is not transactional and should
375            not be called during another transaction!
376
377            :param list ids: optional list of emails ids to send. If passed
378                             no search is performed, and these ids are used
379                             instead.
380            :param dict context: if a 'filters' key is present in context,
381                                 this value will be used as an additional
382                                 filter to further restrict the outgoing
383                                 messages to send (by default all 'outgoing'
384                                 messages are sent).
385         """
386         if context is None:
387             context = {}
388         if not ids:
389             filters = [('state', '=', 'outgoing')]
390             if 'filters' in context:
391                 filters.extend(context['filters'])
392             ids = self.search(cr, uid, filters, context=context)
393         res = None
394         try:
395             # Force auto-commit - this is meant to be called by
396             # the scheduler, and we can't allow rolling back the status
397             # of previously sent emails!
398             res = self.send(cr, uid, ids, auto_commit=True, context=context)
399         except Exception:
400             _logger.exception("Failed processing mail queue")
401         return res
402
403     def parse_message(self, message, save_original=False):
404         """Parses a string or email.message.Message representing an
405            RFC-2822 email, and returns a generic dict holding the
406            message details.
407
408            :param message: the message to parse
409            :type message: email.message.Message | string | unicode
410            :param bool save_original: whether the returned dict
411                should include an ``original`` entry with the base64
412                encoded source of the message.
413            :rtype: dict
414            :return: A dict with the following structure, where each
415                     field may not be present if missing in original
416                     message::
417
418                     { 'message-id': msg_id,
419                       'subject': subject,
420                       'from': from,
421                       'to': to,
422                       'cc': cc,
423                       'headers' : { 'X-Mailer': mailer,
424                                     #.. all X- headers...
425                                   },
426                       'subtype': msg_mime_subtype,
427                       'body_text': plaintext_body
428                       'body_html': html_body,
429                       'attachments': [('file1', 'bytes'),
430                                        ('file2', 'bytes') }
431                        # ...
432                        'original': source_of_email,
433                     }
434         """
435         msg_txt = message
436         if isinstance(message, str):
437             msg_txt = email.message_from_string(message)
438
439         # Warning: message_from_string doesn't always work correctly on unicode,
440         # we must use utf-8 strings here :-(
441         if isinstance(message, unicode):
442             message = message.encode('utf-8')
443             msg_txt = email.message_from_string(message)
444
445         message_id = msg_txt.get('message-id', False)
446         msg = {}
447
448         if save_original:
449             # save original, we need to be able to read the original email sometimes
450             msg['original'] = message.as_string() if isinstance(message, Message) \
451                                                   else message
452             msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
453
454         if not message_id:
455             # Very unusual situation, be we should be fault-tolerant here
456             message_id = time.time()
457             msg_txt['message-id'] = message_id
458             _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
459
460         fields = msg_txt.keys()
461         msg['id'] = message_id
462         msg['message-id'] = message_id
463
464         if 'Subject' in fields:
465             msg['subject'] = decode(msg_txt.get('Subject'))
466
467         if 'Content-Type' in fields:
468             msg['content-type'] = msg_txt.get('Content-Type')
469
470         if 'From' in fields:
471             msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
472
473         if 'To' in fields:
474             msg['to'] = decode(msg_txt.get('To'))
475
476         if 'Delivered-To' in fields:
477             msg['to'] = decode(msg_txt.get('Delivered-To'))
478
479         if 'CC' in fields:
480             msg['cc'] = decode(msg_txt.get('CC'))
481
482         if 'Cc' in fields:
483             msg['cc'] = decode(msg_txt.get('Cc'))
484
485         if 'Reply-To' in fields:
486             msg['reply'] = decode(msg_txt.get('Reply-To'))
487
488         if 'Date' in fields:
489             date_hdr = decode(msg_txt.get('Date'))
490             msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
491
492         if 'Content-Transfer-Encoding' in fields:
493             msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
494
495         if 'References' in fields:
496             msg['references'] = msg_txt.get('References')
497
498         if 'In-Reply-To' in fields:
499             msg['in-reply-to'] = msg_txt.get('In-Reply-To')
500
501         msg['headers'] = {}
502         msg['subtype'] = 'plain'
503         for item in msg_txt.items():
504             if item[0].startswith('X-'):
505                 msg['headers'].update({item[0]: item[1]})
506         if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
507             encoding = msg_txt.get_content_charset()
508             body = msg_txt.get_payload(decode=True)
509             if 'text/html' in msg.get('content-type', ''):
510                 msg['body_html'] =  body
511                 msg['subtype'] = 'html'
512                 body = tools.html2plaintext(body)
513             msg['body_text'] = tools.ustr(body, encoding)
514
515         attachments = []
516         if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
517             body = ""
518             if 'multipart/alternative' in msg.get('content-type', ''):
519                 msg['subtype'] = 'alternative'
520             else:
521                 msg['subtype'] = 'mixed'
522             for part in msg_txt.walk():
523                 if part.get_content_maintype() == 'multipart':
524                     continue
525
526                 encoding = part.get_content_charset()
527                 filename = part.get_filename()
528                 if part.get_content_maintype()=='text':
529                     content = part.get_payload(decode=True)
530                     if filename:
531                         attachments.append((filename, content))
532                     content = tools.ustr(content, encoding)
533                     if part.get_content_subtype() == 'html':
534                         msg['body_html'] = content
535                         msg['subtype'] = 'html' # html version prevails
536                         body = tools.ustr(tools.html2plaintext(content))
537                     elif part.get_content_subtype() == 'plain':
538                         body = content
539                 elif part.get_content_maintype() in ('application', 'image'):
540                     if filename :
541                         attachments.append((filename,part.get_payload(decode=True)))
542                     else:
543                         res = part.get_payload(decode=True)
544                         body += tools.ustr(res, encoding)
545
546             msg['body_text'] = body
547         msg['attachments'] = attachments
548
549         # for backwards compatibility:
550         msg['body'] = msg['body_text']
551         msg['sub_type'] = msg['subtype'] or 'plain'
552         return msg
553
554
555     def send(self, cr, uid, ids, auto_commit=False, context=None):
556         """Sends the selected emails immediately, ignoring their current
557            state (mails that have already been sent should not be passed
558            unless they should actually be re-sent).
559            Emails successfully delivered are marked as 'sent', and those
560            that fail to be deliver are marked as 'exception', and the
561            corresponding error message is output in the server logs.
562
563            :param bool auto_commit: whether to force a commit of the message
564                                     status after sending each message (meant
565                                     only for processing by the scheduler),
566                                     should never be True during normal
567                                     transactions (default: False)
568            :return: True
569         """
570         if context is None:
571             context = {}
572         ir_mail_server = self.pool.get('ir.mail_server')
573         self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
574         for message in self.browse(cr, uid, ids, context=context):
575             try:
576                 attachments = []
577                 for attach in message.attachment_ids:
578                     attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
579
580                 body = message.body_html if message.subtype == 'html' else message.body_text
581                 body_alternative = None
582                 subtype_alternative = None
583                 if message.subtype == 'html' and message.body_text:
584                     # we have a plain text alternative prepared, pass it to 
585                     # build_message instead of letting it build one
586                     body_alternative = message.body_text
587                     subtype_alternative = 'plain'
588
589                 msg = ir_mail_server.build_email(
590                     email_from=message.email_from,
591                     email_to=to_email(message.email_to),
592                     subject=message.subject,
593                     body=body,
594                     body_alternative=body_alternative,
595                     email_cc=to_email(message.email_cc),
596                     email_bcc=to_email(message.email_bcc),
597                     reply_to=message.reply_to,
598                     attachments=attachments, message_id=message.message_id,
599                     references = message.references,
600                     object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
601                     subtype=message.subtype,
602                     subtype_alternative=subtype_alternative,
603                     headers=message.headers and ast.literal_eval(message.headers))
604                 res = ir_mail_server.send_email(cr, uid, msg,
605                                                 mail_server_id=message.mail_server_id.id,
606                                                 context=context)
607                 if res:
608                     message.write({'state':'sent', 'message_id': res})
609                 else:
610                     message.write({'state':'exception'})
611
612                 # if auto_delete=True then delete that sent messages as well as attachments
613                 message.refresh()
614                 if message.state == 'sent' and message.auto_delete:
615                     self.pool.get('ir.attachment').unlink(cr, uid,
616                                                           [x.id for x in message.attachment_ids],
617                                                           context=context)
618                     message.unlink()
619             except Exception:
620                 _logger.exception('failed sending mail.message %s', message.id)
621                 message.write({'state':'exception'})
622
623             if auto_commit == True:
624                 cr.commit()
625         return True
626
627     def cancel(self, cr, uid, ids, context=None):
628         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
629         return True
630
631 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: