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., 59 Temple Place, Suite 330, Boston, MA 02111-1307 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):
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):
65
"""Compose (and possibly send) a merge request
67
:param to: The address to send the request to
68
:param subject: The subject line to use for the request
69
:param directive: A merge directive representing the merge request, as
71
:param basename: The name to use for the attachment, e.g.
74
prompt = self._get_merge_prompt("Please describe these changes:", to,
76
self.compose(prompt, to, subject, directive,
77
'x-patch', '.patch', basename)
79
def _get_merge_prompt(self, prompt, to, subject, attachment):
80
"""Generate a prompt string. Overridden by Editor.
82
:param prompt: A string suggesting what user should do
83
:param to: The address the mail will be sent to
84
:param subject: The subject line of the mail
85
:param attachment: The attachment that will be used
90
class Editor(MailClient):
91
"""DIY mail client that uses commit message editor"""
93
def _get_merge_prompt(self, prompt, to, subject, attachment):
94
"""See MailClient._get_merge_prompt"""
98
u"%s" % (prompt, to, subject,
99
attachment.decode('utf-8', 'replace')))
101
def compose(self, prompt, to, subject, attachment, mime_subtype,
102
extension, basename=None):
103
"""See MailClient.compose"""
105
raise errors.NoMailAddressSpecified()
106
body = msgeditor.edit_commit_message(prompt)
108
raise errors.NoMessageSupplied()
109
email_message.EmailMessage.send(self.config,
110
self.config.username(),
115
attachment_mime_subtype=mime_subtype)
116
mail_client_registry.register('editor', Editor,
120
class ExternalMailClient(MailClient):
121
"""An external mail client."""
123
def _get_client_commands(self):
124
"""Provide a list of commands that may invoke the mail client"""
125
if sys.platform == 'win32':
127
return [win32utils.get_app_path(i) for i in self._client_commands]
129
return self._client_commands
131
def compose(self, prompt, to, subject, attachment, mime_subtype,
132
extension, basename=None):
133
"""See MailClient.compose.
135
Writes the attachment to a temporary file, invokes _compose.
138
basename = 'attachment'
139
pathname = osutils.mkdtemp(prefix='bzr-mail-')
140
attach_path = osutils.pathjoin(pathname, basename + extension)
141
outfile = open(attach_path, 'wb')
143
outfile.write(attachment)
146
self._compose(prompt, to, subject, attach_path, mime_subtype,
149
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
151
"""Invoke a mail client as a commandline process.
153
Overridden by MAPIClient.
154
:param to: The address to send the mail to
155
:param subject: The subject line for the mail
156
:param pathname: The path to the attachment
157
:param mime_subtype: The attachment is assumed to have a major type of
158
"text", but the precise subtype can be specified here
159
:param extension: A file extension (including period) associated with
162
for name in self._get_client_commands():
163
cmdline = [self._encode_path(name, 'executable')]
164
cmdline.extend(self._get_compose_commandline(to, subject,
167
subprocess.call(cmdline)
169
if e.errno != errno.ENOENT:
174
raise errors.MailClientNotFound(self._client_commands)
176
def _get_compose_commandline(self, to, subject, attach_path):
177
"""Determine the commandline to use for composing a message
179
Implemented by various subclasses
180
:param to: The address to send the mail to
181
:param subject: The subject line for the mail
182
:param attach_path: The path to the attachment
184
raise NotImplementedError
186
def _encode_safe(self, u):
187
"""Encode possible unicode string argument to 8-bit string
188
in user_encoding. Unencodable characters will be replaced
191
:param u: possible unicode string.
192
:return: encoded string if u is unicode, u itself otherwise.
194
if isinstance(u, unicode):
195
return u.encode(osutils.get_user_encoding(), 'replace')
198
def _encode_path(self, path, kind):
199
"""Encode unicode path in user encoding.
201
:param path: possible unicode path.
202
:param kind: path kind ('executable' or 'attachment').
203
:return: encoded path if path is unicode,
204
path itself otherwise.
205
:raise: UnableEncodePath.
207
if isinstance(path, unicode):
209
return path.encode(osutils.get_user_encoding())
210
except UnicodeEncodeError:
211
raise errors.UnableEncodePath(path, kind)
215
class Evolution(ExternalMailClient):
216
"""Evolution mail client."""
218
_client_commands = ['evolution']
220
def _get_compose_commandline(self, to, subject, attach_path, body=None):
221
"""See ExternalMailClient._get_compose_commandline"""
223
if subject is not None:
224
message_options['subject'] = subject
225
if attach_path is not None:
226
message_options['attach'] = attach_path
228
message_options['body'] = body
229
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
230
sorted(message_options.iteritems())]
231
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
232
'&'.join(options_list))]
233
mail_client_registry.register('evolution', Evolution,
234
help=Evolution.__doc__)
237
class Mutt(ExternalMailClient):
238
"""Mutt mail client."""
240
_client_commands = ['mutt']
242
def _get_compose_commandline(self, to, subject, attach_path):
243
"""See ExternalMailClient._get_compose_commandline"""
245
if subject is not None:
246
message_options.extend(['-s', self._encode_safe(subject)])
247
if attach_path is not None:
248
message_options.extend(['-a',
249
self._encode_path(attach_path, 'attachment')])
251
message_options.append(self._encode_safe(to))
252
return message_options
253
mail_client_registry.register('mutt', Mutt,
257
class Thunderbird(ExternalMailClient):
258
"""Mozilla Thunderbird (or Icedove)
260
Note that Thunderbird 1.5 is buggy and does not support setting
261
"to" simultaneously with including a attachment.
263
There is a workaround if no attachment is present, but we always need to
267
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
268
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
269
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
271
def _get_compose_commandline(self, to, subject, attach_path, body=None):
272
"""See ExternalMailClient._get_compose_commandline"""
275
message_options['to'] = self._encode_safe(to)
276
if subject is not None:
277
message_options['subject'] = self._encode_safe(subject)
278
if attach_path is not None:
279
message_options['attachment'] = urlutils.local_path_to_url(
282
options_list = ['body=%s' % urllib.quote(self._encode_safe(body))]
285
options_list.extend(["%s='%s'" % (k, v) for k, v in
286
sorted(message_options.iteritems())])
287
return ['-compose', ','.join(options_list)]
288
mail_client_registry.register('thunderbird', Thunderbird,
289
help=Thunderbird.__doc__)
292
class KMail(ExternalMailClient):
293
"""KDE mail client."""
295
_client_commands = ['kmail']
297
def _get_compose_commandline(self, to, subject, attach_path):
298
"""See ExternalMailClient._get_compose_commandline"""
300
if subject is not None:
301
message_options.extend(['-s', self._encode_safe(subject)])
302
if attach_path is not None:
303
message_options.extend(['--attach',
304
self._encode_path(attach_path, 'attachment')])
306
message_options.extend([self._encode_safe(to)])
307
return message_options
308
mail_client_registry.register('kmail', KMail,
312
class Claws(ExternalMailClient):
313
"""Claws mail client."""
315
_client_commands = ['claws-mail']
317
def _get_compose_commandline(self, to, subject, attach_path):
318
"""See ExternalMailClient._get_compose_commandline"""
319
compose_url = ['mailto:']
321
compose_url.append(self._encode_safe(to))
322
compose_url.append('?')
323
if subject is not None:
324
# Don't use urllib.quote_plus because Claws doesn't seem
325
# to recognise spaces encoded as "+".
327
'subject=%s' % urllib.quote(self._encode_safe(subject)))
328
# Collect command-line options.
329
message_options = ['--compose', ''.join(compose_url)]
330
if attach_path is not None:
331
message_options.extend(
332
['--attach', self._encode_path(attach_path, 'attachment')])
333
return message_options
334
mail_client_registry.register('claws', Claws,
338
class XDGEmail(ExternalMailClient):
339
"""xdg-email attempts to invoke the user's preferred mail client"""
341
_client_commands = ['xdg-email']
343
def _get_compose_commandline(self, to, subject, attach_path, body=None):
344
"""See ExternalMailClient._get_compose_commandline"""
346
raise errors.NoMailAddressSpecified()
347
commandline = [self._encode_safe(to)]
348
if subject is not None:
349
commandline.extend(['--subject', self._encode_safe(subject)])
350
if attach_path is not None:
351
commandline.extend(['--attach',
352
self._encode_path(attach_path, 'attachment')])
354
commandline.extend(['--body', self._encode_safe(body)])
356
mail_client_registry.register('xdg-email', XDGEmail,
357
help=XDGEmail.__doc__)
360
class EmacsMail(ExternalMailClient):
361
"""Call emacsclient to have a mail buffer.
363
This only work for emacs >= 22.1 due to recent -e/--eval support.
365
The good news is that this implementation will work with all mail
366
agents registered against ``mail-user-agent``. So there is no need
367
to instantiate ExternalMailClient for each and every GNU Emacs
370
Users just have to ensure that ``mail-user-agent`` is set according
374
_client_commands = ['emacsclient']
376
def _prepare_send_function(self):
377
"""Write our wrapper function into a temporary file.
379
This temporary file will be loaded at runtime in
380
_get_compose_commandline function.
382
This function does not remove the file. That's a wanted
383
behaviour since _get_compose_commandline won't run the send
384
mail function directly but return the eligible command line.
385
Removing our temporary file here would prevent our sendmail
386
function to work. (The file is deleted by some elisp code
387
after being read by Emacs.)
390
_defun = r"""(defun bzr-add-mime-att (file)
391
"Attach FILE to a mail buffer as a MIME attachment."
392
(let ((agent mail-user-agent))
393
(if (and file (file-exists-p file))
395
((eq agent 'sendmail-user-agent)
399
(if (functionp 'etach-attach)
401
(mail-attach-file file))))
402
((or (eq agent 'message-user-agent)
403
(eq agent 'gnus-user-agent)
404
(eq agent 'mh-e-user-agent))
406
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
407
((eq agent 'mew-user-agent)
409
(mew-draft-prepare-attachments)
410
(mew-attach-link file (file-name-nondirectory file))
411
(let* ((nums (mew-syntax-nums))
412
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
413
(mew-syntax-set-cd syntax "BZR merge")
414
(mew-encode-syntax-print mew-encode-syntax))
415
(mew-header-goto-body)))
417
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
418
(error "File %s does not exist." file))))
421
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
426
os.close(fd) # Just close the handle but do not remove the file.
429
def _get_compose_commandline(self, to, subject, attach_path):
430
commandline = ["--eval"]
436
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
437
if subject is not None:
438
_subject = ("\"%s\"" %
439
self._encode_safe(subject).replace('"', '\\"'))
441
# Funcall the default mail composition function
442
# This will work with any mail mode including default mail-mode
443
# User must tweak mail-user-agent variable to tell what function
444
# will be called inside compose-mail.
445
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
446
commandline.append(mail_cmd)
448
# Try to attach a MIME attachment using our wrapper function
449
if attach_path is not None:
450
# Do not create a file if there is no attachment
451
elisp = self._prepare_send_function()
452
lmmform = '(load "%s")' % elisp
453
mmform = '(bzr-add-mime-att "%s")' % \
454
self._encode_path(attach_path, 'attachment')
455
rmform = '(delete-file "%s")' % elisp
456
commandline.append(lmmform)
457
commandline.append(mmform)
458
commandline.append(rmform)
461
mail_client_registry.register('emacsclient', EmacsMail,
462
help=EmacsMail.__doc__)
465
class MAPIClient(ExternalMailClient):
466
"""Default Windows mail client launched using MAPI."""
468
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
470
"""See ExternalMailClient._compose.
472
This implementation uses MAPI via the simplemapi ctypes wrapper
474
from bzrlib.util import simplemapi
476
simplemapi.SendMail(to or '', subject or '', '', attach_path)
477
except simplemapi.MAPIError, e:
478
if e.code != simplemapi.MAPI_USER_ABORT:
479
raise errors.MailClientNotFound(['MAPI supported mail client'
480
' (error %d)' % (e.code,)])
481
mail_client_registry.register('mapi', MAPIClient,
482
help=MAPIClient.__doc__)
485
class DefaultMail(MailClient):
486
"""Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
487
falls back to Editor"""
489
def _mail_client(self):
490
"""Determine the preferred mail client for this platform"""
491
if osutils.supports_mapi():
492
return MAPIClient(self.config)
494
return XDGEmail(self.config)
496
def compose(self, prompt, to, subject, attachment, mime_subtype,
497
extension, basename=None):
498
"""See MailClient.compose"""
500
return self._mail_client().compose(prompt, to, subject,
501
attachment, mimie_subtype,
503
except errors.MailClientNotFound:
504
return Editor(self.config).compose(prompt, to, subject,
505
attachment, mimie_subtype, extension)
507
def compose_merge_request(self, to, subject, directive, basename=None):
508
"""See MailClient.compose_merge_request"""
510
return self._mail_client().compose_merge_request(to, subject,
511
directive, basename=basename)
512
except errors.MailClientNotFound:
513
return Editor(self.config).compose_merge_request(to, subject,
514
directive, basename=basename)
515
mail_client_registry.register('default', DefaultMail,
516
help=DefaultMail.__doc__)
517
mail_client_registry.default_key = 'default'