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)
145
mail_client_registry.register('editor', Editor,
149
class BodyExternalMailClient(MailClient):
153
def _get_client_commands(self):
154
"""Provide a list of commands that may invoke the mail client"""
155
if sys.platform == 'win32':
157
return [win32utils.get_app_path(i) for i in self._client_commands]
159
return self._client_commands
161
def compose(self, prompt, to, subject, attachment, mime_subtype,
162
extension, basename=None, body=None):
163
"""See MailClient.compose.
165
Writes the attachment to a temporary file, invokes _compose.
168
basename = 'attachment'
169
pathname = osutils.mkdtemp(prefix='bzr-mail-')
170
attach_path = osutils.pathjoin(pathname, basename + extension)
171
with open(attach_path, 'wb') as outfile:
172
outfile.write(attachment)
174
kwargs = {'body': body}
177
self._compose(prompt, to, subject, attach_path, mime_subtype,
180
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
181
extension, body=None, from_=None):
182
"""Invoke a mail client as a commandline process.
184
Overridden by MAPIClient.
185
:param to: The address to send the mail to
186
:param subject: The subject line for the mail
187
:param pathname: The path to the attachment
188
:param mime_subtype: The attachment is assumed to have a major type of
189
"text", but the precise subtype can be specified here
190
:param extension: A file extension (including period) associated with
192
:param body: Optional body text.
193
:param from_: Optional From: header.
195
for name in self._get_client_commands():
196
cmdline = [self._encode_path(name, 'executable')]
198
kwargs = {'body': body}
201
if from_ is not None:
202
kwargs['from_'] = from_
203
cmdline.extend(self._get_compose_commandline(to, subject,
207
subprocess.call(cmdline)
209
if e.errno != errno.ENOENT:
214
raise MailClientNotFound(self._client_commands)
216
def _get_compose_commandline(self, to, subject, attach_path, body):
217
"""Determine the commandline to use for composing a message
219
Implemented by various subclasses
220
:param to: The address to send the mail to
221
:param subject: The subject line for the mail
222
:param attach_path: The path to the attachment
224
raise NotImplementedError
226
def _encode_safe(self, u):
227
"""Encode possible unicode string argument to 8-bit string
228
in user_encoding. Unencodable characters will be replaced
231
:param u: possible unicode string.
232
:return: encoded string if u is unicode, u itself otherwise.
236
def _encode_path(self, path, kind):
237
"""Encode unicode path in user encoding.
239
:param path: possible unicode path.
240
:param kind: path kind ('executable' or 'attachment').
241
:return: encoded path if path is unicode,
242
path itself otherwise.
243
:raise: UnableEncodePath.
248
class ExternalMailClient(BodyExternalMailClient):
249
__doc__ = """An external mail client."""
251
supports_body = False
254
class Evolution(BodyExternalMailClient):
255
__doc__ = """Evolution mail client."""
257
_client_commands = ['evolution']
259
def _get_compose_commandline(self, to, subject, attach_path, body=None):
260
"""See ExternalMailClient._get_compose_commandline"""
262
if subject is not None:
263
message_options['subject'] = subject
264
if attach_path is not None:
265
message_options['attach'] = attach_path
267
message_options['body'] = body
268
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
269
sorted(message_options.items())]
270
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
271
'&'.join(options_list))]
274
mail_client_registry.register('evolution', Evolution,
275
help=Evolution.__doc__)
278
class Mutt(BodyExternalMailClient):
279
__doc__ = """Mutt mail client."""
281
_client_commands = ['mutt']
283
def _get_compose_commandline(self, to, subject, attach_path, body=None):
284
"""See ExternalMailClient._get_compose_commandline"""
286
if subject is not None:
287
message_options.extend(
288
['-s', self._encode_safe(subject)])
289
if attach_path is not None:
290
message_options.extend(
291
['-a', self._encode_path(attach_path, 'attachment')])
293
# Store the temp file object in self, so that it does not get
294
# garbage collected and delete the file before mutt can read it.
295
self._temp_file = tempfile.NamedTemporaryFile(
296
prefix="mutt-body-", suffix=".txt", mode="w+")
297
self._temp_file.write(body)
298
self._temp_file.flush()
299
message_options.extend(['-i', self._temp_file.name])
301
message_options.extend(['--', self._encode_safe(to)])
302
return message_options
305
mail_client_registry.register('mutt', Mutt,
309
class Thunderbird(BodyExternalMailClient):
310
__doc__ = """Mozilla Thunderbird (or Icedove)
312
Note that Thunderbird 1.5 is buggy and does not support setting
313
"to" simultaneously with including a attachment.
315
There is a workaround if no attachment is present, but we always need to
320
'thunderbird', 'mozilla-thunderbird', 'icedove',
321
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
322
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
324
def _get_compose_commandline(self, to, subject, attach_path, body=None):
325
"""See ExternalMailClient._get_compose_commandline"""
328
message_options['to'] = self._encode_safe(to)
329
if subject is not None:
330
message_options['subject'] = self._encode_safe(subject)
331
if attach_path is not None:
332
message_options['attachment'] = urlutils.local_path_to_url(
335
options_list = ['body=%s' %
336
urlutils.quote(self._encode_safe(body))]
339
options_list.extend(["%s='%s'" % (k, v) for k, v in
340
sorted(message_options.items())])
341
return ['-compose', ','.join(options_list)]
344
mail_client_registry.register('thunderbird', Thunderbird,
345
help=Thunderbird.__doc__)
348
class KMail(ExternalMailClient):
349
__doc__ = """KDE mail client."""
351
_client_commands = ['kmail']
353
def _get_compose_commandline(self, to, subject, attach_path):
354
"""See ExternalMailClient._get_compose_commandline"""
356
if subject is not None:
357
message_options.extend(['-s', self._encode_safe(subject)])
358
if attach_path is not None:
359
message_options.extend(
360
['--attach', self._encode_path(attach_path, 'attachment')])
362
message_options.extend([self._encode_safe(to)])
363
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 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)])
436
mail_client_registry.register('xdg-email', XDGEmail,
437
help=XDGEmail.__doc__)
440
class EmacsMail(ExternalMailClient):
441
__doc__ = """Call emacsclient to have a mail buffer.
443
This only work for emacs >= 22.1 due to recent -e/--eval support.
445
The good news is that this implementation will work with all mail
446
agents registered against ``mail-user-agent``. So there is no need
447
to instantiate ExternalMailClient for each and every GNU Emacs
450
Users just have to ensure that ``mail-user-agent`` is set according
454
_client_commands = ['emacsclient']
456
def __init__(self, config):
457
super(EmacsMail, self).__init__(config)
458
self.elisp_tmp_file = None
460
def _prepare_send_function(self):
461
"""Write our wrapper function into a temporary file.
463
This temporary file will be loaded at runtime in
464
_get_compose_commandline function.
466
This function does not remove the file. That's a wanted
467
behaviour since _get_compose_commandline won't run the send
468
mail function directly but return the eligible command line.
469
Removing our temporary file here would prevent our sendmail
470
function to work. (The file is deleted by some elisp code
471
after being read by Emacs.)
474
_defun = br"""(defun bzr-add-mime-att (file)
475
"Attach FILE to a mail buffer as a MIME attachment."
476
(let ((agent mail-user-agent))
477
(if (and file (file-exists-p file))
479
((eq agent 'sendmail-user-agent)
483
(if (functionp 'etach-attach)
485
(mail-attach-file file))))
486
((or (eq agent 'message-user-agent)
487
(eq agent 'gnus-user-agent)
488
(eq agent 'mh-e-user-agent))
490
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
491
((eq agent 'mew-user-agent)
493
(mew-draft-prepare-attachments)
494
(mew-attach-link file (file-name-nondirectory file))
495
(let* ((nums (mew-syntax-nums))
496
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
497
(mew-syntax-set-cd syntax "BZR merge")
498
(mew-encode-syntax-print mew-encode-syntax))
499
(mew-header-goto-body)))
501
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
502
(error "File %s does not exist." file))))
505
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
510
os.close(fd) # Just close the handle but do not remove the file.
513
def _get_compose_commandline(self, to, subject, attach_path):
514
commandline = ["--eval"]
520
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
521
if subject is not None:
522
_subject = ("\"%s\"" %
523
self._encode_safe(subject).replace('"', '\\"'))
525
# Funcall the default mail composition function
526
# This will work with any mail mode including default mail-mode
527
# User must tweak mail-user-agent variable to tell what function
528
# will be called inside compose-mail.
529
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
530
commandline.append(mail_cmd)
532
# Try to attach a MIME attachment using our wrapper function
533
if attach_path is not None:
534
# Do not create a file if there is no attachment
535
elisp = self._prepare_send_function()
536
self.elisp_tmp_file = elisp
537
lmmform = '(load "%s")' % elisp
538
mmform = '(bzr-add-mime-att "%s")' % \
539
self._encode_path(attach_path, 'attachment')
540
rmform = '(delete-file "%s")' % elisp
541
commandline.append(lmmform)
542
commandline.append(mmform)
543
commandline.append(rmform)
548
mail_client_registry.register('emacsclient', EmacsMail,
549
help=EmacsMail.__doc__)
552
class MAPIClient(BodyExternalMailClient):
553
__doc__ = """Default Windows mail client launched using MAPI."""
555
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
556
extension, body=None):
557
"""See ExternalMailClient._compose.
559
This implementation uses MAPI via the simplemapi ctypes wrapper
561
from .util import simplemapi
563
simplemapi.SendMail(to or '', subject or '', body or '',
565
except simplemapi.MAPIError as e:
566
if e.code != simplemapi.MAPI_USER_ABORT:
567
raise MailClientNotFound(['MAPI supported mail client'
568
' (error %d)' % (e.code,)])
571
mail_client_registry.register('mapi', MAPIClient,
572
help=MAPIClient.__doc__)
575
class MailApp(BodyExternalMailClient):
576
__doc__ = """Use MacOS X's Mail.app for sending email messages.
578
Although it would be nice to use appscript, it's not installed
579
with the shipped Python installations. We instead build an
580
AppleScript and invoke the script using osascript(1). We don't
581
use the _encode_safe() routines as it's not clear what encoding
582
osascript expects the script to be in.
585
_client_commands = ['osascript']
587
def _get_compose_commandline(self, to, subject, attach_path, body=None,
589
"""See ExternalMailClient._get_compose_commandline"""
591
fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
594
os.write(fd, 'tell application "Mail"\n')
595
os.write(fd, 'set newMessage to make new outgoing message\n')
596
os.write(fd, 'tell newMessage\n')
598
os.write(fd, 'make new to recipient with properties'
599
' {address:"%s"}\n' % to)
600
if from_ is not None:
601
# though from_ doesn't actually seem to be used
602
os.write(fd, 'set sender to "%s"\n'
603
% from_.replace('"', '\\"'))
604
if subject is not None:
605
os.write(fd, 'set subject to "%s"\n'
606
% subject.replace('"', '\\"'))
608
# FIXME: would be nice to prepend the body to the
609
# existing content (e.g., preserve signature), but
610
# can't seem to figure out the right applescript
612
os.write(fd, 'set content to "%s\\n\n"\n' %
613
body.replace('"', '\\"').replace('\n', '\\n'))
615
if attach_path is not None:
616
# FIXME: would be nice to first append a newline to
617
# ensure the attachment is on a new paragraph, but
618
# can't seem to figure out the right applescript
620
os.write(fd, 'tell content to make new attachment'
621
' with properties {file name:"%s"}'
622
' at after the last paragraph\n'
623
% self._encode_path(attach_path, 'attachment'))
624
os.write(fd, 'set visible to true\n')
625
os.write(fd, 'end tell\n')
626
os.write(fd, 'end tell\n')
628
os.close(fd) # Just close the handle but do not remove the file.
629
return [self.temp_file]
632
mail_client_registry.register('mail.app', MailApp,
633
help=MailApp.__doc__)
636
class DefaultMail(MailClient):
637
__doc__ = """Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
638
falls back to Editor"""
642
def _mail_client(self):
643
"""Determine the preferred mail client for this platform"""
644
if osutils.supports_mapi():
645
return MAPIClient(self.config)
647
return XDGEmail(self.config)
649
def compose(self, prompt, to, subject, attachment, mime_subtype,
650
extension, basename=None, body=None):
651
"""See MailClient.compose"""
653
return self._mail_client().compose(prompt, to, subject,
654
attachment, mime_subtype,
655
extension, basename, body)
656
except MailClientNotFound:
657
return Editor(self.config).compose(
658
prompt, to, subject, attachment, mime_subtype, extension, body)
660
def compose_merge_request(self, to, subject, directive, basename=None,
662
"""See MailClient.compose_merge_request"""
664
return self._mail_client().compose_merge_request(
665
to, subject, directive, basename=basename, body=body)
666
except MailClientNotFound:
667
return Editor(self.config).compose_merge_request(
668
to, subject, directive, basename=basename, body=body)
671
mail_client_registry.register(u'default', DefaultMail,
672
help=DefaultMail.__doc__)
673
mail_client_registry.default_key = u'default'
675
opt_mail_client = _mod_config.RegistryOption(
676
'mail_client', mail_client_registry, help='E-mail client to use.',