~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

  • Committer: Tim Penhey
  • Date: 2008-04-25 11:23:00 UTC
  • mto: (3473.1.1 ianc-integration)
  • mto: This revision was merged to the branch mainline in revision 3474.
  • Revision ID: tim@penhey.net-20080425112300-sf5soa5dg2d37kvc
Added tests.

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