1
# Copyright (C) 2007 Canonical Ltd
 
 
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.
 
 
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.
 
 
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
 
 
33
class MailClient(object):
 
 
34
    """A mail client that can send messages with attachements."""
 
 
36
    def __init__(self, config):
 
 
39
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
 
40
                extension, basename=None):
 
 
41
        """Compose (and possibly send) an email message
 
 
43
        Must be implemented by subclasses.
 
 
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
 
 
55
        :param basename: The name to use for the attachment, e.g.
 
 
58
        raise NotImplementedError
 
 
60
    def compose_merge_request(self, to, subject, directive, basename=None):
 
 
61
        """Compose (and possibly send) a merge request
 
 
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
 
 
67
        :param basename: The name to use for the attachment, e.g.
 
 
70
        prompt = self._get_merge_prompt("Please describe these changes:", to,
 
 
72
        self.compose(prompt, to, subject, directive,
 
 
73
            'x-patch', '.patch', basename)
 
 
75
    def _get_merge_prompt(self, prompt, to, subject, attachment):
 
 
76
        """Generate a prompt string.  Overridden by Editor.
 
 
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
 
 
86
class Editor(MailClient):
 
 
87
    """DIY mail client that uses commit message editor"""
 
 
89
    def _get_merge_prompt(self, prompt, to, subject, attachment):
 
 
90
        """See MailClient._get_merge_prompt"""
 
 
94
                u"%s" % (prompt, to, subject,
 
 
95
                         attachment.decode('utf-8', 'replace')))
 
 
97
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
 
98
                extension, basename=None):
 
 
99
        """See MailClient.compose"""
 
 
101
            raise errors.NoMailAddressSpecified()
 
 
102
        body = msgeditor.edit_commit_message(prompt)
 
 
104
            raise errors.NoMessageSupplied()
 
 
105
        email_message.EmailMessage.send(self.config,
 
 
106
                                        self.config.username(),
 
 
111
                                        attachment_mime_subtype=mime_subtype)
 
 
114
class ExternalMailClient(MailClient):
 
 
115
    """An external mail client."""
 
 
117
    def _get_client_commands(self):
 
 
118
        """Provide a list of commands that may invoke the mail client"""
 
 
119
        if sys.platform == 'win32':
 
 
121
            return [win32utils.get_app_path(i) for i in self._client_commands]
 
 
123
            return self._client_commands
 
 
125
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
 
126
                extension, basename=None):
 
 
127
        """See MailClient.compose.
 
 
129
        Writes the attachment to a temporary file, invokes _compose.
 
 
132
            basename = 'attachment'
 
 
133
        pathname = tempfile.mkdtemp(prefix='bzr-mail-')
 
 
134
        attach_path = osutils.pathjoin(pathname, basename + extension)
 
 
135
        outfile = open(attach_path, 'wb')
 
 
137
            outfile.write(attachment)
 
 
140
        self._compose(prompt, to, subject, attach_path, mime_subtype,
 
 
143
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
 
145
        """Invoke a mail client as a commandline process.
 
 
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
 
 
156
        for name in self._get_client_commands():
 
 
157
            cmdline = [self._encode_path(name, 'executable')]
 
 
158
            cmdline.extend(self._get_compose_commandline(to, subject,
 
 
161
                subprocess.call(cmdline)
 
 
163
                if e.errno != errno.ENOENT:
 
 
168
            raise errors.MailClientNotFound(self._client_commands)
 
 
170
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
171
        """Determine the commandline to use for composing a message
 
 
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
 
 
178
        raise NotImplementedError
 
 
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
 
 
185
        :param  u:  possible unicode string.
 
 
186
        :return:    encoded string if u is unicode, u itself otherwise.
 
 
188
        if isinstance(u, unicode):
 
 
189
            return u.encode(bzrlib.user_encoding, 'replace')
 
 
192
    def _encode_path(self, path, kind):
 
 
193
        """Encode unicode path in user encoding.
 
 
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.
 
 
201
        if isinstance(path, unicode):
 
 
203
                return path.encode(bzrlib.user_encoding)
 
 
204
            except UnicodeEncodeError:
 
 
205
                raise errors.UnableEncodePath(path, kind)
 
 
209
class Evolution(ExternalMailClient):
 
 
210
    """Evolution mail client."""
 
 
212
    _client_commands = ['evolution']
 
 
214
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
215
        """See ExternalMailClient._get_compose_commandline"""
 
 
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))]
 
 
227
class Mutt(ExternalMailClient):
 
 
228
    """Mutt mail client."""
 
 
230
    _client_commands = ['mutt']
 
 
232
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
233
        """See ExternalMailClient._get_compose_commandline"""
 
 
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')])
 
 
241
            message_options.append(self._encode_safe(to))
 
 
242
        return message_options
 
 
245
class Thunderbird(ExternalMailClient):
 
 
246
    """Mozilla Thunderbird (or Icedove)
 
 
248
    Note that Thunderbird 1.5 is buggy and does not support setting
 
 
249
    "to" simultaneously with including a attachment.
 
 
251
    There is a workaround if no attachment is present, but we always need to
 
 
255
    _client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
 
 
256
        '/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin']
 
 
258
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
259
        """See ExternalMailClient._get_compose_commandline"""
 
 
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(
 
 
268
        options_list = ["%s='%s'" % (k, v) for k, v in
 
 
269
                        sorted(message_options.iteritems())]
 
 
270
        return ['-compose', ','.join(options_list)]
 
 
273
class KMail(ExternalMailClient):
 
 
274
    """KDE mail client."""
 
 
276
    _client_commands = ['kmail']
 
 
278
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
279
        """See ExternalMailClient._get_compose_commandline"""
 
 
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')])
 
 
287
            message_options.extend([self._encode_safe(to)])
 
 
288
        return message_options
 
 
291
class XDGEmail(ExternalMailClient):
 
 
292
    """xdg-email attempts to invoke the user's preferred mail client"""
 
 
294
    _client_commands = ['xdg-email']
 
 
296
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
297
        """See ExternalMailClient._get_compose_commandline"""
 
 
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')])
 
 
309
class EmacsMail(ExternalMailClient):
 
 
310
    """Call emacsclient to have a mail buffer.
 
 
312
    This only work for emacs >= 22.1 due to recent -e/--eval support.
 
 
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
 
 
319
    Users just have to ensure that ``mail-user-agent`` is set according
 
 
323
    _client_commands = ['emacsclient']
 
 
325
    def _prepare_send_function(self):
 
 
326
        """Write our wrapper function into a temporary file.
 
 
328
        This temporary file will be loaded at runtime in
 
 
329
        _get_compose_commandline function.
 
 
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.)
 
 
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))
 
 
344
         ((eq agent 'sendmail-user-agent)
 
 
348
            (if (functionp 'etach-attach)
 
 
350
              (mail-attach-file file))))
 
 
351
         ((or (eq agent 'message-user-agent)(eq agent 'gnus-user-agent))
 
 
353
            (mml-attach-file file "text/x-patch" "BZR merge" "inline")))
 
 
354
         ((eq agent 'mew-user-agent)
 
 
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)))
 
 
364
          (message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
 
 
365
      (error "File %s does not exist." file))))
 
 
368
        fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
 
 
373
            os.close(fd) # Just close the handle but do not remove the file.
 
 
376
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
377
        commandline = ["--eval"]
 
 
383
            _to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
 
 
384
        if subject is not None:
 
 
385
            _subject = ("\"%s\"" %
 
 
386
                        self._encode_safe(subject).replace('"', '\\"'))
 
 
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)
 
 
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)
 
 
410
class MAPIClient(ExternalMailClient):
 
 
411
    """Default Windows mail client launched using MAPI."""
 
 
413
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
 
415
        """See ExternalMailClient._compose.
 
 
417
        This implementation uses MAPI via the simplemapi ctypes wrapper
 
 
419
        from bzrlib.util import simplemapi
 
 
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,)])
 
 
428
class DefaultMail(MailClient):
 
 
429
    """Default mail handling.  Tries XDGEmail (or MAPIClient on Windows),
 
 
430
    falls back to Editor"""
 
 
432
    def _mail_client(self):
 
 
433
        """Determine the preferred mail client for this platform"""
 
 
434
        if osutils.supports_mapi():
 
 
435
            return MAPIClient(self.config)
 
 
437
            return XDGEmail(self.config)
 
 
439
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
 
440
                extension, basename=None):
 
 
441
        """See MailClient.compose"""
 
 
443
            return self._mail_client().compose(prompt, to, subject,
 
 
444
                                               attachment, mimie_subtype,
 
 
446
        except errors.MailClientNotFound:
 
 
447
            return Editor(self.config).compose(prompt, to, subject,
 
 
448
                          attachment, mimie_subtype, extension)
 
 
450
    def compose_merge_request(self, to, subject, directive, basename=None):
 
 
451
        """See MailClient.compose_merge_request"""
 
 
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)