/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: Martin Pool
  • Date: 2009-06-05 23:00:37 UTC
  • mto: This revision was merged to the branch mainline in revision 4418.
  • Revision ID: mbp@sourcefrog.net-20090605230037-662frzolf4cph3a3
Deprecate DotsProgressBar and TTYProgressBar

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