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
outfile = open(attach_path, 'wb')
177
outfile.write(attachment)
181
kwargs = {'body': body}
184
self._compose(prompt, to, subject, attach_path, mime_subtype,
187
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
188
extension, body=None, from_=None):
189
"""Invoke a mail client as a commandline process.
191
Overridden by MAPIClient.
192
:param to: The address to send the mail to
193
:param subject: The subject line for the mail
194
:param pathname: The path to the attachment
195
:param mime_subtype: The attachment is assumed to have a major type of
196
"text", but the precise subtype can be specified here
197
:param extension: A file extension (including period) associated with
199
:param body: Optional body text.
200
:param from_: Optional From: header.
202
for name in self._get_client_commands():
203
cmdline = [self._encode_path(name, 'executable')]
205
kwargs = {'body': body}
208
if from_ is not None:
209
kwargs['from_'] = from_
210
cmdline.extend(self._get_compose_commandline(to, subject,
214
subprocess.call(cmdline)
216
if e.errno != errno.ENOENT:
221
raise MailClientNotFound(self._client_commands)
223
def _get_compose_commandline(self, to, subject, attach_path, body):
224
"""Determine the commandline to use for composing a message
226
Implemented by various subclasses
227
:param to: The address to send the mail to
228
:param subject: The subject line for the mail
229
:param attach_path: The path to the attachment
231
raise NotImplementedError
233
def _encode_safe(self, u):
234
"""Encode possible unicode string argument to 8-bit string
235
in user_encoding. Unencodable characters will be replaced
238
:param u: possible unicode string.
239
:return: encoded string if u is unicode, u itself otherwise.
241
if not PY3 and isinstance(u, text_type):
242
return u.encode(osutils.get_user_encoding(), 'replace')
245
def _encode_path(self, path, kind):
246
"""Encode unicode path in user encoding.
248
:param path: possible unicode path.
249
:param kind: path kind ('executable' or 'attachment').
250
:return: encoded path if path is unicode,
251
path itself otherwise.
252
:raise: UnableEncodePath.
254
if not PY3 and isinstance(path, text_type):
256
return path.encode(osutils.get_user_encoding())
257
except UnicodeEncodeError:
258
raise errors.UnableEncodePath(path, kind)
262
class ExternalMailClient(BodyExternalMailClient):
263
__doc__ = """An external mail client."""
265
supports_body = False
268
class Evolution(BodyExternalMailClient):
269
__doc__ = """Evolution mail client."""
271
_client_commands = ['evolution']
273
def _get_compose_commandline(self, to, subject, attach_path, body=None):
274
"""See ExternalMailClient._get_compose_commandline"""
276
if subject is not None:
277
message_options['subject'] = subject
278
if attach_path is not None:
279
message_options['attach'] = attach_path
281
message_options['body'] = body
282
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
283
sorted(message_options.items())]
284
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
285
'&'.join(options_list))]
288
mail_client_registry.register('evolution', Evolution,
289
help=Evolution.__doc__)
292
class Mutt(BodyExternalMailClient):
293
__doc__ = """Mutt mail client."""
295
_client_commands = ['mutt']
297
def _get_compose_commandline(self, to, subject, attach_path, body=None):
298
"""See ExternalMailClient._get_compose_commandline"""
300
if subject is not None:
301
message_options.extend(
302
['-s', self._encode_safe(subject)])
303
if attach_path is not None:
304
message_options.extend(
305
['-a', self._encode_path(attach_path, 'attachment')])
307
# Store the temp file object in self, so that it does not get
308
# garbage collected and delete the file before mutt can read it.
309
self._temp_file = tempfile.NamedTemporaryFile(
310
prefix="mutt-body-", suffix=".txt", mode="w+")
311
self._temp_file.write(body)
312
self._temp_file.flush()
313
message_options.extend(['-i', self._temp_file.name])
315
message_options.extend(['--', self._encode_safe(to)])
316
return message_options
319
mail_client_registry.register('mutt', Mutt,
323
class Thunderbird(BodyExternalMailClient):
324
__doc__ = """Mozilla Thunderbird (or Icedove)
326
Note that Thunderbird 1.5 is buggy and does not support setting
327
"to" simultaneously with including a attachment.
329
There is a workaround if no attachment is present, but we always need to
334
'thunderbird', 'mozilla-thunderbird', 'icedove',
335
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
336
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
338
def _get_compose_commandline(self, to, subject, attach_path, body=None):
339
"""See ExternalMailClient._get_compose_commandline"""
342
message_options['to'] = self._encode_safe(to)
343
if subject is not None:
344
message_options['subject'] = self._encode_safe(subject)
345
if attach_path is not None:
346
message_options['attachment'] = urlutils.local_path_to_url(
349
options_list = ['body=%s' %
350
urlutils.quote(self._encode_safe(body))]
353
options_list.extend(["%s='%s'" % (k, v) for k, v in
354
sorted(message_options.items())])
355
return ['-compose', ','.join(options_list)]
358
mail_client_registry.register('thunderbird', Thunderbird,
359
help=Thunderbird.__doc__)
362
class KMail(ExternalMailClient):
363
__doc__ = """KDE mail client."""
365
_client_commands = ['kmail']
367
def _get_compose_commandline(self, to, subject, attach_path):
368
"""See ExternalMailClient._get_compose_commandline"""
370
if subject is not None:
371
message_options.extend(['-s', self._encode_safe(subject)])
372
if attach_path is not None:
373
message_options.extend(
374
['--attach', self._encode_path(attach_path, 'attachment')])
376
message_options.extend([self._encode_safe(to)])
377
return message_options
380
mail_client_registry.register('kmail', KMail,
384
class Claws(ExternalMailClient):
385
__doc__ = """Claws mail client."""
389
_client_commands = ['claws-mail']
391
def _get_compose_commandline(self, to, subject, attach_path, body=None,
393
"""See ExternalMailClient._get_compose_commandline"""
395
if from_ is not None:
396
compose_url.append('from=' + urlutils.quote(from_))
397
if subject is not None:
398
# Don't use urlutils.quote_plus because Claws doesn't seem
399
# to recognise spaces encoded as "+".
401
'subject=' + urlutils.quote(self._encode_safe(subject)))
404
'body=' + urlutils.quote(self._encode_safe(body)))
405
# to must be supplied for the claws-mail --compose syntax to work.
407
raise NoMailAddressSpecified()
408
compose_url = 'mailto:%s?%s' % (
409
self._encode_safe(to), '&'.join(compose_url))
410
# Collect command-line options.
411
message_options = ['--compose', compose_url]
412
if attach_path is not None:
413
message_options.extend(
414
['--attach', self._encode_path(attach_path, 'attachment')])
415
return message_options
417
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
418
extension, body=None, from_=None):
419
"""See ExternalMailClient._compose"""
421
from_ = self.config.get('email')
422
super(Claws, self)._compose(prompt, to, subject, attach_path,
423
mime_subtype, extension, body, from_)
426
mail_client_registry.register('claws', Claws,
430
class XDGEmail(BodyExternalMailClient):
431
__doc__ = """xdg-email attempts to invoke the preferred mail client"""
433
_client_commands = ['xdg-email']
435
def _get_compose_commandline(self, to, subject, attach_path, body=None):
436
"""See ExternalMailClient._get_compose_commandline"""
438
raise NoMailAddressSpecified()
439
commandline = [self._encode_safe(to)]
440
if subject is not None:
441
commandline.extend(['--subject', self._encode_safe(subject)])
442
if attach_path is not None:
443
commandline.extend(['--attach',
444
self._encode_path(attach_path, 'attachment')])
446
commandline.extend(['--body', self._encode_safe(body)])
450
mail_client_registry.register('xdg-email', XDGEmail,
451
help=XDGEmail.__doc__)
454
class EmacsMail(ExternalMailClient):
455
__doc__ = """Call emacsclient to have a mail buffer.
457
This only work for emacs >= 22.1 due to recent -e/--eval support.
459
The good news is that this implementation will work with all mail
460
agents registered against ``mail-user-agent``. So there is no need
461
to instantiate ExternalMailClient for each and every GNU Emacs
464
Users just have to ensure that ``mail-user-agent`` is set according
468
_client_commands = ['emacsclient']
470
def __init__(self, config):
471
super(EmacsMail, self).__init__(config)
472
self.elisp_tmp_file = None
474
def _prepare_send_function(self):
475
"""Write our wrapper function into a temporary file.
477
This temporary file will be loaded at runtime in
478
_get_compose_commandline function.
480
This function does not remove the file. That's a wanted
481
behaviour since _get_compose_commandline won't run the send
482
mail function directly but return the eligible command line.
483
Removing our temporary file here would prevent our sendmail
484
function to work. (The file is deleted by some elisp code
485
after being read by Emacs.)
488
_defun = br"""(defun bzr-add-mime-att (file)
489
"Attach FILE to a mail buffer as a MIME attachment."
490
(let ((agent mail-user-agent))
491
(if (and file (file-exists-p file))
493
((eq agent 'sendmail-user-agent)
497
(if (functionp 'etach-attach)
499
(mail-attach-file file))))
500
((or (eq agent 'message-user-agent)
501
(eq agent 'gnus-user-agent)
502
(eq agent 'mh-e-user-agent))
504
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
505
((eq agent 'mew-user-agent)
507
(mew-draft-prepare-attachments)
508
(mew-attach-link file (file-name-nondirectory file))
509
(let* ((nums (mew-syntax-nums))
510
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
511
(mew-syntax-set-cd syntax "BZR merge")
512
(mew-encode-syntax-print mew-encode-syntax))
513
(mew-header-goto-body)))
515
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
516
(error "File %s does not exist." file))))
519
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
524
os.close(fd) # Just close the handle but do not remove the file.
527
def _get_compose_commandline(self, to, subject, attach_path):
528
commandline = ["--eval"]
534
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
535
if subject is not None:
536
_subject = ("\"%s\"" %
537
self._encode_safe(subject).replace('"', '\\"'))
539
# Funcall the default mail composition function
540
# This will work with any mail mode including default mail-mode
541
# User must tweak mail-user-agent variable to tell what function
542
# will be called inside compose-mail.
543
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
544
commandline.append(mail_cmd)
546
# Try to attach a MIME attachment using our wrapper function
547
if attach_path is not None:
548
# Do not create a file if there is no attachment
549
elisp = self._prepare_send_function()
550
self.elisp_tmp_file = elisp
551
lmmform = '(load "%s")' % elisp
552
mmform = '(bzr-add-mime-att "%s")' % \
553
self._encode_path(attach_path, 'attachment')
554
rmform = '(delete-file "%s")' % elisp
555
commandline.append(lmmform)
556
commandline.append(mmform)
557
commandline.append(rmform)
562
mail_client_registry.register('emacsclient', EmacsMail,
563
help=EmacsMail.__doc__)
566
class MAPIClient(BodyExternalMailClient):
567
__doc__ = """Default Windows mail client launched using MAPI."""
569
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
570
extension, body=None):
571
"""See ExternalMailClient._compose.
573
This implementation uses MAPI via the simplemapi ctypes wrapper
575
from .util import simplemapi
577
simplemapi.SendMail(to or '', subject or '', body or '',
579
except simplemapi.MAPIError as e:
580
if e.code != simplemapi.MAPI_USER_ABORT:
581
raise MailClientNotFound(['MAPI supported mail client'
582
' (error %d)' % (e.code,)])
585
mail_client_registry.register('mapi', MAPIClient,
586
help=MAPIClient.__doc__)
589
class MailApp(BodyExternalMailClient):
590
__doc__ = """Use MacOS X's Mail.app for sending email messages.
592
Although it would be nice to use appscript, it's not installed
593
with the shipped Python installations. We instead build an
594
AppleScript and invoke the script using osascript(1). We don't
595
use the _encode_safe() routines as it's not clear what encoding
596
osascript expects the script to be in.
599
_client_commands = ['osascript']
601
def _get_compose_commandline(self, to, subject, attach_path, body=None,
603
"""See ExternalMailClient._get_compose_commandline"""
605
fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
608
os.write(fd, 'tell application "Mail"\n')
609
os.write(fd, 'set newMessage to make new outgoing message\n')
610
os.write(fd, 'tell newMessage\n')
612
os.write(fd, 'make new to recipient with properties'
613
' {address:"%s"}\n' % to)
614
if from_ is not None:
615
# though from_ doesn't actually seem to be used
616
os.write(fd, 'set sender to "%s"\n'
617
% from_.replace('"', '\\"'))
618
if subject is not None:
619
os.write(fd, 'set subject to "%s"\n'
620
% subject.replace('"', '\\"'))
622
# FIXME: would be nice to prepend the body to the
623
# existing content (e.g., preserve signature), but
624
# can't seem to figure out the right applescript
626
os.write(fd, 'set content to "%s\\n\n"\n' %
627
body.replace('"', '\\"').replace('\n', '\\n'))
629
if attach_path is not None:
630
# FIXME: would be nice to first append a newline to
631
# ensure the attachment is on a new paragraph, but
632
# can't seem to figure out the right applescript
634
os.write(fd, 'tell content to make new attachment'
635
' with properties {file name:"%s"}'
636
' at after the last paragraph\n'
637
% self._encode_path(attach_path, 'attachment'))
638
os.write(fd, 'set visible to true\n')
639
os.write(fd, 'end tell\n')
640
os.write(fd, 'end tell\n')
642
os.close(fd) # Just close the handle but do not remove the file.
643
return [self.temp_file]
646
mail_client_registry.register('mail.app', MailApp,
647
help=MailApp.__doc__)
650
class DefaultMail(MailClient):
651
__doc__ = """Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
652
falls back to Editor"""
656
def _mail_client(self):
657
"""Determine the preferred mail client for this platform"""
658
if osutils.supports_mapi():
659
return MAPIClient(self.config)
661
return XDGEmail(self.config)
663
def compose(self, prompt, to, subject, attachment, mime_subtype,
664
extension, basename=None, body=None):
665
"""See MailClient.compose"""
667
return self._mail_client().compose(prompt, to, subject,
668
attachment, mime_subtype,
669
extension, basename, body)
670
except MailClientNotFound:
671
return Editor(self.config).compose(
672
prompt, to, subject, attachment, mime_subtype, extension, body)
674
def compose_merge_request(self, to, subject, directive, basename=None,
676
"""See MailClient.compose_merge_request"""
678
return self._mail_client().compose_merge_request(
679
to, subject, directive, basename=basename, body=body)
680
except MailClientNotFound:
681
return Editor(self.config).compose_merge_request(
682
to, subject, directive, basename=basename, body=body)
685
mail_client_registry.register(u'default', DefaultMail,
686
help=DefaultMail.__doc__)
687
mail_client_registry.default_key = u'default'
689
opt_mail_client = _mod_config.RegistryOption(
690
'mail_client', mail_client_registry, help='E-mail client to use.',