[REM] Removed useless argument causing problem in case of buggy load_state
[odoo/odoo.git] / addons / mail / mail_message.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2010-today 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 import datetime
30 from email.header import decode_header
31 from email.message import Message
32
33 import tools
34 from osv import osv
35 from osv import fields
36 from tools.translate import _
37 from openerp import SUPERUSER_ID
38
39 _logger = logging.getLogger(__name__)
40
41 def format_date_tz(date, tz=None):
42     if not date:
43         return 'n/a'
44     format = tools.DEFAULT_SERVER_DATETIME_FORMAT
45     return tools.server_to_local_timestamp(date, format, format, tz)
46
47 def truncate_text(text):
48     lines = text and text.split('\n') or []
49     if len(lines) > 3:
50         res = '\n\t'.join(lines[:3]) + '...'
51     else:
52         res = '\n\t'.join(lines)
53     return res
54
55 def decode(text):
56     """Returns unicode() string conversion of the the given encoded smtp header text"""
57     if text:
58         text = decode_header(text.replace('\r', ''))
59         return ''.join([tools.ustr(x[0], x[1]) for x in text])
60
61 def to_email(text):
62     """Return a list of the email addresses found in ``text``"""
63     if not text: return []
64     return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
65
66 class mail_message_common(osv.osv_memory):
67     """Common abstract class for holding the main attributes of a 
68        message object. It could be reused as parent model for any
69        database model or wizard screen that needs to hold a kind of
70        message"""
71
72     def get_body(self, cr, uid, ids, name, arg, context=None):
73         if context is None:
74             context = {}
75         result = dict.fromkeys(ids, '')
76         for message in self.browse(cr, uid, ids, context=context):
77             if message.subtype == 'html':
78                 result[message.id] = message.body_html
79             else:
80                 result[message.id] = message.body_text
81         return result
82     
83     def search_body(self, cr, uid, obj, name, args, context=None):
84         """will receive:
85            - obj: mail.message object
86            - name: 'body'
87            - args: [('body', 'ilike', 'blah')]"""
88         return ['|', '&', ('subtype', '=', 'html'), ('body_html', args[0][1], args[0][2]), ('body_text', args[0][1], args[0][2])]
89     
90     def get_record_name(self, cr, uid, ids, name, arg, context=None):
91         if context is None:
92             context = {}
93         result = dict.fromkeys(ids, '')
94         for message in self.browse(cr, uid, ids, context=context):
95             if not message.model or not message.res_id:
96                 continue
97             result[message.id] = self.pool.get(message.model).name_get(cr, uid, [message.res_id], context=context)[0][1]
98         return result
99
100     def name_get(self, cr, uid, ids, context=None):
101         res = []
102         for message in self.browse(cr, uid, ids, context=context):
103             name = ''
104             if message.subject:
105                 name = '%s: ' % (message.subject)
106             if message.body_text:
107                 name = '%s%s ' % (name, message.body_text[0:20])
108             if message.date:
109                 name = '%s(%s)' % (name, message.date)
110             res.append((message.id, name))
111         return res
112
113     _name = 'mail.message.common'
114     _rec_name = 'subject'
115     _columns = {
116         'subject': fields.char('Subject', size=512),
117         'model': fields.char('Related Document Model', size=128, select=1),
118         'res_id': fields.integer('Related Document ID', select=1),
119         'record_name': fields.function(get_record_name, type='string', string='Message Record Name',
120                         help="Name of the record, matching the result of the name_get."),
121         'date': fields.datetime('Date'),
122         'email_from': fields.char('From', size=128, help='Message sender, taken from user preferences.'),
123         'email_to': fields.char('To', size=256, help='Message recipients'),
124         'email_cc': fields.char('Cc', size=256, help='Carbon copy message recipients'),
125         'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
126         'reply_to':fields.char('Reply-To', size=256, help='Preferred response address for the message'),
127         'headers': fields.text('Message Headers', readonly=1,
128                         help="Full message headers, e.g. SMTP session headers (usually available on inbound messages only)"),
129         'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
130         'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
131         'subtype': fields.char('Message Type', size=32, help="Type of message, usually 'html' or 'plain', used to "
132                                                              "select plaintext or rich text contents accordingly", readonly=1),
133         'body_text': fields.text('Text Contents', help="Plain-text version of the message"),
134         'body_html': fields.text('Rich-text Contents', help="Rich-text/HTML version of the message"),
135         'body': fields.function(get_body, fnct_search = search_body, string='Message Content', type='text',
136                         help="Content of the message. This content equals the body_text field for plain-test messages, and body_html for rich-text/HTML messages. This allows having one field if we want to access the content matching the message subtype."),
137         'parent_id': fields.many2one('mail.message', 'Parent Message', help="Parent message, used for displaying as threads with hierarchy",
138                         select=True, ondelete='set null',),
139         'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
140     }
141
142     _defaults = {
143         'subtype': 'plain',
144         'date': (lambda *a: fields.datetime.now()),
145     }
146
147 class mail_message(osv.osv):
148     '''Model holding messages: system notification (replacing res.log
149        notifications), comments (for OpenSocial feature) and
150        RFC2822 email messages. This model also provides facilities to
151        parse, queue and send new email messages. Type of messages
152        are differentiated using the 'type' column.
153        
154        The ``display_text`` field will have a slightly different
155        presentation for real emails and for log messages.
156        '''
157
158     _name = 'mail.message'
159     _inherit = 'mail.message.common'
160     _description = 'Mail Message (email, comment, notification)'
161     _order = 'date desc'
162
163     # XXX to review - how to determine action to use?
164     def open_document(self, cr, uid, ids, context=None):
165         action_data = False
166         if ids:
167             msg = self.browse(cr, uid, ids[0], context=context)
168             model = msg.model
169             res_id = msg.res_id
170
171             ir_act_window = self.pool.get('ir.actions.act_window')
172             action_ids = ir_act_window.search(cr, uid, [('res_model', '=', model)])
173             if action_ids:
174                 action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
175                 action_data.update({
176                     'domain' : "[('id','=',%d)]"%(res_id),
177                     'nodestroy': True,
178                     'context': {}
179                     })
180         return action_data
181
182     # XXX to review - how to determine action to use?
183     def open_attachment(self, cr, uid, ids, context=None):
184         action_data = False
185         action_pool = self.pool.get('ir.actions.act_window')
186         message = self.browse(cr, uid, ids, context=context)[0]
187         att_ids = [x.id for x in message.attachment_ids]
188         action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')])
189         if action_ids:
190             action_data = action_pool.read(cr, uid, action_ids[0], context=context)
191             action_data.update({
192                 'domain': [('id','in',att_ids)],
193                 'nodestroy': True
194                 })
195         return action_data
196
197     def _get_display_text(self, cr, uid, ids, name, arg, context=None):
198         if context is None:
199             context = {}
200         tz = context.get('tz')
201         result = {}
202
203         # Read message as UID 1 to allow viewing author even if from different company
204         for message in self.browse(cr, SUPERUSER_ID, ids):
205             msg_txt = ''
206             if message.email_from:
207                 msg_txt += _('%s wrote on %s: \n Subject: %s \n\t') % (message.email_from or '/', format_date_tz(message.date, tz), message.subject)
208                 if message.body_text:
209                     msg_txt += truncate_text(message.body_text)
210             else:
211                 msg_txt = (message.user_id.name or '/') + _(' on ') + format_date_tz(message.date, tz) + ':\n\t'
212                 msg_txt += (message.subject or '')
213             result[message.id] = msg_txt
214         return result
215     
216     _columns = {
217         'type': fields.selection([
218                         ('email', 'email'),
219                         ('comment', 'Comment'),
220                         ('notification', 'System notification'),
221                         ], 'Type', help="Message type: email for email message, notification for system message, comment for other messages such as user replies"),
222         'partner_id': fields.many2one('res.partner', 'Related partner'),
223         'user_id': fields.many2one('res.users', 'Related User', readonly=1),
224         'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', 'Attachments'),
225         'display_text': fields.function(_get_display_text, method=True, type='text', size="512", string='Display Text'),
226         'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
227         'state': fields.selection([
228                         ('outgoing', 'Outgoing'),
229                         ('sent', 'Sent'),
230                         ('received', 'Received'),
231                         ('exception', 'Delivery Failed'),
232                         ('cancel', 'Cancelled'),
233                         ], 'Status', readonly=True),
234         'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it, to save space"),
235         'original': fields.binary('Original', help="Original version of the message, as it was sent on the network", readonly=1),
236     }
237         
238     _defaults = {
239         'type': 'email',
240         'state': 'received',
241     }
242     
243     #------------------------------------------------------
244     # Email api
245     #------------------------------------------------------
246     
247     def init(self, cr):
248         cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
249         if not cr.fetchone():
250             cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
251
252     def copy(self, cr, uid, id, default=None, context=None):
253         """Overridden to avoid duplicating fields that are unique to each email"""
254         if default is None:
255             default = {}
256         default.update(message_id=False,original=False,headers=False)
257         return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
258
259     def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
260                              email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
261                              res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
262                              context=None):
263         """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
264            the next time :meth:`process_email_queue` is called explicitly.
265
266            :param string email_from: sender email address
267            :param list email_to: list of recipient addresses (to be joined with commas) 
268            :param string subject: email subject (no pre-encoding/quoting necessary)
269            :param string body: email body, according to the ``subtype`` (by default, plaintext).
270                                If html subtype is used, the message will be automatically converted
271                                to plaintext and wrapped in multipart/alternative.
272            :param list email_cc: optional list of string values for CC header (to be joined with commas)
273            :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
274            :param string model: optional model name of the document this mail is related to (this will also
275                                 be used to generate a tracking id, used to match any response related to the
276                                 same document)
277            :param int res_id: optional resource identifier this mail is related to (this will also
278                               be used to generate a tracking id, used to match any response related to the
279                               same document)
280            :param string reply_to: optional value of Reply-To header
281            :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
282                                   must match the format of the ``body`` parameter. Default is 'plain',
283                                   making the content part of the mail "text/plain".
284            :param dict attachments: map of filename to filecontents, where filecontents is a string
285                                     containing the bytes of the attachment
286            :param dict headers: optional map of headers to set on the outgoing mail (may override the
287                                 other headers, including Subject, Reply-To, Message-Id, etc.)
288            :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
289            :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
290                                     successfully sent (default to False)
291
292         """
293         if context is None:
294             context = {}
295         if attachments is None:
296             attachments = {}
297         attachment_obj = self.pool.get('ir.attachment')
298         for param in (email_to, email_cc, email_bcc):
299             if param and not isinstance(param, list):
300                 param = [param]
301         msg_vals = {
302                 'subject': subject,
303                 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
304                 'user_id': uid,
305                 'model': model,
306                 'res_id': res_id,
307                 'type': 'email',
308                 'body_text': body if subtype != 'html' else False,
309                 'body_html': body if subtype == 'html' else False,
310                 'email_from': email_from,
311                 'email_to': email_to and ','.join(email_to) or '',
312                 'email_cc': email_cc and ','.join(email_cc) or '',
313                 'email_bcc': email_bcc and ','.join(email_bcc) or '',
314                 'reply_to': reply_to,
315                 'message_id': message_id,
316                 'references': references,
317                 'subtype': subtype,
318                 'headers': headers, # serialize the dict on the fly
319                 'mail_server_id': mail_server_id,
320                 'state': 'outgoing',
321                 'auto_delete': auto_delete
322             }
323         email_msg_id = self.create(cr, uid, msg_vals, context)
324         attachment_ids = []
325         for fname, fcontent in attachments.iteritems():
326             attachment_data = {
327                     'name': fname,
328                     'datas_fname': fname,
329                     'datas': fcontent and fcontent.encode('base64'),
330                     'res_model': self._name,
331                     'res_id': email_msg_id,
332             }
333             if context.has_key('default_type'):
334                 del context['default_type']
335             attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
336         if attachment_ids:
337             self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
338         return email_msg_id
339
340     def mark_outgoing(self, cr, uid, ids, context=None):
341         return self.write(cr, uid, ids, {'state':'outgoing'}, context)
342
343     def process_email_queue(self, cr, uid, ids=None, context=None):
344         """Send immediately queued messages, committing after each
345            message is sent - this is not transactional and should
346            not be called during another transaction!
347
348            :param list ids: optional list of emails ids to send. If passed
349                             no search is performed, and these ids are used
350                             instead.
351            :param dict context: if a 'filters' key is present in context,
352                                 this value will be used as an additional
353                                 filter to further restrict the outgoing
354                                 messages to send (by default all 'outgoing'
355                                 messages are sent).
356         """
357         if context is None:
358             context = {}
359         if not ids:
360             filters = [('state', '=', 'outgoing')]
361             if 'filters' in context:
362                 filters.extend(context['filters'])
363             ids = self.search(cr, uid, filters, context=context)
364         res = None
365         try:
366             # Force auto-commit - this is meant to be called by
367             # the scheduler, and we can't allow rolling back the status
368             # of previously sent emails!
369             res = self.send(cr, uid, ids, auto_commit=True, context=context)
370         except Exception:
371             _logger.exception("Failed processing mail queue")
372         return res
373
374     def parse_message(self, message, save_original=False):
375         """Parses a string or email.message.Message representing an
376            RFC-2822 email, and returns a generic dict holding the
377            message details.
378
379            :param message: the message to parse
380            :type message: email.message.Message | string | unicode
381            :param bool save_original: whether the returned dict
382                should include an ``original`` entry with the base64
383                encoded source of the message.
384            :rtype: dict
385            :return: A dict with the following structure, where each
386                     field may not be present if missing in original
387                     message::
388
389                     { 'message-id': msg_id,
390                       'subject': subject,
391                       'from': from,
392                       'to': to,
393                       'cc': cc,
394                       'headers' : { 'X-Mailer': mailer,
395                                     #.. all X- headers...
396                                   },
397                       'subtype': msg_mime_subtype,
398                       'body_text': plaintext_body
399                       'body_html': html_body,
400                       'attachments': [('file1', 'bytes'),
401                                        ('file2', 'bytes') }
402                        # ...
403                        'original': source_of_email,
404                     }
405         """
406         msg_txt = message
407         if isinstance(message, str):
408             msg_txt = email.message_from_string(message)
409
410         # Warning: message_from_string doesn't always work correctly on unicode,
411         # we must use utf-8 strings here :-(
412         if isinstance(message, unicode):
413             message = message.encode('utf-8')
414             msg_txt = email.message_from_string(message)
415
416         message_id = msg_txt.get('message-id', False)
417         msg = {}
418
419         if save_original:
420             # save original, we need to be able to read the original email sometimes
421             msg['original'] = message.as_string() if isinstance(message, Message) \
422                                                   else message
423             msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
424
425         if not message_id:
426             # Very unusual situation, be we should be fault-tolerant here
427             message_id = time.time()
428             msg_txt['message-id'] = message_id
429             _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
430
431         fields = msg_txt.keys()
432         msg['id'] = message_id
433         msg['message-id'] = message_id
434
435         if 'Subject' in fields:
436             msg['subject'] = decode(msg_txt.get('Subject'))
437
438         if 'Content-Type' in fields:
439             msg['content-type'] = msg_txt.get('Content-Type')
440
441         if 'From' in fields:
442             msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
443
444         if 'To' in fields:
445             msg['to'] = decode(msg_txt.get('To'))
446
447         if 'Delivered-To' in fields:
448             msg['to'] = decode(msg_txt.get('Delivered-To'))
449
450         if 'CC' in fields:
451             msg['cc'] = decode(msg_txt.get('CC'))
452
453         if 'Cc' in fields:
454             msg['cc'] = decode(msg_txt.get('Cc'))
455
456         if 'Reply-To' in fields:
457             msg['reply'] = decode(msg_txt.get('Reply-To'))
458
459         if 'Date' in fields:
460             date_hdr = decode(msg_txt.get('Date'))
461             msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
462
463         if 'Content-Transfer-Encoding' in fields:
464             msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
465
466         if 'References' in fields:
467             msg['references'] = msg_txt.get('References')
468
469         if 'In-Reply-To' in fields:
470             msg['in-reply-to'] = msg_txt.get('In-Reply-To')
471
472         msg['headers'] = {}
473         msg['subtype'] = 'plain'
474         for item in msg_txt.items():
475             if item[0].startswith('X-'):
476                 msg['headers'].update({item[0]: item[1]})
477         if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
478             encoding = msg_txt.get_content_charset()
479             body = msg_txt.get_payload(decode=True)
480             if 'text/html' in msg.get('content-type', ''):
481                 msg['body_html'] =  body
482                 msg['subtype'] = 'html'
483                 if body:
484                     body = tools.html2plaintext(body)
485             msg['body_text'] = tools.ustr(body, encoding)
486
487         attachments = []
488         if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
489             body = ""
490             if 'multipart/alternative' in msg.get('content-type', ''):
491                 msg['subtype'] = 'alternative'
492             else:
493                 msg['subtype'] = 'mixed'
494             for part in msg_txt.walk():
495                 if part.get_content_maintype() == 'multipart':
496                     continue
497
498                 encoding = part.get_content_charset()
499                 filename = part.get_filename()
500                 if part.get_content_maintype()=='text':
501                     content = part.get_payload(decode=True)
502                     if filename:
503                         attachments.append((filename, content))
504                     content = tools.ustr(content, encoding)
505                     if part.get_content_subtype() == 'html':
506                         msg['body_html'] = content
507                         msg['subtype'] = 'html' # html version prevails
508                         body = tools.ustr(tools.html2plaintext(content))
509                         body = body.replace('&#13;', '')
510                     elif part.get_content_subtype() == 'plain':
511                         body = content
512                 elif part.get_content_maintype() in ('application', 'image'):
513                     if filename :
514                         attachments.append((filename,part.get_payload(decode=True)))
515                     else:
516                         res = part.get_payload(decode=True)
517                         body += tools.ustr(res, encoding)
518
519             msg['body_text'] = body
520         msg['attachments'] = attachments
521
522         # for backwards compatibility:
523         msg['body'] = msg['body_text']
524         msg['sub_type'] = msg['subtype'] or 'plain'
525         return msg
526
527     def _postprocess_sent_message(self, cr, uid, message, context=None):
528         """Perform any post-processing necessary after sending ``message``
529         successfully, including deleting it completely along with its
530         attachment if the ``auto_delete`` flag of the message was set.
531         Overridden by subclasses for extra post-processing behaviors. 
532
533         :param browse_record message: the message that was just sent
534         :return: True
535         """
536         if message.auto_delete:
537             self.pool.get('ir.attachment').unlink(cr, uid,
538                                                   [x.id for x in message.attachment_ids \
539                                                         if x.res_model == self._name and \
540                                                            x.res_id == message.id],
541                                                   context=context)
542             message.unlink()
543         return True
544
545     def send(self, cr, uid, ids, auto_commit=False, context=None):
546         """Sends the selected emails immediately, ignoring their current
547            state (mails that have already been sent should not be passed
548            unless they should actually be re-sent).
549            Emails successfully delivered are marked as 'sent', and those
550            that fail to be deliver are marked as 'exception', and the
551            corresponding error message is output in the server logs.
552
553            :param bool auto_commit: whether to force a commit of the message
554                                     status after sending each message (meant
555                                     only for processing by the scheduler),
556                                     should never be True during normal
557                                     transactions (default: False)
558            :return: True
559         """
560         if context is None:
561             context = {}
562         ir_mail_server = self.pool.get('ir.mail_server')
563         self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
564         for message in self.browse(cr, uid, ids, context=context):
565             try:
566                 attachments = []
567                 for attach in message.attachment_ids:
568                     attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
569
570                 body = message.body_html if message.subtype == 'html' else message.body_text
571                 body_alternative = None
572                 subtype_alternative = None
573                 if message.subtype == 'html' and message.body_text:
574                     # we have a plain text alternative prepared, pass it to 
575                     # build_message instead of letting it build one
576                     body_alternative = message.body_text
577                     subtype_alternative = 'plain'
578
579                 msg = ir_mail_server.build_email(
580                     email_from=message.email_from,
581                     email_to=to_email(message.email_to),
582                     subject=message.subject,
583                     body=body,
584                     body_alternative=body_alternative,
585                     email_cc=to_email(message.email_cc),
586                     email_bcc=to_email(message.email_bcc),
587                     reply_to=message.reply_to,
588                     attachments=attachments, message_id=message.message_id,
589                     references = message.references,
590                     object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
591                     subtype=message.subtype,
592                     subtype_alternative=subtype_alternative,
593                     headers=message.headers and ast.literal_eval(message.headers))
594                 res = ir_mail_server.send_email(cr, uid, msg,
595                                                 mail_server_id=message.mail_server_id.id,
596                                                 context=context)
597                 if res:
598                     message.write({'state':'sent', 'message_id': res})
599                 else:
600                     message.write({'state':'exception'})
601                 message.refresh()
602                 if message.state == 'sent':
603                     self._postprocess_sent_message(cr, uid, message, context=context)
604             except Exception:
605                 _logger.exception('failed sending mail.message %s', message.id)
606                 message.write({'state':'exception'})
607
608             if auto_commit == True:
609                 cr.commit()
610         return True
611
612     def cancel(self, cr, uid, ids, context=None):
613         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
614         return True
615
616 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: