~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

  • Committer: Martin Pool
  • Date: 2005-05-17 07:51:51 UTC
  • Revision ID: mbp@sourcefrog.net-20050517075151-b64853a6a38a6225
- export bzrlib.find_branch

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.append(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):
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'