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
33
mail_client_registry = registry.Registry()
36
class MailClient(object):
37
"""A mail client that can send messages with attachements."""
39
def __init__(self, config):
42
def compose(self, prompt, to, subject, attachment, mime_subtype,
43
extension, basename=None):
44
"""Compose (and possibly send) an email message
46
Must be implemented by subclasses.
48
:param prompt: A message to tell the user what to do. Supported by
49
the Editor client, but ignored by others
50
:param to: The address to send the message to
51
:param subject: The contents of the subject line
52
:param attachment: An email attachment, as a bytestring
53
:param mime_subtype: The attachment is assumed to be a subtype of
54
Text. This allows the precise subtype to be specified, e.g.
55
"plain", "x-patch", etc.
56
:param extension: The file extension associated with the attachment
58
:param basename: The name to use for the attachment, e.g.
61
raise NotImplementedError
63
def compose_merge_request(self, to, subject, directive, basename=None):
64
"""Compose (and possibly send) a merge request
66
:param to: The address to send the request to
67
:param subject: The subject line to use for the request
68
:param directive: A merge directive representing the merge request, as
70
:param basename: The name to use for the attachment, e.g.
73
prompt = self._get_merge_prompt("Please describe these changes:", to,
75
self.compose(prompt, to, subject, directive,
76
'x-patch', '.patch', basename)
78
def _get_merge_prompt(self, prompt, to, subject, attachment):
79
"""Generate a prompt string. Overridden by Editor.
81
:param prompt: A string suggesting what user should do
82
:param to: The address the mail will be sent to
83
:param subject: The subject line of the mail
84
:param attachment: The attachment that will be used
89
class Editor(MailClient):
90
"""DIY mail client that uses commit message editor"""
92
def _get_merge_prompt(self, prompt, to, subject, attachment):
93
"""See MailClient._get_merge_prompt"""
97
u"%s" % (prompt, to, subject,
98
attachment.decode('utf-8', 'replace')))
100
def compose(self, prompt, to, subject, attachment, mime_subtype,
101
extension, basename=None):
102
"""See MailClient.compose"""
104
raise errors.NoMailAddressSpecified()
105
body = msgeditor.edit_commit_message(prompt)
107
raise errors.NoMessageSupplied()
108
email_message.EmailMessage.send(self.config,
109
self.config.username(),
114
attachment_mime_subtype=mime_subtype)
115
mail_client_registry.register('editor', Editor,
119
class ExternalMailClient(MailClient):
120
"""An external mail client."""
122
def _get_client_commands(self):
123
"""Provide a list of commands that may invoke the mail client"""
124
if sys.platform == 'win32':
126
return [win32utils.get_app_path(i) for i in self._client_commands]
128
return self._client_commands
130
def compose(self, prompt, to, subject, attachment, mime_subtype,
131
extension, basename=None):
132
"""See MailClient.compose.
134
Writes the attachment to a temporary file, invokes _compose.
137
basename = 'attachment'
138
pathname = osutils.mkdtemp(prefix='bzr-mail-')
139
attach_path = osutils.pathjoin(pathname, basename + extension)
140
outfile = open(attach_path, 'wb')
142
outfile.write(attachment)
145
self._compose(prompt, to, subject, attach_path, mime_subtype,
148
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
150
"""Invoke a mail client as a commandline process.
152
Overridden by MAPIClient.
153
:param to: The address to send the mail to
154
:param subject: The subject line for the mail
155
:param pathname: The path to the attachment
156
:param mime_subtype: The attachment is assumed to have a major type of
157
"text", but the precise subtype can be specified here
158
:param extension: A file extension (including period) associated with
161
for name in self._get_client_commands():
162
cmdline = [self._encode_path(name, 'executable')]
163
cmdline.extend(self._get_compose_commandline(to, subject,
166
subprocess.call(cmdline)
168
if e.errno != errno.ENOENT:
173
raise errors.MailClientNotFound(self._client_commands)
175
def _get_compose_commandline(self, to, subject, attach_path):
176
"""Determine the commandline to use for composing a message
178
Implemented by various subclasses
179
:param to: The address to send the mail to
180
:param subject: The subject line for the mail
181
:param attach_path: The path to the attachment
183
raise NotImplementedError
185
def _encode_safe(self, u):
186
"""Encode possible unicode string argument to 8-bit string
187
in user_encoding. Unencodable characters will be replaced
190
:param u: possible unicode string.
191
:return: encoded string if u is unicode, u itself otherwise.
193
if isinstance(u, unicode):
194
return u.encode(bzrlib.user_encoding, 'replace')
197
def _encode_path(self, path, kind):
198
"""Encode unicode path in user encoding.
200
:param path: possible unicode path.
201
:param kind: path kind ('executable' or 'attachment').
202
:return: encoded path if path is unicode,
203
path itself otherwise.
204
:raise: UnableEncodePath.
206
if isinstance(path, unicode):
208
return path.encode(bzrlib.user_encoding)
209
except UnicodeEncodeError:
210
raise errors.UnableEncodePath(path, kind)
214
class Evolution(ExternalMailClient):
215
"""Evolution mail client."""
217
_client_commands = ['evolution']
219
def _get_compose_commandline(self, to, subject, attach_path):
220
"""See ExternalMailClient._get_compose_commandline"""
222
if subject is not None:
223
message_options['subject'] = subject
224
if attach_path is not None:
225
message_options['attach'] = attach_path
226
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
227
sorted(message_options.iteritems())]
228
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
229
'&'.join(options_list))]
230
mail_client_registry.register('evolution', Evolution,
231
help=Evolution.__doc__)
234
class Mutt(ExternalMailClient):
235
"""Mutt mail client."""
237
_client_commands = ['mutt']
239
def _get_compose_commandline(self, to, subject, attach_path):
240
"""See ExternalMailClient._get_compose_commandline"""
242
if subject is not None:
243
message_options.extend(['-s', self._encode_safe(subject)])
244
if attach_path is not None:
245
message_options.extend(['-a',
246
self._encode_path(attach_path, 'attachment')])
248
message_options.append(self._encode_safe(to))
249
return message_options
250
mail_client_registry.register('mutt', Mutt,
254
class Thunderbird(ExternalMailClient):
255
"""Mozilla Thunderbird (or Icedove)
257
Note that Thunderbird 1.5 is buggy and does not support setting
258
"to" simultaneously with including a attachment.
260
There is a workaround if no attachment is present, but we always need to
264
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
265
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
266
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
268
def _get_compose_commandline(self, to, subject, attach_path):
269
"""See ExternalMailClient._get_compose_commandline"""
272
message_options['to'] = self._encode_safe(to)
273
if subject is not None:
274
message_options['subject'] = self._encode_safe(subject)
275
if attach_path is not None:
276
message_options['attachment'] = urlutils.local_path_to_url(
278
options_list = ["%s='%s'" % (k, v) for k, v in
279
sorted(message_options.iteritems())]
280
return ['-compose', ','.join(options_list)]
281
mail_client_registry.register('thunderbird', Thunderbird,
282
help=Thunderbird.__doc__)
285
class KMail(ExternalMailClient):
286
"""KDE mail client."""
288
_client_commands = ['kmail']
290
def _get_compose_commandline(self, to, subject, attach_path):
291
"""See ExternalMailClient._get_compose_commandline"""
293
if subject is not None:
294
message_options.extend(['-s', self._encode_safe(subject)])
295
if attach_path is not None:
296
message_options.extend(['--attach',
297
self._encode_path(attach_path, 'attachment')])
299
message_options.extend([self._encode_safe(to)])
300
return message_options
301
mail_client_registry.register('kmail', KMail,
305
class XDGEmail(ExternalMailClient):
306
"""xdg-email attempts to invoke the user's preferred mail client"""
308
_client_commands = ['xdg-email']
310
def _get_compose_commandline(self, to, subject, attach_path):
311
"""See ExternalMailClient._get_compose_commandline"""
313
raise errors.NoMailAddressSpecified()
314
commandline = [self._encode_safe(to)]
315
if subject is not None:
316
commandline.extend(['--subject', self._encode_safe(subject)])
317
if attach_path is not None:
318
commandline.extend(['--attach',
319
self._encode_path(attach_path, 'attachment')])
321
mail_client_registry.register('xdg-email', XDGEmail,
322
help=XDGEmail.__doc__)
325
class EmacsMail(ExternalMailClient):
326
"""Call emacsclient to have a mail buffer.
328
This only work for emacs >= 22.1 due to recent -e/--eval support.
330
The good news is that this implementation will work with all mail
331
agents registered against ``mail-user-agent``. So there is no need
332
to instantiate ExternalMailClient for each and every GNU Emacs
335
Users just have to ensure that ``mail-user-agent`` is set according
339
_client_commands = ['emacsclient']
341
def _prepare_send_function(self):
342
"""Write our wrapper function into a temporary file.
344
This temporary file will be loaded at runtime in
345
_get_compose_commandline function.
347
This function does not remove the file. That's a wanted
348
behaviour since _get_compose_commandline won't run the send
349
mail function directly but return the eligible command line.
350
Removing our temporary file here would prevent our sendmail
351
function to work. (The file is deleted by some elisp code
352
after being read by Emacs.)
355
_defun = r"""(defun bzr-add-mime-att (file)
356
"Attach FILE to a mail buffer as a MIME attachment."
357
(let ((agent mail-user-agent))
358
(if (and file (file-exists-p file))
360
((eq agent 'sendmail-user-agent)
364
(if (functionp 'etach-attach)
366
(mail-attach-file file))))
367
((or (eq agent 'message-user-agent)(eq agent 'gnus-user-agent))
369
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
370
((eq agent 'mew-user-agent)
372
(mew-draft-prepare-attachments)
373
(mew-attach-link file (file-name-nondirectory file))
374
(let* ((nums (mew-syntax-nums))
375
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
376
(mew-syntax-set-cd syntax "BZR merge")
377
(mew-encode-syntax-print mew-encode-syntax))
378
(mew-header-goto-body)))
380
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
381
(error "File %s does not exist." file))))
384
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
389
os.close(fd) # Just close the handle but do not remove the file.
392
def _get_compose_commandline(self, to, subject, attach_path):
393
commandline = ["--eval"]
399
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
400
if subject is not None:
401
_subject = ("\"%s\"" %
402
self._encode_safe(subject).replace('"', '\\"'))
404
# Funcall the default mail composition function
405
# This will work with any mail mode including default mail-mode
406
# User must tweak mail-user-agent variable to tell what function
407
# will be called inside compose-mail.
408
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
409
commandline.append(mail_cmd)
411
# Try to attach a MIME attachment using our wrapper function
412
if attach_path is not None:
413
# Do not create a file if there is no attachment
414
elisp = self._prepare_send_function()
415
lmmform = '(load "%s")' % elisp
416
mmform = '(bzr-add-mime-att "%s")' % \
417
self._encode_path(attach_path, 'attachment')
418
rmform = '(delete-file "%s")' % elisp
419
commandline.append(lmmform)
420
commandline.append(mmform)
421
commandline.append(rmform)
424
mail_client_registry.register('emacsclient', EmacsMail,
425
help=EmacsMail.__doc__)
428
class MAPIClient(ExternalMailClient):
429
"""Default Windows mail client launched using MAPI."""
431
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
433
"""See ExternalMailClient._compose.
435
This implementation uses MAPI via the simplemapi ctypes wrapper
437
from bzrlib.util import simplemapi
439
simplemapi.SendMail(to or '', subject or '', '', attach_path)
440
except simplemapi.MAPIError, e:
441
if e.code != simplemapi.MAPI_USER_ABORT:
442
raise errors.MailClientNotFound(['MAPI supported mail client'
443
' (error %d)' % (e.code,)])
444
mail_client_registry.register('mapi', MAPIClient,
445
help=MAPIClient.__doc__)
448
class DefaultMail(MailClient):
449
"""Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
450
falls back to Editor"""
452
def _mail_client(self):
453
"""Determine the preferred mail client for this platform"""
454
if osutils.supports_mapi():
455
return MAPIClient(self.config)
457
return XDGEmail(self.config)
459
def compose(self, prompt, to, subject, attachment, mime_subtype,
460
extension, basename=None):
461
"""See MailClient.compose"""
463
return self._mail_client().compose(prompt, to, subject,
464
attachment, mimie_subtype,
466
except errors.MailClientNotFound:
467
return Editor(self.config).compose(prompt, to, subject,
468
attachment, mimie_subtype, extension)
470
def compose_merge_request(self, to, subject, directive, basename=None):
471
"""See MailClient.compose_merge_request"""
473
return self._mail_client().compose_merge_request(to, subject,
474
directive, basename=basename)
475
except errors.MailClientNotFound:
476
return Editor(self.config).compose_merge_request(to, subject,
477
directive, basename=basename)
478
mail_client_registry.register('default', DefaultMail,
479
help=DefaultMail.__doc__)
480
mail_client_registry.default_key = 'default'