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
class MailClient(object):
34
"""A mail client that can send messages with attachements."""
36
def __init__(self, config):
39
def compose(self, prompt, to, subject, attachment, mime_subtype,
40
extension, basename=None):
41
"""Compose (and possibly send) an email message
43
Must be implemented by subclasses.
45
:param prompt: A message to tell the user what to do. Supported by
46
the Editor client, but ignored by others
47
:param to: The address to send the message to
48
:param subject: The contents of the subject line
49
:param attachment: An email attachment, as a bytestring
50
:param mime_subtype: The attachment is assumed to be a subtype of
51
Text. This allows the precise subtype to be specified, e.g.
52
"plain", "x-patch", etc.
53
:param extension: The file extension associated with the attachment
55
:param basename: The name to use for the attachment, e.g.
58
raise NotImplementedError
60
def compose_merge_request(self, to, subject, directive, basename=None):
61
"""Compose (and possibly send) a merge request
63
:param to: The address to send the request to
64
:param subject: The subject line to use for the request
65
:param directive: A merge directive representing the merge request, as
67
:param basename: The name to use for the attachment, e.g.
70
prompt = self._get_merge_prompt("Please describe these changes:", to,
72
self.compose(prompt, to, subject, directive,
73
'x-patch', '.patch', basename)
75
def _get_merge_prompt(self, prompt, to, subject, attachment):
76
"""Generate a prompt string. Overridden by Editor.
78
:param prompt: A string suggesting what user should do
79
:param to: The address the mail will be sent to
80
:param subject: The subject line of the mail
81
:param attachment: The attachment that will be used
86
class Editor(MailClient):
87
"""DIY mail client that uses commit message editor"""
89
def _get_merge_prompt(self, prompt, to, subject, attachment):
90
"""See MailClient._get_merge_prompt"""
94
u"%s" % (prompt, to, subject,
95
attachment.decode('utf-8', 'replace')))
97
def compose(self, prompt, to, subject, attachment, mime_subtype,
98
extension, basename=None):
99
"""See MailClient.compose"""
101
raise errors.NoMailAddressSpecified()
102
body = msgeditor.edit_commit_message(prompt)
104
raise errors.NoMessageSupplied()
105
email_message.EmailMessage.send(self.config,
106
self.config.username(),
111
attachment_mime_subtype=mime_subtype)
114
class ExternalMailClient(MailClient):
115
"""An external mail client."""
117
def _get_client_commands(self):
118
"""Provide a list of commands that may invoke the mail client"""
119
if sys.platform == 'win32':
121
return [win32utils.get_app_path(i) for i in self._client_commands]
123
return self._client_commands
125
def compose(self, prompt, to, subject, attachment, mime_subtype,
126
extension, basename=None):
127
"""See MailClient.compose.
129
Writes the attachment to a temporary file, invokes _compose.
132
basename = 'attachment'
133
pathname = tempfile.mkdtemp(prefix='bzr-mail-')
134
attach_path = osutils.pathjoin(pathname, basename + extension)
135
outfile = open(attach_path, 'wb')
137
outfile.write(attachment)
140
self._compose(prompt, to, subject, attach_path, mime_subtype,
143
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
145
"""Invoke a mail client as a commandline process.
147
Overridden by MAPIClient.
148
:param to: The address to send the mail to
149
:param subject: The subject line for the mail
150
:param pathname: The path to the attachment
151
:param mime_subtype: The attachment is assumed to have a major type of
152
"text", but the precise subtype can be specified here
153
:param extension: A file extension (including period) associated with
156
for name in self._get_client_commands():
157
cmdline = [self._encode_path(name, 'executable')]
158
cmdline.extend(self._get_compose_commandline(to, subject,
161
subprocess.call(cmdline)
163
if e.errno != errno.ENOENT:
168
raise errors.MailClientNotFound(self._client_commands)
170
def _get_compose_commandline(self, to, subject, attach_path):
171
"""Determine the commandline to use for composing a message
173
Implemented by various subclasses
174
:param to: The address to send the mail to
175
:param subject: The subject line for the mail
176
:param attach_path: The path to the attachment
178
raise NotImplementedError
180
def _encode_safe(self, u):
181
"""Encode possible unicode string argument to 8-bit string
182
in user_encoding. Unencodable characters will be replaced
185
:param u: possible unicode string.
186
:return: encoded string if u is unicode, u itself otherwise.
188
if isinstance(u, unicode):
189
return u.encode(bzrlib.user_encoding, 'replace')
192
def _encode_path(self, path, kind):
193
"""Encode unicode path in user encoding.
195
:param path: possible unicode path.
196
:param kind: path kind ('executable' or 'attachment').
197
:return: encoded path if path is unicode,
198
path itself otherwise.
199
:raise: UnableEncodePath.
201
if isinstance(path, unicode):
203
return path.encode(bzrlib.user_encoding)
204
except UnicodeEncodeError:
205
raise errors.UnableEncodePath(path, kind)
209
class Evolution(ExternalMailClient):
210
"""Evolution mail client."""
212
_client_commands = ['evolution']
214
def _get_compose_commandline(self, to, subject, attach_path):
215
"""See ExternalMailClient._get_compose_commandline"""
217
if subject is not None:
218
message_options['subject'] = subject
219
if attach_path is not None:
220
message_options['attach'] = attach_path
221
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
222
sorted(message_options.iteritems())]
223
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
224
'&'.join(options_list))]
227
class Mutt(ExternalMailClient):
228
"""Mutt mail client."""
230
_client_commands = ['mutt']
232
def _get_compose_commandline(self, to, subject, attach_path):
233
"""See ExternalMailClient._get_compose_commandline"""
235
if subject is not None:
236
message_options.extend(['-s', self._encode_safe(subject)])
237
if attach_path is not None:
238
message_options.extend(['-a',
239
self._encode_path(attach_path, 'attachment')])
241
message_options.append(self._encode_safe(to))
242
return message_options
245
class Thunderbird(ExternalMailClient):
246
"""Mozilla Thunderbird (or Icedove)
248
Note that Thunderbird 1.5 is buggy and does not support setting
249
"to" simultaneously with including a attachment.
251
There is a workaround if no attachment is present, but we always need to
255
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
256
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin']
258
def _get_compose_commandline(self, to, subject, attach_path):
259
"""See ExternalMailClient._get_compose_commandline"""
262
message_options['to'] = self._encode_safe(to)
263
if subject is not None:
264
message_options['subject'] = self._encode_safe(subject)
265
if attach_path is not None:
266
message_options['attachment'] = urlutils.local_path_to_url(
268
options_list = ["%s='%s'" % (k, v) for k, v in
269
sorted(message_options.iteritems())]
270
return ['-compose', ','.join(options_list)]
273
class KMail(ExternalMailClient):
274
"""KDE mail client."""
276
_client_commands = ['kmail']
278
def _get_compose_commandline(self, to, subject, attach_path):
279
"""See ExternalMailClient._get_compose_commandline"""
281
if subject is not None:
282
message_options.extend(['-s', self._encode_safe(subject)])
283
if attach_path is not None:
284
message_options.extend(['--attach',
285
self._encode_path(attach_path, 'attachment')])
287
message_options.extend([self._encode_safe(to)])
288
return message_options
291
class XDGEmail(ExternalMailClient):
292
"""xdg-email attempts to invoke the user's preferred mail client"""
294
_client_commands = ['xdg-email']
296
def _get_compose_commandline(self, to, subject, attach_path):
297
"""See ExternalMailClient._get_compose_commandline"""
299
raise errors.NoMailAddressSpecified()
300
commandline = [self._encode_safe(to)]
301
if subject is not None:
302
commandline.extend(['--subject', self._encode_safe(subject)])
303
if attach_path is not None:
304
commandline.extend(['--attach',
305
self._encode_path(attach_path, 'attachment')])
309
class EmacsMail(ExternalMailClient):
310
"""Call emacsclient to have a mail buffer.
312
This only work for emacs >= 22.1 due to recent -e/--eval support.
314
The good news is that this implementation will work with all mail
315
agents registered against ``mail-user-agent``. So there is no need
316
to instantiate ExternalMailClient for each and every GNU Emacs
319
Users just have to ensure that ``mail-user-agent`` is set according
323
_client_commands = ['emacsclient']
325
def _prepare_send_function(self):
326
"""Write our wrapper function into a temporary file.
328
This temporary file will be loaded at runtime in
329
_get_compose_commandline function.
331
FIXME: this function does not remove the file. That's a wanted
332
behaviour since _get_compose_commandline won't run the send
333
mail function directly but return the eligible command line.
334
Removing our temporary file here would prevent our sendmail
337
A possible workaround could be to load the function here with
338
emacsclient --eval '(load temp)' but this is not robust since
339
emacs could have been stopped between here and the call to
343
_defun = r"""(defun bzr-add-mime-att (file)
344
"Attach FILe to a mail buffer as a MIME attachment."
345
(let ((agent mail-user-agent))
348
(if (and file (file-exists-p file))
350
((eq agent 'sendmail-user-agent)
352
((or (eq agent 'message-user-agent)(eq agent 'gnus-user-agent))
353
(mml-attach-file file "text/x-patch" "BZR merge" "attachment"))
355
(message "Unhandled MUA")))
356
(error "File %s does not exist." file))))
359
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
364
os.close(fd) # Just close the handle but do not remove the file.
367
def _get_compose_commandline(self, to, subject, attach_path):
368
commandline = ["--eval"]
374
_to = ("\"%s\"" % self._encode_safe(to))
375
if subject is not None:
376
_subject = ("\"%s\"" % self._encode_safe(subject))
378
# Funcall the default mail composition function
379
# This will work with any mail mode including default mail-mode
380
# User must tweak mail-user-agent variable to tell what function
381
# will be called inside compose-mail.
382
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
383
commandline.append(mail_cmd)
385
# Try to attach a MIME attachment using our wrapper function
386
if attach_path is not None:
387
# Do not create a file if there is no attachment
388
lmmform = '(load "%s")' % self._prepare_send_function()
389
mmform = '(bzr-add-mime-att "%s")' % \
390
self._encode_path(attach_path, 'attachment')
391
commandline.append(lmmform)
392
commandline.append(mmform)
397
class MAPIClient(ExternalMailClient):
398
"""Default Windows mail client launched using MAPI."""
400
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
402
"""See ExternalMailClient._compose.
404
This implementation uses MAPI via the simplemapi ctypes wrapper
406
from bzrlib.util import simplemapi
408
simplemapi.SendMail(to or '', subject or '', '', attach_path)
409
except simplemapi.MAPIError, e:
410
if e.code != simplemapi.MAPI_USER_ABORT:
411
raise errors.MailClientNotFound(['MAPI supported mail client'
412
' (error %d)' % (e.code,)])
415
class DefaultMail(MailClient):
416
"""Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
417
falls back to Editor"""
419
def _mail_client(self):
420
"""Determine the preferred mail client for this platform"""
421
if osutils.supports_mapi():
422
return MAPIClient(self.config)
424
return XDGEmail(self.config)
426
def compose(self, prompt, to, subject, attachment, mime_subtype,
427
extension, basename=None):
428
"""See MailClient.compose"""
430
return self._mail_client().compose(prompt, to, subject,
431
attachment, mimie_subtype,
433
except errors.MailClientNotFound:
434
return Editor(self.config).compose(prompt, to, subject,
435
attachment, mimie_subtype, extension)
437
def compose_merge_request(self, to, subject, directive, basename=None):
438
"""See MailClient.compose_merge_request"""
440
return self._mail_client().compose_merge_request(to, subject,
441
directive, basename=basename)
442
except errors.MailClientNotFound:
443
return Editor(self.config).compose_merge_request(to, subject,
444
directive, basename=basename)