source: mailtotracplugin/0.12/plugin/mail2trac/email2ticket.py

Last change on this file was 16530, checked in by Ryan J Ollos, 6 years ago

Fix indentation

File size: 13.7 KB
Line 
1"""
2Trac email handlers having to do with tickets
3"""
4
5import os, sys
6import re
7from datetime import datetime
8
9from mail2trac.email2trac import EmailException
10from mail2trac.interface import IEmailHandler
11from mail2trac.utils import add_attachments
12from mail2trac.utils import emailaddr2user
13from mail2trac.utils import get_body_and_attachments, get_decoded_subject
14from mail2trac.utils import strip_res
15from trac.core import *
16from trac.mimeview.api import KNOWN_MIME_TYPES
17from trac.config import Option
18from trac.perm import PermissionSystem
19from trac.ticket import Ticket
20from trac.ticket.api import TicketSystem
21from trac.ticket.notification import TicketNotifyEmail
22from trac.ticket.web_ui import TicketModule
23from trac.util.datefmt import to_datetime, utc
24
25
26### email handlers
27
28
29##-----------------------------------------------------------------------------
30## general functions
31
32
33
34
35##-----------------------------------------------------------------------------
36## ticket creation
37
38
39class EmailToTicket(Component):
40    """create a ticket from an email"""
41    implements(IEmailHandler)
42
43
44    possibleFields = ['owner', 'type', 'status', 'priority', 'milestone', 'component', 'version', 'resolution', 'keywords', 'cc', 'time', 'changetime']
45
46    ### methods for IEmailHandler
47
48    # we detect a creation because the subject is "create: title"
49    def match(self, message):
50        self.decoded_subject = get_decoded_subject(message)
51        regexp = r' *create *:'
52        return bool(re.match(regexp, self.decoded_subject.lower()))
53
54
55
56    def invoke(self, message, warnings):
57        """make a new ticket on receiving email"""
58
59
60        # local warnings
61        _warnings = []
62
63        # get the ticket reporter
64        reporter = self._reporter(message)
65        # get the description and attachments
66        mailBody, attachments = get_body_and_attachments(message)
67        mailBody += '\n'
68
69
70        # get the ticket fields
71        fields = self._fields(mailBody, _warnings, reporter=reporter)
72
73        # inset items from email
74        ticket = Ticket(self.env)
75        for key, value in fields.items():
76            ticket.values[key] = value
77
78
79
80        # fill in default values
81        for field in ticket.fields:
82            name = field['name']
83            if name not in fields:
84                option = 'ticket_field.%s' % name
85                if self.env.config.has_option('mail', option):
86                    ticket.values[name] = self.env.config.get('mail', option)
87                else:
88                    try:
89                        value = ticket.get_value_or_default(name) or ''
90                    except AttributeError: # BBB
91                        value = ''
92                    if value is not None:
93                        ticket.values[name] = value
94
95
96        # create the ticket
97        ticket.insert()
98
99        # add attachments to the ticket
100        add_attachments(self.env, ticket, attachments)
101
102        # do whatever post-processing is necessary
103        self.post_process(ticket)
104
105        # add local warnings
106        if _warnings:
107            warning = """A ticket has been created but there is a problem:\n\n%s\n\nPlease edit your ticket by going here: %s""" % ('\n\n'.join([' - %s' % warning for warning in _warnings]), self.env.abs_href('ticket', ticket.id))
108            warnings.append(warning)
109
110    ### internal methods
111
112
113
114    #return the fields present in the bodymail, and the body mail clean (witout this fields)
115    def _get_in_body_fields(self, mailBody) :
116        # #end is the end of what must be read in a body mail.
117        end = mailBody.lower().find('#end')
118        if end <> -1 :
119            mailBody = mailBody[:end]
120
121        mailBody.strip()
122
123        res = {}
124
125        # we look for all possible fields
126        for fieldName in self.possibleFields :
127            matched = re.search(r'(?P<all>^[ \t]*\#[ \t]*%s[ \t]*:[ \t]*(?P<value>.*?))$'%fieldName, mailBody, re.IGNORECASE | re.MULTILINE)
128            if matched :
129                res[fieldName] = matched.group('value').strip()
130
131
132        #we clean the body mail of "^ *#
133        #manage the problem of 'is this field declaration the last line (without \n at end)?'
134        if mailBody[0] <> '\n' : mailBody = '\n' + mailBody
135        mailBody = re.sub(r'\n\s*#.*', "", mailBody, re.MULTILINE)
136
137        return mailBody.strip(), res
138
139
140
141
142    def _reporter(self, message):
143        """return the ticket reporter or updater"""
144        user = emailaddr2user(self.env, message['from'])
145        # check permissions
146        perm = PermissionSystem(self.env)
147        if not perm.check_permission('MAIL2TICKET_CREATE', user) : # None -> 'anoymous'
148            raise EmailException("%s does not have MAIL2TRAC_CREATE permissions" % (user or 'anonymous'))
149
150        reporter = user or message['from']
151        return reporter
152
153    def _fields(self, mailBody, warnings, **fields):
154
155        # effectively the interface for email -> ticket
156
157        mailBody, inBodyFields = self._get_in_body_fields(mailBody)
158        #clean subject : the summary is the message subject, except the 'create:', so we take it after the first ':'
159        subject = self.decoded_subject[self.decoded_subject.find(':')+1:].strip()
160        fields.update(dict(description = mailBody,
161                           summary = subject,
162                           status='new',
163                           resolution=''), **inBodyFields)
164
165        return fields
166
167    def post_process(self, ticket):
168        """actions to perform after the ticket is created"""
169
170        # ticket notification
171        tn = TicketNotifyEmail(self.env)
172        tn.notify(ticket)
173
174
175##-----------------------------------------------------------------------------
176## ticket update
177
178
179
180class ReplyToTicket(Component):
181
182    implements(IEmailHandler)
183
184    possibleFields = ['type', 'priority', 'milestone', 'component', 'version', 'keywords', 'cc']
185    action_aliases = {'fixed' : {'action' : 'resolve' , 'value' : 'fixed'},
186                      'duplicate' : {'action' : 'resolve' , 'value' : 'duplicate'},
187                      'wontfix' : {'action' : 'resolve' , 'value' : 'wontfix'},
188                      'invalid' : {'action' : 'resolve' , 'value' : 'invalid'},
189                      }
190
191
192    def match(self, message):
193        self.decoded_subject = get_decoded_subject(message)
194        self.ticket = self._ticket(message, self.decoded_subject)
195        return bool(self.ticket)
196
197
198    def invoke(self, message, warnings):
199        """reply to a ticket"""
200        ticket = self.ticket
201        reporter = self._reporter(message)
202        # get the mailBody and attachments
203        mailBody, attachments = get_body_and_attachments(message)
204        if not mailBody:
205            warnings.append("Seems to be a reply to %s but I couldn't find a comment")
206            return message
207
208        #go throught work
209
210        ts = TicketSystem(self.env)
211        tm = TicketModule(self.env)
212        perm = PermissionSystem(self.env)
213        # TODO: Deprecate update without time_changed timestamp
214        mockReq = self._MockReq(perm.get_user_permissions(reporter), reporter)
215        avail_actions = ts.get_available_actions(mockReq, ticket)
216
217        mailBody, inBodyFields, actions = self._get_in_body_fields(mailBody, avail_actions, reporter)
218        if inBodyFields or actions :
219            # check permissions
220            perm = PermissionSystem(self.env)
221            #we have properties movement, cheking user permission to do so
222            if not perm.check_permission('MAIL2TICKET_PROPERTIES', reporter) : # None -> 'anoymous'
223                raise ("%s does not have MAIL2TICKET_PROPERTIES permissions" % (user or 'anonymous'))
224
225        action = None
226        if actions :
227            action = actions.keys()[0]
228        controllers = list(tm._get_action_controllers(mockReq, ticket, action))
229        all_fields = [field['name'] for field in ts.get_ticket_fields()]
230
231
232        #impact changes find in inBodyFields
233        for field in inBodyFields :
234            ticket._old[field] = ticket[field]
235            ticket.values[field] = inBodyFields[field]
236            mockReq.args[field] = inBodyFields[field]
237        if action :
238            mockReq.args['action_%s_reassign_owner' % action] = ticket['owner']
239
240
241
242        mockReq.args['comment'] = mailBody
243        mockReq.args['ts'] = datetime.now()#to_datetime(None, utc)
244
245
246        mockReq.args['ts'] = str(ticket.time_changed)
247
248        changes, problems = tm.get_ticket_changes(mockReq, ticket, action)
249        valid = problems and False or tm._validate_ticket(mockReq, ticket)
250
251        tm._apply_ticket_changes(ticket, changes)
252
253
254        # add attachments to the ticket
255        add_attachments(self.env, ticket, attachments)
256
257        ticket.save_changes(reporter, mailBody)
258
259        for controller in controllers:
260            controller.apply_action_side_effects(mockReq, ticket, action)
261            # Call ticket change listeners
262        for listener in ts.change_listeners:
263            listener.ticket_changed(ticket, mailBody, reporter, ticket._old)
264
265        tn = TicketNotifyEmail(self.env)
266        tn.notify(ticket, newticket=0, modtime=ticket.time_changed)
267
268
269    #use a Mock Request to manage correctly permission throught ticketSystem
270
271    class _MockReq :
272
273        permissions = []
274        args = {}
275        chrome = {'warnings' : [] }
276
277        def __init__(self, permissions, reporter) :
278            self.permissions = [ x for x in permissions if permissions[x] ]
279            self.fields = {}
280            self.authname = reporter
281
282        def perm(self, any) :
283            return self.permissions
284
285
286
287    ### internal methods
288
289    def _ticket(self, message, subject):
290        """
291        return a ticket associated with a message subject,
292        or None if not available
293        """
294
295
296        # get and format the subject template
297        subject_template = self.env.config.get('notification', 'ticket_subject_template')
298        prefix = self.env.config.get('notification', 'smtp_subject_prefix')
299        subject_template = subject_template.replace('$prefix', 'prefix').replace('$summary', 'summary').replace('$ticket.id', 'ticketid')
300        subject_template_escaped = re.escape(subject_template)
301
302        # build the regex
303        subject_re = subject_template_escaped.replace('summary', '.*').replace('ticketid', '([0-9]+)').replace('prefix', '.*')
304
305        # get the real subject
306        subject = strip_res(subject)
307        # see if it matches the regex
308        match = re.match(subject_re, subject)
309        if not match:
310            return None
311
312        # get the ticket
313        ticket_id = int(match.groups()[0])
314        #try:
315        ticket = Ticket(self.env, ticket_id)
316        #except:
317        #    return None
318        return ticket
319
320    def _reporter(self, message):
321        """return the ticket updater"""
322        user = emailaddr2user(self.env, message['from'])
323        # check permissions
324        perm = PermissionSystem(self.env)
325        if not perm.check_permission('MAIL2TICKET_COMMENT', user) : # None -> 'anoymous'
326            raise EmailException("%s does not have MAIL2TRAC_COMMENT permissions" % (user or 'anonymous'))
327
328        reporter = user or message['from']
329        return reporter
330
331    #return the fields present in the bodymail, and the body mail clean (witout this fields)
332    def _get_in_body_fields(self, mailBody, avail_actions, reporter) :
333        # #end is the end of what must be read in a body mail.
334        end = mailBody.lower().find('#end')
335        if end <> -1 :
336            mailBody = mailBody[:end]
337
338        mailBody.strip()
339
340        res = {}
341
342        # we look for all possible fields
343        for fieldName in self.possibleFields :
344            matched = re.search(r'(?P<all>^[ \t]*\#[ \t]*%s[ \t]*:[ \t]*(?P<value>.*?))$'%fieldName, mailBody, re.IGNORECASE | re.MULTILINE)
345            if matched :
346                res[fieldName] = matched.group('value').strip()
347                #manage the problem of 'is this field declaration the last line (without \n at end)?'
348
349        #now we look at actions :
350        actions = {}
351        for action in avail_actions :
352            #action with param
353            matched = re.search(r'(?P<all>^[ \t]*\#[ \t]*%s[ \t]*:[ \t]*(?P<value>.*?))$'%action, mailBody, re.IGNORECASE | re.MULTILINE)
354            if not matched :#try action without param
355                matched = re.search(r'(?P<all>^[ \t]*\#[ \t]*%s[ \t]*[ \t]*(?P<value>.*?))$'%action, mailBody, re.IGNORECASE | re.MULTILINE)
356            if matched :
357                actions[action] = matched.group('value').strip()
358                break #only one action #should emit a warning FIXME
359
360
361        #take care of aliases declared (the real action must be in avail_actions
362
363        if not actions :
364            for action in [ x for x in self.action_aliases if self.action_aliases[x]['action'] in avail_actions ] :
365                matched = re.search(r'(?P<all>^[ \t]*\#[ \t]*%s[ \t]*[ \t]*$)'%action, mailBody, re.IGNORECASE | re.MULTILINE)
366                if matched :
367                    #we use values defined by action_aliases
368                    actions[self.action_aliases[action]['action']] = self.action_aliases[action]['value']
369
370
371        #we clean the body mail of "^ *#
372        #manage the problem of 'is this field declaration the last line (without \n at end)?'
373        if mailBody[0] <> '\n' : mailBody = '\n' + mailBody
374        mailBody = re.sub(r'\n\s*#.*', "", mailBody, re.MULTILINE)
375
376
377
378        if actions :
379            action = actions.keys()[0]
380            value = actions[action]
381            if action == 'reassign' :
382                res['owner'] = value
383                res['status'] = 'reassign'
384            if action == 'resolve' :
385                res['status'] = 'closed'
386                res['resolution'] = value
387            if action == 'accept' :
388                res['status'] = 'accepted'
389                res['owner'] = reporter
390            if action == 'reopen':
391                res['status'] = 'reopened'
392                res['resolution'] = ''
393
394
395        return mailBody.strip(), res, actions
Note: See TracBrowser for help on using the repository browser.