877ffd972b6e5795c4e540c3e78c6da863784791
[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 rfeadonly
75         'res_id': fields.integer('Related Document ID', select=1), # was rfeadonly
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     # Note specific api
195     #------------------------------------------------------
196     
197     def create(self, cr, uid, vals, context=None):
198         return super(mail_message, self).create(cr, uid, vals, context)
199     
200     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
201         if not context or not context.has_key('filter_search'):
202             return super(mail_message, self).search(cr, uid, args, offset=offset, limit=limit, order=order, context=context, count=count)
203         
204         # get subscriptions
205         sub_obj = self.pool.get('mail.subscription')
206         sub_ids = sub_obj.search(cr, uid, [('user_id', '=', uid)])
207         subs = sub_obj.browse(cr, uid, sub_ids)
208         
209         # stock tweets to find
210         res_model_ids_dict = {}
211         res_model_all_list = []
212         
213         # check all subscriptions
214         for sub in subs:
215             if sub.res_model and sub.res_id == 0 and sub.res_domain == False:
216                 print "s-1"
217                 if sub.res_model not in res_model_all_list:
218                     res_model_all_list.append(sub.res_model)
219             elif sub.res_model and sub.res_id:
220                 print "s-2"
221                 if res_model_ids_dict.has_key(sub.res_model):
222                     res_model_ids_dict[sub.res_model].append(sub.res_id)
223                 else:
224                     res_model_ids_dict[sub.res_model] = [sub.res_id]
225             elif sub.res_model and sub.res_domain:
226                 print "s-3"
227                 res_obj = self.pool.get(sub.res_model)
228                 print sub.res_domain
229                 #res_ids = res_obj.search(cr, uid, [('id', 'in', [1,2])])
230                 res_ids = res_obj.search(cr, uid, eval(sub.res_domain))
231                 if res_model_ids_dict.has_key(sub.res_model):
232                     res_model_ids_dict[sub.res_model] += res_ids
233                 else:
234                     res_model_ids_dict[sub.res_model] = res_ids
235                 print 'cacaprout'
236             else:
237                 print 'erreur !!!'
238                 print sub
239         
240         # add fully-followed domains
241         args.append('|')
242         args.append(['model', 'in', res_model_all_list])
243         
244         # add partially-followed domains
245         for x in range(0, len(res_model_ids_dict.keys())-1):
246             args.append('|')
247         
248         for res_model in res_model_ids_dict.keys():
249             if res_model not in res_model_all_list:
250                 args.append('&')
251                 args.append(['model', '=', res_model])
252                 args.append(['res_id', 'in', res_model_ids_dict[res_model]])
253         
254         if context and context.has_key('filter_search'):
255             pass
256         else:
257             args = []
258         print args
259         return super(mail_message, self).search(cr, uid, args, offset=offset, limit=limit,order=order, context=context, count=count)
260     
261     #------------------------------------------------------
262     # E-Mail api
263     #------------------------------------------------------
264     
265     def init(self, cr):
266         cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
267         if not cr.fetchone():
268             cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
269
270     def copy(self, cr, uid, id, default=None, context=None):
271         """Overridden to avoid duplicating fields that are unique to each email"""
272         if default is None:
273             default = {}
274         default.update(message_id=False,original=False,headers=False)
275         return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
276
277     def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
278                              email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
279                              res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
280                              context=None):
281         """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
282            the next time :meth:`process_email_queue` is called explicitly.
283
284            :param string email_from: sender email address
285            :param list email_to: list of recipient addresses (to be joined with commas) 
286            :param string subject: email subject (no pre-encoding/quoting necessary)
287            :param string body: email body, according to the ``subtype`` (by default, plaintext).
288                                If html subtype is used, the message will be automatically converted
289                                to plaintext and wrapped in multipart/alternative.
290            :param list email_cc: optional list of string values for CC header (to be joined with commas)
291            :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
292            :param string model: optional model name of the document this mail is related to (this will also
293                                 be used to generate a tracking id, used to match any response related to the
294                                 same document)
295            :param int res_id: optional resource identifier this mail is related to (this will also
296                               be used to generate a tracking id, used to match any response related to the
297                               same document)
298            :param string reply_to: optional value of Reply-To header
299            :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
300                                   must match the format of the ``body`` parameter. Default is 'plain',
301                                   making the content part of the mail "text/plain".
302            :param dict attachments: map of filename to filecontents, where filecontents is a string
303                                     containing the bytes of the attachment
304            :param dict headers: optional map of headers to set on the outgoing mail (may override the
305                                 other headers, including Subject, Reply-To, Message-Id, etc.)
306            :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
307            :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
308                                     successfully sent (default to False)
309
310         """
311         if context is None:
312             context = {}
313         if attachments is None:
314             attachments = {}
315         attachment_obj = self.pool.get('ir.attachment')
316         for param in (email_to, email_cc, email_bcc):
317             if param and not isinstance(param, list):
318                 param = [param]
319         msg_vals = {
320                 'subject': subject,
321                 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
322                 'user_id': uid,
323                 'model': model,
324                 'res_id': res_id,
325                 'body_text': body if subtype != 'html' else False,
326                 'body_html': body if subtype == 'html' else False,
327                 'email_from': email_from,
328                 'email_to': email_to and ','.join(email_to) or '',
329                 'email_cc': email_cc and ','.join(email_cc) or '',
330                 'email_bcc': email_bcc and ','.join(email_bcc) or '',
331                 'reply_to': reply_to,
332                 'message_id': message_id,
333                 'references': references,
334                 'subtype': subtype,
335                 'headers': headers, # serialize the dict on the fly
336                 'mail_server_id': mail_server_id,
337                 'state': 'outgoing',
338                 'auto_delete': auto_delete
339             }
340         email_msg_id = self.create(cr, uid, msg_vals, context)
341         attachment_ids = []
342         for fname, fcontent in attachments.iteritems():
343             attachment_data = {
344                     'name': fname,
345                     'datas_fname': fname,
346                     'datas': fcontent,
347                     'res_model': self._name,
348                     'res_id': email_msg_id,
349             }
350             if context.has_key('default_type'):
351                 del context['default_type']
352             attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
353         if attachment_ids:
354             self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
355         return email_msg_id
356
357     def mark_outgoing(self, cr, uid, ids, context=None):
358         return self.write(cr, uid, ids, {'state':'outgoing'}, context)
359
360     def process_email_queue(self, cr, uid, ids=None, context=None):
361         """Send immediately queued messages, committing after each
362            message is sent - this is not transactional and should
363            not be called during another transaction!
364
365            :param list ids: optional list of emails ids to send. If passed
366                             no search is performed, and these ids are used
367                             instead.
368            :param dict context: if a 'filters' key is present in context,
369                                 this value will be used as an additional
370                                 filter to further restrict the outgoing
371                                 messages to send (by default all 'outgoing'
372                                 messages are sent).
373         """
374         if context is None:
375             context = {}
376         if not ids:
377             filters = [('state', '=', 'outgoing')]
378             if 'filters' in context:
379                 filters.extend(context['filters'])
380             ids = self.search(cr, uid, filters, context=context)
381         res = None
382         try:
383             # Force auto-commit - this is meant to be called by
384             # the scheduler, and we can't allow rolling back the status
385             # of previously sent emails!
386             res = self.send(cr, uid, ids, auto_commit=True, context=context)
387         except Exception:
388             _logger.exception("Failed processing mail queue")
389         return res
390
391     def parse_message(self, message, save_original=False):
392         """Parses a string or email.message.Message representing an
393            RFC-2822 email, and returns a generic dict holding the
394            message details.
395
396            :param message: the message to parse
397            :type message: email.message.Message | string | unicode
398            :param bool save_original: whether the returned dict
399                should include an ``original`` entry with the base64
400                encoded source of the message.
401            :rtype: dict
402            :return: A dict with the following structure, where each
403                     field may not be present if missing in original
404                     message::
405
406                     { 'message-id': msg_id,
407                       'subject': subject,
408                       'from': from,
409                       'to': to,
410                       'cc': cc,
411                       'headers' : { 'X-Mailer': mailer,
412                                     #.. all X- headers...
413                                   },
414                       'subtype': msg_mime_subtype,
415                       'body_text': plaintext_body
416                       'body_html': html_body,
417                       'attachments': [('file1', 'bytes'),
418                                        ('file2', 'bytes') }
419                        # ...
420                        'original': source_of_email,
421                     }
422         """
423         msg_txt = message
424         if isinstance(message, str):
425             msg_txt = email.message_from_string(message)
426
427         # Warning: message_from_string doesn't always work correctly on unicode,
428         # we must use utf-8 strings here :-(
429         if isinstance(message, unicode):
430             message = message.encode('utf-8')
431             msg_txt = email.message_from_string(message)
432
433         message_id = msg_txt.get('message-id', False)
434         msg = {}
435
436         if save_original:
437             # save original, we need to be able to read the original email sometimes
438             msg['original'] = message.as_string() if isinstance(message, Message) \
439                                                   else message
440             msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
441
442         if not message_id:
443             # Very unusual situation, be we should be fault-tolerant here
444             message_id = time.time()
445             msg_txt['message-id'] = message_id
446             _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
447
448         fields = msg_txt.keys()
449         msg['id'] = message_id
450         msg['message-id'] = message_id
451
452         if 'Subject' in fields:
453             msg['subject'] = decode(msg_txt.get('Subject'))
454
455         if 'Content-Type' in fields:
456             msg['content-type'] = msg_txt.get('Content-Type')
457
458         if 'From' in fields:
459             msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
460
461         if 'To' in fields:
462             msg['to'] = decode(msg_txt.get('To'))
463
464         if 'Delivered-To' in fields:
465             msg['to'] = decode(msg_txt.get('Delivered-To'))
466
467         if 'CC' in fields:
468             msg['cc'] = decode(msg_txt.get('CC'))
469
470         if 'Cc' in fields:
471             msg['cc'] = decode(msg_txt.get('Cc'))
472
473         if 'Reply-To' in fields:
474             msg['reply'] = decode(msg_txt.get('Reply-To'))
475
476         if 'Date' in fields:
477             date_hdr = decode(msg_txt.get('Date'))
478             msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
479
480         if 'Content-Transfer-Encoding' in fields:
481             msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
482
483         if 'References' in fields:
484             msg['references'] = msg_txt.get('References')
485
486         if 'In-Reply-To' in fields:
487             msg['in-reply-to'] = msg_txt.get('In-Reply-To')
488
489         msg['headers'] = {}
490         msg['subtype'] = 'plain'
491         for item in msg_txt.items():
492             if item[0].startswith('X-'):
493                 msg['headers'].update({item[0]: item[1]})
494         if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
495             encoding = msg_txt.get_content_charset()
496             body = msg_txt.get_payload(decode=True)
497             if 'text/html' in msg.get('content-type', ''):
498                 msg['body_html'] =  body
499                 msg['subtype'] = 'html'
500                 body = tools.html2plaintext(body)
501             msg['body_text'] = tools.ustr(body, encoding)
502
503         attachments = []
504         if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
505             body = ""
506             if 'multipart/alternative' in msg.get('content-type', ''):
507                 msg['subtype'] = 'alternative'
508             else:
509                 msg['subtype'] = 'mixed'
510             for part in msg_txt.walk():
511                 if part.get_content_maintype() == 'multipart':
512                     continue
513
514                 encoding = part.get_content_charset()
515                 filename = part.get_filename()
516                 if part.get_content_maintype()=='text':
517                     content = part.get_payload(decode=True)
518                     if filename:
519                         attachments.append((filename, content))
520                     content = tools.ustr(content, encoding)
521                     if part.get_content_subtype() == 'html':
522                         msg['body_html'] = content
523                         msg['subtype'] = 'html' # html version prevails
524                         body = tools.ustr(tools.html2plaintext(content))
525                     elif part.get_content_subtype() == 'plain':
526                         body = content
527                 elif part.get_content_maintype() in ('application', 'image'):
528                     if filename :
529                         attachments.append((filename,part.get_payload(decode=True)))
530                     else:
531                         res = part.get_payload(decode=True)
532                         body += tools.ustr(res, encoding)
533
534             msg['body_text'] = body
535         msg['attachments'] = attachments
536
537         # for backwards compatibility:
538         msg['body'] = msg['body_text']
539         msg['sub_type'] = msg['subtype'] or 'plain'
540         return msg
541
542
543     def send(self, cr, uid, ids, auto_commit=False, context=None):
544         """Sends the selected emails immediately, ignoring their current
545            state (mails that have already been sent should not be passed
546            unless they should actually be re-sent).
547            Emails successfully delivered are marked as 'sent', and those
548            that fail to be deliver are marked as 'exception', and the
549            corresponding error message is output in the server logs.
550
551            :param bool auto_commit: whether to force a commit of the message
552                                     status after sending each message (meant
553                                     only for processing by the scheduler),
554                                     should never be True during normal
555                                     transactions (default: False)
556            :return: True
557         """
558         if context is None:
559             context = {}
560         ir_mail_server = self.pool.get('ir.mail_server')
561         self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
562         for message in self.browse(cr, uid, ids, context=context):
563             try:
564                 attachments = []
565                 for attach in message.attachment_ids:
566                     attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
567
568                 body = message.body_html if message.subtype == 'html' else message.body_text
569                 body_alternative = None
570                 subtype_alternative = None
571                 if message.subtype == 'html' and message.body_text:
572                     # we have a plain text alternative prepared, pass it to 
573                     # build_message instead of letting it build one
574                     body_alternative = message.body_text
575                     subtype_alternative = 'plain'
576
577                 msg = ir_mail_server.build_email(
578                     email_from=message.email_from,
579                     email_to=to_email(message.email_to),
580                     subject=message.subject,
581                     body=body,
582                     body_alternative=body_alternative,
583                     email_cc=to_email(message.email_cc),
584                     email_bcc=to_email(message.email_bcc),
585                     reply_to=message.reply_to,
586                     attachments=attachments, message_id=message.message_id,
587                     references = message.references,
588                     object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
589                     subtype=message.subtype,
590                     subtype_alternative=subtype_alternative,
591                     headers=message.headers and ast.literal_eval(message.headers))
592                 res = ir_mail_server.send_email(cr, uid, msg,
593                                                 mail_server_id=message.mail_server_id.id,
594                                                 context=context)
595                 if res:
596                     message.write({'state':'sent', 'message_id': res})
597                 else:
598                     message.write({'state':'exception'})
599
600                 # if auto_delete=True then delete that sent messages as well as attachments
601                 message.refresh()
602                 if message.state == 'sent' and message.auto_delete:
603                     self.pool.get('ir.attachment').unlink(cr, uid,
604                                                           [x.id for x in message.attachment_ids],
605                                                           context=context)
606                     message.unlink()
607             except Exception:
608                 _logger.exception('failed sending mail.message %s', message.id)
609                 message.write({'state':'exception'})
610
611             if auto_commit == True:
612                 cr.commit()
613         return True
614
615     def cancel(self, cr, uid, ids, context=None):
616         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
617         return True
618
619 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: