1
 
# Copyright (C) 2007-2010 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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
 
34
 
mail_client_registry = registry.Registry()
 
37
 
class MailClient(object):
 
38
 
    """A mail client that can send messages with attachements."""
 
40
 
    def __init__(self, config):
 
43
 
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
44
 
                extension, basename=None, body=None):
 
45
 
        """Compose (and possibly send) an email message
 
47
 
        Must be implemented by subclasses.
 
49
 
        :param prompt: A message to tell the user what to do.  Supported by
 
50
 
            the Editor client, but ignored by others
 
51
 
        :param to: The address to send the message to
 
52
 
        :param subject: The contents of the subject line
 
53
 
        :param attachment: An email attachment, as a bytestring
 
54
 
        :param mime_subtype: The attachment is assumed to be a subtype of
 
55
 
            Text.  This allows the precise subtype to be specified, e.g.
 
56
 
            "plain", "x-patch", etc.
 
57
 
        :param extension: The file extension associated with the attachment
 
59
 
        :param basename: The name to use for the attachment, e.g.
 
62
 
        raise NotImplementedError
 
64
 
    def compose_merge_request(self, to, subject, directive, basename=None,
 
66
 
        """Compose (and possibly send) a merge request
 
68
 
        :param to: The address to send the request to
 
69
 
        :param subject: The subject line to use for the request
 
70
 
        :param directive: A merge directive representing the merge request, as
 
72
 
        :param basename: The name to use for the attachment, e.g.
 
75
 
        prompt = self._get_merge_prompt("Please describe these changes:", to,
 
77
 
        self.compose(prompt, to, subject, directive,
 
78
 
            'x-patch', '.patch', basename, body)
 
80
 
    def _get_merge_prompt(self, prompt, to, subject, attachment):
 
81
 
        """Generate a prompt string.  Overridden by Editor.
 
83
 
        :param prompt: A string suggesting what user should do
 
84
 
        :param to: The address the mail will be sent to
 
85
 
        :param subject: The subject line of the mail
 
86
 
        :param attachment: The attachment that will be used
 
91
 
class Editor(MailClient):
 
92
 
    __doc__ = """DIY mail client that uses commit message editor"""
 
96
 
    def _get_merge_prompt(self, prompt, to, subject, attachment):
 
97
 
        """See MailClient._get_merge_prompt"""
 
101
 
                u"%s" % (prompt, to, subject,
 
102
 
                         attachment.decode('utf-8', 'replace')))
 
104
 
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
105
 
                extension, basename=None, body=None):
 
106
 
        """See MailClient.compose"""
 
108
 
            raise errors.NoMailAddressSpecified()
 
109
 
        body = msgeditor.edit_commit_message(prompt, start_message=body)
 
111
 
            raise errors.NoMessageSupplied()
 
112
 
        email_message.EmailMessage.send(self.config,
 
113
 
                                        self.config.username(),
 
118
 
                                        attachment_mime_subtype=mime_subtype)
 
119
 
mail_client_registry.register('editor', Editor,
 
123
 
class BodyExternalMailClient(MailClient):
 
127
 
    def _get_client_commands(self):
 
128
 
        """Provide a list of commands that may invoke the mail client"""
 
129
 
        if sys.platform == 'win32':
 
131
 
            return [win32utils.get_app_path(i) for i in self._client_commands]
 
133
 
            return self._client_commands
 
135
 
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
136
 
                extension, basename=None, body=None):
 
137
 
        """See MailClient.compose.
 
139
 
        Writes the attachment to a temporary file, invokes _compose.
 
142
 
            basename = 'attachment'
 
143
 
        pathname = osutils.mkdtemp(prefix='bzr-mail-')
 
144
 
        attach_path = osutils.pathjoin(pathname, basename + extension)
 
145
 
        outfile = open(attach_path, 'wb')
 
147
 
            outfile.write(attachment)
 
151
 
            kwargs = {'body': body}
 
154
 
        self._compose(prompt, to, subject, attach_path, mime_subtype,
 
157
 
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
158
 
                 extension, body=None, from_=None):
 
159
 
        """Invoke a mail client as a commandline process.
 
161
 
        Overridden by MAPIClient.
 
162
 
        :param to: The address to send the mail to
 
163
 
        :param subject: The subject line for the mail
 
164
 
        :param pathname: The path to the attachment
 
165
 
        :param mime_subtype: The attachment is assumed to have a major type of
 
166
 
            "text", but the precise subtype can be specified here
 
167
 
        :param extension: A file extension (including period) associated with
 
169
 
        :param body: Optional body text.
 
170
 
        :param from_: Optional From: header.
 
172
 
        for name in self._get_client_commands():
 
173
 
            cmdline = [self._encode_path(name, 'executable')]
 
175
 
                kwargs = {'body': body}
 
178
 
            if from_ is not None:
 
179
 
                kwargs['from_'] = from_
 
180
 
            cmdline.extend(self._get_compose_commandline(to, subject,
 
184
 
                subprocess.call(cmdline)
 
186
 
                if e.errno != errno.ENOENT:
 
191
 
            raise errors.MailClientNotFound(self._client_commands)
 
193
 
    def _get_compose_commandline(self, to, subject, attach_path, body):
 
194
 
        """Determine the commandline to use for composing a message
 
196
 
        Implemented by various subclasses
 
197
 
        :param to: The address to send the mail to
 
198
 
        :param subject: The subject line for the mail
 
199
 
        :param attach_path: The path to the attachment
 
201
 
        raise NotImplementedError
 
203
 
    def _encode_safe(self, u):
 
204
 
        """Encode possible unicode string argument to 8-bit string
 
205
 
        in user_encoding. Unencodable characters will be replaced
 
208
 
        :param  u:  possible unicode string.
 
209
 
        :return:    encoded string if u is unicode, u itself otherwise.
 
211
 
        if isinstance(u, unicode):
 
212
 
            return u.encode(osutils.get_user_encoding(), 'replace')
 
215
 
    def _encode_path(self, path, kind):
 
216
 
        """Encode unicode path in user encoding.
 
218
 
        :param  path:   possible unicode path.
 
219
 
        :param  kind:   path kind ('executable' or 'attachment').
 
220
 
        :return:        encoded path if path is unicode,
 
221
 
                        path itself otherwise.
 
222
 
        :raise:         UnableEncodePath.
 
224
 
        if isinstance(path, unicode):
 
226
 
                return path.encode(osutils.get_user_encoding())
 
227
 
            except UnicodeEncodeError:
 
228
 
                raise errors.UnableEncodePath(path, kind)
 
232
 
class ExternalMailClient(BodyExternalMailClient):
 
233
 
    __doc__ = """An external mail client."""
 
235
 
    supports_body = False
 
238
 
class Evolution(BodyExternalMailClient):
 
239
 
    __doc__ = """Evolution mail client."""
 
241
 
    _client_commands = ['evolution']
 
243
 
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
244
 
        """See ExternalMailClient._get_compose_commandline"""
 
246
 
        if subject is not None:
 
247
 
            message_options['subject'] = subject
 
248
 
        if attach_path is not None:
 
249
 
            message_options['attach'] = attach_path
 
251
 
            message_options['body'] = body
 
252
 
        options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
 
253
 
                        sorted(message_options.iteritems())]
 
254
 
        return ['mailto:%s?%s' % (self._encode_safe(to or ''),
 
255
 
            '&'.join(options_list))]
 
256
 
mail_client_registry.register('evolution', Evolution,
 
257
 
                              help=Evolution.__doc__)
 
260
 
class Mutt(BodyExternalMailClient):
 
261
 
    __doc__ = """Mutt mail client."""
 
263
 
    _client_commands = ['mutt']
 
265
 
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
266
 
        """See ExternalMailClient._get_compose_commandline"""
 
268
 
        if subject is not None:
 
269
 
            message_options.extend(['-s', self._encode_safe(subject)])
 
270
 
        if attach_path is not None:
 
271
 
            message_options.extend(['-a',
 
272
 
                self._encode_path(attach_path, 'attachment')])
 
274
 
            # Store the temp file object in self, so that it does not get
 
275
 
            # garbage collected and delete the file before mutt can read it.
 
276
 
            self._temp_file = tempfile.NamedTemporaryFile(
 
277
 
                prefix="mutt-body-", suffix=".txt")
 
278
 
            self._temp_file.write(body)
 
279
 
            self._temp_file.flush()
 
280
 
            message_options.extend(['-i', self._temp_file.name])
 
282
 
            message_options.extend(['--', self._encode_safe(to)])
 
283
 
        return message_options
 
284
 
mail_client_registry.register('mutt', Mutt,
 
288
 
class Thunderbird(BodyExternalMailClient):
 
289
 
    __doc__ = """Mozilla Thunderbird (or Icedove)
 
291
 
    Note that Thunderbird 1.5 is buggy and does not support setting
 
292
 
    "to" simultaneously with including a attachment.
 
294
 
    There is a workaround if no attachment is present, but we always need to
 
298
 
    _client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
 
299
 
        '/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
 
300
 
        '/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
 
302
 
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
303
 
        """See ExternalMailClient._get_compose_commandline"""
 
306
 
            message_options['to'] = self._encode_safe(to)
 
307
 
        if subject is not None:
 
308
 
            message_options['subject'] = self._encode_safe(subject)
 
309
 
        if attach_path is not None:
 
310
 
            message_options['attachment'] = urlutils.local_path_to_url(
 
313
 
            options_list = ['body=%s' % urllib.quote(self._encode_safe(body))]
 
316
 
        options_list.extend(["%s='%s'" % (k, v) for k, v in
 
317
 
                        sorted(message_options.iteritems())])
 
318
 
        return ['-compose', ','.join(options_list)]
 
319
 
mail_client_registry.register('thunderbird', Thunderbird,
 
320
 
                              help=Thunderbird.__doc__)
 
323
 
class KMail(ExternalMailClient):
 
324
 
    __doc__ = """KDE mail client."""
 
326
 
    _client_commands = ['kmail']
 
328
 
    def _get_compose_commandline(self, to, subject, attach_path):
 
329
 
        """See ExternalMailClient._get_compose_commandline"""
 
331
 
        if subject is not None:
 
332
 
            message_options.extend(['-s', self._encode_safe(subject)])
 
333
 
        if attach_path is not None:
 
334
 
            message_options.extend(['--attach',
 
335
 
                self._encode_path(attach_path, 'attachment')])
 
337
 
            message_options.extend([self._encode_safe(to)])
 
338
 
        return message_options
 
339
 
mail_client_registry.register('kmail', KMail,
 
343
 
class Claws(ExternalMailClient):
 
344
 
    __doc__ = """Claws mail client."""
 
348
 
    _client_commands = ['claws-mail']
 
350
 
    def _get_compose_commandline(self, to, subject, attach_path, body=None,
 
352
 
        """See ExternalMailClient._get_compose_commandline"""
 
354
 
        if from_ is not None:
 
355
 
            compose_url.append('from=' + urllib.quote(from_))
 
356
 
        if subject is not None:
 
357
 
            # Don't use urllib.quote_plus because Claws doesn't seem
 
358
 
            # to recognise spaces encoded as "+".
 
360
 
                'subject=' + urllib.quote(self._encode_safe(subject)))
 
363
 
                'body=' + urllib.quote(self._encode_safe(body)))
 
364
 
        # to must be supplied for the claws-mail --compose syntax to work.
 
366
 
            raise errors.NoMailAddressSpecified()
 
367
 
        compose_url = 'mailto:%s?%s' % (
 
368
 
            self._encode_safe(to), '&'.join(compose_url))
 
369
 
        # Collect command-line options.
 
370
 
        message_options = ['--compose', compose_url]
 
371
 
        if attach_path is not None:
 
372
 
            message_options.extend(
 
373
 
                ['--attach', self._encode_path(attach_path, 'attachment')])
 
374
 
        return message_options
 
376
 
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
377
 
                 extension, body=None, from_=None):
 
378
 
        """See ExternalMailClient._compose"""
 
380
 
            from_ = self.config.get_user_option('email')
 
381
 
        super(Claws, self)._compose(prompt, to, subject, attach_path,
 
382
 
                                    mime_subtype, extension, body, from_)
 
385
 
mail_client_registry.register('claws', Claws,
 
389
 
class XDGEmail(BodyExternalMailClient):
 
390
 
    __doc__ = """xdg-email attempts to invoke the user's preferred mail client"""
 
392
 
    _client_commands = ['xdg-email']
 
394
 
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
395
 
        """See ExternalMailClient._get_compose_commandline"""
 
397
 
            raise errors.NoMailAddressSpecified()
 
398
 
        commandline = [self._encode_safe(to)]
 
399
 
        if subject is not None:
 
400
 
            commandline.extend(['--subject', self._encode_safe(subject)])
 
401
 
        if attach_path is not None:
 
402
 
            commandline.extend(['--attach',
 
403
 
                self._encode_path(attach_path, 'attachment')])
 
405
 
            commandline.extend(['--body', self._encode_safe(body)])
 
407
 
mail_client_registry.register('xdg-email', XDGEmail,
 
408
 
                              help=XDGEmail.__doc__)
 
411
 
class EmacsMail(ExternalMailClient):
 
412
 
    __doc__ = """Call emacsclient to have a mail buffer.
 
414
 
    This only work for emacs >= 22.1 due to recent -e/--eval support.
 
416
 
    The good news is that this implementation will work with all mail
 
417
 
    agents registered against ``mail-user-agent``. So there is no need
 
418
 
    to instantiate ExternalMailClient for each and every GNU Emacs
 
421
 
    Users just have to ensure that ``mail-user-agent`` is set according
 
425
 
    _client_commands = ['emacsclient']
 
427
 
    def __init__(self, config):
 
428
 
        super(EmacsMail, self).__init__(config)
 
429
 
        self.elisp_tmp_file = None
 
431
 
    def _prepare_send_function(self):
 
432
 
        """Write our wrapper function into a temporary file.
 
434
 
        This temporary file will be loaded at runtime in
 
435
 
        _get_compose_commandline function.
 
437
 
        This function does not remove the file.  That's a wanted
 
438
 
        behaviour since _get_compose_commandline won't run the send
 
439
 
        mail function directly but return the eligible command line.
 
440
 
        Removing our temporary file here would prevent our sendmail
 
441
 
        function to work.  (The file is deleted by some elisp code
 
442
 
        after being read by Emacs.)
 
445
 
        _defun = r"""(defun bzr-add-mime-att (file)
 
446
 
  "Attach FILE to a mail buffer as a MIME attachment."
 
447
 
  (let ((agent mail-user-agent))
 
448
 
    (if (and file (file-exists-p file))
 
450
 
         ((eq agent 'sendmail-user-agent)
 
454
 
            (if (functionp 'etach-attach)
 
456
 
              (mail-attach-file file))))
 
457
 
         ((or (eq agent 'message-user-agent)
 
458
 
              (eq agent 'gnus-user-agent)
 
459
 
              (eq agent 'mh-e-user-agent))
 
461
 
            (mml-attach-file file "text/x-patch" "BZR merge" "inline")))
 
462
 
         ((eq agent 'mew-user-agent)
 
464
 
            (mew-draft-prepare-attachments)
 
465
 
            (mew-attach-link file (file-name-nondirectory file))
 
466
 
            (let* ((nums (mew-syntax-nums))
 
467
 
                   (syntax (mew-syntax-get-entry mew-encode-syntax nums)))
 
468
 
              (mew-syntax-set-cd syntax "BZR merge")
 
469
 
              (mew-encode-syntax-print mew-encode-syntax))
 
470
 
            (mew-header-goto-body)))
 
472
 
          (message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
 
473
 
      (error "File %s does not exist." file))))
 
476
 
        fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
 
481
 
            os.close(fd) # Just close the handle but do not remove the file.
 
484
 
    def _get_compose_commandline(self, to, subject, attach_path):
 
485
 
        commandline = ["--eval"]
 
491
 
            _to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
 
492
 
        if subject is not None:
 
493
 
            _subject = ("\"%s\"" %
 
494
 
                        self._encode_safe(subject).replace('"', '\\"'))
 
496
 
        # Funcall the default mail composition function
 
497
 
        # This will work with any mail mode including default mail-mode
 
498
 
        # User must tweak mail-user-agent variable to tell what function
 
499
 
        # will be called inside compose-mail.
 
500
 
        mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
 
501
 
        commandline.append(mail_cmd)
 
503
 
        # Try to attach a MIME attachment using our wrapper function
 
504
 
        if attach_path is not None:
 
505
 
            # Do not create a file if there is no attachment
 
506
 
            elisp = self._prepare_send_function()
 
507
 
            self.elisp_tmp_file = elisp
 
508
 
            lmmform = '(load "%s")' % elisp
 
509
 
            mmform  = '(bzr-add-mime-att "%s")' % \
 
510
 
                self._encode_path(attach_path, 'attachment')
 
511
 
            rmform = '(delete-file "%s")' % elisp
 
512
 
            commandline.append(lmmform)
 
513
 
            commandline.append(mmform)
 
514
 
            commandline.append(rmform)
 
517
 
mail_client_registry.register('emacsclient', EmacsMail,
 
518
 
                              help=EmacsMail.__doc__)
 
521
 
class MAPIClient(BodyExternalMailClient):
 
522
 
    __doc__ = """Default Windows mail client launched using MAPI."""
 
524
 
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
525
 
                 extension, body=None):
 
526
 
        """See ExternalMailClient._compose.
 
528
 
        This implementation uses MAPI via the simplemapi ctypes wrapper
 
530
 
        from bzrlib.util import simplemapi
 
532
 
            simplemapi.SendMail(to or '', subject or '', body or '',
 
534
 
        except simplemapi.MAPIError, e:
 
535
 
            if e.code != simplemapi.MAPI_USER_ABORT:
 
536
 
                raise errors.MailClientNotFound(['MAPI supported mail client'
 
537
 
                                                 ' (error %d)' % (e.code,)])
 
538
 
mail_client_registry.register('mapi', MAPIClient,
 
539
 
                              help=MAPIClient.__doc__)
 
542
 
class MailApp(BodyExternalMailClient):
 
543
 
    __doc__ = """Use MacOS X's Mail.app for sending email messages.
 
545
 
    Although it would be nice to use appscript, it's not installed
 
546
 
    with the shipped Python installations.  We instead build an
 
547
 
    AppleScript and invoke the script using osascript(1).  We don't
 
548
 
    use the _encode_safe() routines as it's not clear what encoding
 
549
 
    osascript expects the script to be in.
 
552
 
    _client_commands = ['osascript']
 
554
 
    def _get_compose_commandline(self, to, subject, attach_path, body=None,
 
556
 
       """See ExternalMailClient._get_compose_commandline"""
 
558
 
       fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
 
561
 
           os.write(fd, 'tell application "Mail"\n')
 
562
 
           os.write(fd, 'set newMessage to make new outgoing message\n')
 
563
 
           os.write(fd, 'tell newMessage\n')
 
565
 
               os.write(fd, 'make new to recipient with properties'
 
566
 
                   ' {address:"%s"}\n' % to)
 
567
 
           if from_ is not None:
 
568
 
               # though from_ doesn't actually seem to be used
 
569
 
               os.write(fd, 'set sender to "%s"\n'
 
570
 
                   % sender.replace('"', '\\"'))
 
571
 
           if subject is not None:
 
572
 
               os.write(fd, 'set subject to "%s"\n'
 
573
 
                   % subject.replace('"', '\\"'))
 
575
 
               # FIXME: would be nice to prepend the body to the
 
576
 
               # existing content (e.g., preserve signature), but
 
577
 
               # can't seem to figure out the right applescript
 
579
 
               os.write(fd, 'set content to "%s\\n\n"\n' %
 
580
 
                   body.replace('"', '\\"').replace('\n', '\\n'))
 
582
 
           if attach_path is not None:
 
583
 
               # FIXME: would be nice to first append a newline to
 
584
 
               # ensure the attachment is on a new paragraph, but
 
585
 
               # can't seem to figure out the right applescript
 
587
 
               os.write(fd, 'tell content to make new attachment'
 
588
 
                   ' with properties {file name:"%s"}'
 
589
 
                   ' at after the last paragraph\n'
 
590
 
                   % self._encode_path(attach_path, 'attachment'))
 
591
 
           os.write(fd, 'set visible to true\n')
 
592
 
           os.write(fd, 'end tell\n')
 
593
 
           os.write(fd, 'end tell\n')
 
595
 
           os.close(fd) # Just close the handle but do not remove the file.
 
596
 
       return [self.temp_file]
 
597
 
mail_client_registry.register('mail.app', MailApp,
 
598
 
                              help=MailApp.__doc__)
 
601
 
class DefaultMail(MailClient):
 
602
 
    __doc__ = """Default mail handling.  Tries XDGEmail (or MAPIClient on Windows),
 
603
 
    falls back to Editor"""
 
607
 
    def _mail_client(self):
 
608
 
        """Determine the preferred mail client for this platform"""
 
609
 
        if osutils.supports_mapi():
 
610
 
            return MAPIClient(self.config)
 
612
 
            return XDGEmail(self.config)
 
614
 
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
615
 
                extension, basename=None, body=None):
 
616
 
        """See MailClient.compose"""
 
618
 
            return self._mail_client().compose(prompt, to, subject,
 
619
 
                                               attachment, mimie_subtype,
 
620
 
                                               extension, basename, body)
 
621
 
        except errors.MailClientNotFound:
 
622
 
            return Editor(self.config).compose(prompt, to, subject,
 
623
 
                          attachment, mimie_subtype, extension, body)
 
625
 
    def compose_merge_request(self, to, subject, directive, basename=None,
 
627
 
        """See MailClient.compose_merge_request"""
 
629
 
            return self._mail_client().compose_merge_request(to, subject,
 
630
 
                    directive, basename=basename, body=body)
 
631
 
        except errors.MailClientNotFound:
 
632
 
            return Editor(self.config).compose_merge_request(to, subject,
 
633
 
                          directive, basename=basename, body=body)
 
634
 
mail_client_registry.register('default', DefaultMail,
 
635
 
                              help=DefaultMail.__doc__)
 
636
 
mail_client_registry.default_key = 'default'