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):
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
170
for name in self._get_client_commands():
171
cmdline = [self._encode_path(name, 'executable')]
173
kwargs = {'body': body}
176
cmdline.extend(self._get_compose_commandline(to, subject,
180
subprocess.call(cmdline)
182
if e.errno != errno.ENOENT:
187
raise errors.MailClientNotFound(self._client_commands)
189
def _get_compose_commandline(self, to, subject, attach_path, body):
190
"""Determine the commandline to use for composing a message
192
Implemented by various subclasses
193
:param to: The address to send the mail to
194
:param subject: The subject line for the mail
195
:param attach_path: The path to the attachment
197
raise NotImplementedError
199
def _encode_safe(self, u):
200
"""Encode possible unicode string argument to 8-bit string
201
in user_encoding. Unencodable characters will be replaced
204
:param u: possible unicode string.
205
:return: encoded string if u is unicode, u itself otherwise.
207
if isinstance(u, unicode):
208
return u.encode(osutils.get_user_encoding(), 'replace')
211
def _encode_path(self, path, kind):
212
"""Encode unicode path in user encoding.
214
:param path: possible unicode path.
215
:param kind: path kind ('executable' or 'attachment').
216
:return: encoded path if path is unicode,
217
path itself otherwise.
218
:raise: UnableEncodePath.
220
if isinstance(path, unicode):
222
return path.encode(osutils.get_user_encoding())
223
except UnicodeEncodeError:
224
raise errors.UnableEncodePath(path, kind)
228
class ExternalMailClient(BodyExternalMailClient):
229
"""An external mail client."""
231
supports_body = False
234
class Evolution(BodyExternalMailClient):
235
"""Evolution mail client."""
237
_client_commands = ['evolution']
239
def _get_compose_commandline(self, to, subject, attach_path, body=None):
240
"""See ExternalMailClient._get_compose_commandline"""
242
if subject is not None:
243
message_options['subject'] = subject
244
if attach_path is not None:
245
message_options['attach'] = attach_path
247
message_options['body'] = body
248
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
249
sorted(message_options.iteritems())]
250
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
251
'&'.join(options_list))]
252
mail_client_registry.register('evolution', Evolution,
253
help=Evolution.__doc__)
256
class Mutt(ExternalMailClient):
257
"""Mutt mail client."""
259
_client_commands = ['mutt']
261
def _get_compose_commandline(self, to, subject, attach_path):
262
"""See ExternalMailClient._get_compose_commandline"""
264
if subject is not None:
265
message_options.extend(['-s', self._encode_safe(subject)])
266
if attach_path is not None:
267
message_options.extend(['-a',
268
self._encode_path(attach_path, 'attachment')])
270
message_options.append(self._encode_safe(to))
271
return message_options
272
mail_client_registry.register('mutt', Mutt,
276
class Thunderbird(BodyExternalMailClient):
277
"""Mozilla Thunderbird (or Icedove)
279
Note that Thunderbird 1.5 is buggy and does not support setting
280
"to" simultaneously with including a attachment.
282
There is a workaround if no attachment is present, but we always need to
286
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
287
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
288
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
290
def _get_compose_commandline(self, to, subject, attach_path, body=None):
291
"""See ExternalMailClient._get_compose_commandline"""
294
message_options['to'] = self._encode_safe(to)
295
if subject is not None:
296
message_options['subject'] = self._encode_safe(subject)
297
if attach_path is not None:
298
message_options['attachment'] = urlutils.local_path_to_url(
301
options_list = ['body=%s' % urllib.quote(self._encode_safe(body))]
304
options_list.extend(["%s='%s'" % (k, v) for k, v in
305
sorted(message_options.iteritems())])
306
return ['-compose', ','.join(options_list)]
307
mail_client_registry.register('thunderbird', Thunderbird,
308
help=Thunderbird.__doc__)
311
class KMail(ExternalMailClient):
312
"""KDE mail client."""
314
_client_commands = ['kmail']
316
def _get_compose_commandline(self, to, subject, attach_path):
317
"""See ExternalMailClient._get_compose_commandline"""
319
if subject is not None:
320
message_options.extend(['-s', self._encode_safe(subject)])
321
if attach_path is not None:
322
message_options.extend(['--attach',
323
self._encode_path(attach_path, 'attachment')])
325
message_options.extend([self._encode_safe(to)])
326
return message_options
327
mail_client_registry.register('kmail', KMail,
331
class Claws(ExternalMailClient):
332
"""Claws mail client."""
334
_client_commands = ['claws-mail']
336
def _get_compose_commandline(self, to, subject, attach_path):
337
"""See ExternalMailClient._get_compose_commandline"""
338
compose_url = ['mailto:']
340
compose_url.append(self._encode_safe(to))
341
compose_url.append('?')
342
if subject is not None:
343
# Don't use urllib.quote_plus because Claws doesn't seem
344
# to recognise spaces encoded as "+".
346
'subject=%s' % urllib.quote(self._encode_safe(subject)))
347
# Collect command-line options.
348
message_options = ['--compose', ''.join(compose_url)]
349
if attach_path is not None:
350
message_options.extend(
351
['--attach', self._encode_path(attach_path, 'attachment')])
352
return message_options
353
mail_client_registry.register('claws', Claws,
357
class XDGEmail(BodyExternalMailClient):
358
"""xdg-email attempts to invoke the user's preferred mail client"""
360
_client_commands = ['xdg-email']
362
def _get_compose_commandline(self, to, subject, attach_path, body=None):
363
"""See ExternalMailClient._get_compose_commandline"""
365
raise errors.NoMailAddressSpecified()
366
commandline = [self._encode_safe(to)]
367
if subject is not None:
368
commandline.extend(['--subject', self._encode_safe(subject)])
369
if attach_path is not None:
370
commandline.extend(['--attach',
371
self._encode_path(attach_path, 'attachment')])
373
commandline.extend(['--body', self._encode_safe(body)])
375
mail_client_registry.register('xdg-email', XDGEmail,
376
help=XDGEmail.__doc__)
379
class EmacsMail(ExternalMailClient):
380
"""Call emacsclient to have a mail buffer.
382
This only work for emacs >= 22.1 due to recent -e/--eval support.
384
The good news is that this implementation will work with all mail
385
agents registered against ``mail-user-agent``. So there is no need
386
to instantiate ExternalMailClient for each and every GNU Emacs
389
Users just have to ensure that ``mail-user-agent`` is set according
393
_client_commands = ['emacsclient']
395
def _prepare_send_function(self):
396
"""Write our wrapper function into a temporary file.
398
This temporary file will be loaded at runtime in
399
_get_compose_commandline function.
401
This function does not remove the file. That's a wanted
402
behaviour since _get_compose_commandline won't run the send
403
mail function directly but return the eligible command line.
404
Removing our temporary file here would prevent our sendmail
405
function to work. (The file is deleted by some elisp code
406
after being read by Emacs.)
409
_defun = r"""(defun bzr-add-mime-att (file)
410
"Attach FILE to a mail buffer as a MIME attachment."
411
(let ((agent mail-user-agent))
412
(if (and file (file-exists-p file))
414
((eq agent 'sendmail-user-agent)
418
(if (functionp 'etach-attach)
420
(mail-attach-file file))))
421
((or (eq agent 'message-user-agent)
422
(eq agent 'gnus-user-agent)
423
(eq agent 'mh-e-user-agent))
425
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
426
((eq agent 'mew-user-agent)
428
(mew-draft-prepare-attachments)
429
(mew-attach-link file (file-name-nondirectory file))
430
(let* ((nums (mew-syntax-nums))
431
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
432
(mew-syntax-set-cd syntax "BZR merge")
433
(mew-encode-syntax-print mew-encode-syntax))
434
(mew-header-goto-body)))
436
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
437
(error "File %s does not exist." file))))
440
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
445
os.close(fd) # Just close the handle but do not remove the file.
448
def _get_compose_commandline(self, to, subject, attach_path):
449
commandline = ["--eval"]
455
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
456
if subject is not None:
457
_subject = ("\"%s\"" %
458
self._encode_safe(subject).replace('"', '\\"'))
460
# Funcall the default mail composition function
461
# This will work with any mail mode including default mail-mode
462
# User must tweak mail-user-agent variable to tell what function
463
# will be called inside compose-mail.
464
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
465
commandline.append(mail_cmd)
467
# Try to attach a MIME attachment using our wrapper function
468
if attach_path is not None:
469
# Do not create a file if there is no attachment
470
elisp = self._prepare_send_function()
471
lmmform = '(load "%s")' % elisp
472
mmform = '(bzr-add-mime-att "%s")' % \
473
self._encode_path(attach_path, 'attachment')
474
rmform = '(delete-file "%s")' % elisp
475
commandline.append(lmmform)
476
commandline.append(mmform)
477
commandline.append(rmform)
480
mail_client_registry.register('emacsclient', EmacsMail,
481
help=EmacsMail.__doc__)
484
class MAPIClient(BodyExternalMailClient):
485
"""Default Windows mail client launched using MAPI."""
487
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
489
"""See ExternalMailClient._compose.
491
This implementation uses MAPI via the simplemapi ctypes wrapper
493
from bzrlib.util import simplemapi
495
simplemapi.SendMail(to or '', subject or '', body or '',
497
except simplemapi.MAPIError, e:
498
if e.code != simplemapi.MAPI_USER_ABORT:
499
raise errors.MailClientNotFound(['MAPI supported mail client'
500
' (error %d)' % (e.code,)])
501
mail_client_registry.register('mapi', MAPIClient,
502
help=MAPIClient.__doc__)
505
class DefaultMail(MailClient):
506
"""Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
507
falls back to Editor"""
509
def _mail_client(self):
510
"""Determine the preferred mail client for this platform"""
511
if osutils.supports_mapi():
512
return MAPIClient(self.config)
514
return XDGEmail(self.config)
516
def compose(self, prompt, to, subject, attachment, mime_subtype,
517
extension, basename=None, body=None):
518
"""See MailClient.compose"""
520
return self._mail_client().compose(prompt, to, subject,
521
attachment, mimie_subtype,
522
extension, basename, body)
523
except errors.MailClientNotFound:
524
return Editor(self.config).compose(prompt, to, subject,
525
attachment, mimie_subtype, extension, body)
527
def compose_merge_request(self, to, subject, directive, basename=None,
529
"""See MailClient.compose_merge_request"""
531
return self._mail_client().compose_merge_request(to, subject,
532
directive, basename=basename, body=body)
533
except errors.MailClientNotFound:
534
return Editor(self.config).compose_merge_request(to, subject,
535
directive, basename=basename, body=body)
536
mail_client_registry.register('default', DefaultMail,
537
help=DefaultMail.__doc__)
538
mail_client_registry.default_key = 'default'