/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: Neil Martinsen-Burrell
  • Date: 2008-08-19 19:33:59 UTC
  • mto: (3644.1.2 bzr.dev)
  • mto: This revision was merged to the branch mainline in revision 3646.
  • Revision ID: nmb@wartburg.edu-20080819193359-7ae05y62mfu6x52a
Address JAMs review.  Dont use register_lazy and dont lazy_import anything other than modules

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
 
 
117
 
 
118
class ExternalMailClient(MailClient):
 
119
    """An external mail client."""
 
120
 
 
121
    def _get_client_commands(self):
 
122
        """Provide a list of commands that may invoke the mail client"""
 
123
        if sys.platform == 'win32':
 
124
            import win32utils
 
125
            return [win32utils.get_app_path(i) for i in self._client_commands]
 
126
        else:
 
127
            return self._client_commands
 
128
 
 
129
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
130
                extension, basename=None):
 
131
        """See MailClient.compose.
 
132
 
 
133
        Writes the attachment to a temporary file, invokes _compose.
 
134
        """
 
135
        if basename is None:
 
136
            basename = 'attachment'
 
137
        pathname = tempfile.mkdtemp(prefix='bzr-mail-')
 
138
        attach_path = osutils.pathjoin(pathname, basename + extension)
 
139
        outfile = open(attach_path, 'wb')
 
140
        try:
 
141
            outfile.write(attachment)
 
142
        finally:
 
143
            outfile.close()
 
144
        self._compose(prompt, to, subject, attach_path, mime_subtype,
 
145
                      extension)
 
146
 
 
147
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
148
                extension):
 
149
        """Invoke a mail client as a commandline process.
 
150
 
 
151
        Overridden by MAPIClient.
 
152
        :param to: The address to send the mail to
 
153
        :param subject: The subject line for the mail
 
154
        :param pathname: The path to the attachment
 
155
        :param mime_subtype: The attachment is assumed to have a major type of
 
156
            "text", but the precise subtype can be specified here
 
157
        :param extension: A file extension (including period) associated with
 
158
            the attachment type.
 
159
        """
 
160
        for name in self._get_client_commands():
 
161
            cmdline = [self._encode_path(name, 'executable')]
 
162
            cmdline.extend(self._get_compose_commandline(to, subject,
 
163
                                                         attach_path))
 
164
            try:
 
165
                subprocess.call(cmdline)
 
166
            except OSError, e:
 
167
                if e.errno != errno.ENOENT:
 
168
                    raise
 
169
            else:
 
170
                break
 
171
        else:
 
172
            raise errors.MailClientNotFound(self._client_commands)
 
173
 
 
174
    def _get_compose_commandline(self, to, subject, attach_path):
 
175
        """Determine the commandline to use for composing a message
 
176
 
 
177
        Implemented by various subclasses
 
178
        :param to: The address to send the mail to
 
179
        :param subject: The subject line for the mail
 
180
        :param attach_path: The path to the attachment
 
181
        """
 
182
        raise NotImplementedError
 
183
 
 
184
    def _encode_safe(self, u):
 
185
        """Encode possible unicode string argument to 8-bit string
 
186
        in user_encoding. Unencodable characters will be replaced
 
187
        with '?'.
 
188
 
 
189
        :param  u:  possible unicode string.
 
190
        :return:    encoded string if u is unicode, u itself otherwise.
 
191
        """
 
192
        if isinstance(u, unicode):
 
193
            return u.encode(bzrlib.user_encoding, 'replace')
 
194
        return u
 
195
 
 
196
    def _encode_path(self, path, kind):
 
197
        """Encode unicode path in user encoding.
 
198
 
 
199
        :param  path:   possible unicode path.
 
200
        :param  kind:   path kind ('executable' or 'attachment').
 
201
        :return:        encoded path if path is unicode,
 
202
                        path itself otherwise.
 
203
        :raise:         UnableEncodePath.
 
204
        """
 
205
        if isinstance(path, unicode):
 
206
            try:
 
207
                return path.encode(bzrlib.user_encoding)
 
208
            except UnicodeEncodeError:
 
209
                raise errors.UnableEncodePath(path, kind)
 
210
        return path
 
211
 
 
212
 
 
213
class Evolution(ExternalMailClient):
 
214
    """Evolution mail client."""
 
215
 
 
216
    _client_commands = ['evolution']
 
217
 
 
218
    def _get_compose_commandline(self, to, subject, attach_path):
 
219
        """See ExternalMailClient._get_compose_commandline"""
 
220
        message_options = {}
 
221
        if subject is not None:
 
222
            message_options['subject'] = subject
 
223
        if attach_path is not None:
 
224
            message_options['attach'] = attach_path
 
225
        options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
 
226
                        sorted(message_options.iteritems())]
 
227
        return ['mailto:%s?%s' % (self._encode_safe(to or ''),
 
228
            '&'.join(options_list))]
 
229
mail_client_registry.register('evolution', Evolution)
 
230
 
 
231
 
 
232
class Mutt(ExternalMailClient):
 
233
    """Mutt mail client."""
 
234
 
 
235
    _client_commands = ['mutt']
 
236
 
 
237
    def _get_compose_commandline(self, to, subject, attach_path):
 
238
        """See ExternalMailClient._get_compose_commandline"""
 
239
        message_options = []
 
240
        if subject is not None:
 
241
            message_options.extend(['-s', self._encode_safe(subject)])
 
242
        if attach_path is not None:
 
243
            message_options.extend(['-a',
 
244
                self._encode_path(attach_path, 'attachment')])
 
245
        if to is not None:
 
246
            message_options.append(self._encode_safe(to))
 
247
        return message_options
 
248
mail_client_registry.register('mutt', Mutt)
 
249
 
 
250
 
 
251
class Thunderbird(ExternalMailClient):
 
252
    """Mozilla Thunderbird (or Icedove)
 
253
 
 
254
    Note that Thunderbird 1.5 is buggy and does not support setting
 
255
    "to" simultaneously with including a attachment.
 
256
 
 
257
    There is a workaround if no attachment is present, but we always need to
 
258
    send attachments.
 
259
    """
 
260
 
 
261
    _client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
 
262
        '/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
 
263
        '/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
 
264
 
 
265
    def _get_compose_commandline(self, to, subject, attach_path):
 
266
        """See ExternalMailClient._get_compose_commandline"""
 
267
        message_options = {}
 
268
        if to is not None:
 
269
            message_options['to'] = self._encode_safe(to)
 
270
        if subject is not None:
 
271
            message_options['subject'] = self._encode_safe(subject)
 
272
        if attach_path is not None:
 
273
            message_options['attachment'] = urlutils.local_path_to_url(
 
274
                attach_path)
 
275
        options_list = ["%s='%s'" % (k, v) for k, v in
 
276
                        sorted(message_options.iteritems())]
 
277
        return ['-compose', ','.join(options_list)]
 
278
mail_client_registry.register('thunderbird', Thunderbird)
 
279
 
 
280
 
 
281
class KMail(ExternalMailClient):
 
282
    """KDE mail client."""
 
283
 
 
284
    _client_commands = ['kmail']
 
285
 
 
286
    def _get_compose_commandline(self, to, subject, attach_path):
 
287
        """See ExternalMailClient._get_compose_commandline"""
 
288
        message_options = []
 
289
        if subject is not None:
 
290
            message_options.extend(['-s', self._encode_safe(subject)])
 
291
        if attach_path is not None:
 
292
            message_options.extend(['--attach',
 
293
                self._encode_path(attach_path, 'attachment')])
 
294
        if to is not None:
 
295
            message_options.extend([self._encode_safe(to)])
 
296
        return message_options
 
297
mail_client_registry.register('kmail', KMail)
 
298
 
 
299
 
 
300
class XDGEmail(ExternalMailClient):
 
301
    """xdg-email attempts to invoke the user's preferred mail client"""
 
302
 
 
303
    _client_commands = ['xdg-email']
 
304
 
 
305
    def _get_compose_commandline(self, to, subject, attach_path):
 
306
        """See ExternalMailClient._get_compose_commandline"""
 
307
        if not to:
 
308
            raise errors.NoMailAddressSpecified()
 
309
        commandline = [self._encode_safe(to)]
 
310
        if subject is not None:
 
311
            commandline.extend(['--subject', self._encode_safe(subject)])
 
312
        if attach_path is not None:
 
313
            commandline.extend(['--attach',
 
314
                self._encode_path(attach_path, 'attachment')])
 
315
        return commandline
 
316
mail_client_registry.register('xdg-email', XDGEmail)
 
317
 
 
318
 
 
319
class EmacsMail(ExternalMailClient):
 
320
    """Call emacsclient to have a mail buffer.
 
321
 
 
322
    This only work for emacs >= 22.1 due to recent -e/--eval support.
 
323
 
 
324
    The good news is that this implementation will work with all mail
 
325
    agents registered against ``mail-user-agent``. So there is no need
 
326
    to instantiate ExternalMailClient for each and every GNU Emacs
 
327
    MUA.
 
328
 
 
329
    Users just have to ensure that ``mail-user-agent`` is set according
 
330
    to their tastes.
 
331
    """
 
332
 
 
333
    _client_commands = ['emacsclient']
 
334
 
 
335
    def _prepare_send_function(self):
 
336
        """Write our wrapper function into a temporary file.
 
337
 
 
338
        This temporary file will be loaded at runtime in
 
339
        _get_compose_commandline function.
 
340
 
 
341
        This function does not remove the file.  That's a wanted
 
342
        behaviour since _get_compose_commandline won't run the send
 
343
        mail function directly but return the eligible command line.
 
344
        Removing our temporary file here would prevent our sendmail
 
345
        function to work.  (The file is deleted by some elisp code
 
346
        after being read by Emacs.)
 
347
        """
 
348
 
 
349
        _defun = r"""(defun bzr-add-mime-att (file)
 
350
  "Attach FILE to a mail buffer as a MIME attachment."
 
351
  (let ((agent mail-user-agent))
 
352
    (if (and file (file-exists-p file))
 
353
        (cond
 
354
         ((eq agent 'sendmail-user-agent)
 
355
          (progn
 
356
            (mail-text)
 
357
            (newline)
 
358
            (if (functionp 'etach-attach)
 
359
              (etach-attach file)
 
360
              (mail-attach-file file))))
 
361
         ((or (eq agent 'message-user-agent)(eq agent 'gnus-user-agent))
 
362
          (progn
 
363
            (mml-attach-file file "text/x-patch" "BZR merge" "inline")))
 
364
         ((eq agent 'mew-user-agent)
 
365
          (progn
 
366
            (mew-draft-prepare-attachments)
 
367
            (mew-attach-link file (file-name-nondirectory file))
 
368
            (let* ((nums (mew-syntax-nums))
 
369
                   (syntax (mew-syntax-get-entry mew-encode-syntax nums)))
 
370
              (mew-syntax-set-cd syntax "BZR merge")
 
371
              (mew-encode-syntax-print mew-encode-syntax))
 
372
            (mew-header-goto-body)))
 
373
         (t
 
374
          (message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
 
375
      (error "File %s does not exist." file))))
 
376
"""
 
377
 
 
378
        fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
 
379
                                         suffix=".el")
 
380
        try:
 
381
            os.write(fd, _defun)
 
382
        finally:
 
383
            os.close(fd) # Just close the handle but do not remove the file.
 
384
        return temp_file
 
385
 
 
386
    def _get_compose_commandline(self, to, subject, attach_path):
 
387
        commandline = ["--eval"]
 
388
 
 
389
        _to = "nil"
 
390
        _subject = "nil"
 
391
 
 
392
        if to is not None:
 
393
            _to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
 
394
        if subject is not None:
 
395
            _subject = ("\"%s\"" %
 
396
                        self._encode_safe(subject).replace('"', '\\"'))
 
397
 
 
398
        # Funcall the default mail composition function
 
399
        # This will work with any mail mode including default mail-mode
 
400
        # User must tweak mail-user-agent variable to tell what function
 
401
        # will be called inside compose-mail.
 
402
        mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
 
403
        commandline.append(mail_cmd)
 
404
 
 
405
        # Try to attach a MIME attachment using our wrapper function
 
406
        if attach_path is not None:
 
407
            # Do not create a file if there is no attachment
 
408
            elisp = self._prepare_send_function()
 
409
            lmmform = '(load "%s")' % elisp
 
410
            mmform  = '(bzr-add-mime-att "%s")' % \
 
411
                self._encode_path(attach_path, 'attachment')
 
412
            rmform = '(delete-file "%s")' % elisp
 
413
            commandline.append(lmmform)
 
414
            commandline.append(mmform)
 
415
            commandline.append(rmform)
 
416
 
 
417
        return commandline
 
418
mail_client_registry.register('emacsclient', EmacsMail)
 
419
 
 
420
 
 
421
class MAPIClient(ExternalMailClient):
 
422
    """Default Windows mail client launched using MAPI."""
 
423
 
 
424
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
425
                 extension):
 
426
        """See ExternalMailClient._compose.
 
427
 
 
428
        This implementation uses MAPI via the simplemapi ctypes wrapper
 
429
        """
 
430
        from bzrlib.util import simplemapi
 
431
        try:
 
432
            simplemapi.SendMail(to or '', subject or '', '', attach_path)
 
433
        except simplemapi.MAPIError, e:
 
434
            if e.code != simplemapi.MAPI_USER_ABORT:
 
435
                raise errors.MailClientNotFound(['MAPI supported mail client'
 
436
                                                 ' (error %d)' % (e.code,)])
 
437
mail_client_registry.register('mapi', MAPIClient)
 
438
 
 
439
 
 
440
class DefaultMail(MailClient):
 
441
    """Default mail handling.  Tries XDGEmail (or MAPIClient on Windows),
 
442
    falls back to Editor"""
 
443
 
 
444
    def _mail_client(self):
 
445
        """Determine the preferred mail client for this platform"""
 
446
        if osutils.supports_mapi():
 
447
            return MAPIClient(self.config)
 
448
        else:
 
449
            return XDGEmail(self.config)
 
450
 
 
451
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
452
                extension, basename=None):
 
453
        """See MailClient.compose"""
 
454
        try:
 
455
            return self._mail_client().compose(prompt, to, subject,
 
456
                                               attachment, mimie_subtype,
 
457
                                               extension, basename)
 
458
        except errors.MailClientNotFound:
 
459
            return Editor(self.config).compose(prompt, to, subject,
 
460
                          attachment, mimie_subtype, extension)
 
461
 
 
462
    def compose_merge_request(self, to, subject, directive, basename=None):
 
463
        """See MailClient.compose_merge_request"""
 
464
        try:
 
465
            return self._mail_client().compose_merge_request(to, subject,
 
466
                    directive, basename=basename)
 
467
        except errors.MailClientNotFound:
 
468
            return Editor(self.config).compose_merge_request(to, subject,
 
469
                          directive, basename=basename)
 
470
mail_client_registry.register('default', DefaultMail)
 
471
mail_client_registry.default_key = 'default'