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,
39
mail_client_registry = registry.Registry()
42
class MailClientNotFound(errors.BzrError):
44
_fmt = "Unable to find mail client with the following names:"\
45
" %(mail_command_list_string)s"
47
def __init__(self, mail_command_list):
48
mail_command_list_string = ', '.join(mail_command_list)
49
errors.BzrError.__init__(
50
self, mail_command_list=mail_command_list,
51
mail_command_list_string=mail_command_list_string)
54
class NoMessageSupplied(errors.BzrError):
56
_fmt = "No message supplied."
59
class NoMailAddressSpecified(errors.BzrError):
61
_fmt = "No mail-to address (--mail-to) or output (-o) specified."
64
class MailClient(object):
65
"""A mail client that can send messages with attachements."""
67
def __init__(self, config):
70
def compose(self, prompt, to, subject, attachment, mime_subtype,
71
extension, basename=None, body=None):
72
"""Compose (and possibly send) an email message
74
Must be implemented by subclasses.
76
:param prompt: A message to tell the user what to do. Supported by
77
the Editor client, but ignored by others
78
:param to: The address to send the message to
79
:param subject: The contents of the subject line
80
:param attachment: An email attachment, as a bytestring
81
:param mime_subtype: The attachment is assumed to be a subtype of
82
Text. This allows the precise subtype to be specified, e.g.
83
"plain", "x-patch", etc.
84
:param extension: The file extension associated with the attachment
86
:param basename: The name to use for the attachment, e.g.
89
raise NotImplementedError
91
def compose_merge_request(self, to, subject, directive, basename=None,
93
"""Compose (and possibly send) a merge request
95
:param to: The address to send the request to
96
:param subject: The subject line to use for the request
97
:param directive: A merge directive representing the merge request, as
99
:param basename: The name to use for the attachment, e.g.
102
prompt = self._get_merge_prompt("Please describe these changes:", to,
104
self.compose(prompt, to, subject, directive,
105
'x-patch', '.patch', basename, body)
107
def _get_merge_prompt(self, prompt, to, subject, attachment):
108
"""Generate a prompt string. Overridden by Editor.
110
:param prompt: A string suggesting what user should do
111
:param to: The address the mail will be sent to
112
:param subject: The subject line of the mail
113
:param attachment: The attachment that will be used
118
class Editor(MailClient):
119
__doc__ = """DIY mail client that uses commit message editor"""
123
def _get_merge_prompt(self, prompt, to, subject, attachment):
124
"""See MailClient._get_merge_prompt"""
128
u"%s" % (prompt, to, subject,
129
attachment.decode('utf-8', 'replace')))
131
def compose(self, prompt, to, subject, attachment, mime_subtype,
132
extension, basename=None, body=None):
133
"""See MailClient.compose"""
135
raise NoMailAddressSpecified()
136
body = msgeditor.edit_commit_message(prompt, start_message=body)
138
raise NoMessageSupplied()
139
email_message.EmailMessage.send(self.config,
140
self.config.get('email'),
145
attachment_mime_subtype=mime_subtype)
146
mail_client_registry.register('editor', Editor,
150
class BodyExternalMailClient(MailClient):
154
def _get_client_commands(self):
155
"""Provide a list of commands that may invoke the mail client"""
156
if sys.platform == 'win32':
158
return [win32utils.get_app_path(i) for i in self._client_commands]
160
return self._client_commands
162
def compose(self, prompt, to, subject, attachment, mime_subtype,
163
extension, basename=None, body=None):
164
"""See MailClient.compose.
166
Writes the attachment to a temporary file, invokes _compose.
169
basename = 'attachment'
170
pathname = osutils.mkdtemp(prefix='bzr-mail-')
171
attach_path = osutils.pathjoin(pathname, basename + extension)
172
outfile = open(attach_path, 'wb')
174
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 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 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))]
283
mail_client_registry.register('evolution', Evolution,
284
help=Evolution.__doc__)
287
class Mutt(BodyExternalMailClient):
288
__doc__ = """Mutt mail client."""
290
_client_commands = ['mutt']
292
def _get_compose_commandline(self, to, subject, attach_path, body=None):
293
"""See ExternalMailClient._get_compose_commandline"""
295
if subject is not None:
296
message_options.extend(['-s', self._encode_safe(subject)])
297
if attach_path is not None:
298
message_options.extend(['-a',
299
self._encode_path(attach_path, 'attachment')])
301
# Store the temp file object in self, so that it does not get
302
# garbage collected and delete the file before mutt can read it.
303
self._temp_file = tempfile.NamedTemporaryFile(
304
prefix="mutt-body-", suffix=".txt")
305
self._temp_file.write(body)
306
self._temp_file.flush()
307
message_options.extend(['-i', self._temp_file.name])
309
message_options.extend(['--', self._encode_safe(to)])
310
return message_options
311
mail_client_registry.register('mutt', Mutt,
315
class Thunderbird(BodyExternalMailClient):
316
__doc__ = """Mozilla Thunderbird (or Icedove)
318
Note that Thunderbird 1.5 is buggy and does not support setting
319
"to" simultaneously with including a attachment.
321
There is a workaround if no attachment is present, but we always need to
325
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
326
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
327
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
329
def _get_compose_commandline(self, to, subject, attach_path, body=None):
330
"""See ExternalMailClient._get_compose_commandline"""
333
message_options['to'] = self._encode_safe(to)
334
if subject is not None:
335
message_options['subject'] = self._encode_safe(subject)
336
if attach_path is not None:
337
message_options['attachment'] = urlutils.local_path_to_url(
340
options_list = ['body=%s' % urlutils.quote(self._encode_safe(body))]
343
options_list.extend(["%s='%s'" % (k, v) for k, v in
344
sorted(message_options.items())])
345
return ['-compose', ','.join(options_list)]
346
mail_client_registry.register('thunderbird', Thunderbird,
347
help=Thunderbird.__doc__)
350
class KMail(ExternalMailClient):
351
__doc__ = """KDE mail client."""
353
_client_commands = ['kmail']
355
def _get_compose_commandline(self, to, subject, attach_path):
356
"""See ExternalMailClient._get_compose_commandline"""
358
if subject is not None:
359
message_options.extend(['-s', self._encode_safe(subject)])
360
if attach_path is not None:
361
message_options.extend(['--attach',
362
self._encode_path(attach_path, 'attachment')])
364
message_options.extend([self._encode_safe(to)])
365
return message_options
366
mail_client_registry.register('kmail', KMail,
370
class Claws(ExternalMailClient):
371
__doc__ = """Claws mail client."""
375
_client_commands = ['claws-mail']
377
def _get_compose_commandline(self, to, subject, attach_path, body=None,
379
"""See ExternalMailClient._get_compose_commandline"""
381
if from_ is not None:
382
compose_url.append('from=' + urlutils.quote(from_))
383
if subject is not None:
384
# Don't use urlutils.quote_plus because Claws doesn't seem
385
# to recognise spaces encoded as "+".
387
'subject=' + urlutils.quote(self._encode_safe(subject)))
390
'body=' + urlutils.quote(self._encode_safe(body)))
391
# to must be supplied for the claws-mail --compose syntax to work.
393
raise NoMailAddressSpecified()
394
compose_url = 'mailto:%s?%s' % (
395
self._encode_safe(to), '&'.join(compose_url))
396
# Collect command-line options.
397
message_options = ['--compose', compose_url]
398
if attach_path is not None:
399
message_options.extend(
400
['--attach', self._encode_path(attach_path, 'attachment')])
401
return message_options
403
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
404
extension, body=None, from_=None):
405
"""See ExternalMailClient._compose"""
407
from_ = self.config.get('email')
408
super(Claws, self)._compose(prompt, to, subject, attach_path,
409
mime_subtype, extension, body, from_)
412
mail_client_registry.register('claws', Claws,
416
class XDGEmail(BodyExternalMailClient):
417
__doc__ = """xdg-email attempts to invoke the user's preferred mail client"""
419
_client_commands = ['xdg-email']
421
def _get_compose_commandline(self, to, subject, attach_path, body=None):
422
"""See ExternalMailClient._get_compose_commandline"""
424
raise NoMailAddressSpecified()
425
commandline = [self._encode_safe(to)]
426
if subject is not None:
427
commandline.extend(['--subject', self._encode_safe(subject)])
428
if attach_path is not None:
429
commandline.extend(['--attach',
430
self._encode_path(attach_path, 'attachment')])
432
commandline.extend(['--body', self._encode_safe(body)])
434
mail_client_registry.register('xdg-email', XDGEmail,
435
help=XDGEmail.__doc__)
438
class EmacsMail(ExternalMailClient):
439
__doc__ = """Call emacsclient to have a mail buffer.
441
This only work for emacs >= 22.1 due to recent -e/--eval support.
443
The good news is that this implementation will work with all mail
444
agents registered against ``mail-user-agent``. So there is no need
445
to instantiate ExternalMailClient for each and every GNU Emacs
448
Users just have to ensure that ``mail-user-agent`` is set according
452
_client_commands = ['emacsclient']
454
def __init__(self, config):
455
super(EmacsMail, self).__init__(config)
456
self.elisp_tmp_file = None
458
def _prepare_send_function(self):
459
"""Write our wrapper function into a temporary file.
461
This temporary file will be loaded at runtime in
462
_get_compose_commandline function.
464
This function does not remove the file. That's a wanted
465
behaviour since _get_compose_commandline won't run the send
466
mail function directly but return the eligible command line.
467
Removing our temporary file here would prevent our sendmail
468
function to work. (The file is deleted by some elisp code
469
after being read by Emacs.)
472
_defun = r"""(defun bzr-add-mime-att (file)
473
"Attach FILE to a mail buffer as a MIME attachment."
474
(let ((agent mail-user-agent))
475
(if (and file (file-exists-p file))
477
((eq agent 'sendmail-user-agent)
481
(if (functionp 'etach-attach)
483
(mail-attach-file file))))
484
((or (eq agent 'message-user-agent)
485
(eq agent 'gnus-user-agent)
486
(eq agent 'mh-e-user-agent))
488
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
489
((eq agent 'mew-user-agent)
491
(mew-draft-prepare-attachments)
492
(mew-attach-link file (file-name-nondirectory file))
493
(let* ((nums (mew-syntax-nums))
494
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
495
(mew-syntax-set-cd syntax "BZR merge")
496
(mew-encode-syntax-print mew-encode-syntax))
497
(mew-header-goto-body)))
499
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
500
(error "File %s does not exist." file))))
503
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
508
os.close(fd) # Just close the handle but do not remove the file.
511
def _get_compose_commandline(self, to, subject, attach_path):
512
commandline = ["--eval"]
518
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
519
if subject is not None:
520
_subject = ("\"%s\"" %
521
self._encode_safe(subject).replace('"', '\\"'))
523
# Funcall the default mail composition function
524
# This will work with any mail mode including default mail-mode
525
# User must tweak mail-user-agent variable to tell what function
526
# will be called inside compose-mail.
527
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
528
commandline.append(mail_cmd)
530
# Try to attach a MIME attachment using our wrapper function
531
if attach_path is not None:
532
# Do not create a file if there is no attachment
533
elisp = self._prepare_send_function()
534
self.elisp_tmp_file = elisp
535
lmmform = '(load "%s")' % elisp
536
mmform = '(bzr-add-mime-att "%s")' % \
537
self._encode_path(attach_path, 'attachment')
538
rmform = '(delete-file "%s")' % elisp
539
commandline.append(lmmform)
540
commandline.append(mmform)
541
commandline.append(rmform)
544
mail_client_registry.register('emacsclient', EmacsMail,
545
help=EmacsMail.__doc__)
548
class MAPIClient(BodyExternalMailClient):
549
__doc__ = """Default Windows mail client launched using MAPI."""
551
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
552
extension, body=None):
553
"""See ExternalMailClient._compose.
555
This implementation uses MAPI via the simplemapi ctypes wrapper
557
from .util import simplemapi
559
simplemapi.SendMail(to or '', subject or '', body or '',
561
except simplemapi.MAPIError as e:
562
if e.code != simplemapi.MAPI_USER_ABORT:
563
raise MailClientNotFound(['MAPI supported mail client'
564
' (error %d)' % (e.code,)])
565
mail_client_registry.register('mapi', MAPIClient,
566
help=MAPIClient.__doc__)
569
class MailApp(BodyExternalMailClient):
570
__doc__ = """Use MacOS X's Mail.app for sending email messages.
572
Although it would be nice to use appscript, it's not installed
573
with the shipped Python installations. We instead build an
574
AppleScript and invoke the script using osascript(1). We don't
575
use the _encode_safe() routines as it's not clear what encoding
576
osascript expects the script to be in.
579
_client_commands = ['osascript']
581
def _get_compose_commandline(self, to, subject, attach_path, body=None,
583
"""See ExternalMailClient._get_compose_commandline"""
585
fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
588
os.write(fd, 'tell application "Mail"\n')
589
os.write(fd, 'set newMessage to make new outgoing message\n')
590
os.write(fd, 'tell newMessage\n')
592
os.write(fd, 'make new to recipient with properties'
593
' {address:"%s"}\n' % to)
594
if from_ is not None:
595
# though from_ doesn't actually seem to be used
596
os.write(fd, 'set sender to "%s"\n'
597
% sender.replace('"', '\\"'))
598
if subject is not None:
599
os.write(fd, 'set subject to "%s"\n'
600
% subject.replace('"', '\\"'))
602
# FIXME: would be nice to prepend the body to the
603
# existing content (e.g., preserve signature), but
604
# can't seem to figure out the right applescript
606
os.write(fd, 'set content to "%s\\n\n"\n' %
607
body.replace('"', '\\"').replace('\n', '\\n'))
609
if attach_path is not None:
610
# FIXME: would be nice to first append a newline to
611
# ensure the attachment is on a new paragraph, but
612
# can't seem to figure out the right applescript
614
os.write(fd, 'tell content to make new attachment'
615
' with properties {file name:"%s"}'
616
' at after the last paragraph\n'
617
% self._encode_path(attach_path, 'attachment'))
618
os.write(fd, 'set visible to true\n')
619
os.write(fd, 'end tell\n')
620
os.write(fd, 'end tell\n')
622
os.close(fd) # Just close the handle but do not remove the file.
623
return [self.temp_file]
624
mail_client_registry.register('mail.app', MailApp,
625
help=MailApp.__doc__)
628
class DefaultMail(MailClient):
629
__doc__ = """Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
630
falls back to Editor"""
634
def _mail_client(self):
635
"""Determine the preferred mail client for this platform"""
636
if osutils.supports_mapi():
637
return MAPIClient(self.config)
639
return XDGEmail(self.config)
641
def compose(self, prompt, to, subject, attachment, mime_subtype,
642
extension, basename=None, body=None):
643
"""See MailClient.compose"""
645
return self._mail_client().compose(prompt, to, subject,
646
attachment, mime_subtype,
647
extension, basename, body)
648
except MailClientNotFound:
649
return Editor(self.config).compose(prompt, to, subject,
650
attachment, mime_subtype, extension, body)
652
def compose_merge_request(self, to, subject, directive, basename=None,
654
"""See MailClient.compose_merge_request"""
656
return self._mail_client().compose_merge_request(to, subject,
657
directive, basename=basename, body=body)
658
except MailClientNotFound:
659
return Editor(self.config).compose_merge_request(to, subject,
660
directive, basename=basename, body=body)
661
mail_client_registry.register(u'default', DefaultMail,
662
help=DefaultMail.__doc__)
663
mail_client_registry.default_key = u'default'
665
opt_mail_client = _mod_config.RegistryOption('mail_client',
666
mail_client_registry, help='E-mail client to use.', invalid='error')