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
mail_client_registry = registry.Registry()
 
 
36
class MailClient(object):
 
 
37
    """A mail client that can send messages with attachements."""
 
 
39
    def __init__(self, config):
 
 
42
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
 
43
                extension, basename=None):
 
 
44
        """Compose (and possibly send) an email message
 
 
46
        Must be implemented by subclasses.
 
 
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
 
 
58
        :param basename: The name to use for the attachment, e.g.
 
 
61
        raise NotImplementedError
 
 
63
    def compose_merge_request(self, to, subject, directive, basename=None):
 
 
64
        """Compose (and possibly send) a merge request
 
 
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
 
 
70
        :param basename: The name to use for the attachment, e.g.
 
 
73
        prompt = self._get_merge_prompt("Please describe these changes:", to,
 
 
75
        self.compose(prompt, to, subject, directive,
 
 
76
            'x-patch', '.patch', basename)
 
 
78
    def _get_merge_prompt(self, prompt, to, subject, attachment):
 
 
79
        """Generate a prompt string.  Overridden by Editor.
 
 
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
 
 
89
class Editor(MailClient):
 
 
90
    """DIY mail client that uses commit message editor"""
 
 
92
    def _get_merge_prompt(self, prompt, to, subject, attachment):
 
 
93
        """See MailClient._get_merge_prompt"""
 
 
97
                u"%s" % (prompt, to, subject,
 
 
98
                         attachment.decode('utf-8', 'replace')))
 
 
100
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
 
101
                extension, basename=None):
 
 
102
        """See MailClient.compose"""
 
 
104
            raise errors.NoMailAddressSpecified()
 
 
105
        body = msgeditor.edit_commit_message(prompt)
 
 
107
            raise errors.NoMessageSupplied()
 
 
108
        email_message.EmailMessage.send(self.config,
 
 
109
                                        self.config.username(),
 
 
114
                                        attachment_mime_subtype=mime_subtype)
 
 
115
mail_client_registry.register('editor', Editor,
 
 
119
class ExternalMailClient(MailClient):
 
 
120
    """An external mail client."""
 
 
122
    def _get_client_commands(self):
 
 
123
        """Provide a list of commands that may invoke the mail client"""
 
 
124
        if sys.platform == 'win32':
 
 
126
            return [win32utils.get_app_path(i) for i in self._client_commands]
 
 
128
            return self._client_commands
 
 
130
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
 
131
                extension, basename=None):
 
 
132
        """See MailClient.compose.
 
 
134
        Writes the attachment to a temporary file, invokes _compose.
 
 
137
            basename = 'attachment'
 
 
138
        pathname = tempfile.mkdtemp(prefix='bzr-mail-')
 
 
139
        attach_path = osutils.pathjoin(pathname, basename + extension)
 
 
140
        outfile = open(attach_path, 'wb')
 
 
142
            outfile.write(attachment)
 
 
145
        self._compose(prompt, to, subject, attach_path, mime_subtype,
 
 
148
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
 
150
        """Invoke a mail client as a commandline process.
 
 
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
 
 
161
        for name in self._get_client_commands():
 
 
162
            cmdline = [self._encode_path(name, 'executable')]
 
 
163
            cmdline.extend(self._get_compose_commandline(to, subject,
 
 
166
                subprocess.call(cmdline)
 
 
168
                if e.errno != errno.ENOENT:
 
 
173
            raise errors.MailClientNotFound(self._client_commands)
 
 
175
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
176
        """Determine the commandline to use for composing a message
 
 
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
 
 
183
        raise NotImplementedError
 
 
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
 
 
190
        :param  u:  possible unicode string.
 
 
191
        :return:    encoded string if u is unicode, u itself otherwise.
 
 
193
        if isinstance(u, unicode):
 
 
194
            return u.encode(bzrlib.user_encoding, 'replace')
 
 
197
    def _encode_path(self, path, kind):
 
 
198
        """Encode unicode path in user encoding.
 
 
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.
 
 
206
        if isinstance(path, unicode):
 
 
208
                return path.encode(bzrlib.user_encoding)
 
 
209
            except UnicodeEncodeError:
 
 
210
                raise errors.UnableEncodePath(path, kind)
 
 
214
class Evolution(ExternalMailClient):
 
 
215
    """Evolution mail client."""
 
 
217
    _client_commands = ['evolution']
 
 
219
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
220
        """See ExternalMailClient._get_compose_commandline"""
 
 
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__)
 
 
234
class Mutt(ExternalMailClient):
 
 
235
    """Mutt mail client."""
 
 
237
    _client_commands = ['mutt']
 
 
239
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
240
        """See ExternalMailClient._get_compose_commandline"""
 
 
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')])
 
 
248
            message_options.append(self._encode_safe(to))
 
 
249
        return message_options
 
 
250
mail_client_registry.register('mutt', Mutt,
 
 
254
class Thunderbird(ExternalMailClient):
 
 
255
    """Mozilla Thunderbird (or Icedove)
 
 
257
    Note that Thunderbird 1.5 is buggy and does not support setting
 
 
258
    "to" simultaneously with including a attachment.
 
 
260
    There is a workaround if no attachment is present, but we always need to
 
 
264
    _client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
 
 
265
        '/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
 
 
266
        '/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
 
 
268
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
269
        """See ExternalMailClient._get_compose_commandline"""
 
 
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(
 
 
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__)
 
 
285
class KMail(ExternalMailClient):
 
 
286
    """KDE mail client."""
 
 
288
    _client_commands = ['kmail']
 
 
290
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
291
        """See ExternalMailClient._get_compose_commandline"""
 
 
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')])
 
 
299
            message_options.extend([self._encode_safe(to)])
 
 
300
        return message_options
 
 
301
mail_client_registry.register('kmail', KMail,
 
 
305
class XDGEmail(ExternalMailClient):
 
 
306
    """xdg-email attempts to invoke the user's preferred mail client"""
 
 
308
    _client_commands = ['xdg-email']
 
 
310
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
311
        """See ExternalMailClient._get_compose_commandline"""
 
 
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')])
 
 
321
mail_client_registry.register('xdg-email', XDGEmail,
 
 
322
                              help=XDGEmail.__doc__)
 
 
325
class EmacsMail(ExternalMailClient):
 
 
326
    """Call emacsclient to have a mail buffer.
 
 
328
    This only work for emacs >= 22.1 due to recent -e/--eval support.
 
 
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
 
 
335
    Users just have to ensure that ``mail-user-agent`` is set according
 
 
339
    _client_commands = ['emacsclient']
 
 
341
    def _prepare_send_function(self):
 
 
342
        """Write our wrapper function into a temporary file.
 
 
344
        This temporary file will be loaded at runtime in
 
 
345
        _get_compose_commandline function.
 
 
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.)
 
 
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))
 
 
360
         ((eq agent 'sendmail-user-agent)
 
 
364
            (if (functionp 'etach-attach)
 
 
366
              (mail-attach-file file))))
 
 
367
         ((or (eq agent 'message-user-agent)(eq agent 'gnus-user-agent))
 
 
369
            (mml-attach-file file "text/x-patch" "BZR merge" "inline")))
 
 
370
         ((eq agent 'mew-user-agent)
 
 
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)))
 
 
380
          (message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
 
 
381
      (error "File %s does not exist." file))))
 
 
384
        fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
 
 
389
            os.close(fd) # Just close the handle but do not remove the file.
 
 
392
    def _get_compose_commandline(self, to, subject, attach_path):
 
 
393
        commandline = ["--eval"]
 
 
399
            _to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
 
 
400
        if subject is not None:
 
 
401
            _subject = ("\"%s\"" %
 
 
402
                        self._encode_safe(subject).replace('"', '\\"'))
 
 
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)
 
 
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)
 
 
424
mail_client_registry.register('emacsclient', EmacsMail,
 
 
425
                              help=EmacsMail.__doc__)
 
 
428
class MAPIClient(ExternalMailClient):
 
 
429
    """Default Windows mail client launched using MAPI."""
 
 
431
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
 
433
        """See ExternalMailClient._compose.
 
 
435
        This implementation uses MAPI via the simplemapi ctypes wrapper
 
 
437
        from bzrlib.util import simplemapi
 
 
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__)
 
 
448
class DefaultMail(MailClient):
 
 
449
    """Default mail handling.  Tries XDGEmail (or MAPIClient on Windows),
 
 
450
    falls back to Editor"""
 
 
452
    def _mail_client(self):
 
 
453
        """Determine the preferred mail client for this platform"""
 
 
454
        if osutils.supports_mapi():
 
 
455
            return MAPIClient(self.config)
 
 
457
            return XDGEmail(self.config)
 
 
459
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
 
460
                extension, basename=None):
 
 
461
        """See MailClient.compose"""
 
 
463
            return self._mail_client().compose(prompt, to, subject,
 
 
464
                                               attachment, mimie_subtype,
 
 
466
        except errors.MailClientNotFound:
 
 
467
            return Editor(self.config).compose(prompt, to, subject,
 
 
468
                          attachment, mimie_subtype, extension)
 
 
470
    def compose_merge_request(self, to, subject, directive, basename=None):
 
 
471
        """See MailClient.compose_merge_request"""
 
 
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'