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
17
from __future__ import absolute_import
27
config as _mod_config,
40
mail_client_registry = registry.Registry()
43
class MailClientNotFound(errors.BzrError):
45
_fmt = "Unable to find mail client with the following names:"\
46
" %(mail_command_list_string)s"
48
def __init__(self, mail_command_list):
49
mail_command_list_string = ', '.join(mail_command_list)
50
errors.BzrError.__init__(
51
self, mail_command_list=mail_command_list,
52
mail_command_list_string=mail_command_list_string)
55
class NoMessageSupplied(errors.BzrError):
57
_fmt = "No message supplied."
60
class NoMailAddressSpecified(errors.BzrError):
62
_fmt = "No mail-to address (--mail-to) or output (-o) specified."
65
class MailClient(object):
66
"""A mail client that can send messages with attachements."""
68
def __init__(self, config):
71
def compose(self, prompt, to, subject, attachment, mime_subtype,
72
extension, basename=None, body=None):
73
"""Compose (and possibly send) an email message
75
Must be implemented by subclasses.
77
:param prompt: A message to tell the user what to do. Supported by
78
the Editor client, but ignored by others
79
:param to: The address to send the message to
80
:param subject: The contents of the subject line
81
:param attachment: An email attachment, as a bytestring
82
:param mime_subtype: The attachment is assumed to be a subtype of
83
Text. This allows the precise subtype to be specified, e.g.
84
"plain", "x-patch", etc.
85
:param extension: The file extension associated with the attachment
87
:param basename: The name to use for the attachment, e.g.
90
raise NotImplementedError
92
def compose_merge_request(self, to, subject, directive, basename=None,
94
"""Compose (and possibly send) a merge request
96
:param to: The address to send the request to
97
:param subject: The subject line to use for the request
98
:param directive: A merge directive representing the merge request, as
100
:param basename: The name to use for the attachment, e.g.
103
prompt = self._get_merge_prompt("Please describe these changes:", to,
105
self.compose(prompt, to, subject, directive,
106
'x-patch', '.patch', basename, body)
108
def _get_merge_prompt(self, prompt, to, subject, attachment):
109
"""Generate a prompt string. Overridden by Editor.
111
:param prompt: A string suggesting what user should do
112
:param to: The address the mail will be sent to
113
:param subject: The subject line of the mail
114
:param attachment: The attachment that will be used
119
class Editor(MailClient):
120
__doc__ = """DIY mail client that uses commit message editor"""
124
def _get_merge_prompt(self, prompt, to, subject, attachment):
125
"""See MailClient._get_merge_prompt"""
129
u"%s" % (prompt, to, subject,
130
attachment.decode('utf-8', 'replace')))
132
def compose(self, prompt, to, subject, attachment, mime_subtype,
133
extension, basename=None, body=None):
134
"""See MailClient.compose"""
136
raise NoMailAddressSpecified()
137
body = msgeditor.edit_commit_message(prompt, start_message=body)
139
raise NoMessageSupplied()
140
email_message.EmailMessage.send(self.config,
141
self.config.get('email'),
146
attachment_mime_subtype=mime_subtype)
149
mail_client_registry.register('editor', Editor,
153
class BodyExternalMailClient(MailClient):
157
def _get_client_commands(self):
158
"""Provide a list of commands that may invoke the mail client"""
159
if sys.platform == 'win32':
161
return [win32utils.get_app_path(i) for i in self._client_commands]
163
return self._client_commands
165
def compose(self, prompt, to, subject, attachment, mime_subtype,
166
extension, basename=None, body=None):
167
"""See MailClient.compose.
169
Writes the attachment to a temporary file, invokes _compose.
172
basename = 'attachment'
173
pathname = osutils.mkdtemp(prefix='bzr-mail-')
174
attach_path = osutils.pathjoin(pathname, basename + extension)
175
with open(attach_path, 'wb') as outfile:
176
outfile.write(attachment)
178
kwargs = {'body': body}
181
self._compose(prompt, to, subject, attach_path, mime_subtype,
184
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
185
extension, body=None, from_=None):
186
"""Invoke a mail client as a commandline process.
188
Overridden by MAPIClient.
189
:param to: The address to send the mail to
190
:param subject: The subject line for the mail
191
:param pathname: The path to the attachment
192
:param mime_subtype: The attachment is assumed to have a major type of
193
"text", but the precise subtype can be specified here
194
:param extension: A file extension (including period) associated with
196
:param body: Optional body text.
197
:param from_: Optional From: header.
199
for name in self._get_client_commands():
200
cmdline = [self._encode_path(name, 'executable')]
202
kwargs = {'body': body}
205
if from_ is not None:
206
kwargs['from_'] = from_
207
cmdline.extend(self._get_compose_commandline(to, subject,
211
subprocess.call(cmdline)
213
if e.errno != errno.ENOENT:
218
raise MailClientNotFound(self._client_commands)
220
def _get_compose_commandline(self, to, subject, attach_path, body):
221
"""Determine the commandline to use for composing a message
223
Implemented by various subclasses
224
:param to: The address to send the mail to
225
:param subject: The subject line for the mail
226
:param attach_path: The path to the attachment
228
raise NotImplementedError
230
def _encode_safe(self, u):
231
"""Encode possible unicode string argument to 8-bit string
232
in user_encoding. Unencodable characters will be replaced
235
:param u: possible unicode string.
236
:return: encoded string if u is unicode, u itself otherwise.
238
if not PY3 and isinstance(u, text_type):
239
return u.encode(osutils.get_user_encoding(), 'replace')
242
def _encode_path(self, path, kind):
243
"""Encode unicode path in user encoding.
245
:param path: possible unicode path.
246
:param kind: path kind ('executable' or 'attachment').
247
:return: encoded path if path is unicode,
248
path itself otherwise.
249
:raise: UnableEncodePath.
251
if not PY3 and isinstance(path, text_type):
253
return path.encode(osutils.get_user_encoding())
254
except UnicodeEncodeError:
255
raise errors.UnableEncodePath(path, kind)
259
class ExternalMailClient(BodyExternalMailClient):
260
__doc__ = """An external mail client."""
262
supports_body = False
265
class Evolution(BodyExternalMailClient):
266
__doc__ = """Evolution mail client."""
268
_client_commands = ['evolution']
270
def _get_compose_commandline(self, to, subject, attach_path, body=None):
271
"""See ExternalMailClient._get_compose_commandline"""
273
if subject is not None:
274
message_options['subject'] = subject
275
if attach_path is not None:
276
message_options['attach'] = attach_path
278
message_options['body'] = body
279
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
280
sorted(message_options.items())]
281
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
282
'&'.join(options_list))]
285
mail_client_registry.register('evolution', Evolution,
286
help=Evolution.__doc__)
289
class Mutt(BodyExternalMailClient):
290
__doc__ = """Mutt mail client."""
292
_client_commands = ['mutt']
294
def _get_compose_commandline(self, to, subject, attach_path, body=None):
295
"""See ExternalMailClient._get_compose_commandline"""
297
if subject is not None:
298
message_options.extend(
299
['-s', self._encode_safe(subject)])
300
if attach_path is not None:
301
message_options.extend(
302
['-a', self._encode_path(attach_path, 'attachment')])
304
# Store the temp file object in self, so that it does not get
305
# garbage collected and delete the file before mutt can read it.
306
self._temp_file = tempfile.NamedTemporaryFile(
307
prefix="mutt-body-", suffix=".txt", mode="w+")
308
self._temp_file.write(body)
309
self._temp_file.flush()
310
message_options.extend(['-i', self._temp_file.name])
312
message_options.extend(['--', self._encode_safe(to)])
313
return message_options
316
mail_client_registry.register('mutt', Mutt,
320
class Thunderbird(BodyExternalMailClient):
321
__doc__ = """Mozilla Thunderbird (or Icedove)
323
Note that Thunderbird 1.5 is buggy and does not support setting
324
"to" simultaneously with including a attachment.
326
There is a workaround if no attachment is present, but we always need to
331
'thunderbird', 'mozilla-thunderbird', 'icedove',
332
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
333
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
335
def _get_compose_commandline(self, to, subject, attach_path, body=None):
336
"""See ExternalMailClient._get_compose_commandline"""
339
message_options['to'] = self._encode_safe(to)
340
if subject is not None:
341
message_options['subject'] = self._encode_safe(subject)
342
if attach_path is not None:
343
message_options['attachment'] = urlutils.local_path_to_url(
346
options_list = ['body=%s' %
347
urlutils.quote(self._encode_safe(body))]
350
options_list.extend(["%s='%s'" % (k, v) for k, v in
351
sorted(message_options.items())])
352
return ['-compose', ','.join(options_list)]
355
mail_client_registry.register('thunderbird', Thunderbird,
356
help=Thunderbird.__doc__)
359
class KMail(ExternalMailClient):
360
__doc__ = """KDE mail client."""
362
_client_commands = ['kmail']
364
def _get_compose_commandline(self, to, subject, attach_path):
365
"""See ExternalMailClient._get_compose_commandline"""
367
if subject is not None:
368
message_options.extend(['-s', self._encode_safe(subject)])
369
if attach_path is not None:
370
message_options.extend(
371
['--attach', self._encode_path(attach_path, 'attachment')])
373
message_options.extend([self._encode_safe(to)])
374
return message_options
377
mail_client_registry.register('kmail', KMail,
381
class Claws(ExternalMailClient):
382
__doc__ = """Claws mail client."""
386
_client_commands = ['claws-mail']
388
def _get_compose_commandline(self, to, subject, attach_path, body=None,
390
"""See ExternalMailClient._get_compose_commandline"""
392
if from_ is not None:
393
compose_url.append('from=' + urlutils.quote(from_))
394
if subject is not None:
395
# Don't use urlutils.quote_plus because Claws doesn't seem
396
# to recognise spaces encoded as "+".
398
'subject=' + urlutils.quote(self._encode_safe(subject)))
401
'body=' + urlutils.quote(self._encode_safe(body)))
402
# to must be supplied for the claws-mail --compose syntax to work.
404
raise NoMailAddressSpecified()
405
compose_url = 'mailto:%s?%s' % (
406
self._encode_safe(to), '&'.join(compose_url))
407
# Collect command-line options.
408
message_options = ['--compose', compose_url]
409
if attach_path is not None:
410
message_options.extend(
411
['--attach', self._encode_path(attach_path, 'attachment')])
412
return message_options
414
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
415
extension, body=None, from_=None):
416
"""See ExternalMailClient._compose"""
418
from_ = self.config.get('email')
419
super(Claws, self)._compose(prompt, to, subject, attach_path,
420
mime_subtype, extension, body, from_)
423
mail_client_registry.register('claws', Claws,
427
class XDGEmail(BodyExternalMailClient):
428
__doc__ = """xdg-email attempts to invoke the preferred mail client"""
430
_client_commands = ['xdg-email']
432
def _get_compose_commandline(self, to, subject, attach_path, body=None):
433
"""See ExternalMailClient._get_compose_commandline"""
435
raise NoMailAddressSpecified()
436
commandline = [self._encode_safe(to)]
437
if subject is not None:
438
commandline.extend(['--subject', self._encode_safe(subject)])
439
if attach_path is not None:
440
commandline.extend(['--attach',
441
self._encode_path(attach_path, 'attachment')])
443
commandline.extend(['--body', self._encode_safe(body)])
447
mail_client_registry.register('xdg-email', XDGEmail,
448
help=XDGEmail.__doc__)
451
class EmacsMail(ExternalMailClient):
452
__doc__ = """Call emacsclient to have a mail buffer.
454
This only work for emacs >= 22.1 due to recent -e/--eval support.
456
The good news is that this implementation will work with all mail
457
agents registered against ``mail-user-agent``. So there is no need
458
to instantiate ExternalMailClient for each and every GNU Emacs
461
Users just have to ensure that ``mail-user-agent`` is set according
465
_client_commands = ['emacsclient']
467
def __init__(self, config):
468
super(EmacsMail, self).__init__(config)
469
self.elisp_tmp_file = None
471
def _prepare_send_function(self):
472
"""Write our wrapper function into a temporary file.
474
This temporary file will be loaded at runtime in
475
_get_compose_commandline function.
477
This function does not remove the file. That's a wanted
478
behaviour since _get_compose_commandline won't run the send
479
mail function directly but return the eligible command line.
480
Removing our temporary file here would prevent our sendmail
481
function to work. (The file is deleted by some elisp code
482
after being read by Emacs.)
485
_defun = br"""(defun bzr-add-mime-att (file)
486
"Attach FILE to a mail buffer as a MIME attachment."
487
(let ((agent mail-user-agent))
488
(if (and file (file-exists-p file))
490
((eq agent 'sendmail-user-agent)
494
(if (functionp 'etach-attach)
496
(mail-attach-file file))))
497
((or (eq agent 'message-user-agent)
498
(eq agent 'gnus-user-agent)
499
(eq agent 'mh-e-user-agent))
501
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
502
((eq agent 'mew-user-agent)
504
(mew-draft-prepare-attachments)
505
(mew-attach-link file (file-name-nondirectory file))
506
(let* ((nums (mew-syntax-nums))
507
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
508
(mew-syntax-set-cd syntax "BZR merge")
509
(mew-encode-syntax-print mew-encode-syntax))
510
(mew-header-goto-body)))
512
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
513
(error "File %s does not exist." file))))
516
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
521
os.close(fd) # Just close the handle but do not remove the file.
524
def _get_compose_commandline(self, to, subject, attach_path):
525
commandline = ["--eval"]
531
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
532
if subject is not None:
533
_subject = ("\"%s\"" %
534
self._encode_safe(subject).replace('"', '\\"'))
536
# Funcall the default mail composition function
537
# This will work with any mail mode including default mail-mode
538
# User must tweak mail-user-agent variable to tell what function
539
# will be called inside compose-mail.
540
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
541
commandline.append(mail_cmd)
543
# Try to attach a MIME attachment using our wrapper function
544
if attach_path is not None:
545
# Do not create a file if there is no attachment
546
elisp = self._prepare_send_function()
547
self.elisp_tmp_file = elisp
548
lmmform = '(load "%s")' % elisp
549
mmform = '(bzr-add-mime-att "%s")' % \
550
self._encode_path(attach_path, 'attachment')
551
rmform = '(delete-file "%s")' % elisp
552
commandline.append(lmmform)
553
commandline.append(mmform)
554
commandline.append(rmform)
559
mail_client_registry.register('emacsclient', EmacsMail,
560
help=EmacsMail.__doc__)
563
class MAPIClient(BodyExternalMailClient):
564
__doc__ = """Default Windows mail client launched using MAPI."""
566
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
567
extension, body=None):
568
"""See ExternalMailClient._compose.
570
This implementation uses MAPI via the simplemapi ctypes wrapper
572
from .util import simplemapi
574
simplemapi.SendMail(to or '', subject or '', body or '',
576
except simplemapi.MAPIError as e:
577
if e.code != simplemapi.MAPI_USER_ABORT:
578
raise MailClientNotFound(['MAPI supported mail client'
579
' (error %d)' % (e.code,)])
582
mail_client_registry.register('mapi', MAPIClient,
583
help=MAPIClient.__doc__)
586
class MailApp(BodyExternalMailClient):
587
__doc__ = """Use MacOS X's Mail.app for sending email messages.
589
Although it would be nice to use appscript, it's not installed
590
with the shipped Python installations. We instead build an
591
AppleScript and invoke the script using osascript(1). We don't
592
use the _encode_safe() routines as it's not clear what encoding
593
osascript expects the script to be in.
596
_client_commands = ['osascript']
598
def _get_compose_commandline(self, to, subject, attach_path, body=None,
600
"""See ExternalMailClient._get_compose_commandline"""
602
fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
605
os.write(fd, 'tell application "Mail"\n')
606
os.write(fd, 'set newMessage to make new outgoing message\n')
607
os.write(fd, 'tell newMessage\n')
609
os.write(fd, 'make new to recipient with properties'
610
' {address:"%s"}\n' % to)
611
if from_ is not None:
612
# though from_ doesn't actually seem to be used
613
os.write(fd, 'set sender to "%s"\n'
614
% from_.replace('"', '\\"'))
615
if subject is not None:
616
os.write(fd, 'set subject to "%s"\n'
617
% subject.replace('"', '\\"'))
619
# FIXME: would be nice to prepend the body to the
620
# existing content (e.g., preserve signature), but
621
# can't seem to figure out the right applescript
623
os.write(fd, 'set content to "%s\\n\n"\n' %
624
body.replace('"', '\\"').replace('\n', '\\n'))
626
if attach_path is not None:
627
# FIXME: would be nice to first append a newline to
628
# ensure the attachment is on a new paragraph, but
629
# can't seem to figure out the right applescript
631
os.write(fd, 'tell content to make new attachment'
632
' with properties {file name:"%s"}'
633
' at after the last paragraph\n'
634
% self._encode_path(attach_path, 'attachment'))
635
os.write(fd, 'set visible to true\n')
636
os.write(fd, 'end tell\n')
637
os.write(fd, 'end tell\n')
639
os.close(fd) # Just close the handle but do not remove the file.
640
return [self.temp_file]
643
mail_client_registry.register('mail.app', MailApp,
644
help=MailApp.__doc__)
647
class DefaultMail(MailClient):
648
__doc__ = """Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
649
falls back to Editor"""
653
def _mail_client(self):
654
"""Determine the preferred mail client for this platform"""
655
if osutils.supports_mapi():
656
return MAPIClient(self.config)
658
return XDGEmail(self.config)
660
def compose(self, prompt, to, subject, attachment, mime_subtype,
661
extension, basename=None, body=None):
662
"""See MailClient.compose"""
664
return self._mail_client().compose(prompt, to, subject,
665
attachment, mime_subtype,
666
extension, basename, body)
667
except MailClientNotFound:
668
return Editor(self.config).compose(
669
prompt, to, subject, attachment, mime_subtype, extension, body)
671
def compose_merge_request(self, to, subject, directive, basename=None,
673
"""See MailClient.compose_merge_request"""
675
return self._mail_client().compose_merge_request(
676
to, subject, directive, basename=basename, body=body)
677
except MailClientNotFound:
678
return Editor(self.config).compose_merge_request(
679
to, subject, directive, basename=basename, body=body)
682
mail_client_registry.register(u'default', DefaultMail,
683
help=DefaultMail.__doc__)
684
mail_client_registry.default_key = u'default'
686
opt_mail_client = _mod_config.RegistryOption(
687
'mail_client', mail_client_registry, help='E-mail client to use.',