~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

(vila) Fix test failures blocking package builds. (Vincent Ladeuil)

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2007-2010 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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
 
16
 
 
17
from __future__ import absolute_import
 
18
 
 
19
import errno
 
20
import os
 
21
import subprocess
 
22
import sys
 
23
import tempfile
 
24
 
 
25
import bzrlib
 
26
from bzrlib import (
 
27
    config as _mod_config,
 
28
    email_message,
 
29
    errors,
 
30
    msgeditor,
 
31
    osutils,
 
32
    urlutils,
 
33
    registry
 
34
    )
 
35
 
 
36
mail_client_registry = registry.Registry()
 
37
 
 
38
 
 
39
class MailClient(object):
 
40
    """A mail client that can send messages with attachements."""
 
41
 
 
42
    def __init__(self, config):
 
43
        self.config = config
 
44
 
 
45
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
46
                extension, basename=None, body=None):
 
47
        """Compose (and possibly send) an email message
 
48
 
 
49
        Must be implemented by subclasses.
 
50
 
 
51
        :param prompt: A message to tell the user what to do.  Supported by
 
52
            the Editor client, but ignored by others
 
53
        :param to: The address to send the message to
 
54
        :param subject: The contents of the subject line
 
55
        :param attachment: An email attachment, as a bytestring
 
56
        :param mime_subtype: The attachment is assumed to be a subtype of
 
57
            Text.  This allows the precise subtype to be specified, e.g.
 
58
            "plain", "x-patch", etc.
 
59
        :param extension: The file extension associated with the attachment
 
60
            type, e.g. ".patch"
 
61
        :param basename: The name to use for the attachment, e.g.
 
62
            "send-nick-3252"
 
63
        """
 
64
        raise NotImplementedError
 
65
 
 
66
    def compose_merge_request(self, to, subject, directive, basename=None,
 
67
                              body=None):
 
68
        """Compose (and possibly send) a merge request
 
69
 
 
70
        :param to: The address to send the request to
 
71
        :param subject: The subject line to use for the request
 
72
        :param directive: A merge directive representing the merge request, as
 
73
            a bytestring.
 
74
        :param basename: The name to use for the attachment, e.g.
 
75
            "send-nick-3252"
 
76
        """
 
77
        prompt = self._get_merge_prompt("Please describe these changes:", to,
 
78
                                        subject, directive)
 
79
        self.compose(prompt, to, subject, directive,
 
80
            'x-patch', '.patch', basename, body)
 
81
 
 
82
    def _get_merge_prompt(self, prompt, to, subject, attachment):
 
83
        """Generate a prompt string.  Overridden by Editor.
 
84
 
 
85
        :param prompt: A string suggesting what user should do
 
86
        :param to: The address the mail will be sent to
 
87
        :param subject: The subject line of the mail
 
88
        :param attachment: The attachment that will be used
 
89
        """
 
90
        return ''
 
91
 
 
92
 
 
93
class Editor(MailClient):
 
94
    __doc__ = """DIY mail client that uses commit message editor"""
 
95
 
 
96
    supports_body = True
 
97
 
 
98
    def _get_merge_prompt(self, prompt, to, subject, attachment):
 
99
        """See MailClient._get_merge_prompt"""
 
100
        return (u"%s\n\n"
 
101
                u"To: %s\n"
 
102
                u"Subject: %s\n\n"
 
103
                u"%s" % (prompt, to, subject,
 
104
                         attachment.decode('utf-8', 'replace')))
 
105
 
 
106
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
107
                extension, basename=None, body=None):
 
108
        """See MailClient.compose"""
 
109
        if not to:
 
110
            raise errors.NoMailAddressSpecified()
 
111
        body = msgeditor.edit_commit_message(prompt, start_message=body)
 
112
        if body == '':
 
113
            raise errors.NoMessageSupplied()
 
114
        email_message.EmailMessage.send(self.config,
 
115
                                        self.config.get('email'),
 
116
                                        to,
 
117
                                        subject,
 
118
                                        body,
 
119
                                        attachment,
 
120
                                        attachment_mime_subtype=mime_subtype)
 
121
mail_client_registry.register('editor', Editor,
 
122
                              help=Editor.__doc__)
 
123
 
 
124
 
 
125
class BodyExternalMailClient(MailClient):
 
126
 
 
127
    supports_body = True
 
128
 
 
129
    def _get_client_commands(self):
 
130
        """Provide a list of commands that may invoke the mail client"""
 
131
        if sys.platform == 'win32':
 
132
            import win32utils
 
133
            return [win32utils.get_app_path(i) for i in self._client_commands]
 
134
        else:
 
135
            return self._client_commands
 
136
 
 
137
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
138
                extension, basename=None, body=None):
 
139
        """See MailClient.compose.
 
140
 
 
141
        Writes the attachment to a temporary file, invokes _compose.
 
142
        """
 
143
        if basename is None:
 
144
            basename = 'attachment'
 
145
        pathname = osutils.mkdtemp(prefix='bzr-mail-')
 
146
        attach_path = osutils.pathjoin(pathname, basename + extension)
 
147
        outfile = open(attach_path, 'wb')
 
148
        try:
 
149
            outfile.write(attachment)
 
150
        finally:
 
151
            outfile.close()
 
152
        if body is not None:
 
153
            kwargs = {'body': body}
 
154
        else:
 
155
            kwargs = {}
 
156
        self._compose(prompt, to, subject, attach_path, mime_subtype,
 
157
                      extension, **kwargs)
 
158
 
 
159
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
160
                 extension, body=None, from_=None):
 
161
        """Invoke a mail client as a commandline process.
 
162
 
 
163
        Overridden by MAPIClient.
 
164
        :param to: The address to send the mail to
 
165
        :param subject: The subject line for the mail
 
166
        :param pathname: The path to the attachment
 
167
        :param mime_subtype: The attachment is assumed to have a major type of
 
168
            "text", but the precise subtype can be specified here
 
169
        :param extension: A file extension (including period) associated with
 
170
            the attachment type.
 
171
        :param body: Optional body text.
 
172
        :param from_: Optional From: header.
 
173
        """
 
174
        for name in self._get_client_commands():
 
175
            cmdline = [self._encode_path(name, 'executable')]
 
176
            if body is not None:
 
177
                kwargs = {'body': body}
 
178
            else:
 
179
                kwargs = {}
 
180
            if from_ is not None:
 
181
                kwargs['from_'] = from_
 
182
            cmdline.extend(self._get_compose_commandline(to, subject,
 
183
                                                         attach_path,
 
184
                                                         **kwargs))
 
185
            try:
 
186
                subprocess.call(cmdline)
 
187
            except OSError, e:
 
188
                if e.errno != errno.ENOENT:
 
189
                    raise
 
190
            else:
 
191
                break
 
192
        else:
 
193
            raise errors.MailClientNotFound(self._client_commands)
 
194
 
 
195
    def _get_compose_commandline(self, to, subject, attach_path, body):
 
196
        """Determine the commandline to use for composing a message
 
197
 
 
198
        Implemented by various subclasses
 
199
        :param to: The address to send the mail to
 
200
        :param subject: The subject line for the mail
 
201
        :param attach_path: The path to the attachment
 
202
        """
 
203
        raise NotImplementedError
 
204
 
 
205
    def _encode_safe(self, u):
 
206
        """Encode possible unicode string argument to 8-bit string
 
207
        in user_encoding. Unencodable characters will be replaced
 
208
        with '?'.
 
209
 
 
210
        :param  u:  possible unicode string.
 
211
        :return:    encoded string if u is unicode, u itself otherwise.
 
212
        """
 
213
        if isinstance(u, unicode):
 
214
            return u.encode(osutils.get_user_encoding(), 'replace')
 
215
        return u
 
216
 
 
217
    def _encode_path(self, path, kind):
 
218
        """Encode unicode path in user encoding.
 
219
 
 
220
        :param  path:   possible unicode path.
 
221
        :param  kind:   path kind ('executable' or 'attachment').
 
222
        :return:        encoded path if path is unicode,
 
223
                        path itself otherwise.
 
224
        :raise:         UnableEncodePath.
 
225
        """
 
226
        if isinstance(path, unicode):
 
227
            try:
 
228
                return path.encode(osutils.get_user_encoding())
 
229
            except UnicodeEncodeError:
 
230
                raise errors.UnableEncodePath(path, kind)
 
231
        return path
 
232
 
 
233
 
 
234
class ExternalMailClient(BodyExternalMailClient):
 
235
    __doc__ = """An external mail client."""
 
236
 
 
237
    supports_body = False
 
238
 
 
239
 
 
240
class Evolution(BodyExternalMailClient):
 
241
    __doc__ = """Evolution mail client."""
 
242
 
 
243
    _client_commands = ['evolution']
 
244
 
 
245
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
246
        """See ExternalMailClient._get_compose_commandline"""
 
247
        message_options = {}
 
248
        if subject is not None:
 
249
            message_options['subject'] = subject
 
250
        if attach_path is not None:
 
251
            message_options['attach'] = attach_path
 
252
        if body is not None:
 
253
            message_options['body'] = body
 
254
        options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
 
255
                        sorted(message_options.iteritems())]
 
256
        return ['mailto:%s?%s' % (self._encode_safe(to or ''),
 
257
            '&'.join(options_list))]
 
258
mail_client_registry.register('evolution', Evolution,
 
259
                              help=Evolution.__doc__)
 
260
 
 
261
 
 
262
class Mutt(BodyExternalMailClient):
 
263
    __doc__ = """Mutt mail client."""
 
264
 
 
265
    _client_commands = ['mutt']
 
266
 
 
267
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
268
        """See ExternalMailClient._get_compose_commandline"""
 
269
        message_options = []
 
270
        if subject is not None:
 
271
            message_options.extend(['-s', self._encode_safe(subject)])
 
272
        if attach_path is not None:
 
273
            message_options.extend(['-a',
 
274
                self._encode_path(attach_path, 'attachment')])
 
275
        if body is not None:
 
276
            # Store the temp file object in self, so that it does not get
 
277
            # garbage collected and delete the file before mutt can read it.
 
278
            self._temp_file = tempfile.NamedTemporaryFile(
 
279
                prefix="mutt-body-", suffix=".txt")
 
280
            self._temp_file.write(body)
 
281
            self._temp_file.flush()
 
282
            message_options.extend(['-i', self._temp_file.name])
 
283
        if to is not None:
 
284
            message_options.extend(['--', self._encode_safe(to)])
 
285
        return message_options
 
286
mail_client_registry.register('mutt', Mutt,
 
287
                              help=Mutt.__doc__)
 
288
 
 
289
 
 
290
class Thunderbird(BodyExternalMailClient):
 
291
    __doc__ = """Mozilla Thunderbird (or Icedove)
 
292
 
 
293
    Note that Thunderbird 1.5 is buggy and does not support setting
 
294
    "to" simultaneously with including a attachment.
 
295
 
 
296
    There is a workaround if no attachment is present, but we always need to
 
297
    send attachments.
 
298
    """
 
299
 
 
300
    _client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
 
301
        '/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
 
302
        '/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
 
303
 
 
304
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
305
        """See ExternalMailClient._get_compose_commandline"""
 
306
        message_options = {}
 
307
        if to is not None:
 
308
            message_options['to'] = self._encode_safe(to)
 
309
        if subject is not None:
 
310
            message_options['subject'] = self._encode_safe(subject)
 
311
        if attach_path is not None:
 
312
            message_options['attachment'] = urlutils.local_path_to_url(
 
313
                attach_path)
 
314
        if body is not None:
 
315
            options_list = ['body=%s' % urlutils.quote(self._encode_safe(body))]
 
316
        else:
 
317
            options_list = []
 
318
        options_list.extend(["%s='%s'" % (k, v) for k, v in
 
319
                        sorted(message_options.iteritems())])
 
320
        return ['-compose', ','.join(options_list)]
 
321
mail_client_registry.register('thunderbird', Thunderbird,
 
322
                              help=Thunderbird.__doc__)
 
323
 
 
324
 
 
325
class KMail(ExternalMailClient):
 
326
    __doc__ = """KDE mail client."""
 
327
 
 
328
    _client_commands = ['kmail']
 
329
 
 
330
    def _get_compose_commandline(self, to, subject, attach_path):
 
331
        """See ExternalMailClient._get_compose_commandline"""
 
332
        message_options = []
 
333
        if subject is not None:
 
334
            message_options.extend(['-s', self._encode_safe(subject)])
 
335
        if attach_path is not None:
 
336
            message_options.extend(['--attach',
 
337
                self._encode_path(attach_path, 'attachment')])
 
338
        if to is not None:
 
339
            message_options.extend([self._encode_safe(to)])
 
340
        return message_options
 
341
mail_client_registry.register('kmail', KMail,
 
342
                              help=KMail.__doc__)
 
343
 
 
344
 
 
345
class Claws(ExternalMailClient):
 
346
    __doc__ = """Claws mail client."""
 
347
 
 
348
    supports_body = True
 
349
 
 
350
    _client_commands = ['claws-mail']
 
351
 
 
352
    def _get_compose_commandline(self, to, subject, attach_path, body=None,
 
353
                                 from_=None):
 
354
        """See ExternalMailClient._get_compose_commandline"""
 
355
        compose_url = []
 
356
        if from_ is not None:
 
357
            compose_url.append('from=' + urlutils.quote(from_))
 
358
        if subject is not None:
 
359
            # Don't use urlutils.quote_plus because Claws doesn't seem
 
360
            # to recognise spaces encoded as "+".
 
361
            compose_url.append(
 
362
                'subject=' + urlutils.quote(self._encode_safe(subject)))
 
363
        if body is not None:
 
364
            compose_url.append(
 
365
                'body=' + urlutils.quote(self._encode_safe(body)))
 
366
        # to must be supplied for the claws-mail --compose syntax to work.
 
367
        if to is None:
 
368
            raise errors.NoMailAddressSpecified()
 
369
        compose_url = 'mailto:%s?%s' % (
 
370
            self._encode_safe(to), '&'.join(compose_url))
 
371
        # Collect command-line options.
 
372
        message_options = ['--compose', compose_url]
 
373
        if attach_path is not None:
 
374
            message_options.extend(
 
375
                ['--attach', self._encode_path(attach_path, 'attachment')])
 
376
        return message_options
 
377
 
 
378
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
379
                 extension, body=None, from_=None):
 
380
        """See ExternalMailClient._compose"""
 
381
        if from_ is None:
 
382
            from_ = self.config.get('email')
 
383
        super(Claws, self)._compose(prompt, to, subject, attach_path,
 
384
                                    mime_subtype, extension, body, from_)
 
385
 
 
386
 
 
387
mail_client_registry.register('claws', Claws,
 
388
                              help=Claws.__doc__)
 
389
 
 
390
 
 
391
class XDGEmail(BodyExternalMailClient):
 
392
    __doc__ = """xdg-email attempts to invoke the user's preferred mail client"""
 
393
 
 
394
    _client_commands = ['xdg-email']
 
395
 
 
396
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
397
        """See ExternalMailClient._get_compose_commandline"""
 
398
        if not to:
 
399
            raise errors.NoMailAddressSpecified()
 
400
        commandline = [self._encode_safe(to)]
 
401
        if subject is not None:
 
402
            commandline.extend(['--subject', self._encode_safe(subject)])
 
403
        if attach_path is not None:
 
404
            commandline.extend(['--attach',
 
405
                self._encode_path(attach_path, 'attachment')])
 
406
        if body is not None:
 
407
            commandline.extend(['--body', self._encode_safe(body)])
 
408
        return commandline
 
409
mail_client_registry.register('xdg-email', XDGEmail,
 
410
                              help=XDGEmail.__doc__)
 
411
 
 
412
 
 
413
class EmacsMail(ExternalMailClient):
 
414
    __doc__ = """Call emacsclient to have a mail buffer.
 
415
 
 
416
    This only work for emacs >= 22.1 due to recent -e/--eval support.
 
417
 
 
418
    The good news is that this implementation will work with all mail
 
419
    agents registered against ``mail-user-agent``. So there is no need
 
420
    to instantiate ExternalMailClient for each and every GNU Emacs
 
421
    MUA.
 
422
 
 
423
    Users just have to ensure that ``mail-user-agent`` is set according
 
424
    to their tastes.
 
425
    """
 
426
 
 
427
    _client_commands = ['emacsclient']
 
428
 
 
429
    def __init__(self, config):
 
430
        super(EmacsMail, self).__init__(config)
 
431
        self.elisp_tmp_file = None
 
432
 
 
433
    def _prepare_send_function(self):
 
434
        """Write our wrapper function into a temporary file.
 
435
 
 
436
        This temporary file will be loaded at runtime in
 
437
        _get_compose_commandline function.
 
438
 
 
439
        This function does not remove the file.  That's a wanted
 
440
        behaviour since _get_compose_commandline won't run the send
 
441
        mail function directly but return the eligible command line.
 
442
        Removing our temporary file here would prevent our sendmail
 
443
        function to work.  (The file is deleted by some elisp code
 
444
        after being read by Emacs.)
 
445
        """
 
446
 
 
447
        _defun = r"""(defun bzr-add-mime-att (file)
 
448
  "Attach FILE to a mail buffer as a MIME attachment."
 
449
  (let ((agent mail-user-agent))
 
450
    (if (and file (file-exists-p file))
 
451
        (cond
 
452
         ((eq agent 'sendmail-user-agent)
 
453
          (progn
 
454
            (mail-text)
 
455
            (newline)
 
456
            (if (functionp 'etach-attach)
 
457
              (etach-attach file)
 
458
              (mail-attach-file file))))
 
459
         ((or (eq agent 'message-user-agent)
 
460
              (eq agent 'gnus-user-agent)
 
461
              (eq agent 'mh-e-user-agent))
 
462
          (progn
 
463
            (mml-attach-file file "text/x-patch" "BZR merge" "inline")))
 
464
         ((eq agent 'mew-user-agent)
 
465
          (progn
 
466
            (mew-draft-prepare-attachments)
 
467
            (mew-attach-link file (file-name-nondirectory file))
 
468
            (let* ((nums (mew-syntax-nums))
 
469
                   (syntax (mew-syntax-get-entry mew-encode-syntax nums)))
 
470
              (mew-syntax-set-cd syntax "BZR merge")
 
471
              (mew-encode-syntax-print mew-encode-syntax))
 
472
            (mew-header-goto-body)))
 
473
         (t
 
474
          (message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
 
475
      (error "File %s does not exist." file))))
 
476
"""
 
477
 
 
478
        fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
 
479
                                         suffix=".el")
 
480
        try:
 
481
            os.write(fd, _defun)
 
482
        finally:
 
483
            os.close(fd) # Just close the handle but do not remove the file.
 
484
        return temp_file
 
485
 
 
486
    def _get_compose_commandline(self, to, subject, attach_path):
 
487
        commandline = ["--eval"]
 
488
 
 
489
        _to = "nil"
 
490
        _subject = "nil"
 
491
 
 
492
        if to is not None:
 
493
            _to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
 
494
        if subject is not None:
 
495
            _subject = ("\"%s\"" %
 
496
                        self._encode_safe(subject).replace('"', '\\"'))
 
497
 
 
498
        # Funcall the default mail composition function
 
499
        # This will work with any mail mode including default mail-mode
 
500
        # User must tweak mail-user-agent variable to tell what function
 
501
        # will be called inside compose-mail.
 
502
        mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
 
503
        commandline.append(mail_cmd)
 
504
 
 
505
        # Try to attach a MIME attachment using our wrapper function
 
506
        if attach_path is not None:
 
507
            # Do not create a file if there is no attachment
 
508
            elisp = self._prepare_send_function()
 
509
            self.elisp_tmp_file = elisp
 
510
            lmmform = '(load "%s")' % elisp
 
511
            mmform  = '(bzr-add-mime-att "%s")' % \
 
512
                self._encode_path(attach_path, 'attachment')
 
513
            rmform = '(delete-file "%s")' % elisp
 
514
            commandline.append(lmmform)
 
515
            commandline.append(mmform)
 
516
            commandline.append(rmform)
 
517
 
 
518
        return commandline
 
519
mail_client_registry.register('emacsclient', EmacsMail,
 
520
                              help=EmacsMail.__doc__)
 
521
 
 
522
 
 
523
class MAPIClient(BodyExternalMailClient):
 
524
    __doc__ = """Default Windows mail client launched using MAPI."""
 
525
 
 
526
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
 
527
                 extension, body=None):
 
528
        """See ExternalMailClient._compose.
 
529
 
 
530
        This implementation uses MAPI via the simplemapi ctypes wrapper
 
531
        """
 
532
        from bzrlib.util import simplemapi
 
533
        try:
 
534
            simplemapi.SendMail(to or '', subject or '', body or '',
 
535
                                attach_path)
 
536
        except simplemapi.MAPIError, e:
 
537
            if e.code != simplemapi.MAPI_USER_ABORT:
 
538
                raise errors.MailClientNotFound(['MAPI supported mail client'
 
539
                                                 ' (error %d)' % (e.code,)])
 
540
mail_client_registry.register('mapi', MAPIClient,
 
541
                              help=MAPIClient.__doc__)
 
542
 
 
543
 
 
544
class MailApp(BodyExternalMailClient):
 
545
    __doc__ = """Use MacOS X's Mail.app for sending email messages.
 
546
 
 
547
    Although it would be nice to use appscript, it's not installed
 
548
    with the shipped Python installations.  We instead build an
 
549
    AppleScript and invoke the script using osascript(1).  We don't
 
550
    use the _encode_safe() routines as it's not clear what encoding
 
551
    osascript expects the script to be in.
 
552
    """
 
553
 
 
554
    _client_commands = ['osascript']
 
555
 
 
556
    def _get_compose_commandline(self, to, subject, attach_path, body=None,
 
557
                                from_=None):
 
558
       """See ExternalMailClient._get_compose_commandline"""
 
559
 
 
560
       fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
 
561
                                         suffix=".scpt")
 
562
       try:
 
563
           os.write(fd, 'tell application "Mail"\n')
 
564
           os.write(fd, 'set newMessage to make new outgoing message\n')
 
565
           os.write(fd, 'tell newMessage\n')
 
566
           if to is not None:
 
567
               os.write(fd, 'make new to recipient with properties'
 
568
                   ' {address:"%s"}\n' % to)
 
569
           if from_ is not None:
 
570
               # though from_ doesn't actually seem to be used
 
571
               os.write(fd, 'set sender to "%s"\n'
 
572
                   % sender.replace('"', '\\"'))
 
573
           if subject is not None:
 
574
               os.write(fd, 'set subject to "%s"\n'
 
575
                   % subject.replace('"', '\\"'))
 
576
           if body is not None:
 
577
               # FIXME: would be nice to prepend the body to the
 
578
               # existing content (e.g., preserve signature), but
 
579
               # can't seem to figure out the right applescript
 
580
               # incantation.
 
581
               os.write(fd, 'set content to "%s\\n\n"\n' %
 
582
                   body.replace('"', '\\"').replace('\n', '\\n'))
 
583
 
 
584
           if attach_path is not None:
 
585
               # FIXME: would be nice to first append a newline to
 
586
               # ensure the attachment is on a new paragraph, but
 
587
               # can't seem to figure out the right applescript
 
588
               # incantation.
 
589
               os.write(fd, 'tell content to make new attachment'
 
590
                   ' with properties {file name:"%s"}'
 
591
                   ' at after the last paragraph\n'
 
592
                   % self._encode_path(attach_path, 'attachment'))
 
593
           os.write(fd, 'set visible to true\n')
 
594
           os.write(fd, 'end tell\n')
 
595
           os.write(fd, 'end tell\n')
 
596
       finally:
 
597
           os.close(fd) # Just close the handle but do not remove the file.
 
598
       return [self.temp_file]
 
599
mail_client_registry.register('mail.app', MailApp,
 
600
                              help=MailApp.__doc__)
 
601
 
 
602
 
 
603
class DefaultMail(MailClient):
 
604
    __doc__ = """Default mail handling.  Tries XDGEmail (or MAPIClient on Windows),
 
605
    falls back to Editor"""
 
606
 
 
607
    supports_body = True
 
608
 
 
609
    def _mail_client(self):
 
610
        """Determine the preferred mail client for this platform"""
 
611
        if osutils.supports_mapi():
 
612
            return MAPIClient(self.config)
 
613
        else:
 
614
            return XDGEmail(self.config)
 
615
 
 
616
    def compose(self, prompt, to, subject, attachment, mime_subtype,
 
617
                extension, basename=None, body=None):
 
618
        """See MailClient.compose"""
 
619
        try:
 
620
            return self._mail_client().compose(prompt, to, subject,
 
621
                                               attachment, mime_subtype,
 
622
                                               extension, basename, body)
 
623
        except errors.MailClientNotFound:
 
624
            return Editor(self.config).compose(prompt, to, subject,
 
625
                          attachment, mime_subtype, extension, body)
 
626
 
 
627
    def compose_merge_request(self, to, subject, directive, basename=None,
 
628
                              body=None):
 
629
        """See MailClient.compose_merge_request"""
 
630
        try:
 
631
            return self._mail_client().compose_merge_request(to, subject,
 
632
                    directive, basename=basename, body=body)
 
633
        except errors.MailClientNotFound:
 
634
            return Editor(self.config).compose_merge_request(to, subject,
 
635
                          directive, basename=basename, body=body)
 
636
mail_client_registry.register('default', DefaultMail,
 
637
                              help=DefaultMail.__doc__)
 
638
mail_client_registry.default_key = 'default'
 
639
 
 
640
opt_mail_client = _mod_config.RegistryOption('mail_client',
 
641
        mail_client_registry, help='E-mail client to use.', invalid='error')