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
35
mail_client_registry = registry.Registry()
38
class MailClient(object):
39
"""A mail client that can send messages with attachements."""
41
def __init__(self, config):
44
def compose(self, prompt, to, subject, attachment, mime_subtype,
45
extension, basename=None, body=None):
46
"""Compose (and possibly send) an email message
48
Must be implemented by subclasses.
50
:param prompt: A message to tell the user what to do. Supported by
51
the Editor client, but ignored by others
52
:param to: The address to send the message to
53
:param subject: The contents of the subject line
54
:param attachment: An email attachment, as a bytestring
55
:param mime_subtype: The attachment is assumed to be a subtype of
56
Text. This allows the precise subtype to be specified, e.g.
57
"plain", "x-patch", etc.
58
:param extension: The file extension associated with the attachment
60
:param basename: The name to use for the attachment, e.g.
63
raise NotImplementedError
65
def compose_merge_request(self, to, subject, directive, basename=None,
67
"""Compose (and possibly send) a merge request
69
:param to: The address to send the request to
70
:param subject: The subject line to use for the request
71
:param directive: A merge directive representing the merge request, as
73
:param basename: The name to use for the attachment, e.g.
76
prompt = self._get_merge_prompt("Please describe these changes:", to,
78
self.compose(prompt, to, subject, directive,
79
'x-patch', '.patch', basename, body)
81
def _get_merge_prompt(self, prompt, to, subject, attachment):
82
"""Generate a prompt string. Overridden by Editor.
84
:param prompt: A string suggesting what user should do
85
:param to: The address the mail will be sent to
86
:param subject: The subject line of the mail
87
:param attachment: The attachment that will be used
92
class Editor(MailClient):
93
__doc__ = """DIY mail client that uses commit message editor"""
97
def _get_merge_prompt(self, prompt, to, subject, attachment):
98
"""See MailClient._get_merge_prompt"""
102
u"%s" % (prompt, to, subject,
103
attachment.decode('utf-8', 'replace')))
105
def compose(self, prompt, to, subject, attachment, mime_subtype,
106
extension, basename=None, body=None):
107
"""See MailClient.compose"""
109
raise errors.NoMailAddressSpecified()
110
body = msgeditor.edit_commit_message(prompt, start_message=body)
112
raise errors.NoMessageSupplied()
113
email_message.EmailMessage.send(self.config,
114
self.config.username(),
119
attachment_mime_subtype=mime_subtype)
120
mail_client_registry.register('editor', Editor,
124
class BodyExternalMailClient(MailClient):
128
def _get_client_commands(self):
129
"""Provide a list of commands that may invoke the mail client"""
130
if sys.platform == 'win32':
132
return [win32utils.get_app_path(i) for i in self._client_commands]
134
return self._client_commands
136
def compose(self, prompt, to, subject, attachment, mime_subtype,
137
extension, basename=None, body=None):
138
"""See MailClient.compose.
140
Writes the attachment to a temporary file, invokes _compose.
143
basename = 'attachment'
144
pathname = osutils.mkdtemp(prefix='bzr-mail-')
145
attach_path = osutils.pathjoin(pathname, basename + extension)
146
outfile = open(attach_path, 'wb')
148
outfile.write(attachment)
152
kwargs = {'body': body}
155
self._compose(prompt, to, subject, attach_path, mime_subtype,
158
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
159
extension, body=None, from_=None):
160
"""Invoke a mail client as a commandline process.
162
Overridden by MAPIClient.
163
:param to: The address to send the mail to
164
:param subject: The subject line for the mail
165
:param pathname: The path to the attachment
166
:param mime_subtype: The attachment is assumed to have a major type of
167
"text", but the precise subtype can be specified here
168
:param extension: A file extension (including period) associated with
170
:param body: Optional body text.
171
:param from_: Optional From: header.
173
for name in self._get_client_commands():
174
cmdline = [self._encode_path(name, 'executable')]
176
kwargs = {'body': body}
179
if from_ is not None:
180
kwargs['from_'] = from_
181
cmdline.extend(self._get_compose_commandline(to, subject,
185
subprocess.call(cmdline)
187
if e.errno != errno.ENOENT:
192
raise errors.MailClientNotFound(self._client_commands)
194
def _get_compose_commandline(self, to, subject, attach_path, body):
195
"""Determine the commandline to use for composing a message
197
Implemented by various subclasses
198
:param to: The address to send the mail to
199
:param subject: The subject line for the mail
200
:param attach_path: The path to the attachment
202
raise NotImplementedError
204
def _encode_safe(self, u):
205
"""Encode possible unicode string argument to 8-bit string
206
in user_encoding. Unencodable characters will be replaced
209
:param u: possible unicode string.
210
:return: encoded string if u is unicode, u itself otherwise.
212
if isinstance(u, unicode):
213
return u.encode(osutils.get_user_encoding(), 'replace')
216
def _encode_path(self, path, kind):
217
"""Encode unicode path in user encoding.
219
:param path: possible unicode path.
220
:param kind: path kind ('executable' or 'attachment').
221
:return: encoded path if path is unicode,
222
path itself otherwise.
223
:raise: UnableEncodePath.
225
if isinstance(path, unicode):
227
return path.encode(osutils.get_user_encoding())
228
except UnicodeEncodeError:
229
raise errors.UnableEncodePath(path, kind)
233
class ExternalMailClient(BodyExternalMailClient):
234
__doc__ = """An external mail client."""
236
supports_body = False
239
class Evolution(BodyExternalMailClient):
240
__doc__ = """Evolution mail client."""
242
_client_commands = ['evolution']
244
def _get_compose_commandline(self, to, subject, attach_path, body=None):
245
"""See ExternalMailClient._get_compose_commandline"""
247
if subject is not None:
248
message_options['subject'] = subject
249
if attach_path is not None:
250
message_options['attach'] = attach_path
252
message_options['body'] = body
253
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
254
sorted(message_options.iteritems())]
255
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
256
'&'.join(options_list))]
257
mail_client_registry.register('evolution', Evolution,
258
help=Evolution.__doc__)
261
class Mutt(BodyExternalMailClient):
262
__doc__ = """Mutt mail client."""
264
_client_commands = ['mutt']
266
def _get_compose_commandline(self, to, subject, attach_path, body=None):
267
"""See ExternalMailClient._get_compose_commandline"""
269
if subject is not None:
270
message_options.extend(['-s', self._encode_safe(subject)])
271
if attach_path is not None:
272
message_options.extend(['-a',
273
self._encode_path(attach_path, 'attachment')])
275
# Store the temp file object in self, so that it does not get
276
# garbage collected and delete the file before mutt can read it.
277
self._temp_file = tempfile.NamedTemporaryFile(
278
prefix="mutt-body-", suffix=".txt")
279
self._temp_file.write(body)
280
self._temp_file.flush()
281
message_options.extend(['-i', self._temp_file.name])
283
message_options.extend(['--', self._encode_safe(to)])
284
return message_options
285
mail_client_registry.register('mutt', Mutt,
289
class Thunderbird(BodyExternalMailClient):
290
__doc__ = """Mozilla Thunderbird (or Icedove)
292
Note that Thunderbird 1.5 is buggy and does not support setting
293
"to" simultaneously with including a attachment.
295
There is a workaround if no attachment is present, but we always need to
299
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
300
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
301
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
303
def _get_compose_commandline(self, to, subject, attach_path, body=None):
304
"""See ExternalMailClient._get_compose_commandline"""
307
message_options['to'] = self._encode_safe(to)
308
if subject is not None:
309
message_options['subject'] = self._encode_safe(subject)
310
if attach_path is not None:
311
message_options['attachment'] = urlutils.local_path_to_url(
314
options_list = ['body=%s' % urlutils.quote(self._encode_safe(body))]
317
options_list.extend(["%s='%s'" % (k, v) for k, v in
318
sorted(message_options.iteritems())])
319
return ['-compose', ','.join(options_list)]
320
mail_client_registry.register('thunderbird', Thunderbird,
321
help=Thunderbird.__doc__)
324
class KMail(ExternalMailClient):
325
__doc__ = """KDE mail client."""
327
_client_commands = ['kmail']
329
def _get_compose_commandline(self, to, subject, attach_path):
330
"""See ExternalMailClient._get_compose_commandline"""
332
if subject is not None:
333
message_options.extend(['-s', self._encode_safe(subject)])
334
if attach_path is not None:
335
message_options.extend(['--attach',
336
self._encode_path(attach_path, 'attachment')])
338
message_options.extend([self._encode_safe(to)])
339
return message_options
340
mail_client_registry.register('kmail', KMail,
344
class Claws(ExternalMailClient):
345
__doc__ = """Claws mail client."""
349
_client_commands = ['claws-mail']
351
def _get_compose_commandline(self, to, subject, attach_path, body=None,
353
"""See ExternalMailClient._get_compose_commandline"""
355
if from_ is not None:
356
compose_url.append('from=' + urlutils.quote(from_))
357
if subject is not None:
358
# Don't use urlutils.quote_plus because Claws doesn't seem
359
# to recognise spaces encoded as "+".
361
'subject=' + urlutils.quote(self._encode_safe(subject)))
364
'body=' + urlutils.quote(self._encode_safe(body)))
365
# to must be supplied for the claws-mail --compose syntax to work.
367
raise errors.NoMailAddressSpecified()
368
compose_url = 'mailto:%s?%s' % (
369
self._encode_safe(to), '&'.join(compose_url))
370
# Collect command-line options.
371
message_options = ['--compose', compose_url]
372
if attach_path is not None:
373
message_options.extend(
374
['--attach', self._encode_path(attach_path, 'attachment')])
375
return message_options
377
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
378
extension, body=None, from_=None):
379
"""See ExternalMailClient._compose"""
381
from_ = self.config.get_user_option('email')
382
super(Claws, self)._compose(prompt, to, subject, attach_path,
383
mime_subtype, extension, body, from_)
386
mail_client_registry.register('claws', Claws,
390
class XDGEmail(BodyExternalMailClient):
391
__doc__ = """xdg-email attempts to invoke the user's preferred mail client"""
393
_client_commands = ['xdg-email']
395
def _get_compose_commandline(self, to, subject, attach_path, body=None):
396
"""See ExternalMailClient._get_compose_commandline"""
398
raise errors.NoMailAddressSpecified()
399
commandline = [self._encode_safe(to)]
400
if subject is not None:
401
commandline.extend(['--subject', self._encode_safe(subject)])
402
if attach_path is not None:
403
commandline.extend(['--attach',
404
self._encode_path(attach_path, 'attachment')])
406
commandline.extend(['--body', self._encode_safe(body)])
408
mail_client_registry.register('xdg-email', XDGEmail,
409
help=XDGEmail.__doc__)
412
class EmacsMail(ExternalMailClient):
413
__doc__ = """Call emacsclient to have a mail buffer.
415
This only work for emacs >= 22.1 due to recent -e/--eval support.
417
The good news is that this implementation will work with all mail
418
agents registered against ``mail-user-agent``. So there is no need
419
to instantiate ExternalMailClient for each and every GNU Emacs
422
Users just have to ensure that ``mail-user-agent`` is set according
426
_client_commands = ['emacsclient']
428
def __init__(self, config):
429
super(EmacsMail, self).__init__(config)
430
self.elisp_tmp_file = None
432
def _prepare_send_function(self):
433
"""Write our wrapper function into a temporary file.
435
This temporary file will be loaded at runtime in
436
_get_compose_commandline function.
438
This function does not remove the file. That's a wanted
439
behaviour since _get_compose_commandline won't run the send
440
mail function directly but return the eligible command line.
441
Removing our temporary file here would prevent our sendmail
442
function to work. (The file is deleted by some elisp code
443
after being read by Emacs.)
446
_defun = r"""(defun bzr-add-mime-att (file)
447
"Attach FILE to a mail buffer as a MIME attachment."
448
(let ((agent mail-user-agent))
449
(if (and file (file-exists-p file))
451
((eq agent 'sendmail-user-agent)
455
(if (functionp 'etach-attach)
457
(mail-attach-file file))))
458
((or (eq agent 'message-user-agent)
459
(eq agent 'gnus-user-agent)
460
(eq agent 'mh-e-user-agent))
462
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
463
((eq agent 'mew-user-agent)
465
(mew-draft-prepare-attachments)
466
(mew-attach-link file (file-name-nondirectory file))
467
(let* ((nums (mew-syntax-nums))
468
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
469
(mew-syntax-set-cd syntax "BZR merge")
470
(mew-encode-syntax-print mew-encode-syntax))
471
(mew-header-goto-body)))
473
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
474
(error "File %s does not exist." file))))
477
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
482
os.close(fd) # Just close the handle but do not remove the file.
485
def _get_compose_commandline(self, to, subject, attach_path):
486
commandline = ["--eval"]
492
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
493
if subject is not None:
494
_subject = ("\"%s\"" %
495
self._encode_safe(subject).replace('"', '\\"'))
497
# Funcall the default mail composition function
498
# This will work with any mail mode including default mail-mode
499
# User must tweak mail-user-agent variable to tell what function
500
# will be called inside compose-mail.
501
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
502
commandline.append(mail_cmd)
504
# Try to attach a MIME attachment using our wrapper function
505
if attach_path is not None:
506
# Do not create a file if there is no attachment
507
elisp = self._prepare_send_function()
508
self.elisp_tmp_file = elisp
509
lmmform = '(load "%s")' % elisp
510
mmform = '(bzr-add-mime-att "%s")' % \
511
self._encode_path(attach_path, 'attachment')
512
rmform = '(delete-file "%s")' % elisp
513
commandline.append(lmmform)
514
commandline.append(mmform)
515
commandline.append(rmform)
518
mail_client_registry.register('emacsclient', EmacsMail,
519
help=EmacsMail.__doc__)
522
class MAPIClient(BodyExternalMailClient):
523
__doc__ = """Default Windows mail client launched using MAPI."""
525
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
526
extension, body=None):
527
"""See ExternalMailClient._compose.
529
This implementation uses MAPI via the simplemapi ctypes wrapper
531
from bzrlib.util import simplemapi
533
simplemapi.SendMail(to or '', subject or '', body or '',
535
except simplemapi.MAPIError, e:
536
if e.code != simplemapi.MAPI_USER_ABORT:
537
raise errors.MailClientNotFound(['MAPI supported mail client'
538
' (error %d)' % (e.code,)])
539
mail_client_registry.register('mapi', MAPIClient,
540
help=MAPIClient.__doc__)
543
class MailApp(BodyExternalMailClient):
544
__doc__ = """Use MacOS X's Mail.app for sending email messages.
546
Although it would be nice to use appscript, it's not installed
547
with the shipped Python installations. We instead build an
548
AppleScript and invoke the script using osascript(1). We don't
549
use the _encode_safe() routines as it's not clear what encoding
550
osascript expects the script to be in.
553
_client_commands = ['osascript']
555
def _get_compose_commandline(self, to, subject, attach_path, body=None,
557
"""See ExternalMailClient._get_compose_commandline"""
559
fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
562
os.write(fd, 'tell application "Mail"\n')
563
os.write(fd, 'set newMessage to make new outgoing message\n')
564
os.write(fd, 'tell newMessage\n')
566
os.write(fd, 'make new to recipient with properties'
567
' {address:"%s"}\n' % to)
568
if from_ is not None:
569
# though from_ doesn't actually seem to be used
570
os.write(fd, 'set sender to "%s"\n'
571
% sender.replace('"', '\\"'))
572
if subject is not None:
573
os.write(fd, 'set subject to "%s"\n'
574
% subject.replace('"', '\\"'))
576
# FIXME: would be nice to prepend the body to the
577
# existing content (e.g., preserve signature), but
578
# can't seem to figure out the right applescript
580
os.write(fd, 'set content to "%s\\n\n"\n' %
581
body.replace('"', '\\"').replace('\n', '\\n'))
583
if attach_path is not None:
584
# FIXME: would be nice to first append a newline to
585
# ensure the attachment is on a new paragraph, but
586
# can't seem to figure out the right applescript
588
os.write(fd, 'tell content to make new attachment'
589
' with properties {file name:"%s"}'
590
' at after the last paragraph\n'
591
% self._encode_path(attach_path, 'attachment'))
592
os.write(fd, 'set visible to true\n')
593
os.write(fd, 'end tell\n')
594
os.write(fd, 'end tell\n')
596
os.close(fd) # Just close the handle but do not remove the file.
597
return [self.temp_file]
598
mail_client_registry.register('mail.app', MailApp,
599
help=MailApp.__doc__)
602
class DefaultMail(MailClient):
603
__doc__ = """Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
604
falls back to Editor"""
608
def _mail_client(self):
609
"""Determine the preferred mail client for this platform"""
610
if osutils.supports_mapi():
611
return MAPIClient(self.config)
613
return XDGEmail(self.config)
615
def compose(self, prompt, to, subject, attachment, mime_subtype,
616
extension, basename=None, body=None):
617
"""See MailClient.compose"""
619
return self._mail_client().compose(prompt, to, subject,
620
attachment, mimie_subtype,
621
extension, basename, body)
622
except errors.MailClientNotFound:
623
return Editor(self.config).compose(prompt, to, subject,
624
attachment, mimie_subtype, extension, body)
626
def compose_merge_request(self, to, subject, directive, basename=None,
628
"""See MailClient.compose_merge_request"""
630
return self._mail_client().compose_merge_request(to, subject,
631
directive, basename=basename, body=body)
632
except errors.MailClientNotFound:
633
return Editor(self.config).compose_merge_request(to, subject,
634
directive, basename=basename, body=body)
635
mail_client_registry.register('default', DefaultMail,
636
help=DefaultMail.__doc__)
637
mail_client_registry.default_key = 'default'