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 MAPIClient(ExternalMailClient):
310
"""Default Windows mail client launched using MAPI."""
312
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
314
"""See ExternalMailClient._compose.
316
This implementation uses MAPI via the simplemapi ctypes wrapper
318
from bzrlib.util import simplemapi
320
simplemapi.SendMail(to or '', subject or '', '', attach_path)
321
except simplemapi.MAPIError, e:
322
if e.code != simplemapi.MAPI_USER_ABORT:
323
raise errors.MailClientNotFound(['MAPI supported mail client'
324
' (error %d)' % (e.code,)])
327
class DefaultMail(MailClient):
328
"""Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
329
falls back to Editor"""
331
def _mail_client(self):
332
"""Determine the preferred mail client for this platform"""
333
if osutils.supports_mapi():
334
return MAPIClient(self.config)
336
return XDGEmail(self.config)
338
def compose(self, prompt, to, subject, attachment, mime_subtype,
339
extension, basename=None):
340
"""See MailClient.compose"""
342
return self._mail_client().compose(prompt, to, subject,
343
attachment, mimie_subtype,
345
except errors.MailClientNotFound:
346
return Editor(self.config).compose(prompt, to, subject,
347
attachment, mimie_subtype, extension)
349
def compose_merge_request(self, to, subject, directive):
350
"""See MailClient.compose_merge_request"""
352
return self._mail_client().compose_merge_request(to, subject,
354
except errors.MailClientNotFound:
355
return Editor(self.config).compose_merge_request(to, subject,