/brz/remove-bazaar

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/brz/remove-bazaar

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

  • Committer: Aaron Bentley
  • Date: 2009-03-10 08:23:34 UTC
  • mto: This revision was merged to the branch mainline in revision 4130.
  • Revision ID: aaron@aaronbentley.com-20090310082334-u4dphlqn32w9zi0n
Allow specifying body for t-bird, evo and xdg

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, body=None):
 
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
        if body is not None:
 
228
            message_options['body'] = body
 
229
        options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
 
230
                        sorted(message_options.iteritems())]
 
231
        return ['mailto:%s?%s' % (self._encode_safe(to or ''),
 
232
            '&'.join(options_list))]
 
233
mail_client_registry.register('evolution', Evolution,
 
234
                              help=Evolution.__doc__)
 
235
 
 
236
 
 
237
class Mutt(ExternalMailClient):
 
238
    """Mutt mail client."""
 
239
 
 
240
    _client_commands = ['mutt']
 
241
 
 
242
    def _get_compose_commandline(self, to, subject, attach_path):
 
243
        """See ExternalMailClient._get_compose_commandline"""
 
244
        message_options = []
 
245
        if subject is not None:
 
246
            message_options.extend(['-s', self._encode_safe(subject)])
 
247
        if attach_path is not None:
 
248
            message_options.extend(['-a',
 
249
                self._encode_path(attach_path, 'attachment')])
 
250
        if to is not None:
 
251
            message_options.append(self._encode_safe(to))
 
252
        return message_options
 
253
mail_client_registry.register('mutt', Mutt,
 
254
                              help=Mutt.__doc__)
 
255
 
 
256
 
 
257
class Thunderbird(ExternalMailClient):
 
258
    """Mozilla Thunderbird (or Icedove)
 
259
 
 
260
    Note that Thunderbird 1.5 is buggy and does not support setting
 
261
    "to" simultaneously with including a attachment.
 
262
 
 
263
    There is a workaround if no attachment is present, but we always need to
 
264
    send attachments.
 
265
    """
 
266
 
 
267
    _client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
 
268
        '/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
 
269
        '/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
 
270
 
 
271
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
272
        """See ExternalMailClient._get_compose_commandline"""
 
273
        message_options = {}
 
274
        if to is not None:
 
275
            message_options['to'] = self._encode_safe(to)
 
276
        if subject is not None:
 
277
            message_options['subject'] = self._encode_safe(subject)
 
278
        if attach_path is not None:
 
279
            message_options['attachment'] = urlutils.local_path_to_url(
 
280
                attach_path)
 
281
        if body is not None:
 
282
            options_list = ['body=%s' % urllib.quote(self._encode_safe(body))]
 
283
        else:
 
284
            options_list = []
 
285
        options_list.extend(["%s='%s'" % (k, v) for k, v in
 
286
                        sorted(message_options.iteritems())])
 
287
        return ['-compose', ','.join(options_list)]
 
288
mail_client_registry.register('thunderbird', Thunderbird,
 
289
                              help=Thunderbird.__doc__)
 
290
 
 
291
 
 
292
class KMail(ExternalMailClient):
 
293
    """KDE mail client."""
 
294
 
 
295
    _client_commands = ['kmail']
 
296
 
 
297
    def _get_compose_commandline(self, to, subject, attach_path):
 
298
        """See ExternalMailClient._get_compose_commandline"""
 
299
        message_options = []
 
300
        if subject is not None:
 
301
            message_options.extend(['-s', self._encode_safe(subject)])
 
302
        if attach_path is not None:
 
303
            message_options.extend(['--attach',
 
304
                self._encode_path(attach_path, 'attachment')])
 
305
        if to is not None:
 
306
            message_options.extend([self._encode_safe(to)])
 
307
        return message_options
 
308
mail_client_registry.register('kmail', KMail,
 
309
                              help=KMail.__doc__)
 
310
 
 
311
 
 
312
class Claws(ExternalMailClient):
 
313
    """Claws mail client."""
 
314
 
 
315
    _client_commands = ['claws-mail']
 
316
 
 
317
    def _get_compose_commandline(self, to, subject, attach_path):
 
318
        """See ExternalMailClient._get_compose_commandline"""
 
319
        compose_url = ['mailto:']
 
320
        if to is not None:
 
321
            compose_url.append(self._encode_safe(to))
 
322
        compose_url.append('?')
 
323
        if subject is not None:
 
324
            # Don't use urllib.quote_plus because Claws doesn't seem
 
325
            # to recognise spaces encoded as "+".
 
326
            compose_url.append(
 
327
                'subject=%s' % urllib.quote(self._encode_safe(subject)))
 
328
        # Collect command-line options.
 
329
        message_options = ['--compose', ''.join(compose_url)]
 
330
        if attach_path is not None:
 
331
            message_options.extend(
 
332
                ['--attach', self._encode_path(attach_path, 'attachment')])
 
333
        return message_options
 
334
mail_client_registry.register('claws', Claws,
 
335
                              help=Claws.__doc__)
 
336
 
 
337
 
 
338
class XDGEmail(ExternalMailClient):
 
339
    """xdg-email attempts to invoke the user's preferred mail client"""
 
340
 
 
341
    _client_commands = ['xdg-email']
 
342
 
 
343
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
344
        """See ExternalMailClient._get_compose_commandline"""
 
345
        if not to:
 
346
            raise errors.NoMailAddressSpecified()
 
347
        commandline = [self._encode_safe(to)]
 
348
        if subject is not None:
 
349
            commandline.extend(['--subject', self._encode_safe(subject)])
 
350
        if attach_path is not None:
 
351
            commandline.extend(['--attach',
 
352
                self._encode_path(attach_path, 'attachment')])
 
353
        if body is not None:
 
354
            commandline.extend(['--body', self._encode_safe(body)])
 
355
        return commandline
 
356
mail_client_registry.register('xdg-email', XDGEmail,
 
357
                              help=XDGEmail.__doc__)
 
358
 
 
359
 
 
360
class EmacsMail(ExternalMailClient):
 
361
    """Call emacsclient to have a mail buffer.
 
362
 
 
363
    This only work for emacs >= 22.1 due to recent -e/--eval support.
 
364
 
 
365
    The good news is that this implementation will work with all mail
 
366
    agents registered against ``mail-user-agent``. So there is no need
 
367
    to instantiate ExternalMailClient for each and every GNU Emacs
 
368
    MUA.
 
369
 
 
370
    Users just have to ensure that ``mail-user-agent`` is set according
 
371
    to their tastes.
 
372
    """
 
373
 
 
374
    _client_commands = ['emacsclient']
 
375
 
 
376
    def _prepare_send_function(self):
 
377
        """Write our wrapper function into a temporary file.
 
378
 
 
379
        This temporary file will be loaded at runtime in
 
380
        _get_compose_commandline function.
 
381
 
 
382
        This function does not remove the file.  That's a wanted
 
383
        behaviour since _get_compose_commandline won't run the send
 
384
        mail function directly but return the eligible command line.
 
385
        Removing our temporary file here would prevent our sendmail
 
386
        function to work.  (The file is deleted by some elisp code
 
387
        after being read by Emacs.)
 
388
        """
 
389
 
 
390
        _defun = r"""(defun bzr-add-mime-att (file)
 
391
  "Attach FILE to a mail buffer as a MIME attachment."
 
392
  (let ((agent mail-user-agent))
 
393
    (if (and file (file-exists-p file))
 
394
        (cond
 
395
         ((eq agent 'sendmail-user-agent)
 
396
          (progn
 
397
            (mail-text)
 
398
            (newline)
 
399
            (if (functionp 'etach-attach)
 
400
              (etach-attach file)
 
401
              (mail-attach-file file))))
 
402
         ((or (eq agent 'message-user-agent)
 
403
              (eq agent 'gnus-user-agent)
 
404
              (eq agent 'mh-e-user-agent))
 
405
          (progn
 
406
            (mml-attach-file file "text/x-patch" "BZR merge" "inline")))
 
407
         ((eq agent 'mew-user-agent)
 
408
          (progn
 
409
            (mew-draft-prepare-attachments)
 
410
            (mew-attach-link file (file-name-nondirectory file))
 
411
            (let* ((nums (mew-syntax-nums))
 
412
                   (syntax (mew-syntax-get-entry mew-encode-syntax nums)))
 
413
              (mew-syntax-set-cd syntax "BZR merge")
 
414
              (mew-encode-syntax-print mew-encode-syntax))
 
415
            (mew-header-goto-body)))
 
416
         (t
 
417
          (message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
 
418
      (error "File %s does not exist." file))))
 
419
"""
 
420
 
 
421
        fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
 
422
                                         suffix=".el")
 
423
        try:
 
424
            os.write(fd, _defun)
 
425
        finally:
 
426
            os.close(fd) # Just close the handle but do not remove the file.
 
427
        return temp_file
 
428
 
 
429
    def _get_compose_commandline(self, to, subject, attach_path):
 
430
        commandline = ["--eval"]
 
431
 
 
432
        _to = "nil"
 
433
        _subject = "nil"
 
434
 
 
435
        if to is not None:
 
436
            _to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
 
437
        if subject is not None:
 
438
            _subject = ("\"%s\"" %
 
439
                        self._encode_safe(subject).replace('"', '\\"'))
 
440
 
 
441
        # Funcall the default mail composition function
 
442
        # This will work with any mail mode including default mail-mode
 
443
        # User must tweak mail-user-agent variable to tell what function
 
444
        # will be called inside compose-mail.
 
445
        mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
 
446
        commandline.append(mail_cmd)
 
447
 
 
448
        # Try to attach a MIME attachment using our wrapper function
 
449
        if attach_path is not None:
 
450
            # Do not create a file if there is no attachment
 
451
            elisp = self._prepare_send_function()
 
452
            lmmform = '(load "%s")' % elisp
 
453
            mmform  = '(bzr-add-mime-att "%s")' % \
 
454
                self._encode_path(attach_path, 'attachment')
 
455
            rmform = '(delete-file "%s")' % elisp
 
456
            commandline.append(lmmform)
 
457
            commandline.append(mmform)
 
458
            commandline.append(rmform)
 
459
 
 
460
        return commandline
 
461
mail_client_registry.register('emacsclient', EmacsMail,
 
462
                              help=EmacsMail.__doc__)
 
463
 
 
464
 
 
465
class MAPIClient(ExternalMailClient):
 
466
    """Default Windows mail client launched using MAPI."""
 
467
 
 
468
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
469
                 extension):
 
470
        """See ExternalMailClient._compose.
 
471
 
 
472
        This implementation uses MAPI via the simplemapi ctypes wrapper
 
473
        """
 
474
        from bzrlib.util import simplemapi
 
475
        try:
 
476
            simplemapi.SendMail(to or '', subject or '', '', attach_path)
 
477
        except simplemapi.MAPIError, e:
 
478
            if e.code != simplemapi.MAPI_USER_ABORT:
 
479
                raise errors.MailClientNotFound(['MAPI supported mail client'
 
480
                                                 ' (error %d)' % (e.code,)])
 
481
mail_client_registry.register('mapi', MAPIClient,
 
482
                              help=MAPIClient.__doc__)
 
483
 
 
484
 
 
485
class DefaultMail(MailClient):
 
486
    """Default mail handling.  Tries XDGEmail (or MAPIClient on Windows),
 
487
    falls back to Editor"""
 
488
 
 
489
    def _mail_client(self):
 
490
        """Determine the preferred mail client for this platform"""
 
491
        if osutils.supports_mapi():
 
492
            return MAPIClient(self.config)
 
493
        else:
 
494
            return XDGEmail(self.config)
 
495
 
 
496
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
497
                extension, basename=None):
 
498
        """See MailClient.compose"""
 
499
        try:
 
500
            return self._mail_client().compose(prompt, to, subject,
 
501
                                               attachment, mimie_subtype,
 
502
                                               extension, basename)
 
503
        except errors.MailClientNotFound:
 
504
            return Editor(self.config).compose(prompt, to, subject,
 
505
                          attachment, mimie_subtype, extension)
 
506
 
 
507
    def compose_merge_request(self, to, subject, directive, basename=None):
 
508
        """See MailClient.compose_merge_request"""
 
509
        try:
 
510
            return self._mail_client().compose_merge_request(to, subject,
 
511
                    directive, basename=basename)
 
512
        except errors.MailClientNotFound:
 
513
            return Editor(self.config).compose_merge_request(to, subject,
 
514
                          directive, basename=basename)
 
515
mail_client_registry.register('default', DefaultMail,
 
516
                              help=DefaultMail.__doc__)
 
517
mail_client_registry.default_key = 'default'