[IMP] mail: added catchall and bounce aliases to config
[odoo/odoo.git] / addons / mail / mail_alias.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Business Applications
5 #    Copyright (C) 2012 OpenERP S.A. (<http://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 logging
23 import re
24 import unicodedata
25
26 from openerp.osv import fields, osv
27 from openerp.tools import ustr
28 from openerp.modules.registry import RegistryManager
29 from openerp import SUPERUSER_ID
30
31 _logger = logging.getLogger(__name__)
32
33 # Inspired by http://stackoverflow.com/questions/517923
34 def remove_accents(input_str):
35     """Suboptimal-but-better-than-nothing way to replace accented
36     latin letters by an ASCII equivalent. Will obviously change the
37     meaning of input_str and work only for some cases"""
38     input_str = ustr(input_str)
39     nkfd_form = unicodedata.normalize('NFKD', input_str)
40     return u''.join([c for c in nkfd_form if not unicodedata.combining(c)])
41
42
43 class mail_alias(osv.Model):
44     """A Mail Alias is a mapping of an email address with a given OpenERP Document
45        model. It is used by OpenERP's mail gateway when processing incoming emails
46        sent to the system. If the recipient address (To) of the message matches
47        a Mail Alias, the message will be either processed following the rules
48        of that alias. If the message is a reply it will be attached to the
49        existing discussion on the corresponding record, otherwise a new
50        record of the corresponding model will be created.
51
52        This is meant to be used in combination with a catch-all email configuration
53        on the company's mail server, so that as soon as a new mail.alias is
54        created, it becomes immediately usable and OpenERP will accept email for it.
55      """
56     _name = 'mail.alias'
57     _description = "Email Aliases"
58     _rec_name = 'alias_name'
59     _order = 'alias_model_id, alias_name'
60
61     def _get_alias_domain(self, cr, uid, ids, name, args, context=None):
62         ir_config_parameter = self.pool.get("ir.config_parameter")
63         domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
64         return dict.fromkeys(ids, domain or "")
65
66     _columns = {
67         'alias_name': fields.char('Alias',
68             help="The name of the email alias, e.g. 'jobs' if you want to catch emails for <jobs@example.my.openerp.com>",),
69         'alias_model_id': fields.many2one('ir.model', 'Aliased Model', required=True, ondelete="cascade",
70                                           help="The model (OpenERP Document Kind) to which this alias "
71                                                "corresponds. Any incoming email that does not reply to an "
72                                                "existing record will cause the creation of a new record "
73                                                "of this model (e.g. a Project Task)",
74                                           # hack to only allow selecting mail_thread models (we might
75                                           # (have a few false positives, though)
76                                           domain="[('field_id.name', '=', 'message_ids')]"),
77         'alias_user_id': fields.many2one('res.users', 'Owner',
78                                            help="The owner of records created upon receiving emails on this alias. "
79                                                 "If this field is not set the system will attempt to find the right owner "
80                                                 "based on the sender (From) address, or will use the Administrator account "
81                                                 "if no system user is found for that address."),
82         'alias_defaults': fields.text('Default Values', required=True,
83                                       help="A Python dictionary that will be evaluated to provide "
84                                            "default values when creating new records for this alias."),
85         'alias_force_thread_id': fields.integer('Record Thread ID',
86                                       help="Optional ID of a thread (record) to which all incoming "
87                                            "messages will be attached, even if they did not reply to it. "
88                                            "If set, this will disable the creation of new records completely."),
89         'alias_domain': fields.function(_get_alias_domain, string="Alias domain", type='char', size=None),
90         'alias_parent_model_id': fields.many2one('ir.model', 'Parent Model',
91             help="Parent model holding the alias. The model holding the alias reference\n"
92                     "is not necessarily the model given by alias_model_id\n"
93                     "(example: project (parent_model) and task (model))"),
94         'alias_parent_thread_id': fields.integer('Parent Record Thread ID',
95             help="ID of the parent record holding the alias (example: project holding the task creation alias)"),
96         'alias_contact': fields.selection([
97                 ('everyone', 'Everyone'),
98                 ('partners', 'Authenticated Partners'),
99                 ('followers', 'Followers only'),
100             ], string='Alias Contact Security', required=True,
101             help="Policy to post a message on the document using the mailgateway.\n"
102                     "- everyone: everyone can post\n"
103                     "- partners: only authenticated partners\n"
104                     "- followers: only followers of the related document\n"),
105     }
106
107     _defaults = {
108         'alias_defaults': '{}',
109         'alias_user_id': lambda self, cr, uid, context: uid,
110         # looks better when creating new aliases - even if the field is informative only
111         'alias_domain': lambda self, cr, uid, context: self._get_alias_domain(cr, SUPERUSER_ID, [1], None, None)[1],
112         'alias_contact': 'everyone',
113     }
114
115     _sql_constraints = [
116         ('alias_unique', 'UNIQUE(alias_name)', 'Unfortunately this email alias is already used, please choose a unique one')
117     ]
118
119     def _check_alias_defaults(self, cr, uid, ids, context=None):
120         try:
121             for record in self.browse(cr, uid, ids, context=context):
122                 dict(eval(record.alias_defaults))
123         except Exception:
124             return False
125         return True
126
127     _constraints = [
128         (_check_alias_defaults, '''Invalid expression, it must be a literal python dictionary definition e.g. "{'field': 'value'}"''', ['alias_defaults']),
129     ]
130
131     def name_get(self, cr, uid, ids, context=None):
132         """Return the mail alias display alias_name, inclusing the implicit
133            mail catchall domain from config.
134            e.g. `jobs@openerp.my.openerp.com` or `sales@openerp.my.openerp.com`
135         """
136         res = []
137         for record in self.browse(cr, uid, ids, context=context):
138             if record.alias_name and record.alias_domain:
139                 res.append((record['id'], "%s@%s" % (record.alias_name, record.alias_domain)))
140             else:
141                 res.append((record['id'], False))
142         return res
143
144     def _find_unique(self, cr, uid, name, context=None):
145         """Find a unique alias name similar to ``name``. If ``name`` is
146            already taken, make a variant by adding an integer suffix until
147            an unused alias is found.
148         """
149         sequence = None
150         while True:
151             new_name = "%s%s" % (name, sequence) if sequence is not None else name
152             if not self.search(cr, uid, [('alias_name', '=', new_name)]):
153                 break
154             sequence = (sequence + 1) if sequence else 2
155         return new_name
156
157     def migrate_to_alias(self, cr, child_model_name, child_table_name, child_model_auto_init_fct,
158         alias_model_name, alias_id_column, alias_key, alias_prefix='', alias_force_key='', alias_defaults={},
159         alias_generate_name=False, context=None):
160         """ Installation hook to create aliases for all users and avoid constraint errors.
161
162             :param child_model_name: model name of the child class (i.e. res.users)
163             :param child_table_name: table name of the child class (i.e. res_users)
164             :param child_model_auto_init_fct: pointer to the _auto_init function
165                 (i.e. super(res_users,self)._auto_init(cr, context=context))
166             :param alias_model_name: name of the aliased model
167             :param alias_id_column: alias_id column (i.e. self._columns['alias_id'])
168             :param alias_key: name of the column used for the unique name (i.e. 'login')
169             :param alias_prefix: prefix for the unique name (i.e. 'jobs' + ...)
170             :param alias_force_key': name of the column for force_thread_id;
171                 if empty string, not taken into account
172             :param alias_defaults: dict, keys = mail.alias columns, values = child
173                 model column name used for default values (i.e. {'job_id': 'id'})
174             :param alias_generate_name: automatically generate alias name using prefix / alias key;
175                 default alias_name value is False because since 8.0 it is not required anymore
176         """
177         if context is None:
178             context = {}
179
180         # disable the unique alias_id not null constraint, to avoid spurious warning during
181         # super.auto_init. We'll reinstall it afterwards.
182         alias_id_column.required = False
183
184         # call _auto_init
185         res = child_model_auto_init_fct(cr, context=context)
186
187         registry = RegistryManager.get(cr.dbname)
188         mail_alias = registry.get('mail.alias')
189         child_class_model = registry[child_model_name]
190         no_alias_ids = child_class_model.search(cr, SUPERUSER_ID, [('alias_id', '=', False)], context={'active_test': False})
191         # Use read() not browse(), to avoid prefetching uninitialized inherited fields
192         for obj_data in child_class_model.read(cr, SUPERUSER_ID, no_alias_ids, [alias_key]):
193             alias_vals = {'alias_name': False}
194             if alias_generate_name:
195                 alias_vals['alias_name'] = '%s%s' % (alias_prefix, obj_data[alias_key])
196             if alias_force_key:
197                 alias_vals['alias_force_thread_id'] = obj_data[alias_force_key]
198             alias_vals['alias_defaults'] = dict((k, obj_data[v]) for k, v in alias_defaults.iteritems())
199             alias_vals['alias_parent_thread_id'] = obj_data['id']
200             alias_create_ctx = dict(context, alias_model_name=alias_model_name, alias_parent_model_name=child_model_name)
201             alias_id = mail_alias.create(cr, SUPERUSER_ID, alias_vals, context=alias_create_ctx)
202             child_class_model.write(cr, SUPERUSER_ID, obj_data['id'], {'alias_id': alias_id})
203             _logger.info('Mail alias created for %s %s (id %s)', child_model_name, obj_data[alias_key], obj_data['id'])
204
205         # Finally attempt to reinstate the missing constraint
206         try:
207             cr.execute('ALTER TABLE %s ALTER COLUMN alias_id SET NOT NULL' % (child_table_name))
208         except Exception:
209             _logger.warning("Table '%s': unable to set a NOT NULL constraint on column '%s' !\n"\
210                             "If you want to have it, you should update the records and execute manually:\n"\
211                             "ALTER TABLE %s ALTER COLUMN %s SET NOT NULL",
212                             child_table_name, 'alias_id', child_table_name, 'alias_id')
213
214         # set back the unique alias_id constraint
215         alias_id_column.required = True
216         return res
217
218     def create(self, cr, uid, vals, context=None):
219         """ Creates an email.alias record according to the values provided in ``vals``,
220             with 2 alterations: the ``alias_name`` value may be suffixed in order to
221             make it unique (and certain unsafe characters replaced), and
222             he ``alias_model_id`` value will set to the model ID of the ``model_name``
223             context value, if provided.
224         """
225         if context is None:
226             context = {}
227         model_name = context.get('alias_model_name')
228         parent_model_name = context.get('alias_parent_model_name')
229         if vals.get('alias_name'):
230             # when an alias name appears to already be an email, we keep the local part only
231             alias_name = remove_accents(vals['alias_name']).lower().split('@')[0]
232             alias_name = re.sub(r'[^\w+.]+', '-', alias_name)
233             alias_name = self._find_unique(cr, uid, alias_name, context=context)
234             vals['alias_name'] = alias_name
235         if model_name:
236             model_id = self.pool.get('ir.model').search(cr, uid, [('model', '=', model_name)], context=context)[0]
237             vals['alias_model_id'] = model_id
238         if parent_model_name:
239             model_id = self.pool.get('ir.model').search(cr, uid, [('model', '=', parent_model_name)], context=context)[0]
240             vals['alias_parent_model_id'] = model_id
241         return super(mail_alias, self).create(cr, uid, vals, context=context)
242
243     def open_document(self, cr, uid, ids, context=None):
244         alias = self.browse(cr, uid, ids, context=context)[0]
245         if not alias.alias_model_id or not alias.alias_force_thread_id:
246             return False
247         return {
248             'view_type': 'form',
249             'view_mode': 'form',
250             'res_model': alias.alias_model_id.model,
251             'res_id': alias.alias_force_thread_id,
252             'type': 'ir.actions.act_window',
253         }
254
255     def open_parent_document(self, cr, uid, ids, context=None):
256         alias = self.browse(cr, uid, ids, context=context)[0]
257         if not alias.alias_parent_model_id or not alias.alias_parent_thread_id:
258             return False
259         return {
260             'view_type': 'form',
261             'view_mode': 'form',
262             'res_model': alias.alias_parent_model_id.model,
263             'res_id': alias.alias_parent_thread_id,
264             'type': 'ir.actions.act_window',
265         }