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 MailClient(object):
40
"""A mail client that can send messages with attachements."""
42
def __init__(self, config):
45
def compose(self, prompt, to, subject, attachment, mime_subtype,
46
extension, basename=None, body=None):
47
"""Compose (and possibly send) an email message
49
Must be implemented by subclasses.
51
:param prompt: A message to tell the user what to do. Supported by
52
the Editor client, but ignored by others
53
:param to: The address to send the message to
54
:param subject: The contents of the subject line
55
:param attachment: An email attachment, as a bytestring
56
:param mime_subtype: The attachment is assumed to be a subtype of
57
Text. This allows the precise subtype to be specified, e.g.
58
"plain", "x-patch", etc.
59
:param extension: The file extension associated with the attachment
61
:param basename: The name to use for the attachment, e.g.
64
raise NotImplementedError
66
def compose_merge_request(self, to, subject, directive, basename=None,
68
"""Compose (and possibly send) a merge request
70
:param to: The address to send the request to
71
:param subject: The subject line to use for the request
72
:param directive: A merge directive representing the merge request, as
74
:param basename: The name to use for the attachment, e.g.
77
prompt = self._get_merge_prompt("Please describe these changes:", to,
79
self.compose(prompt, to, subject, directive,
80
'x-patch', '.patch', basename, body)
82
def _get_merge_prompt(self, prompt, to, subject, attachment):
83
"""Generate a prompt string. Overridden by Editor.
85
:param prompt: A string suggesting what user should do
86
:param to: The address the mail will be sent to
87
:param subject: The subject line of the mail
88
:param attachment: The attachment that will be used
93
class Editor(MailClient):
94
__doc__ = """DIY mail client that uses commit message editor"""
98
def _get_merge_prompt(self, prompt, to, subject, attachment):
99
"""See MailClient._get_merge_prompt"""
103
u"%s" % (prompt, to, subject,
104
attachment.decode('utf-8', 'replace')))
106
def compose(self, prompt, to, subject, attachment, mime_subtype,
107
extension, basename=None, body=None):
108
"""See MailClient.compose"""
110
raise errors.NoMailAddressSpecified()
111
body = msgeditor.edit_commit_message(prompt, start_message=body)
113
raise errors.NoMessageSupplied()
114
email_message.EmailMessage.send(self.config,
115
self.config.get('email'),
120
attachment_mime_subtype=mime_subtype)
121
mail_client_registry.register('editor', Editor,
125
class BodyExternalMailClient(MailClient):
129
def _get_client_commands(self):
130
"""Provide a list of commands that may invoke the mail client"""
131
if sys.platform == 'win32':
133
return [win32utils.get_app_path(i) for i in self._client_commands]
135
return self._client_commands
137
def compose(self, prompt, to, subject, attachment, mime_subtype,
138
extension, basename=None, body=None):
139
"""See MailClient.compose.
141
Writes the attachment to a temporary file, invokes _compose.
144
basename = 'attachment'
145
pathname = osutils.mkdtemp(prefix='bzr-mail-')
146
attach_path = osutils.pathjoin(pathname, basename + extension)
147
outfile = open(attach_path, 'wb')
149
outfile.write(attachment)
153
kwargs = {'body': body}
156
self._compose(prompt, to, subject, attach_path, mime_subtype,
159
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
160
extension, body=None, from_=None):
161
"""Invoke a mail client as a commandline process.
163
Overridden by MAPIClient.
164
:param to: The address to send the mail to
165
:param subject: The subject line for the mail
166
:param pathname: The path to the attachment
167
:param mime_subtype: The attachment is assumed to have a major type of
168
"text", but the precise subtype can be specified here
169
:param extension: A file extension (including period) associated with
171
:param body: Optional body text.
172
:param from_: Optional From: header.
174
for name in self._get_client_commands():
175
cmdline = [self._encode_path(name, 'executable')]
177
kwargs = {'body': body}
180
if from_ is not None:
181
kwargs['from_'] = from_
182
cmdline.extend(self._get_compose_commandline(to, subject,
186
subprocess.call(cmdline)
188
if e.errno != errno.ENOENT:
193
raise errors.MailClientNotFound(self._client_commands)
195
def _get_compose_commandline(self, to, subject, attach_path, body):
196
"""Determine the commandline to use for composing a message
198
Implemented by various subclasses
199
:param to: The address to send the mail to
200
:param subject: The subject line for the mail
201
:param attach_path: The path to the attachment
203
raise NotImplementedError
205
def _encode_safe(self, u):
206
"""Encode possible unicode string argument to 8-bit string
207
in user_encoding. Unencodable characters will be replaced
210
:param u: possible unicode string.
211
:return: encoded string if u is unicode, u itself otherwise.
213
if isinstance(u, unicode):
214
return u.encode(osutils.get_user_encoding(), 'replace')
217
def _encode_path(self, path, kind):
218
"""Encode unicode path in user encoding.
220
:param path: possible unicode path.
221
:param kind: path kind ('executable' or 'attachment').
222
:return: encoded path if path is unicode,
223
path itself otherwise.
224
:raise: UnableEncodePath.
226
if isinstance(path, unicode):
228
return path.encode(osutils.get_user_encoding())
229
except UnicodeEncodeError:
230
raise errors.UnableEncodePath(path, kind)
234
class ExternalMailClient(BodyExternalMailClient):
235
__doc__ = """An external mail client."""
237
supports_body = False
240
class Evolution(BodyExternalMailClient):
241
__doc__ = """Evolution mail client."""
243
_client_commands = ['evolution']
245
def _get_compose_commandline(self, to, subject, attach_path, body=None):
246
"""See ExternalMailClient._get_compose_commandline"""
248
if subject is not None:
249
message_options['subject'] = subject
250
if attach_path is not None:
251
message_options['attach'] = attach_path
253
message_options['body'] = body
254
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
255
sorted(message_options.iteritems())]
256
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
257
'&'.join(options_list))]
258
mail_client_registry.register('evolution', Evolution,
259
help=Evolution.__doc__)
262
class Mutt(BodyExternalMailClient):
263
__doc__ = """Mutt mail client."""
265
_client_commands = ['mutt']
267
def _get_compose_commandline(self, to, subject, attach_path, body=None):
268
"""See ExternalMailClient._get_compose_commandline"""
270
if subject is not None:
271
message_options.extend(['-s', self._encode_safe(subject)])
272
if attach_path is not None:
273
message_options.extend(['-a',
274
self._encode_path(attach_path, 'attachment')])
276
# Store the temp file object in self, so that it does not get
277
# garbage collected and delete the file before mutt can read it.
278
self._temp_file = tempfile.NamedTemporaryFile(
279
prefix="mutt-body-", suffix=".txt")
280
self._temp_file.write(body)
281
self._temp_file.flush()
282
message_options.extend(['-i', self._temp_file.name])
284
message_options.extend(['--', self._encode_safe(to)])
285
return message_options
286
mail_client_registry.register('mutt', Mutt,
290
class Thunderbird(BodyExternalMailClient):
291
__doc__ = """Mozilla Thunderbird (or Icedove)
293
Note that Thunderbird 1.5 is buggy and does not support setting
294
"to" simultaneously with including a attachment.
296
There is a workaround if no attachment is present, but we always need to
300
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
301
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
302
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
304
def _get_compose_commandline(self, to, subject, attach_path, body=None):
305
"""See ExternalMailClient._get_compose_commandline"""
308
message_options['to'] = self._encode_safe(to)
309
if subject is not None:
310
message_options['subject'] = self._encode_safe(subject)
311
if attach_path is not None:
312
message_options['attachment'] = urlutils.local_path_to_url(
315
options_list = ['body=%s' % urlutils.quote(self._encode_safe(body))]
318
options_list.extend(["%s='%s'" % (k, v) for k, v in
319
sorted(message_options.iteritems())])
320
return ['-compose', ','.join(options_list)]
321
mail_client_registry.register('thunderbird', Thunderbird,
322
help=Thunderbird.__doc__)
325
class KMail(ExternalMailClient):
326
__doc__ = """KDE mail client."""
328
_client_commands = ['kmail']
330
def _get_compose_commandline(self, to, subject, attach_path):
331
"""See ExternalMailClient._get_compose_commandline"""
333
if subject is not None:
334
message_options.extend(['-s', self._encode_safe(subject)])
335
if attach_path is not None:
336
message_options.extend(['--attach',
337
self._encode_path(attach_path, 'attachment')])
339
message_options.extend([self._encode_safe(to)])
340
return message_options
341
mail_client_registry.register('kmail', KMail,
345
class Claws(ExternalMailClient):
346
__doc__ = """Claws mail client."""
350
_client_commands = ['claws-mail']
352
def _get_compose_commandline(self, to, subject, attach_path, body=None,
354
"""See ExternalMailClient._get_compose_commandline"""
356
if from_ is not None:
357
compose_url.append('from=' + urlutils.quote(from_))
358
if subject is not None:
359
# Don't use urlutils.quote_plus because Claws doesn't seem
360
# to recognise spaces encoded as "+".
362
'subject=' + urlutils.quote(self._encode_safe(subject)))
365
'body=' + urlutils.quote(self._encode_safe(body)))
366
# to must be supplied for the claws-mail --compose syntax to work.
368
raise errors.NoMailAddressSpecified()
369
compose_url = 'mailto:%s?%s' % (
370
self._encode_safe(to), '&'.join(compose_url))
371
# Collect command-line options.
372
message_options = ['--compose', compose_url]
373
if attach_path is not None:
374
message_options.extend(
375
['--attach', self._encode_path(attach_path, 'attachment')])
376
return message_options
378
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
379
extension, body=None, from_=None):
380
"""See ExternalMailClient._compose"""
382
from_ = self.config.get('email')
383
super(Claws, self)._compose(prompt, to, subject, attach_path,
384
mime_subtype, extension, body, from_)
387
mail_client_registry.register('claws', Claws,
391
class XDGEmail(BodyExternalMailClient):
392
__doc__ = """xdg-email attempts to invoke the user's preferred mail client"""
394
_client_commands = ['xdg-email']
396
def _get_compose_commandline(self, to, subject, attach_path, body=None):
397
"""See ExternalMailClient._get_compose_commandline"""
399
raise errors.NoMailAddressSpecified()
400
commandline = [self._encode_safe(to)]
401
if subject is not None:
402
commandline.extend(['--subject', self._encode_safe(subject)])
403
if attach_path is not None:
404
commandline.extend(['--attach',
405
self._encode_path(attach_path, 'attachment')])
407
commandline.extend(['--body', self._encode_safe(body)])
409
mail_client_registry.register('xdg-email', XDGEmail,
410
help=XDGEmail.__doc__)
413
class EmacsMail(ExternalMailClient):
414
__doc__ = """Call emacsclient to have a mail buffer.
416
This only work for emacs >= 22.1 due to recent -e/--eval support.
418
The good news is that this implementation will work with all mail
419
agents registered against ``mail-user-agent``. So there is no need
420
to instantiate ExternalMailClient for each and every GNU Emacs
423
Users just have to ensure that ``mail-user-agent`` is set according
427
_client_commands = ['emacsclient']
429
def __init__(self, config):
430
super(EmacsMail, self).__init__(config)
431
self.elisp_tmp_file = None
433
def _prepare_send_function(self):
434
"""Write our wrapper function into a temporary file.
436
This temporary file will be loaded at runtime in
437
_get_compose_commandline function.
439
This function does not remove the file. That's a wanted
440
behaviour since _get_compose_commandline won't run the send
441
mail function directly but return the eligible command line.
442
Removing our temporary file here would prevent our sendmail
443
function to work. (The file is deleted by some elisp code
444
after being read by Emacs.)
447
_defun = r"""(defun bzr-add-mime-att (file)
448
"Attach FILE to a mail buffer as a MIME attachment."
449
(let ((agent mail-user-agent))
450
(if (and file (file-exists-p file))
452
((eq agent 'sendmail-user-agent)
456
(if (functionp 'etach-attach)
458
(mail-attach-file file))))
459
((or (eq agent 'message-user-agent)
460
(eq agent 'gnus-user-agent)
461
(eq agent 'mh-e-user-agent))
463
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
464
((eq agent 'mew-user-agent)
466
(mew-draft-prepare-attachments)
467
(mew-attach-link file (file-name-nondirectory file))
468
(let* ((nums (mew-syntax-nums))
469
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
470
(mew-syntax-set-cd syntax "BZR merge")
471
(mew-encode-syntax-print mew-encode-syntax))
472
(mew-header-goto-body)))
474
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
475
(error "File %s does not exist." file))))
478
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
483
os.close(fd) # Just close the handle but do not remove the file.
486
def _get_compose_commandline(self, to, subject, attach_path):
487
commandline = ["--eval"]
493
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
494
if subject is not None:
495
_subject = ("\"%s\"" %
496
self._encode_safe(subject).replace('"', '\\"'))
498
# Funcall the default mail composition function
499
# This will work with any mail mode including default mail-mode
500
# User must tweak mail-user-agent variable to tell what function
501
# will be called inside compose-mail.
502
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
503
commandline.append(mail_cmd)
505
# Try to attach a MIME attachment using our wrapper function
506
if attach_path is not None:
507
# Do not create a file if there is no attachment
508
elisp = self._prepare_send_function()
509
self.elisp_tmp_file = elisp
510
lmmform = '(load "%s")' % elisp
511
mmform = '(bzr-add-mime-att "%s")' % \
512
self._encode_path(attach_path, 'attachment')
513
rmform = '(delete-file "%s")' % elisp
514
commandline.append(lmmform)
515
commandline.append(mmform)
516
commandline.append(rmform)
519
mail_client_registry.register('emacsclient', EmacsMail,
520
help=EmacsMail.__doc__)
523
class MAPIClient(BodyExternalMailClient):
524
__doc__ = """Default Windows mail client launched using MAPI."""
526
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
527
extension, body=None):
528
"""See ExternalMailClient._compose.
530
This implementation uses MAPI via the simplemapi ctypes wrapper
532
from bzrlib.util import simplemapi
534
simplemapi.SendMail(to or '', subject or '', body or '',
536
except simplemapi.MAPIError, e:
537
if e.code != simplemapi.MAPI_USER_ABORT:
538
raise errors.MailClientNotFound(['MAPI supported mail client'
539
' (error %d)' % (e.code,)])
540
mail_client_registry.register('mapi', MAPIClient,
541
help=MAPIClient.__doc__)
544
class MailApp(BodyExternalMailClient):
545
__doc__ = """Use MacOS X's Mail.app for sending email messages.
547
Although it would be nice to use appscript, it's not installed
548
with the shipped Python installations. We instead build an
549
AppleScript and invoke the script using osascript(1). We don't
550
use the _encode_safe() routines as it's not clear what encoding
551
osascript expects the script to be in.
554
_client_commands = ['osascript']
556
def _get_compose_commandline(self, to, subject, attach_path, body=None,
558
"""See ExternalMailClient._get_compose_commandline"""
560
fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
563
os.write(fd, 'tell application "Mail"\n')
564
os.write(fd, 'set newMessage to make new outgoing message\n')
565
os.write(fd, 'tell newMessage\n')
567
os.write(fd, 'make new to recipient with properties'
568
' {address:"%s"}\n' % to)
569
if from_ is not None:
570
# though from_ doesn't actually seem to be used
571
os.write(fd, 'set sender to "%s"\n'
572
% sender.replace('"', '\\"'))
573
if subject is not None:
574
os.write(fd, 'set subject to "%s"\n'
575
% subject.replace('"', '\\"'))
577
# FIXME: would be nice to prepend the body to the
578
# existing content (e.g., preserve signature), but
579
# can't seem to figure out the right applescript
581
os.write(fd, 'set content to "%s\\n\n"\n' %
582
body.replace('"', '\\"').replace('\n', '\\n'))
584
if attach_path is not None:
585
# FIXME: would be nice to first append a newline to
586
# ensure the attachment is on a new paragraph, but
587
# can't seem to figure out the right applescript
589
os.write(fd, 'tell content to make new attachment'
590
' with properties {file name:"%s"}'
591
' at after the last paragraph\n'
592
% self._encode_path(attach_path, 'attachment'))
593
os.write(fd, 'set visible to true\n')
594
os.write(fd, 'end tell\n')
595
os.write(fd, 'end tell\n')
597
os.close(fd) # Just close the handle but do not remove the file.
598
return [self.temp_file]
599
mail_client_registry.register('mail.app', MailApp,
600
help=MailApp.__doc__)
603
class DefaultMail(MailClient):
604
__doc__ = """Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
605
falls back to Editor"""
609
def _mail_client(self):
610
"""Determine the preferred mail client for this platform"""
611
if osutils.supports_mapi():
612
return MAPIClient(self.config)
614
return XDGEmail(self.config)
616
def compose(self, prompt, to, subject, attachment, mime_subtype,
617
extension, basename=None, body=None):
618
"""See MailClient.compose"""
620
return self._mail_client().compose(prompt, to, subject,
621
attachment, mime_subtype,
622
extension, basename, body)
623
except errors.MailClientNotFound:
624
return Editor(self.config).compose(prompt, to, subject,
625
attachment, mime_subtype, extension, body)
627
def compose_merge_request(self, to, subject, directive, basename=None,
629
"""See MailClient.compose_merge_request"""
631
return self._mail_client().compose_merge_request(to, subject,
632
directive, basename=basename, body=body)
633
except errors.MailClientNotFound:
634
return Editor(self.config).compose_merge_request(to, subject,
635
directive, basename=basename, body=body)
636
mail_client_registry.register('default', DefaultMail,
637
help=DefaultMail.__doc__)
638
mail_client_registry.default_key = 'default'
640
opt_mail_client = _mod_config.RegistryOption('mail_client',
641
mail_client_registry, help='E-mail client to use.', invalid='error')