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)
147
mail_client_registry.register('editor', Editor,
151
class BodyExternalMailClient(MailClient):
155
def _get_client_commands(self):
156
"""Provide a list of commands that may invoke the mail client"""
157
if sys.platform == 'win32':
159
return [win32utils.get_app_path(i) for i in self._client_commands]
161
return self._client_commands
163
def compose(self, prompt, to, subject, attachment, mime_subtype,
164
extension, basename=None, body=None):
165
"""See MailClient.compose.
167
Writes the attachment to a temporary file, invokes _compose.
170
basename = 'attachment'
171
pathname = osutils.mkdtemp(prefix='bzr-mail-')
172
attach_path = osutils.pathjoin(pathname, basename + extension)
173
outfile = open(attach_path, 'wb')
175
outfile.write(attachment)
179
kwargs = {'body': body}
182
self._compose(prompt, to, subject, attach_path, mime_subtype,
185
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
186
extension, body=None, from_=None):
187
"""Invoke a mail client as a commandline process.
189
Overridden by MAPIClient.
190
:param to: The address to send the mail to
191
:param subject: The subject line for the mail
192
:param pathname: The path to the attachment
193
:param mime_subtype: The attachment is assumed to have a major type of
194
"text", but the precise subtype can be specified here
195
:param extension: A file extension (including period) associated with
197
:param body: Optional body text.
198
:param from_: Optional From: header.
200
for name in self._get_client_commands():
201
cmdline = [self._encode_path(name, 'executable')]
203
kwargs = {'body': body}
206
if from_ is not None:
207
kwargs['from_'] = from_
208
cmdline.extend(self._get_compose_commandline(to, subject,
212
subprocess.call(cmdline)
214
if e.errno != errno.ENOENT:
219
raise MailClientNotFound(self._client_commands)
221
def _get_compose_commandline(self, to, subject, attach_path, body):
222
"""Determine the commandline to use for composing a message
224
Implemented by various subclasses
225
:param to: The address to send the mail to
226
:param subject: The subject line for the mail
227
:param attach_path: The path to the attachment
229
raise NotImplementedError
231
def _encode_safe(self, u):
232
"""Encode possible unicode string argument to 8-bit string
233
in user_encoding. Unencodable characters will be replaced
236
:param u: possible unicode string.
237
:return: encoded string if u is unicode, u itself otherwise.
239
if not PY3 and isinstance(u, text_type):
240
return u.encode(osutils.get_user_encoding(), 'replace')
243
def _encode_path(self, path, kind):
244
"""Encode unicode path in user encoding.
246
:param path: possible unicode path.
247
:param kind: path kind ('executable' or 'attachment').
248
:return: encoded path if path is unicode,
249
path itself otherwise.
250
:raise: UnableEncodePath.
252
if not PY3 and isinstance(path, text_type):
254
return path.encode(osutils.get_user_encoding())
255
except UnicodeEncodeError:
256
raise errors.UnableEncodePath(path, kind)
260
class ExternalMailClient(BodyExternalMailClient):
261
__doc__ = """An external mail client."""
263
supports_body = False
266
class Evolution(BodyExternalMailClient):
267
__doc__ = """Evolution mail client."""
269
_client_commands = ['evolution']
271
def _get_compose_commandline(self, to, subject, attach_path, body=None):
272
"""See ExternalMailClient._get_compose_commandline"""
274
if subject is not None:
275
message_options['subject'] = subject
276
if attach_path is not None:
277
message_options['attach'] = attach_path
279
message_options['body'] = body
280
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
281
sorted(message_options.items())]
282
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
283
'&'.join(options_list))]
284
mail_client_registry.register('evolution', Evolution,
285
help=Evolution.__doc__)
288
class Mutt(BodyExternalMailClient):
289
__doc__ = """Mutt mail client."""
291
_client_commands = ['mutt']
293
def _get_compose_commandline(self, to, subject, attach_path, body=None):
294
"""See ExternalMailClient._get_compose_commandline"""
296
if subject is not None:
297
message_options.extend(['-s', self._encode_safe(subject)])
298
if attach_path is not None:
299
message_options.extend(['-a',
300
self._encode_path(attach_path, 'attachment')])
302
# Store the temp file object in self, so that it does not get
303
# garbage collected and delete the file before mutt can read it.
304
self._temp_file = tempfile.NamedTemporaryFile(
305
prefix="mutt-body-", suffix=".txt", mode="w+")
306
self._temp_file.write(body)
307
self._temp_file.flush()
308
message_options.extend(['-i', self._temp_file.name])
310
message_options.extend(['--', self._encode_safe(to)])
311
return message_options
312
mail_client_registry.register('mutt', Mutt,
316
class Thunderbird(BodyExternalMailClient):
317
__doc__ = """Mozilla Thunderbird (or Icedove)
319
Note that Thunderbird 1.5 is buggy and does not support setting
320
"to" simultaneously with including a attachment.
322
There is a workaround if no attachment is present, but we always need to
326
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
327
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
328
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
330
def _get_compose_commandline(self, to, subject, attach_path, body=None):
331
"""See ExternalMailClient._get_compose_commandline"""
334
message_options['to'] = self._encode_safe(to)
335
if subject is not None:
336
message_options['subject'] = self._encode_safe(subject)
337
if attach_path is not None:
338
message_options['attachment'] = urlutils.local_path_to_url(
341
options_list = ['body=%s' % urlutils.quote(self._encode_safe(body))]
344
options_list.extend(["%s='%s'" % (k, v) for k, v in
345
sorted(message_options.items())])
346
return ['-compose', ','.join(options_list)]
347
mail_client_registry.register('thunderbird', Thunderbird,
348
help=Thunderbird.__doc__)
351
class KMail(ExternalMailClient):
352
__doc__ = """KDE mail client."""
354
_client_commands = ['kmail']
356
def _get_compose_commandline(self, to, subject, attach_path):
357
"""See ExternalMailClient._get_compose_commandline"""
359
if subject is not None:
360
message_options.extend(['-s', self._encode_safe(subject)])
361
if attach_path is not None:
362
message_options.extend(['--attach',
363
self._encode_path(attach_path, 'attachment')])
365
message_options.extend([self._encode_safe(to)])
366
return message_options
367
mail_client_registry.register('kmail', KMail,
371
class Claws(ExternalMailClient):
372
__doc__ = """Claws mail client."""
376
_client_commands = ['claws-mail']
378
def _get_compose_commandline(self, to, subject, attach_path, body=None,
380
"""See ExternalMailClient._get_compose_commandline"""
382
if from_ is not None:
383
compose_url.append('from=' + urlutils.quote(from_))
384
if subject is not None:
385
# Don't use urlutils.quote_plus because Claws doesn't seem
386
# to recognise spaces encoded as "+".
388
'subject=' + urlutils.quote(self._encode_safe(subject)))
391
'body=' + urlutils.quote(self._encode_safe(body)))
392
# to must be supplied for the claws-mail --compose syntax to work.
394
raise NoMailAddressSpecified()
395
compose_url = 'mailto:%s?%s' % (
396
self._encode_safe(to), '&'.join(compose_url))
397
# Collect command-line options.
398
message_options = ['--compose', compose_url]
399
if attach_path is not None:
400
message_options.extend(
401
['--attach', self._encode_path(attach_path, 'attachment')])
402
return message_options
404
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
405
extension, body=None, from_=None):
406
"""See ExternalMailClient._compose"""
408
from_ = self.config.get('email')
409
super(Claws, self)._compose(prompt, to, subject, attach_path,
410
mime_subtype, extension, body, from_)
413
mail_client_registry.register('claws', Claws,
417
class XDGEmail(BodyExternalMailClient):
418
__doc__ = """xdg-email attempts to invoke the user's preferred mail client"""
420
_client_commands = ['xdg-email']
422
def _get_compose_commandline(self, to, subject, attach_path, body=None):
423
"""See ExternalMailClient._get_compose_commandline"""
425
raise NoMailAddressSpecified()
426
commandline = [self._encode_safe(to)]
427
if subject is not None:
428
commandline.extend(['--subject', self._encode_safe(subject)])
429
if attach_path is not None:
430
commandline.extend(['--attach',
431
self._encode_path(attach_path, 'attachment')])
433
commandline.extend(['--body', self._encode_safe(body)])
435
mail_client_registry.register('xdg-email', XDGEmail,
436
help=XDGEmail.__doc__)
439
class EmacsMail(ExternalMailClient):
440
__doc__ = """Call emacsclient to have a mail buffer.
442
This only work for emacs >= 22.1 due to recent -e/--eval support.
444
The good news is that this implementation will work with all mail
445
agents registered against ``mail-user-agent``. So there is no need
446
to instantiate ExternalMailClient for each and every GNU Emacs
449
Users just have to ensure that ``mail-user-agent`` is set according
453
_client_commands = ['emacsclient']
455
def __init__(self, config):
456
super(EmacsMail, self).__init__(config)
457
self.elisp_tmp_file = None
459
def _prepare_send_function(self):
460
"""Write our wrapper function into a temporary file.
462
This temporary file will be loaded at runtime in
463
_get_compose_commandline function.
465
This function does not remove the file. That's a wanted
466
behaviour since _get_compose_commandline won't run the send
467
mail function directly but return the eligible command line.
468
Removing our temporary file here would prevent our sendmail
469
function to work. (The file is deleted by some elisp code
470
after being read by Emacs.)
473
_defun = br"""(defun bzr-add-mime-att (file)
474
"Attach FILE to a mail buffer as a MIME attachment."
475
(let ((agent mail-user-agent))
476
(if (and file (file-exists-p file))
478
((eq agent 'sendmail-user-agent)
482
(if (functionp 'etach-attach)
484
(mail-attach-file file))))
485
((or (eq agent 'message-user-agent)
486
(eq agent 'gnus-user-agent)
487
(eq agent 'mh-e-user-agent))
489
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
490
((eq agent 'mew-user-agent)
492
(mew-draft-prepare-attachments)
493
(mew-attach-link file (file-name-nondirectory file))
494
(let* ((nums (mew-syntax-nums))
495
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
496
(mew-syntax-set-cd syntax "BZR merge")
497
(mew-encode-syntax-print mew-encode-syntax))
498
(mew-header-goto-body)))
500
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
501
(error "File %s does not exist." file))))
504
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
509
os.close(fd) # Just close the handle but do not remove the file.
512
def _get_compose_commandline(self, to, subject, attach_path):
513
commandline = ["--eval"]
519
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
520
if subject is not None:
521
_subject = ("\"%s\"" %
522
self._encode_safe(subject).replace('"', '\\"'))
524
# Funcall the default mail composition function
525
# This will work with any mail mode including default mail-mode
526
# User must tweak mail-user-agent variable to tell what function
527
# will be called inside compose-mail.
528
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
529
commandline.append(mail_cmd)
531
# Try to attach a MIME attachment using our wrapper function
532
if attach_path is not None:
533
# Do not create a file if there is no attachment
534
elisp = self._prepare_send_function()
535
self.elisp_tmp_file = elisp
536
lmmform = '(load "%s")' % elisp
537
mmform = '(bzr-add-mime-att "%s")' % \
538
self._encode_path(attach_path, 'attachment')
539
rmform = '(delete-file "%s")' % elisp
540
commandline.append(lmmform)
541
commandline.append(mmform)
542
commandline.append(rmform)
545
mail_client_registry.register('emacsclient', EmacsMail,
546
help=EmacsMail.__doc__)
549
class MAPIClient(BodyExternalMailClient):
550
__doc__ = """Default Windows mail client launched using MAPI."""
552
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
553
extension, body=None):
554
"""See ExternalMailClient._compose.
556
This implementation uses MAPI via the simplemapi ctypes wrapper
558
from .util import simplemapi
560
simplemapi.SendMail(to or '', subject or '', body or '',
562
except simplemapi.MAPIError as e:
563
if e.code != simplemapi.MAPI_USER_ABORT:
564
raise MailClientNotFound(['MAPI supported mail client'
565
' (error %d)' % (e.code,)])
566
mail_client_registry.register('mapi', MAPIClient,
567
help=MAPIClient.__doc__)
570
class MailApp(BodyExternalMailClient):
571
__doc__ = """Use MacOS X's Mail.app for sending email messages.
573
Although it would be nice to use appscript, it's not installed
574
with the shipped Python installations. We instead build an
575
AppleScript and invoke the script using osascript(1). We don't
576
use the _encode_safe() routines as it's not clear what encoding
577
osascript expects the script to be in.
580
_client_commands = ['osascript']
582
def _get_compose_commandline(self, to, subject, attach_path, body=None,
584
"""See ExternalMailClient._get_compose_commandline"""
586
fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
589
os.write(fd, 'tell application "Mail"\n')
590
os.write(fd, 'set newMessage to make new outgoing message\n')
591
os.write(fd, 'tell newMessage\n')
593
os.write(fd, 'make new to recipient with properties'
594
' {address:"%s"}\n' % to)
595
if from_ is not None:
596
# though from_ doesn't actually seem to be used
597
os.write(fd, 'set sender to "%s"\n'
598
% sender.replace('"', '\\"'))
599
if subject is not None:
600
os.write(fd, 'set subject to "%s"\n'
601
% subject.replace('"', '\\"'))
603
# FIXME: would be nice to prepend the body to the
604
# existing content (e.g., preserve signature), but
605
# can't seem to figure out the right applescript
607
os.write(fd, 'set content to "%s\\n\n"\n' %
608
body.replace('"', '\\"').replace('\n', '\\n'))
610
if attach_path is not None:
611
# FIXME: would be nice to first append a newline to
612
# ensure the attachment is on a new paragraph, but
613
# can't seem to figure out the right applescript
615
os.write(fd, 'tell content to make new attachment'
616
' with properties {file name:"%s"}'
617
' at after the last paragraph\n'
618
% self._encode_path(attach_path, 'attachment'))
619
os.write(fd, 'set visible to true\n')
620
os.write(fd, 'end tell\n')
621
os.write(fd, 'end tell\n')
623
os.close(fd) # Just close the handle but do not remove the file.
624
return [self.temp_file]
625
mail_client_registry.register('mail.app', MailApp,
626
help=MailApp.__doc__)
629
class DefaultMail(MailClient):
630
__doc__ = """Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
631
falls back to Editor"""
635
def _mail_client(self):
636
"""Determine the preferred mail client for this platform"""
637
if osutils.supports_mapi():
638
return MAPIClient(self.config)
640
return XDGEmail(self.config)
642
def compose(self, prompt, to, subject, attachment, mime_subtype,
643
extension, basename=None, body=None):
644
"""See MailClient.compose"""
646
return self._mail_client().compose(prompt, to, subject,
647
attachment, mime_subtype,
648
extension, basename, body)
649
except MailClientNotFound:
650
return Editor(self.config).compose(prompt, to, subject,
651
attachment, mime_subtype, extension, body)
653
def compose_merge_request(self, to, subject, directive, basename=None,
655
"""See MailClient.compose_merge_request"""
657
return self._mail_client().compose_merge_request(to, subject,
658
directive, basename=basename, body=body)
659
except MailClientNotFound:
660
return Editor(self.config).compose_merge_request(to, subject,
661
directive, basename=basename, body=body)
662
mail_client_registry.register(u'default', DefaultMail,
663
help=DefaultMail.__doc__)
664
mail_client_registry.default_key = u'default'
666
opt_mail_client = _mod_config.RegistryOption('mail_client',
667
mail_client_registry, help='E-mail client to use.', invalid='error')