1
# Copyright (C) 2007 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
34
mail_client_registry = registry.Registry()
37
class MailClient(object):
38
"""A mail client that can send messages with attachements."""
40
def __init__(self, config):
43
def compose(self, prompt, to, subject, attachment, mime_subtype,
44
extension, basename=None, body=None):
45
"""Compose (and possibly send) an email message
47
Must be implemented by subclasses.
49
:param prompt: A message to tell the user what to do. Supported by
50
the Editor client, but ignored by others
51
:param to: The address to send the message to
52
:param subject: The contents of the subject line
53
:param attachment: An email attachment, as a bytestring
54
:param mime_subtype: The attachment is assumed to be a subtype of
55
Text. This allows the precise subtype to be specified, e.g.
56
"plain", "x-patch", etc.
57
:param extension: The file extension associated with the attachment
59
:param basename: The name to use for the attachment, e.g.
62
raise NotImplementedError
64
def compose_merge_request(self, to, subject, directive, basename=None,
66
"""Compose (and possibly send) a merge request
68
:param to: The address to send the request to
69
:param subject: The subject line to use for the request
70
:param directive: A merge directive representing the merge request, as
72
:param basename: The name to use for the attachment, e.g.
75
prompt = self._get_merge_prompt("Please describe these changes:", to,
77
self.compose(prompt, to, subject, directive,
78
'x-patch', '.patch', basename, body)
80
def _get_merge_prompt(self, prompt, to, subject, attachment):
81
"""Generate a prompt string. Overridden by Editor.
83
:param prompt: A string suggesting what user should do
84
:param to: The address the mail will be sent to
85
:param subject: The subject line of the mail
86
:param attachment: The attachment that will be used
91
class Editor(MailClient):
92
"""DIY mail client that uses commit message editor"""
96
def _get_merge_prompt(self, prompt, to, subject, attachment):
97
"""See MailClient._get_merge_prompt"""
101
u"%s" % (prompt, to, subject,
102
attachment.decode('utf-8', 'replace')))
104
def compose(self, prompt, to, subject, attachment, mime_subtype,
105
extension, basename=None, body=None):
106
"""See MailClient.compose"""
108
raise errors.NoMailAddressSpecified()
109
body = msgeditor.edit_commit_message(prompt, start_message=body)
111
raise errors.NoMessageSupplied()
112
email_message.EmailMessage.send(self.config,
113
self.config.username(),
118
attachment_mime_subtype=mime_subtype)
119
mail_client_registry.register('editor', Editor,
123
class BodyExternalMailClient(MailClient):
127
def _get_client_commands(self):
128
"""Provide a list of commands that may invoke the mail client"""
129
if sys.platform == 'win32':
131
return [win32utils.get_app_path(i) for i in self._client_commands]
133
return self._client_commands
135
def compose(self, prompt, to, subject, attachment, mime_subtype,
136
extension, basename=None, body=None):
137
"""See MailClient.compose.
139
Writes the attachment to a temporary file, invokes _compose.
142
basename = 'attachment'
143
pathname = osutils.mkdtemp(prefix='bzr-mail-')
144
attach_path = osutils.pathjoin(pathname, basename + extension)
145
outfile = open(attach_path, 'wb')
147
outfile.write(attachment)
151
kwargs = {'body': body}
154
self._compose(prompt, to, subject, attach_path, mime_subtype,
157
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
158
extension, body=None, from_=None):
159
"""Invoke a mail client as a commandline process.
161
Overridden by MAPIClient.
162
:param to: The address to send the mail to
163
:param subject: The subject line for the mail
164
:param pathname: The path to the attachment
165
:param mime_subtype: The attachment is assumed to have a major type of
166
"text", but the precise subtype can be specified here
167
:param extension: A file extension (including period) associated with
169
:param body: Optional body text.
170
:param from_: Optional From: header.
172
for name in self._get_client_commands():
173
cmdline = [self._encode_path(name, 'executable')]
175
kwargs = {'body': body}
178
if from_ is not None:
179
kwargs['from_'] = from_
180
cmdline.extend(self._get_compose_commandline(to, subject,
184
subprocess.call(cmdline)
186
if e.errno != errno.ENOENT:
191
raise errors.MailClientNotFound(self._client_commands)
193
def _get_compose_commandline(self, to, subject, attach_path, body):
194
"""Determine the commandline to use for composing a message
196
Implemented by various subclasses
197
:param to: The address to send the mail to
198
:param subject: The subject line for the mail
199
:param attach_path: The path to the attachment
201
raise NotImplementedError
203
def _encode_safe(self, u):
204
"""Encode possible unicode string argument to 8-bit string
205
in user_encoding. Unencodable characters will be replaced
208
:param u: possible unicode string.
209
:return: encoded string if u is unicode, u itself otherwise.
211
if isinstance(u, unicode):
212
return u.encode(osutils.get_user_encoding(), 'replace')
215
def _encode_path(self, path, kind):
216
"""Encode unicode path in user encoding.
218
:param path: possible unicode path.
219
:param kind: path kind ('executable' or 'attachment').
220
:return: encoded path if path is unicode,
221
path itself otherwise.
222
:raise: UnableEncodePath.
224
if isinstance(path, unicode):
226
return path.encode(osutils.get_user_encoding())
227
except UnicodeEncodeError:
228
raise errors.UnableEncodePath(path, kind)
232
class ExternalMailClient(BodyExternalMailClient):
233
"""An external mail client."""
235
supports_body = False
238
class Evolution(BodyExternalMailClient):
239
"""Evolution mail client."""
241
_client_commands = ['evolution']
243
def _get_compose_commandline(self, to, subject, attach_path, body=None):
244
"""See ExternalMailClient._get_compose_commandline"""
246
if subject is not None:
247
message_options['subject'] = subject
248
if attach_path is not None:
249
message_options['attach'] = attach_path
251
message_options['body'] = body
252
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
253
sorted(message_options.iteritems())]
254
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
255
'&'.join(options_list))]
256
mail_client_registry.register('evolution', Evolution,
257
help=Evolution.__doc__)
260
class Mutt(ExternalMailClient):
261
"""Mutt mail client."""
263
_client_commands = ['mutt']
265
def _get_compose_commandline(self, to, subject, attach_path):
266
"""See ExternalMailClient._get_compose_commandline"""
268
if subject is not None:
269
message_options.extend(['-s', self._encode_safe(subject)])
270
if attach_path is not None:
271
message_options.extend(['-a',
272
self._encode_path(attach_path, 'attachment')])
274
message_options.extend(['--', self._encode_safe(to)])
275
return message_options
276
mail_client_registry.register('mutt', Mutt,
280
class Thunderbird(BodyExternalMailClient):
281
"""Mozilla Thunderbird (or Icedove)
283
Note that Thunderbird 1.5 is buggy and does not support setting
284
"to" simultaneously with including a attachment.
286
There is a workaround if no attachment is present, but we always need to
290
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
291
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
292
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
294
def _get_compose_commandline(self, to, subject, attach_path, body=None):
295
"""See ExternalMailClient._get_compose_commandline"""
298
message_options['to'] = self._encode_safe(to)
299
if subject is not None:
300
message_options['subject'] = self._encode_safe(subject)
301
if attach_path is not None:
302
message_options['attachment'] = urlutils.local_path_to_url(
305
options_list = ['body=%s' % urllib.quote(self._encode_safe(body))]
308
options_list.extend(["%s='%s'" % (k, v) for k, v in
309
sorted(message_options.iteritems())])
310
return ['-compose', ','.join(options_list)]
311
mail_client_registry.register('thunderbird', Thunderbird,
312
help=Thunderbird.__doc__)
315
class KMail(ExternalMailClient):
316
"""KDE mail client."""
318
_client_commands = ['kmail']
320
def _get_compose_commandline(self, to, subject, attach_path):
321
"""See ExternalMailClient._get_compose_commandline"""
323
if subject is not None:
324
message_options.extend(['-s', self._encode_safe(subject)])
325
if attach_path is not None:
326
message_options.extend(['--attach',
327
self._encode_path(attach_path, 'attachment')])
329
message_options.extend([self._encode_safe(to)])
330
return message_options
331
mail_client_registry.register('kmail', KMail,
335
class Claws(ExternalMailClient):
336
"""Claws mail client."""
340
_client_commands = ['claws-mail']
342
def _get_compose_commandline(self, to, subject, attach_path, body=None,
344
"""See ExternalMailClient._get_compose_commandline"""
346
if from_ is not None:
347
compose_url.append('from=' + urllib.quote(from_))
348
if subject is not None:
349
# Don't use urllib.quote_plus because Claws doesn't seem
350
# to recognise spaces encoded as "+".
352
'subject=' + urllib.quote(self._encode_safe(subject)))
355
'body=' + urllib.quote(self._encode_safe(body)))
356
# to must be supplied for the claws-mail --compose syntax to work.
358
raise errors.NoMailAddressSpecified()
359
compose_url = 'mailto:%s?%s' % (
360
self._encode_safe(to), '&'.join(compose_url))
361
# Collect command-line options.
362
message_options = ['--compose', compose_url]
363
if attach_path is not None:
364
message_options.extend(
365
['--attach', self._encode_path(attach_path, 'attachment')])
366
return message_options
368
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
369
extension, body=None, from_=None):
370
"""See ExternalMailClient._compose"""
372
from_ = self.config.get_user_option('email')
373
super(Claws, self)._compose(prompt, to, subject, attach_path,
374
mime_subtype, extension, body, from_)
377
mail_client_registry.register('claws', Claws,
381
class XDGEmail(BodyExternalMailClient):
382
"""xdg-email attempts to invoke the user's preferred mail client"""
384
_client_commands = ['xdg-email']
386
def _get_compose_commandline(self, to, subject, attach_path, body=None):
387
"""See ExternalMailClient._get_compose_commandline"""
389
raise errors.NoMailAddressSpecified()
390
commandline = [self._encode_safe(to)]
391
if subject is not None:
392
commandline.extend(['--subject', self._encode_safe(subject)])
393
if attach_path is not None:
394
commandline.extend(['--attach',
395
self._encode_path(attach_path, 'attachment')])
397
commandline.extend(['--body', self._encode_safe(body)])
399
mail_client_registry.register('xdg-email', XDGEmail,
400
help=XDGEmail.__doc__)
403
class EmacsMail(ExternalMailClient):
404
"""Call emacsclient to have a mail buffer.
406
This only work for emacs >= 22.1 due to recent -e/--eval support.
408
The good news is that this implementation will work with all mail
409
agents registered against ``mail-user-agent``. So there is no need
410
to instantiate ExternalMailClient for each and every GNU Emacs
413
Users just have to ensure that ``mail-user-agent`` is set according
417
_client_commands = ['emacsclient']
419
def _prepare_send_function(self):
420
"""Write our wrapper function into a temporary file.
422
This temporary file will be loaded at runtime in
423
_get_compose_commandline function.
425
This function does not remove the file. That's a wanted
426
behaviour since _get_compose_commandline won't run the send
427
mail function directly but return the eligible command line.
428
Removing our temporary file here would prevent our sendmail
429
function to work. (The file is deleted by some elisp code
430
after being read by Emacs.)
433
_defun = r"""(defun bzr-add-mime-att (file)
434
"Attach FILE to a mail buffer as a MIME attachment."
435
(let ((agent mail-user-agent))
436
(if (and file (file-exists-p file))
438
((eq agent 'sendmail-user-agent)
442
(if (functionp 'etach-attach)
444
(mail-attach-file file))))
445
((or (eq agent 'message-user-agent)
446
(eq agent 'gnus-user-agent)
447
(eq agent 'mh-e-user-agent))
449
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
450
((eq agent 'mew-user-agent)
452
(mew-draft-prepare-attachments)
453
(mew-attach-link file (file-name-nondirectory file))
454
(let* ((nums (mew-syntax-nums))
455
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
456
(mew-syntax-set-cd syntax "BZR merge")
457
(mew-encode-syntax-print mew-encode-syntax))
458
(mew-header-goto-body)))
460
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
461
(error "File %s does not exist." file))))
464
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
469
os.close(fd) # Just close the handle but do not remove the file.
472
def _get_compose_commandline(self, to, subject, attach_path):
473
commandline = ["--eval"]
479
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
480
if subject is not None:
481
_subject = ("\"%s\"" %
482
self._encode_safe(subject).replace('"', '\\"'))
484
# Funcall the default mail composition function
485
# This will work with any mail mode including default mail-mode
486
# User must tweak mail-user-agent variable to tell what function
487
# will be called inside compose-mail.
488
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
489
commandline.append(mail_cmd)
491
# Try to attach a MIME attachment using our wrapper function
492
if attach_path is not None:
493
# Do not create a file if there is no attachment
494
elisp = self._prepare_send_function()
495
lmmform = '(load "%s")' % elisp
496
mmform = '(bzr-add-mime-att "%s")' % \
497
self._encode_path(attach_path, 'attachment')
498
rmform = '(delete-file "%s")' % elisp
499
commandline.append(lmmform)
500
commandline.append(mmform)
501
commandline.append(rmform)
504
mail_client_registry.register('emacsclient', EmacsMail,
505
help=EmacsMail.__doc__)
508
class MAPIClient(BodyExternalMailClient):
509
"""Default Windows mail client launched using MAPI."""
511
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
512
extension, body=None):
513
"""See ExternalMailClient._compose.
515
This implementation uses MAPI via the simplemapi ctypes wrapper
517
from bzrlib.util import simplemapi
519
simplemapi.SendMail(to or '', subject or '', body or '',
521
except simplemapi.MAPIError, e:
522
if e.code != simplemapi.MAPI_USER_ABORT:
523
raise errors.MailClientNotFound(['MAPI supported mail client'
524
' (error %d)' % (e.code,)])
525
mail_client_registry.register('mapi', MAPIClient,
526
help=MAPIClient.__doc__)
529
class DefaultMail(MailClient):
530
"""Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
531
falls back to Editor"""
535
def _mail_client(self):
536
"""Determine the preferred mail client for this platform"""
537
if osutils.supports_mapi():
538
return MAPIClient(self.config)
540
return XDGEmail(self.config)
542
def compose(self, prompt, to, subject, attachment, mime_subtype,
543
extension, basename=None, body=None):
544
"""See MailClient.compose"""
546
return self._mail_client().compose(prompt, to, subject,
547
attachment, mimie_subtype,
548
extension, basename, body)
549
except errors.MailClientNotFound:
550
return Editor(self.config).compose(prompt, to, subject,
551
attachment, mimie_subtype, extension, body)
553
def compose_merge_request(self, to, subject, directive, basename=None,
555
"""See MailClient.compose_merge_request"""
557
return self._mail_client().compose_merge_request(to, subject,
558
directive, basename=basename, body=body)
559
except errors.MailClientNotFound:
560
return Editor(self.config).compose_merge_request(to, subject,
561
directive, basename=basename, body=body)
562
mail_client_registry.register('default', DefaultMail,
563
help=DefaultMail.__doc__)
564
mail_client_registry.default_key = 'default'