/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

First attempt to merge .dev and resolve the conflicts (but tests are 
failing)

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
        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.  (The file is deleted by some elisp code
 
336
        after being read by Emacs.)
 
337
        """
 
338
 
 
339
        _defun = r"""(defun bzr-add-mime-att (file)
 
340
  "Attach FILE to a mail buffer as a MIME attachment."
 
341
  (let ((agent mail-user-agent))
 
342
    (if (and file (file-exists-p file))
 
343
        (cond
 
344
         ((eq agent 'sendmail-user-agent)
 
345
          (progn
 
346
            (mail-text)
 
347
            (newline)
 
348
            (if (functionp 'etach-attach)
 
349
              (etach-attach file)
 
350
              (mail-attach-file file))))
 
351
         ((or (eq agent 'message-user-agent)(eq agent 'gnus-user-agent))
 
352
          (progn
 
353
            (mml-attach-file file "text/x-patch" "BZR merge" "inline")))
 
354
         ((eq agent 'mew-user-agent)
 
355
          (progn
 
356
            (mew-draft-prepare-attachments)
 
357
            (mew-attach-link file (file-name-nondirectory file))
 
358
            (let* ((nums (mew-syntax-nums))
 
359
                   (syntax (mew-syntax-get-entry mew-encode-syntax nums)))
 
360
              (mew-syntax-set-cd syntax "BZR merge")
 
361
              (mew-encode-syntax-print mew-encode-syntax))
 
362
            (mew-header-goto-body)))
 
363
         (t
 
364
          (message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
 
365
      (error "File %s does not exist." file))))
 
366
"""
 
367
 
 
368
        fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
 
369
                                         suffix=".el")
 
370
        try:
 
371
            os.write(fd, _defun)
 
372
        finally:
 
373
            os.close(fd) # Just close the handle but do not remove the file.
 
374
        return temp_file
 
375
 
 
376
    def _get_compose_commandline(self, to, subject, attach_path):
 
377
        commandline = ["--eval"]
 
378
 
 
379
        _to = "nil"
 
380
        _subject = "nil"
 
381
 
 
382
        if to is not None:
 
383
            _to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
 
384
        if subject is not None:
 
385
            _subject = ("\"%s\"" %
 
386
                        self._encode_safe(subject).replace('"', '\\"'))
 
387
 
 
388
        # Funcall the default mail composition function
 
389
        # This will work with any mail mode including default mail-mode
 
390
        # User must tweak mail-user-agent variable to tell what function
 
391
        # will be called inside compose-mail.
 
392
        mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
 
393
        commandline.append(mail_cmd)
 
394
 
 
395
        # Try to attach a MIME attachment using our wrapper function
 
396
        if attach_path is not None:
 
397
            # Do not create a file if there is no attachment
 
398
            elisp = self._prepare_send_function()
 
399
            lmmform = '(load "%s")' % elisp
 
400
            mmform  = '(bzr-add-mime-att "%s")' % \
 
401
                self._encode_path(attach_path, 'attachment')
 
402
            rmform = '(delete-file "%s")' % elisp
 
403
            commandline.append(lmmform)
 
404
            commandline.append(mmform)
 
405
            commandline.append(rmform)
 
406
 
 
407
        return commandline
 
408
 
 
409
 
 
410
class MAPIClient(ExternalMailClient):
 
411
    """Default Windows mail client launched using MAPI."""
 
412
 
 
413
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
414
                 extension):
 
415
        """See ExternalMailClient._compose.
 
416
 
 
417
        This implementation uses MAPI via the simplemapi ctypes wrapper
 
418
        """
 
419
        from bzrlib.util import simplemapi
 
420
        try:
 
421
            simplemapi.SendMail(to or '', subject or '', '', attach_path)
 
422
        except simplemapi.MAPIError, e:
 
423
            if e.code != simplemapi.MAPI_USER_ABORT:
 
424
                raise errors.MailClientNotFound(['MAPI supported mail client'
 
425
                                                 ' (error %d)' % (e.code,)])
 
426
 
 
427
 
 
428
class DefaultMail(MailClient):
 
429
    """Default mail handling.  Tries XDGEmail (or MAPIClient on Windows),
 
430
    falls back to Editor"""
 
431
 
 
432
    def _mail_client(self):
 
433
        """Determine the preferred mail client for this platform"""
 
434
        if osutils.supports_mapi():
 
435
            return MAPIClient(self.config)
 
436
        else:
 
437
            return XDGEmail(self.config)
 
438
 
 
439
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
440
                extension, basename=None):
 
441
        """See MailClient.compose"""
 
442
        try:
 
443
            return self._mail_client().compose(prompt, to, subject,
 
444
                                               attachment, mimie_subtype,
 
445
                                               extension, basename)
 
446
        except errors.MailClientNotFound:
 
447
            return Editor(self.config).compose(prompt, to, subject,
 
448
                          attachment, mimie_subtype, extension)
 
449
 
 
450
    def compose_merge_request(self, to, subject, directive, basename=None):
 
451
        """See MailClient.compose_merge_request"""
 
452
        try:
 
453
            return self._mail_client().compose_merge_request(to, subject,
 
454
                    directive, basename=basename)
 
455
        except errors.MailClientNotFound:
 
456
            return Editor(self.config).compose_merge_request(to, subject,
 
457
                          directive, basename=basename)