~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

  • Committer: Robert Collins
  • Date: 2005-08-23 06:52:09 UTC
  • mto: (974.1.50) (1185.1.10) (1092.3.1)
  • mto: This revision was merged to the branch mainline in revision 1139.
  • Revision ID: robertc@robertcollins.net-20050823065209-81cd5962c401751b
move io redirection into each test case from the global runner

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'