~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

  • Committer: Canonical.com Patch Queue Manager
  • Date: 2009-05-28 14:25:00 UTC
  • mfrom: (4354.4.9 commit-preview)
  • Revision ID: pqm@pqm.ubuntu.com-20090528142500-n7ki7gucmkxzx611
(abentley) move get_file_with_stat from MutableTree to Tree.

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