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)
118
class ExternalMailClient(MailClient):
119
"""An external mail client."""
121
def _get_client_commands(self):
122
"""Provide a list of commands that may invoke the mail client"""
123
if sys.platform == 'win32':
125
return [win32utils.get_app_path(i) for i in self._client_commands]
127
return self._client_commands
129
def compose(self, prompt, to, subject, attachment, mime_subtype,
130
extension, basename=None):
131
"""See MailClient.compose.
133
Writes the attachment to a temporary file, invokes _compose.
136
basename = 'attachment'
137
pathname = tempfile.mkdtemp(prefix='bzr-mail-')
138
attach_path = osutils.pathjoin(pathname, basename + extension)
139
outfile = open(attach_path, 'wb')
141
outfile.write(attachment)
144
self._compose(prompt, to, subject, attach_path, mime_subtype,
147
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
149
"""Invoke a mail client as a commandline process.
151
Overridden by MAPIClient.
152
:param to: The address to send the mail to
153
:param subject: The subject line for the mail
154
:param pathname: The path to the attachment
155
:param mime_subtype: The attachment is assumed to have a major type of
156
"text", but the precise subtype can be specified here
157
:param extension: A file extension (including period) associated with
160
for name in self._get_client_commands():
161
cmdline = [self._encode_path(name, 'executable')]
162
cmdline.extend(self._get_compose_commandline(to, subject,
165
subprocess.call(cmdline)
167
if e.errno != errno.ENOENT:
172
raise errors.MailClientNotFound(self._client_commands)
174
def _get_compose_commandline(self, to, subject, attach_path):
175
"""Determine the commandline to use for composing a message
177
Implemented by various subclasses
178
:param to: The address to send the mail to
179
:param subject: The subject line for the mail
180
:param attach_path: The path to the attachment
182
raise NotImplementedError
184
def _encode_safe(self, u):
185
"""Encode possible unicode string argument to 8-bit string
186
in user_encoding. Unencodable characters will be replaced
189
:param u: possible unicode string.
190
:return: encoded string if u is unicode, u itself otherwise.
192
if isinstance(u, unicode):
193
return u.encode(bzrlib.user_encoding, 'replace')
196
def _encode_path(self, path, kind):
197
"""Encode unicode path in user encoding.
199
:param path: possible unicode path.
200
:param kind: path kind ('executable' or 'attachment').
201
:return: encoded path if path is unicode,
202
path itself otherwise.
203
:raise: UnableEncodePath.
205
if isinstance(path, unicode):
207
return path.encode(bzrlib.user_encoding)
208
except UnicodeEncodeError:
209
raise errors.UnableEncodePath(path, kind)
213
class Evolution(ExternalMailClient):
214
"""Evolution mail client."""
216
_client_commands = ['evolution']
218
def _get_compose_commandline(self, to, subject, attach_path):
219
"""See ExternalMailClient._get_compose_commandline"""
221
if subject is not None:
222
message_options['subject'] = subject
223
if attach_path is not None:
224
message_options['attach'] = attach_path
225
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
226
sorted(message_options.iteritems())]
227
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
228
'&'.join(options_list))]
229
mail_client_registry.register('evolution', Evolution)
232
class Mutt(ExternalMailClient):
233
"""Mutt mail client."""
235
_client_commands = ['mutt']
237
def _get_compose_commandline(self, to, subject, attach_path):
238
"""See ExternalMailClient._get_compose_commandline"""
240
if subject is not None:
241
message_options.extend(['-s', self._encode_safe(subject)])
242
if attach_path is not None:
243
message_options.extend(['-a',
244
self._encode_path(attach_path, 'attachment')])
246
message_options.append(self._encode_safe(to))
247
return message_options
248
mail_client_registry.register('mutt', Mutt)
251
class Thunderbird(ExternalMailClient):
252
"""Mozilla Thunderbird (or Icedove)
254
Note that Thunderbird 1.5 is buggy and does not support setting
255
"to" simultaneously with including a attachment.
257
There is a workaround if no attachment is present, but we always need to
261
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
262
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
263
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
265
def _get_compose_commandline(self, to, subject, attach_path):
266
"""See ExternalMailClient._get_compose_commandline"""
269
message_options['to'] = self._encode_safe(to)
270
if subject is not None:
271
message_options['subject'] = self._encode_safe(subject)
272
if attach_path is not None:
273
message_options['attachment'] = urlutils.local_path_to_url(
275
options_list = ["%s='%s'" % (k, v) for k, v in
276
sorted(message_options.iteritems())]
277
return ['-compose', ','.join(options_list)]
278
mail_client_registry.register('thunderbird', Thunderbird)
281
class KMail(ExternalMailClient):
282
"""KDE mail client."""
284
_client_commands = ['kmail']
286
def _get_compose_commandline(self, to, subject, attach_path):
287
"""See ExternalMailClient._get_compose_commandline"""
289
if subject is not None:
290
message_options.extend(['-s', self._encode_safe(subject)])
291
if attach_path is not None:
292
message_options.extend(['--attach',
293
self._encode_path(attach_path, 'attachment')])
295
message_options.extend([self._encode_safe(to)])
296
return message_options
297
mail_client_registry.register('kmail', KMail)
300
class XDGEmail(ExternalMailClient):
301
"""xdg-email attempts to invoke the user's preferred mail client"""
303
_client_commands = ['xdg-email']
305
def _get_compose_commandline(self, to, subject, attach_path):
306
"""See ExternalMailClient._get_compose_commandline"""
308
raise errors.NoMailAddressSpecified()
309
commandline = [self._encode_safe(to)]
310
if subject is not None:
311
commandline.extend(['--subject', self._encode_safe(subject)])
312
if attach_path is not None:
313
commandline.extend(['--attach',
314
self._encode_path(attach_path, 'attachment')])
316
mail_client_registry.register('xdg-email', XDGEmail)
319
class EmacsMail(ExternalMailClient):
320
"""Call emacsclient to have a mail buffer.
322
This only work for emacs >= 22.1 due to recent -e/--eval support.
324
The good news is that this implementation will work with all mail
325
agents registered against ``mail-user-agent``. So there is no need
326
to instantiate ExternalMailClient for each and every GNU Emacs
329
Users just have to ensure that ``mail-user-agent`` is set according
333
_client_commands = ['emacsclient']
335
def _prepare_send_function(self):
336
"""Write our wrapper function into a temporary file.
338
This temporary file will be loaded at runtime in
339
_get_compose_commandline function.
341
This function does not remove the file. That's a wanted
342
behaviour since _get_compose_commandline won't run the send
343
mail function directly but return the eligible command line.
344
Removing our temporary file here would prevent our sendmail
345
function to work. (The file is deleted by some elisp code
346
after being read by Emacs.)
349
_defun = r"""(defun bzr-add-mime-att (file)
350
"Attach FILE to a mail buffer as a MIME attachment."
351
(let ((agent mail-user-agent))
352
(if (and file (file-exists-p file))
354
((eq agent 'sendmail-user-agent)
358
(if (functionp 'etach-attach)
360
(mail-attach-file file))))
361
((or (eq agent 'message-user-agent)(eq agent 'gnus-user-agent))
363
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
364
((eq agent 'mew-user-agent)
366
(mew-draft-prepare-attachments)
367
(mew-attach-link file (file-name-nondirectory file))
368
(let* ((nums (mew-syntax-nums))
369
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
370
(mew-syntax-set-cd syntax "BZR merge")
371
(mew-encode-syntax-print mew-encode-syntax))
372
(mew-header-goto-body)))
374
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
375
(error "File %s does not exist." file))))
378
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
383
os.close(fd) # Just close the handle but do not remove the file.
386
def _get_compose_commandline(self, to, subject, attach_path):
387
commandline = ["--eval"]
393
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
394
if subject is not None:
395
_subject = ("\"%s\"" %
396
self._encode_safe(subject).replace('"', '\\"'))
398
# Funcall the default mail composition function
399
# This will work with any mail mode including default mail-mode
400
# User must tweak mail-user-agent variable to tell what function
401
# will be called inside compose-mail.
402
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
403
commandline.append(mail_cmd)
405
# Try to attach a MIME attachment using our wrapper function
406
if attach_path is not None:
407
# Do not create a file if there is no attachment
408
elisp = self._prepare_send_function()
409
lmmform = '(load "%s")' % elisp
410
mmform = '(bzr-add-mime-att "%s")' % \
411
self._encode_path(attach_path, 'attachment')
412
rmform = '(delete-file "%s")' % elisp
413
commandline.append(lmmform)
414
commandline.append(mmform)
415
commandline.append(rmform)
418
mail_client_registry.register('emacsclient', EmacsMail)
421
class MAPIClient(ExternalMailClient):
422
"""Default Windows mail client launched using MAPI."""
424
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
426
"""See ExternalMailClient._compose.
428
This implementation uses MAPI via the simplemapi ctypes wrapper
430
from bzrlib.util import simplemapi
432
simplemapi.SendMail(to or '', subject or '', '', attach_path)
433
except simplemapi.MAPIError, e:
434
if e.code != simplemapi.MAPI_USER_ABORT:
435
raise errors.MailClientNotFound(['MAPI supported mail client'
436
' (error %d)' % (e.code,)])
437
mail_client_registry.register('mapi', MAPIClient)
440
class DefaultMail(MailClient):
441
"""Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
442
falls back to Editor"""
444
def _mail_client(self):
445
"""Determine the preferred mail client for this platform"""
446
if osutils.supports_mapi():
447
return MAPIClient(self.config)
449
return XDGEmail(self.config)
451
def compose(self, prompt, to, subject, attachment, mime_subtype,
452
extension, basename=None):
453
"""See MailClient.compose"""
455
return self._mail_client().compose(prompt, to, subject,
456
attachment, mimie_subtype,
458
except errors.MailClientNotFound:
459
return Editor(self.config).compose(prompt, to, subject,
460
attachment, mimie_subtype, extension)
462
def compose_merge_request(self, to, subject, directive, basename=None):
463
"""See MailClient.compose_merge_request"""
465
return self._mail_client().compose_merge_request(to, subject,
466
directive, basename=basename)
467
except errors.MailClientNotFound:
468
return Editor(self.config).compose_merge_request(to, subject,
469
directive, basename=basename)
470
mail_client_registry.register('default', DefaultMail)
471
mail_client_registry.default_key = 'default'