~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

  • Committer: Aaron Bentley
  • Date: 2008-10-16 21:27:10 UTC
  • mfrom: (0.15.26 unshelve)
  • mto: (0.16.74 shelf-ui)
  • mto: This revision was merged to the branch mainline in revision 3820.
  • Revision ID: aaron@aaronbentley.com-20081016212710-h9av3nhk76dvmsv5
Merge with unshelve

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