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(BodyExternalMailClient):
261
"""Mutt mail client."""
263
_client_commands = ['mutt']
265
def _get_compose_commandline(self, to, subject, attach_path, body=None):
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
# Store the temp file object in self, so that it does not get
275
# garbage collected and delete the file before mutt can read it.
276
self._temp_file = tempfile.NamedTemporaryFile(
277
prefix="mutt-body-", suffix=".txt")
278
self._temp_file.write(body)
279
self._temp_file.flush()
280
message_options.extend(['-i', self._temp_file.name])
282
message_options.extend(['--', self._encode_safe(to)])
283
return message_options
284
mail_client_registry.register('mutt', Mutt,
288
class Thunderbird(BodyExternalMailClient):
289
"""Mozilla Thunderbird (or Icedove)
291
Note that Thunderbird 1.5 is buggy and does not support setting
292
"to" simultaneously with including a attachment.
294
There is a workaround if no attachment is present, but we always need to
298
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
299
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
300
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
302
def _get_compose_commandline(self, to, subject, attach_path, body=None):
303
"""See ExternalMailClient._get_compose_commandline"""
306
message_options['to'] = self._encode_safe(to)
307
if subject is not None:
308
message_options['subject'] = self._encode_safe(subject)
309
if attach_path is not None:
310
message_options['attachment'] = urlutils.local_path_to_url(
313
options_list = ['body=%s' % urllib.quote(self._encode_safe(body))]
316
options_list.extend(["%s='%s'" % (k, v) for k, v in
317
sorted(message_options.iteritems())])
318
return ['-compose', ','.join(options_list)]
319
mail_client_registry.register('thunderbird', Thunderbird,
320
help=Thunderbird.__doc__)
323
class KMail(ExternalMailClient):
324
"""KDE mail client."""
326
_client_commands = ['kmail']
328
def _get_compose_commandline(self, to, subject, attach_path):
329
"""See ExternalMailClient._get_compose_commandline"""
331
if subject is not None:
332
message_options.extend(['-s', self._encode_safe(subject)])
333
if attach_path is not None:
334
message_options.extend(['--attach',
335
self._encode_path(attach_path, 'attachment')])
337
message_options.extend([self._encode_safe(to)])
338
return message_options
339
mail_client_registry.register('kmail', KMail,
343
class Claws(ExternalMailClient):
344
"""Claws mail client."""
348
_client_commands = ['claws-mail']
350
def _get_compose_commandline(self, to, subject, attach_path, body=None,
352
"""See ExternalMailClient._get_compose_commandline"""
354
if from_ is not None:
355
compose_url.append('from=' + urllib.quote(from_))
356
if subject is not None:
357
# Don't use urllib.quote_plus because Claws doesn't seem
358
# to recognise spaces encoded as "+".
360
'subject=' + urllib.quote(self._encode_safe(subject)))
363
'body=' + urllib.quote(self._encode_safe(body)))
364
# to must be supplied for the claws-mail --compose syntax to work.
366
raise errors.NoMailAddressSpecified()
367
compose_url = 'mailto:%s?%s' % (
368
self._encode_safe(to), '&'.join(compose_url))
369
# Collect command-line options.
370
message_options = ['--compose', compose_url]
371
if attach_path is not None:
372
message_options.extend(
373
['--attach', self._encode_path(attach_path, 'attachment')])
374
return message_options
376
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
377
extension, body=None, from_=None):
378
"""See ExternalMailClient._compose"""
380
from_ = self.config.get_user_option('email')
381
super(Claws, self)._compose(prompt, to, subject, attach_path,
382
mime_subtype, extension, body, from_)
385
mail_client_registry.register('claws', Claws,
389
class XDGEmail(BodyExternalMailClient):
390
"""xdg-email attempts to invoke the user's preferred mail client"""
392
_client_commands = ['xdg-email']
394
def _get_compose_commandline(self, to, subject, attach_path, body=None):
395
"""See ExternalMailClient._get_compose_commandline"""
397
raise errors.NoMailAddressSpecified()
398
commandline = [self._encode_safe(to)]
399
if subject is not None:
400
commandline.extend(['--subject', self._encode_safe(subject)])
401
if attach_path is not None:
402
commandline.extend(['--attach',
403
self._encode_path(attach_path, 'attachment')])
405
commandline.extend(['--body', self._encode_safe(body)])
407
mail_client_registry.register('xdg-email', XDGEmail,
408
help=XDGEmail.__doc__)
411
class EmacsMail(ExternalMailClient):
412
"""Call emacsclient to have a mail buffer.
414
This only work for emacs >= 22.1 due to recent -e/--eval support.
416
The good news is that this implementation will work with all mail
417
agents registered against ``mail-user-agent``. So there is no need
418
to instantiate ExternalMailClient for each and every GNU Emacs
421
Users just have to ensure that ``mail-user-agent`` is set according
425
_client_commands = ['emacsclient']
427
def _prepare_send_function(self):
428
"""Write our wrapper function into a temporary file.
430
This temporary file will be loaded at runtime in
431
_get_compose_commandline function.
433
This function does not remove the file. That's a wanted
434
behaviour since _get_compose_commandline won't run the send
435
mail function directly but return the eligible command line.
436
Removing our temporary file here would prevent our sendmail
437
function to work. (The file is deleted by some elisp code
438
after being read by Emacs.)
441
_defun = r"""(defun bzr-add-mime-att (file)
442
"Attach FILE to a mail buffer as a MIME attachment."
443
(let ((agent mail-user-agent))
444
(if (and file (file-exists-p file))
446
((eq agent 'sendmail-user-agent)
450
(if (functionp 'etach-attach)
452
(mail-attach-file file))))
453
((or (eq agent 'message-user-agent)
454
(eq agent 'gnus-user-agent)
455
(eq agent 'mh-e-user-agent))
457
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
458
((eq agent 'mew-user-agent)
460
(mew-draft-prepare-attachments)
461
(mew-attach-link file (file-name-nondirectory file))
462
(let* ((nums (mew-syntax-nums))
463
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
464
(mew-syntax-set-cd syntax "BZR merge")
465
(mew-encode-syntax-print mew-encode-syntax))
466
(mew-header-goto-body)))
468
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
469
(error "File %s does not exist." file))))
472
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
477
os.close(fd) # Just close the handle but do not remove the file.
480
def _get_compose_commandline(self, to, subject, attach_path):
481
commandline = ["--eval"]
487
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
488
if subject is not None:
489
_subject = ("\"%s\"" %
490
self._encode_safe(subject).replace('"', '\\"'))
492
# Funcall the default mail composition function
493
# This will work with any mail mode including default mail-mode
494
# User must tweak mail-user-agent variable to tell what function
495
# will be called inside compose-mail.
496
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
497
commandline.append(mail_cmd)
499
# Try to attach a MIME attachment using our wrapper function
500
if attach_path is not None:
501
# Do not create a file if there is no attachment
502
elisp = self._prepare_send_function()
503
lmmform = '(load "%s")' % elisp
504
mmform = '(bzr-add-mime-att "%s")' % \
505
self._encode_path(attach_path, 'attachment')
506
rmform = '(delete-file "%s")' % elisp
507
commandline.append(lmmform)
508
commandline.append(mmform)
509
commandline.append(rmform)
512
mail_client_registry.register('emacsclient', EmacsMail,
513
help=EmacsMail.__doc__)
516
class MAPIClient(BodyExternalMailClient):
517
"""Default Windows mail client launched using MAPI."""
519
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
520
extension, body=None):
521
"""See ExternalMailClient._compose.
523
This implementation uses MAPI via the simplemapi ctypes wrapper
525
from bzrlib.util import simplemapi
527
simplemapi.SendMail(to or '', subject or '', body or '',
529
except simplemapi.MAPIError, e:
530
if e.code != simplemapi.MAPI_USER_ABORT:
531
raise errors.MailClientNotFound(['MAPI supported mail client'
532
' (error %d)' % (e.code,)])
533
mail_client_registry.register('mapi', MAPIClient,
534
help=MAPIClient.__doc__)
537
class DefaultMail(MailClient):
538
"""Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
539
falls back to Editor"""
543
def _mail_client(self):
544
"""Determine the preferred mail client for this platform"""
545
if osutils.supports_mapi():
546
return MAPIClient(self.config)
548
return XDGEmail(self.config)
550
def compose(self, prompt, to, subject, attachment, mime_subtype,
551
extension, basename=None, body=None):
552
"""See MailClient.compose"""
554
return self._mail_client().compose(prompt, to, subject,
555
attachment, mimie_subtype,
556
extension, basename, body)
557
except errors.MailClientNotFound:
558
return Editor(self.config).compose(prompt, to, subject,
559
attachment, mimie_subtype, extension, body)
561
def compose_merge_request(self, to, subject, directive, basename=None,
563
"""See MailClient.compose_merge_request"""
565
return self._mail_client().compose_merge_request(to, subject,
566
directive, basename=basename, body=body)
567
except errors.MailClientNotFound:
568
return Editor(self.config).compose_merge_request(to, subject,
569
directive, basename=basename, body=body)
570
mail_client_registry.register('default', DefaultMail,
571
help=DefaultMail.__doc__)
572
mail_client_registry.default_key = 'default'