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,
36
mail_client_registry = registry.Registry()
39
class MailClientNotFound(errors.BzrError):
41
_fmt = "Unable to find mail client with the following names:"\
42
" %(mail_command_list_string)s"
44
def __init__(self, mail_command_list):
45
mail_command_list_string = ', '.join(mail_command_list)
46
errors.BzrError.__init__(
47
self, mail_command_list=mail_command_list,
48
mail_command_list_string=mail_command_list_string)
51
class NoMessageSupplied(errors.BzrError):
53
_fmt = "No message supplied."
56
class NoMailAddressSpecified(errors.BzrError):
58
_fmt = "No mail-to address (--mail-to) or output (-o) specified."
61
class MailClient(object):
62
"""A mail client that can send messages with attachements."""
64
def __init__(self, config):
67
def compose(self, prompt, to, subject, attachment, mime_subtype,
68
extension, basename=None, body=None):
69
"""Compose (and possibly send) an email message
71
Must be implemented by subclasses.
73
:param prompt: A message to tell the user what to do. Supported by
74
the Editor client, but ignored by others
75
:param to: The address to send the message to
76
:param subject: The contents of the subject line
77
:param attachment: An email attachment, as a bytestring
78
:param mime_subtype: The attachment is assumed to be a subtype of
79
Text. This allows the precise subtype to be specified, e.g.
80
"plain", "x-patch", etc.
81
:param extension: The file extension associated with the attachment
83
:param basename: The name to use for the attachment, e.g.
86
raise NotImplementedError
88
def compose_merge_request(self, to, subject, directive, basename=None,
90
"""Compose (and possibly send) a merge request
92
:param to: The address to send the request to
93
:param subject: The subject line to use for the request
94
:param directive: A merge directive representing the merge request, as
96
:param basename: The name to use for the attachment, e.g.
99
prompt = self._get_merge_prompt("Please describe these changes:", to,
101
self.compose(prompt, to, subject, directive,
102
'x-patch', '.patch', basename, body)
104
def _get_merge_prompt(self, prompt, to, subject, attachment):
105
"""Generate a prompt string. Overridden by Editor.
107
:param prompt: A string suggesting what user should do
108
:param to: The address the mail will be sent to
109
:param subject: The subject line of the mail
110
:param attachment: The attachment that will be used
115
class Editor(MailClient):
116
__doc__ = """DIY mail client that uses commit message editor"""
120
def _get_merge_prompt(self, prompt, to, subject, attachment):
121
"""See MailClient._get_merge_prompt"""
125
u"%s" % (prompt, to, subject,
126
attachment.decode('utf-8', 'replace')))
128
def compose(self, prompt, to, subject, attachment, mime_subtype,
129
extension, basename=None, body=None):
130
"""See MailClient.compose"""
132
raise NoMailAddressSpecified()
133
body = msgeditor.edit_commit_message(prompt, start_message=body)
135
raise NoMessageSupplied()
136
email_message.EmailMessage.send(self.config,
137
self.config.get('email'),
142
attachment_mime_subtype=mime_subtype)
143
mail_client_registry.register('editor', Editor,
147
class BodyExternalMailClient(MailClient):
151
def _get_client_commands(self):
152
"""Provide a list of commands that may invoke the mail client"""
153
if sys.platform == 'win32':
155
return [win32utils.get_app_path(i) for i in self._client_commands]
157
return self._client_commands
159
def compose(self, prompt, to, subject, attachment, mime_subtype,
160
extension, basename=None, body=None):
161
"""See MailClient.compose.
163
Writes the attachment to a temporary file, invokes _compose.
166
basename = 'attachment'
167
pathname = osutils.mkdtemp(prefix='bzr-mail-')
168
attach_path = osutils.pathjoin(pathname, basename + extension)
169
outfile = open(attach_path, 'wb')
171
outfile.write(attachment)
175
kwargs = {'body': body}
178
self._compose(prompt, to, subject, attach_path, mime_subtype,
181
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
182
extension, body=None, from_=None):
183
"""Invoke a mail client as a commandline process.
185
Overridden by MAPIClient.
186
:param to: The address to send the mail to
187
:param subject: The subject line for the mail
188
:param pathname: The path to the attachment
189
:param mime_subtype: The attachment is assumed to have a major type of
190
"text", but the precise subtype can be specified here
191
:param extension: A file extension (including period) associated with
193
:param body: Optional body text.
194
:param from_: Optional From: header.
196
for name in self._get_client_commands():
197
cmdline = [self._encode_path(name, 'executable')]
199
kwargs = {'body': body}
202
if from_ is not None:
203
kwargs['from_'] = from_
204
cmdline.extend(self._get_compose_commandline(to, subject,
208
subprocess.call(cmdline)
210
if e.errno != errno.ENOENT:
215
raise MailClientNotFound(self._client_commands)
217
def _get_compose_commandline(self, to, subject, attach_path, body):
218
"""Determine the commandline to use for composing a message
220
Implemented by various subclasses
221
:param to: The address to send the mail to
222
:param subject: The subject line for the mail
223
:param attach_path: The path to the attachment
225
raise NotImplementedError
227
def _encode_safe(self, u):
228
"""Encode possible unicode string argument to 8-bit string
229
in user_encoding. Unencodable characters will be replaced
232
:param u: possible unicode string.
233
:return: encoded string if u is unicode, u itself otherwise.
235
if isinstance(u, unicode):
236
return u.encode(osutils.get_user_encoding(), 'replace')
239
def _encode_path(self, path, kind):
240
"""Encode unicode path in user encoding.
242
:param path: possible unicode path.
243
:param kind: path kind ('executable' or 'attachment').
244
:return: encoded path if path is unicode,
245
path itself otherwise.
246
:raise: UnableEncodePath.
248
if isinstance(path, unicode):
250
return path.encode(osutils.get_user_encoding())
251
except UnicodeEncodeError:
252
raise errors.UnableEncodePath(path, kind)
256
class ExternalMailClient(BodyExternalMailClient):
257
__doc__ = """An external mail client."""
259
supports_body = False
262
class Evolution(BodyExternalMailClient):
263
__doc__ = """Evolution mail client."""
265
_client_commands = ['evolution']
267
def _get_compose_commandline(self, to, subject, attach_path, body=None):
268
"""See ExternalMailClient._get_compose_commandline"""
270
if subject is not None:
271
message_options['subject'] = subject
272
if attach_path is not None:
273
message_options['attach'] = attach_path
275
message_options['body'] = body
276
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
277
sorted(message_options.items())]
278
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
279
'&'.join(options_list))]
280
mail_client_registry.register('evolution', Evolution,
281
help=Evolution.__doc__)
284
class Mutt(BodyExternalMailClient):
285
__doc__ = """Mutt mail client."""
287
_client_commands = ['mutt']
289
def _get_compose_commandline(self, to, subject, attach_path, body=None):
290
"""See ExternalMailClient._get_compose_commandline"""
292
if subject is not None:
293
message_options.extend(['-s', self._encode_safe(subject)])
294
if attach_path is not None:
295
message_options.extend(['-a',
296
self._encode_path(attach_path, 'attachment')])
298
# Store the temp file object in self, so that it does not get
299
# garbage collected and delete the file before mutt can read it.
300
self._temp_file = tempfile.NamedTemporaryFile(
301
prefix="mutt-body-", suffix=".txt")
302
self._temp_file.write(body)
303
self._temp_file.flush()
304
message_options.extend(['-i', self._temp_file.name])
306
message_options.extend(['--', self._encode_safe(to)])
307
return message_options
308
mail_client_registry.register('mutt', Mutt,
312
class Thunderbird(BodyExternalMailClient):
313
__doc__ = """Mozilla Thunderbird (or Icedove)
315
Note that Thunderbird 1.5 is buggy and does not support setting
316
"to" simultaneously with including a attachment.
318
There is a workaround if no attachment is present, but we always need to
322
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
323
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
324
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
326
def _get_compose_commandline(self, to, subject, attach_path, body=None):
327
"""See ExternalMailClient._get_compose_commandline"""
330
message_options['to'] = self._encode_safe(to)
331
if subject is not None:
332
message_options['subject'] = self._encode_safe(subject)
333
if attach_path is not None:
334
message_options['attachment'] = urlutils.local_path_to_url(
337
options_list = ['body=%s' % urlutils.quote(self._encode_safe(body))]
340
options_list.extend(["%s='%s'" % (k, v) for k, v in
341
sorted(message_options.items())])
342
return ['-compose', ','.join(options_list)]
343
mail_client_registry.register('thunderbird', Thunderbird,
344
help=Thunderbird.__doc__)
347
class KMail(ExternalMailClient):
348
__doc__ = """KDE mail client."""
350
_client_commands = ['kmail']
352
def _get_compose_commandline(self, to, subject, attach_path):
353
"""See ExternalMailClient._get_compose_commandline"""
355
if subject is not None:
356
message_options.extend(['-s', self._encode_safe(subject)])
357
if attach_path is not None:
358
message_options.extend(['--attach',
359
self._encode_path(attach_path, 'attachment')])
361
message_options.extend([self._encode_safe(to)])
362
return message_options
363
mail_client_registry.register('kmail', KMail,
367
class Claws(ExternalMailClient):
368
__doc__ = """Claws mail client."""
372
_client_commands = ['claws-mail']
374
def _get_compose_commandline(self, to, subject, attach_path, body=None,
376
"""See ExternalMailClient._get_compose_commandline"""
378
if from_ is not None:
379
compose_url.append('from=' + urlutils.quote(from_))
380
if subject is not None:
381
# Don't use urlutils.quote_plus because Claws doesn't seem
382
# to recognise spaces encoded as "+".
384
'subject=' + urlutils.quote(self._encode_safe(subject)))
387
'body=' + urlutils.quote(self._encode_safe(body)))
388
# to must be supplied for the claws-mail --compose syntax to work.
390
raise NoMailAddressSpecified()
391
compose_url = 'mailto:%s?%s' % (
392
self._encode_safe(to), '&'.join(compose_url))
393
# Collect command-line options.
394
message_options = ['--compose', compose_url]
395
if attach_path is not None:
396
message_options.extend(
397
['--attach', self._encode_path(attach_path, 'attachment')])
398
return message_options
400
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
401
extension, body=None, from_=None):
402
"""See ExternalMailClient._compose"""
404
from_ = self.config.get('email')
405
super(Claws, self)._compose(prompt, to, subject, attach_path,
406
mime_subtype, extension, body, from_)
409
mail_client_registry.register('claws', Claws,
413
class XDGEmail(BodyExternalMailClient):
414
__doc__ = """xdg-email attempts to invoke the user's preferred mail client"""
416
_client_commands = ['xdg-email']
418
def _get_compose_commandline(self, to, subject, attach_path, body=None):
419
"""See ExternalMailClient._get_compose_commandline"""
421
raise NoMailAddressSpecified()
422
commandline = [self._encode_safe(to)]
423
if subject is not None:
424
commandline.extend(['--subject', self._encode_safe(subject)])
425
if attach_path is not None:
426
commandline.extend(['--attach',
427
self._encode_path(attach_path, 'attachment')])
429
commandline.extend(['--body', self._encode_safe(body)])
431
mail_client_registry.register('xdg-email', XDGEmail,
432
help=XDGEmail.__doc__)
435
class EmacsMail(ExternalMailClient):
436
__doc__ = """Call emacsclient to have a mail buffer.
438
This only work for emacs >= 22.1 due to recent -e/--eval support.
440
The good news is that this implementation will work with all mail
441
agents registered against ``mail-user-agent``. So there is no need
442
to instantiate ExternalMailClient for each and every GNU Emacs
445
Users just have to ensure that ``mail-user-agent`` is set according
449
_client_commands = ['emacsclient']
451
def __init__(self, config):
452
super(EmacsMail, self).__init__(config)
453
self.elisp_tmp_file = None
455
def _prepare_send_function(self):
456
"""Write our wrapper function into a temporary file.
458
This temporary file will be loaded at runtime in
459
_get_compose_commandline function.
461
This function does not remove the file. That's a wanted
462
behaviour since _get_compose_commandline won't run the send
463
mail function directly but return the eligible command line.
464
Removing our temporary file here would prevent our sendmail
465
function to work. (The file is deleted by some elisp code
466
after being read by Emacs.)
469
_defun = r"""(defun bzr-add-mime-att (file)
470
"Attach FILE to a mail buffer as a MIME attachment."
471
(let ((agent mail-user-agent))
472
(if (and file (file-exists-p file))
474
((eq agent 'sendmail-user-agent)
478
(if (functionp 'etach-attach)
480
(mail-attach-file file))))
481
((or (eq agent 'message-user-agent)
482
(eq agent 'gnus-user-agent)
483
(eq agent 'mh-e-user-agent))
485
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
486
((eq agent 'mew-user-agent)
488
(mew-draft-prepare-attachments)
489
(mew-attach-link file (file-name-nondirectory file))
490
(let* ((nums (mew-syntax-nums))
491
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
492
(mew-syntax-set-cd syntax "BZR merge")
493
(mew-encode-syntax-print mew-encode-syntax))
494
(mew-header-goto-body)))
496
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
497
(error "File %s does not exist." file))))
500
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
505
os.close(fd) # Just close the handle but do not remove the file.
508
def _get_compose_commandline(self, to, subject, attach_path):
509
commandline = ["--eval"]
515
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
516
if subject is not None:
517
_subject = ("\"%s\"" %
518
self._encode_safe(subject).replace('"', '\\"'))
520
# Funcall the default mail composition function
521
# This will work with any mail mode including default mail-mode
522
# User must tweak mail-user-agent variable to tell what function
523
# will be called inside compose-mail.
524
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
525
commandline.append(mail_cmd)
527
# Try to attach a MIME attachment using our wrapper function
528
if attach_path is not None:
529
# Do not create a file if there is no attachment
530
elisp = self._prepare_send_function()
531
self.elisp_tmp_file = elisp
532
lmmform = '(load "%s")' % elisp
533
mmform = '(bzr-add-mime-att "%s")' % \
534
self._encode_path(attach_path, 'attachment')
535
rmform = '(delete-file "%s")' % elisp
536
commandline.append(lmmform)
537
commandline.append(mmform)
538
commandline.append(rmform)
541
mail_client_registry.register('emacsclient', EmacsMail,
542
help=EmacsMail.__doc__)
545
class MAPIClient(BodyExternalMailClient):
546
__doc__ = """Default Windows mail client launched using MAPI."""
548
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
549
extension, body=None):
550
"""See ExternalMailClient._compose.
552
This implementation uses MAPI via the simplemapi ctypes wrapper
554
from .util import simplemapi
556
simplemapi.SendMail(to or '', subject or '', body or '',
558
except simplemapi.MAPIError as e:
559
if e.code != simplemapi.MAPI_USER_ABORT:
560
raise MailClientNotFound(['MAPI supported mail client'
561
' (error %d)' % (e.code,)])
562
mail_client_registry.register('mapi', MAPIClient,
563
help=MAPIClient.__doc__)
566
class MailApp(BodyExternalMailClient):
567
__doc__ = """Use MacOS X's Mail.app for sending email messages.
569
Although it would be nice to use appscript, it's not installed
570
with the shipped Python installations. We instead build an
571
AppleScript and invoke the script using osascript(1). We don't
572
use the _encode_safe() routines as it's not clear what encoding
573
osascript expects the script to be in.
576
_client_commands = ['osascript']
578
def _get_compose_commandline(self, to, subject, attach_path, body=None,
580
"""See ExternalMailClient._get_compose_commandline"""
582
fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
585
os.write(fd, 'tell application "Mail"\n')
586
os.write(fd, 'set newMessage to make new outgoing message\n')
587
os.write(fd, 'tell newMessage\n')
589
os.write(fd, 'make new to recipient with properties'
590
' {address:"%s"}\n' % to)
591
if from_ is not None:
592
# though from_ doesn't actually seem to be used
593
os.write(fd, 'set sender to "%s"\n'
594
% sender.replace('"', '\\"'))
595
if subject is not None:
596
os.write(fd, 'set subject to "%s"\n'
597
% subject.replace('"', '\\"'))
599
# FIXME: would be nice to prepend the body to the
600
# existing content (e.g., preserve signature), but
601
# can't seem to figure out the right applescript
603
os.write(fd, 'set content to "%s\\n\n"\n' %
604
body.replace('"', '\\"').replace('\n', '\\n'))
606
if attach_path is not None:
607
# FIXME: would be nice to first append a newline to
608
# ensure the attachment is on a new paragraph, but
609
# can't seem to figure out the right applescript
611
os.write(fd, 'tell content to make new attachment'
612
' with properties {file name:"%s"}'
613
' at after the last paragraph\n'
614
% self._encode_path(attach_path, 'attachment'))
615
os.write(fd, 'set visible to true\n')
616
os.write(fd, 'end tell\n')
617
os.write(fd, 'end tell\n')
619
os.close(fd) # Just close the handle but do not remove the file.
620
return [self.temp_file]
621
mail_client_registry.register('mail.app', MailApp,
622
help=MailApp.__doc__)
625
class DefaultMail(MailClient):
626
__doc__ = """Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
627
falls back to Editor"""
631
def _mail_client(self):
632
"""Determine the preferred mail client for this platform"""
633
if osutils.supports_mapi():
634
return MAPIClient(self.config)
636
return XDGEmail(self.config)
638
def compose(self, prompt, to, subject, attachment, mime_subtype,
639
extension, basename=None, body=None):
640
"""See MailClient.compose"""
642
return self._mail_client().compose(prompt, to, subject,
643
attachment, mime_subtype,
644
extension, basename, body)
645
except MailClientNotFound:
646
return Editor(self.config).compose(prompt, to, subject,
647
attachment, mime_subtype, extension, body)
649
def compose_merge_request(self, to, subject, directive, basename=None,
651
"""See MailClient.compose_merge_request"""
653
return self._mail_client().compose_merge_request(to, subject,
654
directive, basename=basename, body=body)
655
except MailClientNotFound:
656
return Editor(self.config).compose_merge_request(to, subject,
657
directive, basename=basename, body=body)
658
mail_client_registry.register('default', DefaultMail,
659
help=DefaultMail.__doc__)
660
mail_client_registry.default_key = 'default'
662
opt_mail_client = _mod_config.RegistryOption('mail_client',
663
mail_client_registry, help='E-mail client to use.', invalid='error')