~launchpad-pqm/launchpad/devel

13668.1.21 by Curtis Hovey
Updated copyrights.
1
# Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
2
# GNU Affero General Public License version 3 (see the file LICENSE).
3
12043.4.4 by Gavin Panella
Typos.
4
"""Handle incoming Bugs email."""
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
5
6
__metaclass__ = type
7
__all__ = [
8
    "MaloneHandler",
12043.4.4 by Gavin Panella
Typos.
9
    ]
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
10
13779.2.3 by Curtis Hovey
Merged bug command group helpers.
11
from operator import attrgetter
13668.1.16 by Curtis Hovey
Moved bug mail error templates to lp.bugs.mail.
12
import os
13
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
14
from lazr.lifecycle.event import ObjectCreatedEvent
15
from lazr.lifecycle.interfaces import IObjectCreatedEvent
13970.10.7 by William Grant
Abolish sqlbase's begin and abort.
16
import transaction
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
17
from zope.component import getUtility
18
from zope.event import notify
19
from zope.interface import implements
20
14027.3.7 by Jeroen Vermeulen
Conflicts.
21
from lp.bugs.interfaces.bug import (
22
    CreateBugParams,
23
    CreatedBugWithNoBugTasksError,
24
    )
13668.1.22 by Curtis Hovey
Sorted imports.
25
from lp.bugs.interfaces.bugattachment import (
26
    BugAttachmentType,
27
    IBugAttachmentSet,
28
    )
29
from lp.bugs.interfaces.bugmessage import IBugMessageSet
13668.1.24 by Curtis Hovey
Extracted ProcessMailLayer from test_system_documentation.
30
from lp.bugs.mail.commands import BugEmailCommands
14550.1.1 by Steve Kowalik
Run format-imports over lib/lp and lib/canonical/launchpad
31
from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
13668.1.8 by Curtis Hovey
Moved canonical.launchpad.mail.helpers to lp.services.mail.
32
from lp.services.mail.helpers import (
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
33
    ensure_not_weakly_authenticated,
14612.2.1 by William Grant
format-imports on lib/. So many imports.
34
    get_email_template,
13668.1.24 by Curtis Hovey
Extracted ProcessMailLayer from test_system_documentation.
35
    get_error_message,
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
36
    get_main_body,
37
    guess_bugtask,
38
    IncomingEmailError,
39
    parse_commands,
40
    reformat_wiki_text,
41
    )
13668.1.22 by Curtis Hovey
Sorted imports.
42
from lp.services.mail.interfaces import (
43
    EmailProcessingError,
44
    IBugEditEmailCommand,
45
    IBugEmailCommand,
46
    IBugTaskEditEmailCommand,
47
    IBugTaskEmailCommand,
48
    IMailHandler,
49
    )
14612.2.1 by William Grant
format-imports on lib/. So many imports.
50
from lp.services.mail.mailwrapper import MailWrapper
51
from lp.services.mail.notification import send_process_error_notification
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
52
from lp.services.mail.sendmail import simple_sendmail
13668.1.22 by Curtis Hovey
Sorted imports.
53
from lp.services.messages.interfaces.message import IMessageSet
14612.2.1 by William Grant
format-imports on lib/. So many imports.
54
from lp.services.webapp.interfaces import ILaunchBag
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
55
56
13668.1.16 by Curtis Hovey
Moved bug mail error templates to lp.bugs.mail.
57
error_templates = os.path.join(os.path.dirname(__file__), 'errortemplates')
58
59
13779.2.3 by Curtis Hovey
Merged bug command group helpers.
60
class BugTaskCommandGroup:
61
62
    def __init__(self, command=None):
63
        self._commands = []
64
        if command is not None:
65
            self._commands.append(command)
66
67
    def __nonzero__(self):
68
        return len(self._commands) > 0
69
70
    def __str__(self):
71
        text_commands = [str(cmd) for cmd in self.commands]
72
        return '\n'.join(text_commands).strip()
73
74
    @property
75
    def commands(self):
76
        "Return the `EmailCommand`s ordered by their rank."
77
        return sorted(self._commands, key=attrgetter('RANK'))
78
79
    def add(self, command):
80
        "Add an `EmailCommand` to the commands."
81
        self._commands.append(command)
82
83
84
class BugCommandGroup(BugTaskCommandGroup):
85
86
    def __init__(self, command=None):
87
        super(BugCommandGroup, self).__init__(command=command)
88
        self._groups = []
89
90
    def __nonzero__(self):
91
        if len(self._groups) > 0:
92
            return True
93
        else:
94
            return super(BugCommandGroup, self).__nonzero__()
95
96
    def __str__(self):
97
        text_commands = [super(BugCommandGroup, self).__str__()]
98
        for group in self.groups:
99
            text_commands += [str(group)]
100
        return '\n'.join(text_commands).strip()
101
102
    @property
103
    def groups(self):
13788.1.9 by Curtis Hovey
Allow new bugs that affect only one target to place the affect-command
104
        "Return the `BugTaskCommandGroup`s."
13788.1.10 by Curtis Hovey
Revised the rule to fix the order of the affects command.
105
        is_new_bug = (
106
            len(self.commands) > 0
13788.1.9 by Curtis Hovey
Allow new bugs that affect only one target to place the affect-command
107
            and self.commands[0].RANK == 0
13788.1.10 by Curtis Hovey
Revised the rule to fix the order of the affects command.
108
            and self.commands[0].string_args == ['new'])
109
        has_split_affects = (
110
            len(self._groups) == 2
111
            and self._groups[0].commands[0].RANK != 0
112
            and self._groups[1].commands[0].RANK == 0)
113
        if is_new_bug and has_split_affects:
114
            # The affects line was in the wrong position and this
115
            # exact case can be fixed.
116
            self._groups[0]._commands += self._groups[1]._commands
117
            del self._groups[1]
13779.2.3 by Curtis Hovey
Merged bug command group helpers.
118
        return list(self._groups)
119
120
    def add(self, command_or_group):
121
        """Add an `EmailCommand` or `BugTaskCommandGroup` to the commands.
122
123
        Empty BugTaskCommandGroup are ignored.
124
        """
125
        if isinstance(command_or_group, BugTaskCommandGroup):
126
            if command_or_group:
127
                self._groups.append(command_or_group)
128
        else:
129
            super(BugCommandGroup, self).add(command_or_group)
130
131
132
class BugCommandGroups(BugCommandGroup):
133
134
    def __init__(self, commands):
135
        super(BugCommandGroups, self).__init__(command=None)
136
        self._groups = []
137
        this_bug = BugCommandGroup()
138
        this_bugtask = BugTaskCommandGroup()
139
        for command in commands:
140
            if IBugEmailCommand.providedBy(command) and command.RANK == 0:
141
                # Multiple bugs are being edited.
142
                this_bug.add(this_bugtask)
143
                self.add(this_bug)
144
                this_bug = BugCommandGroup(command)
145
                this_bugtask = BugTaskCommandGroup()
146
            elif IBugEditEmailCommand.providedBy(command):
147
                this_bug.add(command)
148
            elif (IBugTaskEmailCommand.providedBy(command)
149
                  and command.RANK == 0):
150
                # Multiple or explicit bugtasks are being edited.
151
                this_bug.add(this_bugtask)
152
                this_bugtask = BugTaskCommandGroup(command)
153
            elif IBugTaskEditEmailCommand.providedBy(command):
154
                this_bugtask.add(command)
155
        this_bug.add(this_bugtask)
156
        self.add(this_bug)
157
13788.1.11 by Curtis Hovey
Added an iterator to BugCommandGroups to provide a controlled order
158
    def __iter__(self):
159
        for bug_group in self.groups:
160
            for command in bug_group.commands:
161
                yield command
162
            for bugtask_group in bug_group.groups:
163
                for command in bugtask_group.commands:
164
                    yield command
165
13779.2.3 by Curtis Hovey
Merged bug command group helpers.
166
    def add(self, command_or_group):
167
        """Add a `BugCommandGroup` to the groups of commands.
13779.2.4 by Curtis Hovey
Removed whitespace.
168
13779.2.3 by Curtis Hovey
Merged bug command group helpers.
169
        Empty BugCommandGroups are ignored.
170
        """
171
        if isinstance(command_or_group, BugCommandGroup):
172
            if command_or_group:
173
                self._groups.append(command_or_group)
174
175
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
176
class MaloneHandler:
177
    """Handles emails sent to Malone.
178
179
    It only handles mail sent to new@... and $bugid@..., where $bugid is a
180
    positive integer.
181
    """
182
    implements(IMailHandler)
183
184
    allow_unknown_users = False
185
186
    def getCommands(self, signed_msg):
187
        """Returns a list of all the commands found in the email."""
188
        content = get_main_body(signed_msg)
189
        if content is None:
190
            return []
191
        return [BugEmailCommands.get(name=name, string_args=args) for
192
                name, args in parse_commands(content,
193
                                             BugEmailCommands.names())]
194
195
    def extractAndAuthenticateCommands(self, signed_msg, to_addr):
196
        """Extract commands and handle special destinations.
197
198
        NB: The authentication is carried out against the current principal,
199
        not directly against the message.  authenticateEmail must previously
200
        have been called on this thread.
201
202
        :returns: (final_result, add_comment_to_bug, commands)
203
            If final_result is non-none, stop processing and return this value
204
            to indicate whether the message was dealt with or not.
205
            If add_comment_to_bug, add the contents to the first bug
206
            selected.
207
            commands is a list of bug commands.
208
        """
209
        CONTEXT = 'bug report'
210
        commands = self.getCommands(signed_msg)
211
        to_user, to_host = to_addr.split('@')
212
        add_comment_to_bug = False
14033.1.1 by Curtis Hovey
Send the bug-mail help instructions to non-active users.
213
        from_user = getUtility(ILaunchBag).user
14033.1.3 by Curtis Hovey
Only check for preferredemail if it will be needed.
214
        if to_user.lower() == 'help' or from_user is None:
215
            if from_user is not None and from_user.preferredemail is not None:
216
                to_address = str(from_user.preferredemail.email)
14033.1.2 by Curtis Hovey
Updated tests to be clear about the kind of address and user that
217
            else:
218
                to_address = signed_msg['From']
219
                address = getUtility(IEmailAddressSet).getByEmail(to_address)
220
                if address is None:
221
                    to_address = None
222
            if to_address is not None:
14033.1.1 by Curtis Hovey
Send the bug-mail help instructions to non-active users.
223
                self.sendHelpEmail(to_address)
224
            return True, False, None
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
225
        # If there are any commands, we must have strong authentication.
226
        # We send a different failure message for attempts to create a new
227
        # bug.
14033.1.1 by Curtis Hovey
Send the bug-mail help instructions to non-active users.
228
        elif to_user.lower() == 'new':
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
229
            ensure_not_weakly_authenticated(signed_msg, CONTEXT,
13668.1.26 by Curtis Hovey
Extracted bug process mail tests to lp.bugs.
230
                'unauthenticated-bug-creation.txt',
231
                error_templates=error_templates)
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
232
        elif len(commands) > 0:
233
            ensure_not_weakly_authenticated(signed_msg, CONTEXT)
234
        if to_user.lower() == 'new':
235
            commands.insert(0, BugEmailCommands.get('bug', ['new']))
236
        elif to_user.isdigit():
237
            # A comment to a bug. We set add_comment_to_bug to True so
238
            # that the comment gets added to the bug later. We don't add
239
            # the comment now, since we want to let the 'bug' command
240
            # handle the possible errors that can occur while getting
241
            # the bug.
242
            add_comment_to_bug = True
243
            commands.insert(0, BugEmailCommands.get('bug', [to_user]))
244
        elif to_user.lower() != 'edit':
245
            # Indicate that we didn't handle the mail.
246
            return False, False, None
13788.1.15 by Curtis Hovey
Style fixes.
247
        bug_commands = list(BugCommandGroups(commands))
13788.1.11 by Curtis Hovey
Added an iterator to BugCommandGroups to provide a controlled order
248
        return None, add_comment_to_bug, bug_commands
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
249
250
    def process(self, signed_msg, to_addr, filealias=None, log=None):
251
        """See IMailHandler."""
252
253
        try:
254
            (final_result, add_comment_to_bug,
255
                commands, ) = self.extractAndAuthenticateCommands(
256
                    signed_msg, to_addr)
257
            if final_result is not None:
258
                return final_result
259
260
            bug = None
261
            bug_event = None
262
            bugtask = None
263
            bugtask_event = None
264
265
            processing_errors = []
13788.1.11 by Curtis Hovey
Added an iterator to BugCommandGroups to provide a controlled order
266
            while len(commands) > 0:
267
                command = commands.pop(0)
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
268
                try:
13788.1.11 by Curtis Hovey
Added an iterator to BugCommandGroups to provide a controlled order
269
                    if IBugEmailCommand.providedBy(command):
270
                        # Finish outstanding work from the previous bug.
271
                        self.notify_bug_event(bug_event)
272
                        self.notify_bugtask_event(bugtask_event, bug_event)
273
                        bugtask = None
274
                        bugtask_event = None
275
                        # Get or start building a new bug.
276
                        bug, bug_event = command.execute(
277
                            signed_msg, filealias)
278
                        if add_comment_to_bug:
279
                            message = self.appendBugComment(
280
                                bug, signed_msg, filealias)
281
                            add_comment_to_bug = False
13939.3.18 by Curtis Hovey
Reconcile the command changes with the handler using bug-emailinterface.txt
282
                            self.processAttachments(bug, message, signed_msg)
283
                    elif IBugTaskEmailCommand.providedBy(command):
284
                        self.notify_bugtask_event(bugtask_event, bug_event)
285
                        bugtask, bugtask_event, bug_event = command.execute(
286
                            bug, bug_event)
287
                        if isinstance(bug, CreateBugParams):
288
                            bug = bugtask.bug
13788.1.11 by Curtis Hovey
Added an iterator to BugCommandGroups to provide a controlled order
289
                            message = bug.initial_message
13939.3.18 by Curtis Hovey
Reconcile the command changes with the handler using bug-emailinterface.txt
290
                            self.processAttachments(bug, message, signed_msg)
13788.1.11 by Curtis Hovey
Added an iterator to BugCommandGroups to provide a controlled order
291
                    elif IBugEditEmailCommand.providedBy(command):
292
                        bug, bug_event = command.execute(bug, bug_event)
293
                    elif IBugTaskEditEmailCommand.providedBy(command):
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
294
                        if bugtask is None:
13939.3.18 by Curtis Hovey
Reconcile the command changes with the handler using bug-emailinterface.txt
295
                            if isinstance(bug, CreateBugParams):
13779.1.1 by Curtis Hovey
Merged MaloneHandler.process() refactoring.
296
                                self.handleNoAffectsTarget()
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
297
                            bugtask = guess_bugtask(
298
                                bug, getUtility(ILaunchBag).user)
299
                            if bugtask is None:
13779.1.3 by Curtis Hovey
Unwrapped lines.
300
                                self.handleNoDefaultAffectsTarget(bug)
13788.1.11 by Curtis Hovey
Added an iterator to BugCommandGroups to provide a controlled order
301
                        bugtask, bugtask_event = command.execute(
302
                            bugtask, bugtask_event)
303
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
304
                except EmailProcessingError, error:
305
                    processing_errors.append((error, command))
306
                    if error.stop_processing:
307
                        commands = []
13970.10.7 by William Grant
Abolish sqlbase's begin and abort.
308
                        transaction.abort()
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
309
                    else:
310
                        continue
13788.1.11 by Curtis Hovey
Added an iterator to BugCommandGroups to provide a controlled order
311
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
312
            if len(processing_errors) > 0:
313
                raise IncomingEmailError(
13788.1.11 by Curtis Hovey
Added an iterator to BugCommandGroups to provide a controlled order
314
                    '\n'.join(str(error) for error, command
315
                              in processing_errors),
316
                    [command for error, command in processing_errors])
13939.3.18 by Curtis Hovey
Reconcile the command changes with the handler using bug-emailinterface.txt
317
            if isinstance(bug, CreateBugParams):
318
                # A new bug without any commands was sent.
319
                self.handleNoAffectsTarget()
13788.1.11 by Curtis Hovey
Added an iterator to BugCommandGroups to provide a controlled order
320
            self.notify_bug_event(bug_event)
321
            self.notify_bugtask_event(bugtask_event, bug_event)
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
322
323
        except IncomingEmailError, error:
324
            send_process_error_notification(
325
                str(getUtility(ILaunchBag).user.preferredemail.email),
326
                'Submit Request Failure',
327
                error.message, signed_msg, error.failing_command)
328
329
        return True
330
331
    def sendHelpEmail(self, to_address):
332
        """Send usage help to `to_address`."""
333
        # Get the help text (formatted as MoinMoin markup)
14538.2.34 by Curtis Hovey
Move email templates to lp.bugs.
334
        help_text = get_email_template('help.txt', app='bugs')
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
335
        help_text = reformat_wiki_text(help_text)
336
        # Wrap text
337
        mailwrapper = MailWrapper(width=72)
338
        help_text = mailwrapper.format(help_text)
339
        simple_sendmail(
340
            'help@bugs.launchpad.net', to_address,
341
            'Launchpad Bug Tracker Email Interface Help',
342
            help_text)
343
344
    # Some content types indicate that an attachment has a special
345
    # purpose. The current set is based on parsing emails from
346
    # one mail account and may need to be extended.
347
    #
348
    # Mail signatures are most likely generated by the mail client
349
    # and hence contain not data that is interesting except for
350
    # mail authentication.
351
    #
352
    # Resource forks of MacOS files are not easily represented outside
353
    # MacOS; if a resource fork contains useful debugging information,
354
    # the entire MacOS file should be sent encapsulated for example in
355
    # MacBinary format.
356
    #
357
    # application/ms-tnef attachment are created by Outlook; they
358
    # seem to store no more than an RTF representation of an email.
359
360
    irrelevant_content_types = set((
13668.1.16 by Curtis Hovey
Moved bug mail error templates to lp.bugs.mail.
361
        'application/applefile',  # the resource fork of a MacOS file
12043.4.1 by Gavin Panella
Move MaloneHandler into lp.bugs.mail.handler.
362
        'application/pgp-signature',
363
        'application/pkcs7-signature',
364
        'application/x-pkcs7-signature',
365
        'text/x-vcard',
366
        'application/ms-tnef',
367
        ))
368
369
    def processAttachments(self, bug, message, signed_mail):
370
        """Create Bugattachments for "reasonable" mail attachments.
371
372
        A mail attachment is stored as a bugattachment if its
373
        content type is not listed in irrelevant_content_types.
374
        """
375
        for chunk in message.chunks:
376
            blob = chunk.blob
377
            if blob is None:
378
                continue
379
            # Mutt (other mail clients too?) appends the filename to the
380
            # content type.
381
            content_type = blob.mimetype.split(';', 1)[0]
382
            if content_type in self.irrelevant_content_types:
383
                continue
384
385
            if content_type == 'text/html' and blob.filename == 'unnamed':
386
                # This is the HTML representation of the main part of
387
                # an email.
388
                continue
389
390
            if content_type in ('text/x-diff', 'text/x-patch'):
391
                attach_type = BugAttachmentType.PATCH
392
            else:
393
                attach_type = BugAttachmentType.UNSPECIFIED
394
395
            getUtility(IBugAttachmentSet).create(
396
                bug=bug, filealias=blob, attach_type=attach_type,
397
                title=blob.filename, message=message, send_notifications=True)
13779.1.1 by Curtis Hovey
Merged MaloneHandler.process() refactoring.
398
399
    def appendBugComment(self, bug, signed_msg, filealias=None):
400
        """Append the message text to the bug comments."""
401
        messageset = getUtility(IMessageSet)
402
        message = messageset.fromEmail(
403
            signed_msg.as_string(),
404
            owner=getUtility(ILaunchBag).user,
405
            filealias=filealias,
406
            parsed_message=signed_msg,
407
            fallback_parent=bug.initial_message)
408
        # If the new message's parent is linked to
409
        # a bug watch we also link this message to
410
        # that bug watch.
411
        bug_message_set = getUtility(IBugMessageSet)
412
        parent_bug_message = (
413
            bug_message_set.getByBugAndMessage(bug, message.parent))
414
        if (parent_bug_message is not None and
415
            parent_bug_message.bugwatch):
416
            bug_watch = parent_bug_message.bugwatch
417
        else:
418
            bug_watch = None
419
        bugmessage = bug.linkMessage(
420
            message, bug_watch)
421
        notify(ObjectCreatedEvent(bugmessage))
422
        return message
423
424
    def notify_bug_event(self, bug_event):
425
        if bug_event is  None:
426
            return
427
        try:
428
            notify(bug_event)
429
        except CreatedBugWithNoBugTasksError:
430
            self.handleNoAffectsTarget()
431
432
    def notify_bugtask_event(self, bugtask_event, bug_event):
433
            if bugtask_event is None:
434
                return
435
            if not IObjectCreatedEvent.providedBy(bug_event):
436
                notify(bugtask_event)
437
438
    def handleNoAffectsTarget(self):
13970.10.7 by William Grant
Abolish sqlbase's begin and abort.
439
        transaction.abort()
13779.1.1 by Curtis Hovey
Merged MaloneHandler.process() refactoring.
440
        raise IncomingEmailError(
441
            get_error_message(
442
                'no-affects-target-on-submit.txt',
443
                error_templates=error_templates))
444
445
    def handleNoDefaultAffectsTarget(self, bug):
446
        raise IncomingEmailError(get_error_message(
447
            'no-default-affects.txt',
448
            error_templates=error_templates,
449
            bug_id=bug.id,
450
            nr_of_bugtasks=len(bug.bugtasks)))