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
25
config as _mod_config,
34
mail_client_registry = registry.Registry()
37
class MailClientNotFound(errors.BzrError):
39
_fmt = "Unable to find mail client with the following names:"\
40
" %(mail_command_list_string)s"
42
def __init__(self, mail_command_list):
43
mail_command_list_string = ', '.join(mail_command_list)
44
errors.BzrError.__init__(
45
self, mail_command_list=mail_command_list,
46
mail_command_list_string=mail_command_list_string)
49
class NoMessageSupplied(errors.BzrError):
51
_fmt = "No message supplied."
54
class NoMailAddressSpecified(errors.BzrError):
56
_fmt = "No mail-to address (--mail-to) or output (-o) specified."
59
class MailClient(object):
60
"""A mail client that can send messages with attachements."""
62
def __init__(self, config):
65
def compose(self, prompt, to, subject, attachment, mime_subtype,
66
extension, basename=None, body=None):
67
"""Compose (and possibly send) an email message
69
Must be implemented by subclasses.
71
:param prompt: A message to tell the user what to do. Supported by
72
the Editor client, but ignored by others
73
:param to: The address to send the message to
74
:param subject: The contents of the subject line
75
:param attachment: An email attachment, as a bytestring
76
:param mime_subtype: The attachment is assumed to be a subtype of
77
Text. This allows the precise subtype to be specified, e.g.
78
"plain", "x-patch", etc.
79
:param extension: The file extension associated with the attachment
81
:param basename: The name to use for the attachment, e.g.
84
raise NotImplementedError
86
def compose_merge_request(self, to, subject, directive, basename=None,
88
"""Compose (and possibly send) a merge request
90
:param to: The address to send the request to
91
:param subject: The subject line to use for the request
92
:param directive: A merge directive representing the merge request, as
94
:param basename: The name to use for the attachment, e.g.
97
prompt = self._get_merge_prompt("Please describe these changes:", to,
99
self.compose(prompt, to, subject, directive,
100
'x-patch', '.patch', basename, body)
102
def _get_merge_prompt(self, prompt, to, subject, attachment):
103
"""Generate a prompt string. Overridden by Editor.
105
:param prompt: A string suggesting what user should do
106
:param to: The address the mail will be sent to
107
:param subject: The subject line of the mail
108
:param attachment: The attachment that will be used
113
class Editor(MailClient):
114
__doc__ = """DIY mail client that uses commit message editor"""
118
def _get_merge_prompt(self, prompt, to, subject, attachment):
119
"""See MailClient._get_merge_prompt"""
123
u"%s" % (prompt, to, subject,
124
attachment.decode('utf-8', 'replace')))
126
def compose(self, prompt, to, subject, attachment, mime_subtype,
127
extension, basename=None, body=None):
128
"""See MailClient.compose"""
130
raise NoMailAddressSpecified()
131
body = msgeditor.edit_commit_message(prompt, start_message=body)
133
raise NoMessageSupplied()
134
email_message.EmailMessage.send(self.config,
135
self.config.get('email'),
140
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
with open(attach_path, 'wb') as outfile:
170
outfile.write(attachment)
172
kwargs = {'body': body}
175
self._compose(prompt, to, subject, attach_path, mime_subtype,
178
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
179
extension, body=None, from_=None):
180
"""Invoke a mail client as a commandline process.
182
Overridden by MAPIClient.
183
:param to: The address to send the mail to
184
:param subject: The subject line for the mail
185
:param pathname: The path to the attachment
186
:param mime_subtype: The attachment is assumed to have a major type of
187
"text", but the precise subtype can be specified here
188
:param extension: A file extension (including period) associated with
190
:param body: Optional body text.
191
:param from_: Optional From: header.
193
for name in self._get_client_commands():
194
cmdline = [self._encode_path(name, 'executable')]
196
kwargs = {'body': body}
199
if from_ is not None:
200
kwargs['from_'] = from_
201
cmdline.extend(self._get_compose_commandline(to, subject,
205
subprocess.call(cmdline)
207
if e.errno != errno.ENOENT:
212
raise MailClientNotFound(self._client_commands)
214
def _get_compose_commandline(self, to, subject, attach_path, body):
215
"""Determine the commandline to use for composing a message
217
Implemented by various subclasses
218
:param to: The address to send the mail to
219
:param subject: The subject line for the mail
220
:param attach_path: The path to the attachment
222
raise NotImplementedError
224
def _encode_safe(self, u):
225
"""Encode possible unicode string argument to 8-bit string
226
in user_encoding. Unencodable characters will be replaced
229
:param u: possible unicode string.
230
:return: encoded string if u is unicode, u itself otherwise.
234
def _encode_path(self, path, kind):
235
"""Encode unicode path in user encoding.
237
:param path: possible unicode path.
238
:param kind: path kind ('executable' or 'attachment').
239
:return: encoded path if path is unicode,
240
path itself otherwise.
241
:raise: UnableEncodePath.
246
class ExternalMailClient(BodyExternalMailClient):
247
__doc__ = """An external mail client."""
249
supports_body = False
252
class Evolution(BodyExternalMailClient):
253
__doc__ = """Evolution mail client."""
255
_client_commands = ['evolution']
257
def _get_compose_commandline(self, to, subject, attach_path, body=None):
258
"""See ExternalMailClient._get_compose_commandline"""
260
if subject is not None:
261
message_options['subject'] = subject
262
if attach_path is not None:
263
message_options['attach'] = attach_path
265
message_options['body'] = body
266
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
267
sorted(message_options.items())]
268
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
269
'&'.join(options_list))]
272
mail_client_registry.register('evolution', Evolution,
273
help=Evolution.__doc__)
276
class Mutt(BodyExternalMailClient):
277
__doc__ = """Mutt mail client."""
279
_client_commands = ['mutt']
281
def _get_compose_commandline(self, to, subject, attach_path, body=None):
282
"""See ExternalMailClient._get_compose_commandline"""
284
if subject is not None:
285
message_options.extend(
286
['-s', self._encode_safe(subject)])
287
if attach_path is not None:
288
message_options.extend(
289
['-a', self._encode_path(attach_path, 'attachment')])
291
# Store the temp file object in self, so that it does not get
292
# garbage collected and delete the file before mutt can read it.
293
self._temp_file = tempfile.NamedTemporaryFile(
294
prefix="mutt-body-", suffix=".txt", mode="w+")
295
self._temp_file.write(body)
296
self._temp_file.flush()
297
message_options.extend(['-i', self._temp_file.name])
299
message_options.extend(['--', self._encode_safe(to)])
300
return message_options
303
mail_client_registry.register('mutt', Mutt,
307
class Thunderbird(BodyExternalMailClient):
308
__doc__ = """Mozilla Thunderbird (or Icedove)
310
Note that Thunderbird 1.5 is buggy and does not support setting
311
"to" simultaneously with including a attachment.
313
There is a workaround if no attachment is present, but we always need to
318
'thunderbird', 'mozilla-thunderbird', 'icedove',
319
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
320
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
322
def _get_compose_commandline(self, to, subject, attach_path, body=None):
323
"""See ExternalMailClient._get_compose_commandline"""
326
message_options['to'] = self._encode_safe(to)
327
if subject is not None:
328
message_options['subject'] = self._encode_safe(subject)
329
if attach_path is not None:
330
message_options['attachment'] = urlutils.local_path_to_url(
333
options_list = ['body=%s' %
334
urlutils.quote(self._encode_safe(body))]
337
options_list.extend(["%s='%s'" % (k, v) for k, v in
338
sorted(message_options.items())])
339
return ['-compose', ','.join(options_list)]
342
mail_client_registry.register('thunderbird', Thunderbird,
343
help=Thunderbird.__doc__)
346
class KMail(ExternalMailClient):
347
__doc__ = """KDE mail client."""
349
_client_commands = ['kmail']
351
def _get_compose_commandline(self, to, subject, attach_path):
352
"""See ExternalMailClient._get_compose_commandline"""
354
if subject is not None:
355
message_options.extend(['-s', self._encode_safe(subject)])
356
if attach_path is not None:
357
message_options.extend(
358
['--attach', self._encode_path(attach_path, 'attachment')])
360
message_options.extend([self._encode_safe(to)])
361
return message_options
364
mail_client_registry.register('kmail', KMail,
368
class Claws(ExternalMailClient):
369
__doc__ = """Claws mail client."""
373
_client_commands = ['claws-mail']
375
def _get_compose_commandline(self, to, subject, attach_path, body=None,
377
"""See ExternalMailClient._get_compose_commandline"""
379
if from_ is not None:
380
compose_url.append('from=' + urlutils.quote(from_))
381
if subject is not None:
382
# Don't use urlutils.quote_plus because Claws doesn't seem
383
# to recognise spaces encoded as "+".
385
'subject=' + urlutils.quote(self._encode_safe(subject)))
388
'body=' + urlutils.quote(self._encode_safe(body)))
389
# to must be supplied for the claws-mail --compose syntax to work.
391
raise NoMailAddressSpecified()
392
compose_url = 'mailto:%s?%s' % (
393
self._encode_safe(to), '&'.join(compose_url))
394
# Collect command-line options.
395
message_options = ['--compose', compose_url]
396
if attach_path is not None:
397
message_options.extend(
398
['--attach', self._encode_path(attach_path, 'attachment')])
399
return message_options
401
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
402
extension, body=None, from_=None):
403
"""See ExternalMailClient._compose"""
405
from_ = self.config.get('email')
406
super(Claws, self)._compose(prompt, to, subject, attach_path,
407
mime_subtype, extension, body, from_)
410
mail_client_registry.register('claws', Claws,
414
class XDGEmail(BodyExternalMailClient):
415
__doc__ = """xdg-email attempts to invoke the preferred mail client"""
417
_client_commands = ['xdg-email']
419
def _get_compose_commandline(self, to, subject, attach_path, body=None):
420
"""See ExternalMailClient._get_compose_commandline"""
422
raise NoMailAddressSpecified()
423
commandline = [self._encode_safe(to)]
424
if subject is not None:
425
commandline.extend(['--subject', self._encode_safe(subject)])
426
if attach_path is not None:
427
commandline.extend(['--attach',
428
self._encode_path(attach_path, 'attachment')])
430
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 = br"""(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)
546
mail_client_registry.register('emacsclient', EmacsMail,
547
help=EmacsMail.__doc__)
550
class MAPIClient(BodyExternalMailClient):
551
__doc__ = """Default Windows mail client launched using MAPI."""
553
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
554
extension, body=None):
555
"""See ExternalMailClient._compose.
557
This implementation uses MAPI via the simplemapi ctypes wrapper
559
from .util import simplemapi
561
simplemapi.SendMail(to or '', subject or '', body or '',
563
except simplemapi.MAPIError as e:
564
if e.code != simplemapi.MAPI_USER_ABORT:
565
raise MailClientNotFound(['MAPI supported mail client'
566
' (error %d)' % (e.code,)])
569
mail_client_registry.register('mapi', MAPIClient,
570
help=MAPIClient.__doc__)
573
class MailApp(BodyExternalMailClient):
574
__doc__ = """Use MacOS X's Mail.app for sending email messages.
576
Although it would be nice to use appscript, it's not installed
577
with the shipped Python installations. We instead build an
578
AppleScript and invoke the script using osascript(1). We don't
579
use the _encode_safe() routines as it's not clear what encoding
580
osascript expects the script to be in.
583
_client_commands = ['osascript']
585
def _get_compose_commandline(self, to, subject, attach_path, body=None,
587
"""See ExternalMailClient._get_compose_commandline"""
589
fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
592
os.write(fd, 'tell application "Mail"\n')
593
os.write(fd, 'set newMessage to make new outgoing message\n')
594
os.write(fd, 'tell newMessage\n')
596
os.write(fd, 'make new to recipient with properties'
597
' {address:"%s"}\n' % to)
598
if from_ is not None:
599
# though from_ doesn't actually seem to be used
600
os.write(fd, 'set sender to "%s"\n'
601
% from_.replace('"', '\\"'))
602
if subject is not None:
603
os.write(fd, 'set subject to "%s"\n'
604
% subject.replace('"', '\\"'))
606
# FIXME: would be nice to prepend the body to the
607
# existing content (e.g., preserve signature), but
608
# can't seem to figure out the right applescript
610
os.write(fd, 'set content to "%s\\n\n"\n' %
611
body.replace('"', '\\"').replace('\n', '\\n'))
613
if attach_path is not None:
614
# FIXME: would be nice to first append a newline to
615
# ensure the attachment is on a new paragraph, but
616
# can't seem to figure out the right applescript
618
os.write(fd, 'tell content to make new attachment'
619
' with properties {file name:"%s"}'
620
' at after the last paragraph\n'
621
% self._encode_path(attach_path, 'attachment'))
622
os.write(fd, 'set visible to true\n')
623
os.write(fd, 'end tell\n')
624
os.write(fd, 'end tell\n')
626
os.close(fd) # Just close the handle but do not remove the file.
627
return [self.temp_file]
630
mail_client_registry.register('mail.app', MailApp,
631
help=MailApp.__doc__)
634
class DefaultMail(MailClient):
635
__doc__ = """Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
636
falls back to Editor"""
640
def _mail_client(self):
641
"""Determine the preferred mail client for this platform"""
642
if osutils.supports_mapi():
643
return MAPIClient(self.config)
645
return XDGEmail(self.config)
647
def compose(self, prompt, to, subject, attachment, mime_subtype,
648
extension, basename=None, body=None):
649
"""See MailClient.compose"""
651
return self._mail_client().compose(prompt, to, subject,
652
attachment, mime_subtype,
653
extension, basename, body)
654
except MailClientNotFound:
655
return Editor(self.config).compose(
656
prompt, to, subject, attachment, mime_subtype, extension, body)
658
def compose_merge_request(self, to, subject, directive, basename=None,
660
"""See MailClient.compose_merge_request"""
662
return self._mail_client().compose_merge_request(
663
to, subject, directive, basename=basename, body=body)
664
except MailClientNotFound:
665
return Editor(self.config).compose_merge_request(
666
to, subject, directive, basename=basename, body=body)
669
mail_client_registry.register(u'default', DefaultMail,
670
help=DefaultMail.__doc__)
671
mail_client_registry.default_key = u'default'
673
opt_mail_client = _mod_config.RegistryOption(
674
'mail_client', mail_client_registry, help='E-mail client to use.',