~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

  • Committer: Aaron Bentley
  • Date: 2009-03-10 04:55:49 UTC
  • mto: This revision was merged to the branch mainline in revision 4112.
  • Revision ID: aaron@aaronbentley.com-20090310045549-j5jmgq190872oem7
Remove locking decorator

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2007 Canonical Ltd
 
2
#
 
3
# This program is free software; you can redistribute it and/or modify
 
4
# it under the terms of the GNU General Public License as published by
 
5
# the Free Software Foundation; either version 2 of the License, or
 
6
# (at your option) any later version.
 
7
#
 
8
# This program is distributed in the hope that it will be useful,
 
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
11
# GNU General Public License for more details.
 
12
#
 
13
# You should have received a copy of the GNU General Public License
 
14
# along with this program; if not, write to the Free Software
 
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 
16
 
 
17
import errno
 
18
import os
 
19
import subprocess
 
20
import sys
 
21
import tempfile
 
22
import urllib
 
23
 
 
24
import bzrlib
 
25
from bzrlib import (
 
26
    email_message,
 
27
    errors,
 
28
    msgeditor,
 
29
    osutils,
 
30
    urlutils,
 
31
    registry
 
32
    )
 
33
 
 
34
mail_client_registry = registry.Registry()
 
35
 
 
36
 
 
37
class MailClient(object):
 
38
    """A mail client that can send messages with attachements."""
 
39
 
 
40
    def __init__(self, config):
 
41
        self.config = config
 
42
 
 
43
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
44
                extension, basename=None):
 
45
        """Compose (and possibly send) an email message
 
46
 
 
47
        Must be implemented by subclasses.
 
48
 
 
49
        :param prompt: A message to tell the user what to do.  Supported by
 
50
            the Editor client, but ignored by others
 
51
        :param to: The address to send the message to
 
52
        :param subject: The contents of the subject line
 
53
        :param attachment: An email attachment, as a bytestring
 
54
        :param mime_subtype: The attachment is assumed to be a subtype of
 
55
            Text.  This allows the precise subtype to be specified, e.g.
 
56
            "plain", "x-patch", etc.
 
57
        :param extension: The file extension associated with the attachment
 
58
            type, e.g. ".patch"
 
59
        :param basename: The name to use for the attachment, e.g.
 
60
            "send-nick-3252"
 
61
        """
 
62
        raise NotImplementedError
 
63
 
 
64
    def compose_merge_request(self, to, subject, directive, basename=None):
 
65
        """Compose (and possibly send) a merge request
 
66
 
 
67
        :param to: The address to send the request to
 
68
        :param subject: The subject line to use for the request
 
69
        :param directive: A merge directive representing the merge request, as
 
70
            a bytestring.
 
71
        :param basename: The name to use for the attachment, e.g.
 
72
            "send-nick-3252"
 
73
        """
 
74
        prompt = self._get_merge_prompt("Please describe these changes:", to,
 
75
                                        subject, directive)
 
76
        self.compose(prompt, to, subject, directive,
 
77
            'x-patch', '.patch', basename)
 
78
 
 
79
    def _get_merge_prompt(self, prompt, to, subject, attachment):
 
80
        """Generate a prompt string.  Overridden by Editor.
 
81
 
 
82
        :param prompt: A string suggesting what user should do
 
83
        :param to: The address the mail will be sent to
 
84
        :param subject: The subject line of the mail
 
85
        :param attachment: The attachment that will be used
 
86
        """
 
87
        return ''
 
88
 
 
89
 
 
90
class Editor(MailClient):
 
91
    """DIY mail client that uses commit message editor"""
 
92
 
 
93
    def _get_merge_prompt(self, prompt, to, subject, attachment):
 
94
        """See MailClient._get_merge_prompt"""
 
95
        return (u"%s\n\n"
 
96
                u"To: %s\n"
 
97
                u"Subject: %s\n\n"
 
98
                u"%s" % (prompt, to, subject,
 
99
                         attachment.decode('utf-8', 'replace')))
 
100
 
 
101
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
102
                extension, basename=None):
 
103
        """See MailClient.compose"""
 
104
        if not to:
 
105
            raise errors.NoMailAddressSpecified()
 
106
        body = msgeditor.edit_commit_message(prompt)
 
107
        if body == '':
 
108
            raise errors.NoMessageSupplied()
 
109
        email_message.EmailMessage.send(self.config,
 
110
                                        self.config.username(),
 
111
                                        to,
 
112
                                        subject,
 
113
                                        body,
 
114
                                        attachment,
 
115
                                        attachment_mime_subtype=mime_subtype)
 
116
mail_client_registry.register('editor', Editor,
 
117
                              help=Editor.__doc__)
 
118
 
 
119
 
 
120
class ExternalMailClient(MailClient):
 
121
    """An external mail client."""
 
122
 
 
123
    def _get_client_commands(self):
 
124
        """Provide a list of commands that may invoke the mail client"""
 
125
        if sys.platform == 'win32':
 
126
            import win32utils
 
127
            return [win32utils.get_app_path(i) for i in self._client_commands]
 
128
        else:
 
129
            return self._client_commands
 
130
 
 
131
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
132
                extension, basename=None):
 
133
        """See MailClient.compose.
 
134
 
 
135
        Writes the attachment to a temporary file, invokes _compose.
 
136
        """
 
137
        if basename is None:
 
138
            basename = 'attachment'
 
139
        pathname = osutils.mkdtemp(prefix='bzr-mail-')
 
140
        attach_path = osutils.pathjoin(pathname, basename + extension)
 
141
        outfile = open(attach_path, 'wb')
 
142
        try:
 
143
            outfile.write(attachment)
 
144
        finally:
 
145
            outfile.close()
 
146
        self._compose(prompt, to, subject, attach_path, mime_subtype,
 
147
                      extension)
 
148
 
 
149
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
150
                extension):
 
151
        """Invoke a mail client as a commandline process.
 
152
 
 
153
        Overridden by MAPIClient.
 
154
        :param to: The address to send the mail to
 
155
        :param subject: The subject line for the mail
 
156
        :param pathname: The path to the attachment
 
157
        :param mime_subtype: The attachment is assumed to have a major type of
 
158
            "text", but the precise subtype can be specified here
 
159
        :param extension: A file extension (including period) associated with
 
160
            the attachment type.
 
161
        """
 
162
        for name in self._get_client_commands():
 
163
            cmdline = [self._encode_path(name, 'executable')]
 
164
            cmdline.extend(self._get_compose_commandline(to, subject,
 
165
                                                         attach_path))
 
166
            try:
 
167
                subprocess.call(cmdline)
 
168
            except OSError, e:
 
169
                if e.errno != errno.ENOENT:
 
170
                    raise
 
171
            else:
 
172
                break
 
173
        else:
 
174
            raise errors.MailClientNotFound(self._client_commands)
 
175
 
 
176
    def _get_compose_commandline(self, to, subject, attach_path):
 
177
        """Determine the commandline to use for composing a message
 
178
 
 
179
        Implemented by various subclasses
 
180
        :param to: The address to send the mail to
 
181
        :param subject: The subject line for the mail
 
182
        :param attach_path: The path to the attachment
 
183
        """
 
184
        raise NotImplementedError
 
185
 
 
186
    def _encode_safe(self, u):
 
187
        """Encode possible unicode string argument to 8-bit string
 
188
        in user_encoding. Unencodable characters will be replaced
 
189
        with '?'.
 
190
 
 
191
        :param  u:  possible unicode string.
 
192
        :return:    encoded string if u is unicode, u itself otherwise.
 
193
        """
 
194
        if isinstance(u, unicode):
 
195
            return u.encode(osutils.get_user_encoding(), 'replace')
 
196
        return u
 
197
 
 
198
    def _encode_path(self, path, kind):
 
199
        """Encode unicode path in user encoding.
 
200
 
 
201
        :param  path:   possible unicode path.
 
202
        :param  kind:   path kind ('executable' or 'attachment').
 
203
        :return:        encoded path if path is unicode,
 
204
                        path itself otherwise.
 
205
        :raise:         UnableEncodePath.
 
206
        """
 
207
        if isinstance(path, unicode):
 
208
            try:
 
209
                return path.encode(osutils.get_user_encoding())
 
210
            except UnicodeEncodeError:
 
211
                raise errors.UnableEncodePath(path, kind)
 
212
        return path
 
213
 
 
214
 
 
215
class Evolution(ExternalMailClient):
 
216
    """Evolution mail client."""
 
217
 
 
218
    _client_commands = ['evolution']
 
219
 
 
220
    def _get_compose_commandline(self, to, subject, attach_path):
 
221
        """See ExternalMailClient._get_compose_commandline"""
 
222
        message_options = {}
 
223
        if subject is not None:
 
224
            message_options['subject'] = subject
 
225
        if attach_path is not None:
 
226
            message_options['attach'] = attach_path
 
227
        options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
 
228
                        sorted(message_options.iteritems())]
 
229
        return ['mailto:%s?%s' % (self._encode_safe(to or ''),
 
230
            '&'.join(options_list))]
 
231
mail_client_registry.register('evolution', Evolution,
 
232
                              help=Evolution.__doc__)
 
233
 
 
234
 
 
235
class Mutt(ExternalMailClient):
 
236
    """Mutt mail client."""
 
237
 
 
238
    _client_commands = ['mutt']
 
239
 
 
240
    def _get_compose_commandline(self, to, subject, attach_path):
 
241
        """See ExternalMailClient._get_compose_commandline"""
 
242
        message_options = []
 
243
        if subject is not None:
 
244
            message_options.extend(['-s', self._encode_safe(subject)])
 
245
        if attach_path is not None:
 
246
            message_options.extend(['-a',
 
247
                self._encode_path(attach_path, 'attachment')])
 
248
        if to is not None:
 
249
            message_options.append(self._encode_safe(to))
 
250
        return message_options
 
251
mail_client_registry.register('mutt', Mutt,
 
252
                              help=Mutt.__doc__)
 
253
 
 
254
 
 
255
class Thunderbird(ExternalMailClient):
 
256
    """Mozilla Thunderbird (or Icedove)
 
257
 
 
258
    Note that Thunderbird 1.5 is buggy and does not support setting
 
259
    "to" simultaneously with including a attachment.
 
260
 
 
261
    There is a workaround if no attachment is present, but we always need to
 
262
    send attachments.
 
263
    """
 
264
 
 
265
    _client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
 
266
        '/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
 
267
        '/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
 
268
 
 
269
    def _get_compose_commandline(self, to, subject, attach_path):
 
270
        """See ExternalMailClient._get_compose_commandline"""
 
271
        message_options = {}
 
272
        if to is not None:
 
273
            message_options['to'] = self._encode_safe(to)
 
274
        if subject is not None:
 
275
            message_options['subject'] = self._encode_safe(subject)
 
276
        if attach_path is not None:
 
277
            message_options['attachment'] = urlutils.local_path_to_url(
 
278
                attach_path)
 
279
        options_list = ["%s='%s'" % (k, v) for k, v in
 
280
                        sorted(message_options.iteritems())]
 
281
        return ['-compose', ','.join(options_list)]
 
282
mail_client_registry.register('thunderbird', Thunderbird,
 
283
                              help=Thunderbird.__doc__)
 
284
 
 
285
 
 
286
class KMail(ExternalMailClient):
 
287
    """KDE mail client."""
 
288
 
 
289
    _client_commands = ['kmail']
 
290
 
 
291
    def _get_compose_commandline(self, to, subject, attach_path):
 
292
        """See ExternalMailClient._get_compose_commandline"""
 
293
        message_options = []
 
294
        if subject is not None:
 
295
            message_options.extend(['-s', self._encode_safe(subject)])
 
296
        if attach_path is not None:
 
297
            message_options.extend(['--attach',
 
298
                self._encode_path(attach_path, 'attachment')])
 
299
        if to is not None:
 
300
            message_options.extend([self._encode_safe(to)])
 
301
        return message_options
 
302
mail_client_registry.register('kmail', KMail,
 
303
                              help=KMail.__doc__)
 
304
 
 
305
 
 
306
class Claws(ExternalMailClient):
 
307
    """Claws mail client."""
 
308
 
 
309
    _client_commands = ['claws-mail']
 
310
 
 
311
    def _get_compose_commandline(self, to, subject, attach_path):
 
312
        """See ExternalMailClient._get_compose_commandline"""
 
313
        compose_url = ['mailto:']
 
314
        if to is not None:
 
315
            compose_url.append(self._encode_safe(to))
 
316
        compose_url.append('?')
 
317
        if subject is not None:
 
318
            # Don't use urllib.quote_plus because Claws doesn't seem
 
319
            # to recognise spaces encoded as "+".
 
320
            compose_url.append(
 
321
                'subject=%s' % urllib.quote(self._encode_safe(subject)))
 
322
        # Collect command-line options.
 
323
        message_options = ['--compose', ''.join(compose_url)]
 
324
        if attach_path is not None:
 
325
            message_options.extend(
 
326
                ['--attach', self._encode_path(attach_path, 'attachment')])
 
327
        return message_options
 
328
mail_client_registry.register('claws', Claws,
 
329
                              help=Claws.__doc__)
 
330
 
 
331
 
 
332
class XDGEmail(ExternalMailClient):
 
333
    """xdg-email attempts to invoke the user's preferred mail client"""
 
334
 
 
335
    _client_commands = ['xdg-email']
 
336
 
 
337
    def _get_compose_commandline(self, to, subject, attach_path):
 
338
        """See ExternalMailClient._get_compose_commandline"""
 
339
        if not to:
 
340
            raise errors.NoMailAddressSpecified()
 
341
        commandline = [self._encode_safe(to)]
 
342
        if subject is not None:
 
343
            commandline.extend(['--subject', self._encode_safe(subject)])
 
344
        if attach_path is not None:
 
345
            commandline.extend(['--attach',
 
346
                self._encode_path(attach_path, 'attachment')])
 
347
        return commandline
 
348
mail_client_registry.register('xdg-email', XDGEmail,
 
349
                              help=XDGEmail.__doc__)
 
350
 
 
351
 
 
352
class EmacsMail(ExternalMailClient):
 
353
    """Call emacsclient to have a mail buffer.
 
354
 
 
355
    This only work for emacs >= 22.1 due to recent -e/--eval support.
 
356
 
 
357
    The good news is that this implementation will work with all mail
 
358
    agents registered against ``mail-user-agent``. So there is no need
 
359
    to instantiate ExternalMailClient for each and every GNU Emacs
 
360
    MUA.
 
361
 
 
362
    Users just have to ensure that ``mail-user-agent`` is set according
 
363
    to their tastes.
 
364
    """
 
365
 
 
366
    _client_commands = ['emacsclient']
 
367
 
 
368
    def _prepare_send_function(self):
 
369
        """Write our wrapper function into a temporary file.
 
370
 
 
371
        This temporary file will be loaded at runtime in
 
372
        _get_compose_commandline function.
 
373
 
 
374
        This function does not remove the file.  That's a wanted
 
375
        behaviour since _get_compose_commandline won't run the send
 
376
        mail function directly but return the eligible command line.
 
377
        Removing our temporary file here would prevent our sendmail
 
378
        function to work.  (The file is deleted by some elisp code
 
379
        after being read by Emacs.)
 
380
        """
 
381
 
 
382
        _defun = r"""(defun bzr-add-mime-att (file)
 
383
  "Attach FILE to a mail buffer as a MIME attachment."
 
384
  (let ((agent mail-user-agent))
 
385
    (if (and file (file-exists-p file))
 
386
        (cond
 
387
         ((eq agent 'sendmail-user-agent)
 
388
          (progn
 
389
            (mail-text)
 
390
            (newline)
 
391
            (if (functionp 'etach-attach)
 
392
              (etach-attach file)
 
393
              (mail-attach-file file))))
 
394
         ((or (eq agent 'message-user-agent)
 
395
              (eq agent 'gnus-user-agent)
 
396
              (eq agent 'mh-e-user-agent))
 
397
          (progn
 
398
            (mml-attach-file file "text/x-patch" "BZR merge" "inline")))
 
399
         ((eq agent 'mew-user-agent)
 
400
          (progn
 
401
            (mew-draft-prepare-attachments)
 
402
            (mew-attach-link file (file-name-nondirectory file))
 
403
            (let* ((nums (mew-syntax-nums))
 
404
                   (syntax (mew-syntax-get-entry mew-encode-syntax nums)))
 
405
              (mew-syntax-set-cd syntax "BZR merge")
 
406
              (mew-encode-syntax-print mew-encode-syntax))
 
407
            (mew-header-goto-body)))
 
408
         (t
 
409
          (message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
 
410
      (error "File %s does not exist." file))))
 
411
"""
 
412
 
 
413
        fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
 
414
                                         suffix=".el")
 
415
        try:
 
416
            os.write(fd, _defun)
 
417
        finally:
 
418
            os.close(fd) # Just close the handle but do not remove the file.
 
419
        return temp_file
 
420
 
 
421
    def _get_compose_commandline(self, to, subject, attach_path):
 
422
        commandline = ["--eval"]
 
423
 
 
424
        _to = "nil"
 
425
        _subject = "nil"
 
426
 
 
427
        if to is not None:
 
428
            _to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
 
429
        if subject is not None:
 
430
            _subject = ("\"%s\"" %
 
431
                        self._encode_safe(subject).replace('"', '\\"'))
 
432
 
 
433
        # Funcall the default mail composition function
 
434
        # This will work with any mail mode including default mail-mode
 
435
        # User must tweak mail-user-agent variable to tell what function
 
436
        # will be called inside compose-mail.
 
437
        mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
 
438
        commandline.append(mail_cmd)
 
439
 
 
440
        # Try to attach a MIME attachment using our wrapper function
 
441
        if attach_path is not None:
 
442
            # Do not create a file if there is no attachment
 
443
            elisp = self._prepare_send_function()
 
444
            lmmform = '(load "%s")' % elisp
 
445
            mmform  = '(bzr-add-mime-att "%s")' % \
 
446
                self._encode_path(attach_path, 'attachment')
 
447
            rmform = '(delete-file "%s")' % elisp
 
448
            commandline.append(lmmform)
 
449
            commandline.append(mmform)
 
450
            commandline.append(rmform)
 
451
 
 
452
        return commandline
 
453
mail_client_registry.register('emacsclient', EmacsMail,
 
454
                              help=EmacsMail.__doc__)
 
455
 
 
456
 
 
457
class MAPIClient(ExternalMailClient):
 
458
    """Default Windows mail client launched using MAPI."""
 
459
 
 
460
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
461
                 extension):
 
462
        """See ExternalMailClient._compose.
 
463
 
 
464
        This implementation uses MAPI via the simplemapi ctypes wrapper
 
465
        """
 
466
        from bzrlib.util import simplemapi
 
467
        try:
 
468
            simplemapi.SendMail(to or '', subject or '', '', attach_path)
 
469
        except simplemapi.MAPIError, e:
 
470
            if e.code != simplemapi.MAPI_USER_ABORT:
 
471
                raise errors.MailClientNotFound(['MAPI supported mail client'
 
472
                                                 ' (error %d)' % (e.code,)])
 
473
mail_client_registry.register('mapi', MAPIClient,
 
474
                              help=MAPIClient.__doc__)
 
475
 
 
476
 
 
477
class DefaultMail(MailClient):
 
478
    """Default mail handling.  Tries XDGEmail (or MAPIClient on Windows),
 
479
    falls back to Editor"""
 
480
 
 
481
    def _mail_client(self):
 
482
        """Determine the preferred mail client for this platform"""
 
483
        if osutils.supports_mapi():
 
484
            return MAPIClient(self.config)
 
485
        else:
 
486
            return XDGEmail(self.config)
 
487
 
 
488
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
489
                extension, basename=None):
 
490
        """See MailClient.compose"""
 
491
        try:
 
492
            return self._mail_client().compose(prompt, to, subject,
 
493
                                               attachment, mimie_subtype,
 
494
                                               extension, basename)
 
495
        except errors.MailClientNotFound:
 
496
            return Editor(self.config).compose(prompt, to, subject,
 
497
                          attachment, mimie_subtype, extension)
 
498
 
 
499
    def compose_merge_request(self, to, subject, directive, basename=None):
 
500
        """See MailClient.compose_merge_request"""
 
501
        try:
 
502
            return self._mail_client().compose_merge_request(to, subject,
 
503
                    directive, basename=basename)
 
504
        except errors.MailClientNotFound:
 
505
            return Editor(self.config).compose_merge_request(to, subject,
 
506
                          directive, basename=basename)
 
507
mail_client_registry.register('default', DefaultMail,
 
508
                              help=DefaultMail.__doc__)
 
509
mail_client_registry.default_key = 'default'