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
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
335
function to work. (The file is deleted by some elisp code
336
after being read by Emacs.)
339
_defun = r"""(defun bzr-add-mime-att (file)
340
"Attach FILE to a mail buffer as a MIME attachment."
341
(let ((agent mail-user-agent))
342
(if (and file (file-exists-p file))
344
((eq agent 'sendmail-user-agent)
348
(if (functionp 'etach-attach)
350
(mail-attach-file file))))
351
((or (eq agent 'message-user-agent)(eq agent 'gnus-user-agent))
353
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
354
((eq agent 'mew-user-agent)
356
(mew-draft-prepare-attachments)
357
(mew-attach-link file (file-name-nondirectory file))
358
(let* ((nums (mew-syntax-nums))
359
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
360
(mew-syntax-set-cd syntax "BZR merge")
361
(mew-encode-syntax-print mew-encode-syntax))
362
(mew-header-goto-body)))
364
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
365
(error "File %s does not exist." file))))
368
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
373
os.close(fd) # Just close the handle but do not remove the file.
376
def _get_compose_commandline(self, to, subject, attach_path):
377
commandline = ["--eval"]
383
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
384
if subject is not None:
385
_subject = ("\"%s\"" %
386
self._encode_safe(subject).replace('"', '\\"'))
388
# Funcall the default mail composition function
389
# This will work with any mail mode including default mail-mode
390
# User must tweak mail-user-agent variable to tell what function
391
# will be called inside compose-mail.
392
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
393
commandline.append(mail_cmd)
395
# Try to attach a MIME attachment using our wrapper function
396
if attach_path is not None:
397
# Do not create a file if there is no attachment
398
elisp = self._prepare_send_function()
399
lmmform = '(load "%s")' % elisp
400
mmform = '(bzr-add-mime-att "%s")' % \
401
self._encode_path(attach_path, 'attachment')
402
rmform = '(delete-file "%s")' % elisp
403
commandline.append(lmmform)
404
commandline.append(mmform)
405
commandline.append(rmform)
410
class MAPIClient(ExternalMailClient):
411
"""Default Windows mail client launched using MAPI."""
413
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
415
"""See ExternalMailClient._compose.
417
This implementation uses MAPI via the simplemapi ctypes wrapper
419
from bzrlib.util import simplemapi
421
simplemapi.SendMail(to or '', subject or '', '', attach_path)
422
except simplemapi.MAPIError, e:
423
if e.code != simplemapi.MAPI_USER_ABORT:
424
raise errors.MailClientNotFound(['MAPI supported mail client'
425
' (error %d)' % (e.code,)])
428
class DefaultMail(MailClient):
429
"""Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
430
falls back to Editor"""
432
def _mail_client(self):
433
"""Determine the preferred mail client for this platform"""
434
if osutils.supports_mapi():
435
return MAPIClient(self.config)
437
return XDGEmail(self.config)
439
def compose(self, prompt, to, subject, attachment, mime_subtype,
440
extension, basename=None):
441
"""See MailClient.compose"""
443
return self._mail_client().compose(prompt, to, subject,
444
attachment, mimie_subtype,
446
except errors.MailClientNotFound:
447
return Editor(self.config).compose(prompt, to, subject,
448
attachment, mimie_subtype, extension)
450
def compose_merge_request(self, to, subject, directive, basename=None):
451
"""See MailClient.compose_merge_request"""
453
return self._mail_client().compose_merge_request(to, subject,
454
directive, basename=basename)
455
except errors.MailClientNotFound:
456
return Editor(self.config).compose_merge_request(to, subject,
457
directive, basename=basename)