41
36
self.config = config
43
38
def compose(self, prompt, to, subject, attachment, mime_subtype,
44
extension, basename=None, body=None):
45
"""Compose (and possibly send) an email message
47
Must be implemented by subclasses.
49
:param prompt: A message to tell the user what to do. Supported by
50
the Editor client, but ignored by others
51
:param to: The address to send the message to
52
:param subject: The contents of the subject line
53
:param attachment: An email attachment, as a bytestring
54
:param mime_subtype: The attachment is assumed to be a subtype of
55
Text. This allows the precise subtype to be specified, e.g.
56
"plain", "x-patch", etc.
57
:param extension: The file extension associated with the attachment
59
:param basename: The name to use for the attachment, e.g.
62
40
raise NotImplementedError
64
def compose_merge_request(self, to, subject, directive, basename=None,
66
"""Compose (and possibly send) a merge request
68
:param to: The address to send the request to
69
:param subject: The subject line to use for the request
70
:param directive: A merge directive representing the merge request, as
72
:param basename: The name to use for the attachment, e.g.
42
def compose_merge_request(self, to, subject, directive):
75
43
prompt = self._get_merge_prompt("Please describe these changes:", to,
76
44
subject, directive)
77
45
self.compose(prompt, to, subject, directive,
78
'x-patch', '.patch', basename, body)
80
48
def _get_merge_prompt(self, prompt, to, subject, attachment):
81
"""Generate a prompt string. Overridden by Editor.
83
:param prompt: A string suggesting what user should do
84
:param to: The address the mail will be sent to
85
:param subject: The subject line of the mail
86
:param attachment: The attachment that will be used
91
52
class Editor(MailClient):
92
__doc__ = """DIY mail client that uses commit message editor"""
53
"""DIY mail client that uses commit message editor"""
96
55
def _get_merge_prompt(self, prompt, to, subject, attachment):
97
"""See MailClient._get_merge_prompt"""
101
u"%s" % (prompt, to, subject,
102
attachment.decode('utf-8', 'replace')))
56
return "%s\n\nTo: %s\nSubject: %s\n\n%s" % (prompt, to, subject,
57
attachment.decode('utf-8', 'replace'))
104
59
def compose(self, prompt, to, subject, attachment, mime_subtype,
105
extension, basename=None, body=None):
106
"""See MailClient.compose"""
108
raise errors.NoMailAddressSpecified()
109
body = msgeditor.edit_commit_message(prompt, start_message=body)
61
body = msgeditor.edit_commit_message(prompt)
111
63
raise errors.NoMessageSupplied()
112
64
email_message.EmailMessage.send(self.config,
191
99
raise errors.MailClientNotFound(self._client_commands)
193
def _get_compose_commandline(self, to, subject, attach_path, body):
194
"""Determine the commandline to use for composing a message
196
Implemented by various subclasses
197
:param to: The address to send the mail to
198
:param subject: The subject line for the mail
199
:param attach_path: The path to the attachment
101
def _get_compose_commandline(self, to, subject, attach_path):
201
102
raise NotImplementedError
203
def _encode_safe(self, u):
204
"""Encode possible unicode string argument to 8-bit string
205
in user_encoding. Unencodable characters will be replaced
208
:param u: possible unicode string.
209
:return: encoded string if u is unicode, u itself otherwise.
211
if isinstance(u, unicode):
212
return u.encode(osutils.get_user_encoding(), 'replace')
215
def _encode_path(self, path, kind):
216
"""Encode unicode path in user encoding.
218
:param path: possible unicode path.
219
:param kind: path kind ('executable' or 'attachment').
220
:return: encoded path if path is unicode,
221
path itself otherwise.
222
:raise: UnableEncodePath.
224
if isinstance(path, unicode):
226
return path.encode(osutils.get_user_encoding())
227
except UnicodeEncodeError:
228
raise errors.UnableEncodePath(path, kind)
232
class ExternalMailClient(BodyExternalMailClient):
233
__doc__ = """An external mail client."""
235
supports_body = False
238
class Evolution(BodyExternalMailClient):
239
__doc__ = """Evolution mail client."""
105
class Evolution(ExternalMailClient):
106
"""Evolution mail client."""
241
108
_client_commands = ['evolution']
243
def _get_compose_commandline(self, to, subject, attach_path, body=None):
244
"""See ExternalMailClient._get_compose_commandline"""
110
def _get_compose_commandline(self, to, subject, attach_path):
245
111
message_options = {}
246
112
if subject is not None:
247
113
message_options['subject'] = subject
248
114
if attach_path is not None:
249
115
message_options['attach'] = attach_path
251
message_options['body'] = body
252
116
options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
253
sorted(message_options.iteritems())]
254
return ['mailto:%s?%s' % (self._encode_safe(to or ''),
255
'&'.join(options_list))]
256
mail_client_registry.register('evolution', Evolution,
257
help=Evolution.__doc__)
260
class Mutt(BodyExternalMailClient):
261
__doc__ = """Mutt mail client."""
263
_client_commands = ['mutt']
265
def _get_compose_commandline(self, to, subject, attach_path, body=None):
266
"""See ExternalMailClient._get_compose_commandline"""
268
if subject is not None:
269
message_options.extend(['-s', self._encode_safe(subject)])
270
if attach_path is not None:
271
message_options.extend(['-a',
272
self._encode_path(attach_path, 'attachment')])
274
# Store the temp file object in self, so that it does not get
275
# garbage collected and delete the file before mutt can read it.
276
self._temp_file = tempfile.NamedTemporaryFile(
277
prefix="mutt-body-", suffix=".txt")
278
self._temp_file.write(body)
279
self._temp_file.flush()
280
message_options.extend(['-i', self._temp_file.name])
282
message_options.extend(['--', self._encode_safe(to)])
283
return message_options
284
mail_client_registry.register('mutt', Mutt,
288
class Thunderbird(BodyExternalMailClient):
289
__doc__ = """Mozilla Thunderbird (or Icedove)
117
message_options.iteritems()]
118
return ['mailto:%s?%s' % (to or '', '&'.join(options_list))]
121
class Thunderbird(ExternalMailClient):
122
"""Mozilla Thunderbird (or Icedove)
291
124
Note that Thunderbird 1.5 is buggy and does not support setting
292
125
"to" simultaneously with including a attachment.
295
128
send attachments.
298
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
299
'/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
300
'/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
131
_client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove']
302
def _get_compose_commandline(self, to, subject, attach_path, body=None):
303
"""See ExternalMailClient._get_compose_commandline"""
133
def _get_compose_commandline(self, to, subject, attach_path):
304
134
message_options = {}
305
135
if to is not None:
306
message_options['to'] = self._encode_safe(to)
136
message_options['to'] = to
307
137
if subject is not None:
308
message_options['subject'] = self._encode_safe(subject)
138
message_options['subject'] = subject
309
139
if attach_path is not None:
310
140
message_options['attachment'] = urlutils.local_path_to_url(
313
options_list = ['body=%s' % urllib.quote(self._encode_safe(body))]
316
options_list.extend(["%s='%s'" % (k, v) for k, v in
317
sorted(message_options.iteritems())])
142
options_list = ["%s='%s'" % (k, v) for k, v in
143
sorted(message_options.iteritems())]
318
144
return ['-compose', ','.join(options_list)]
319
mail_client_registry.register('thunderbird', Thunderbird,
320
help=Thunderbird.__doc__)
323
class KMail(ExternalMailClient):
324
__doc__ = """KDE mail client."""
326
_client_commands = ['kmail']
328
def _get_compose_commandline(self, to, subject, attach_path):
329
"""See ExternalMailClient._get_compose_commandline"""
331
if subject is not None:
332
message_options.extend(['-s', self._encode_safe(subject)])
333
if attach_path is not None:
334
message_options.extend(['--attach',
335
self._encode_path(attach_path, 'attachment')])
337
message_options.extend([self._encode_safe(to)])
338
return message_options
339
mail_client_registry.register('kmail', KMail,
343
class Claws(ExternalMailClient):
344
__doc__ = """Claws mail client."""
348
_client_commands = ['claws-mail']
350
def _get_compose_commandline(self, to, subject, attach_path, body=None,
352
"""See ExternalMailClient._get_compose_commandline"""
354
if from_ is not None:
355
compose_url.append('from=' + urllib.quote(from_))
356
if subject is not None:
357
# Don't use urllib.quote_plus because Claws doesn't seem
358
# to recognise spaces encoded as "+".
360
'subject=' + urllib.quote(self._encode_safe(subject)))
363
'body=' + urllib.quote(self._encode_safe(body)))
364
# to must be supplied for the claws-mail --compose syntax to work.
366
raise errors.NoMailAddressSpecified()
367
compose_url = 'mailto:%s?%s' % (
368
self._encode_safe(to), '&'.join(compose_url))
369
# Collect command-line options.
370
message_options = ['--compose', compose_url]
371
if attach_path is not None:
372
message_options.extend(
373
['--attach', self._encode_path(attach_path, 'attachment')])
374
return message_options
376
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
377
extension, body=None, from_=None):
378
"""See ExternalMailClient._compose"""
380
from_ = self.config.get_user_option('email')
381
super(Claws, self)._compose(prompt, to, subject, attach_path,
382
mime_subtype, extension, body, from_)
385
mail_client_registry.register('claws', Claws,
389
class XDGEmail(BodyExternalMailClient):
390
__doc__ = """xdg-email attempts to invoke the user's preferred mail client"""
147
class XDGEmail(ExternalMailClient):
148
"""xdg-email attempts to invoke the user's preferred mail client"""
392
150
_client_commands = ['xdg-email']
394
def _get_compose_commandline(self, to, subject, attach_path, body=None):
395
"""See ExternalMailClient._get_compose_commandline"""
397
raise errors.NoMailAddressSpecified()
398
commandline = [self._encode_safe(to)]
399
if subject is not None:
400
commandline.extend(['--subject', self._encode_safe(subject)])
401
if attach_path is not None:
402
commandline.extend(['--attach',
403
self._encode_path(attach_path, 'attachment')])
405
commandline.extend(['--body', self._encode_safe(body)])
407
mail_client_registry.register('xdg-email', XDGEmail,
408
help=XDGEmail.__doc__)
411
class EmacsMail(ExternalMailClient):
412
__doc__ = """Call emacsclient to have a mail buffer.
414
This only work for emacs >= 22.1 due to recent -e/--eval support.
416
The good news is that this implementation will work with all mail
417
agents registered against ``mail-user-agent``. So there is no need
418
to instantiate ExternalMailClient for each and every GNU Emacs
421
Users just have to ensure that ``mail-user-agent`` is set according
425
_client_commands = ['emacsclient']
427
def __init__(self, config):
428
super(EmacsMail, self).__init__(config)
429
self.elisp_tmp_file = None
431
def _prepare_send_function(self):
432
"""Write our wrapper function into a temporary file.
434
This temporary file will be loaded at runtime in
435
_get_compose_commandline function.
437
This function does not remove the file. That's a wanted
438
behaviour since _get_compose_commandline won't run the send
439
mail function directly but return the eligible command line.
440
Removing our temporary file here would prevent our sendmail
441
function to work. (The file is deleted by some elisp code
442
after being read by Emacs.)
445
_defun = r"""(defun bzr-add-mime-att (file)
446
"Attach FILE to a mail buffer as a MIME attachment."
447
(let ((agent mail-user-agent))
448
(if (and file (file-exists-p file))
450
((eq agent 'sendmail-user-agent)
454
(if (functionp 'etach-attach)
456
(mail-attach-file file))))
457
((or (eq agent 'message-user-agent)
458
(eq agent 'gnus-user-agent)
459
(eq agent 'mh-e-user-agent))
461
(mml-attach-file file "text/x-patch" "BZR merge" "inline")))
462
((eq agent 'mew-user-agent)
464
(mew-draft-prepare-attachments)
465
(mew-attach-link file (file-name-nondirectory file))
466
(let* ((nums (mew-syntax-nums))
467
(syntax (mew-syntax-get-entry mew-encode-syntax nums)))
468
(mew-syntax-set-cd syntax "BZR merge")
469
(mew-encode-syntax-print mew-encode-syntax))
470
(mew-header-goto-body)))
472
(message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
473
(error "File %s does not exist." file))))
476
fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
481
os.close(fd) # Just close the handle but do not remove the file.
484
152
def _get_compose_commandline(self, to, subject, attach_path):
485
commandline = ["--eval"]
491
_to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
492
154
if subject is not None:
493
_subject = ("\"%s\"" %
494
self._encode_safe(subject).replace('"', '\\"'))
496
# Funcall the default mail composition function
497
# This will work with any mail mode including default mail-mode
498
# User must tweak mail-user-agent variable to tell what function
499
# will be called inside compose-mail.
500
mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
501
commandline.append(mail_cmd)
503
# Try to attach a MIME attachment using our wrapper function
155
commandline.extend(['--subject', subject])
504
156
if attach_path is not None:
505
# Do not create a file if there is no attachment
506
elisp = self._prepare_send_function()
507
self.elisp_tmp_file = elisp
508
lmmform = '(load "%s")' % elisp
509
mmform = '(bzr-add-mime-att "%s")' % \
510
self._encode_path(attach_path, 'attachment')
511
rmform = '(delete-file "%s")' % elisp
512
commandline.append(lmmform)
513
commandline.append(mmform)
514
commandline.append(rmform)
157
commandline.extend(['--attach', attach_path])
516
158
return commandline
517
mail_client_registry.register('emacsclient', EmacsMail,
518
help=EmacsMail.__doc__)
521
class MAPIClient(BodyExternalMailClient):
522
__doc__ = """Default Windows mail client launched using MAPI."""
161
class MAPIClient(ExternalMailClient):
162
"""Default Windows mail client launched using MAPI."""
524
164
def _compose(self, prompt, to, subject, attach_path, mime_subtype,
525
extension, body=None):
526
"""See ExternalMailClient._compose.
528
This implementation uses MAPI via the simplemapi ctypes wrapper
530
166
from bzrlib.util import simplemapi
532
simplemapi.SendMail(to or '', subject or '', body or '',
168
simplemapi.SendMail(to or '', subject or '', '', attach_path)
534
169
except simplemapi.MAPIError, e:
535
170
if e.code != simplemapi.MAPI_USER_ABORT:
536
171
raise errors.MailClientNotFound(['MAPI supported mail client'
537
172
' (error %d)' % (e.code,)])
538
mail_client_registry.register('mapi', MAPIClient,
539
help=MAPIClient.__doc__)
542
class MailApp(BodyExternalMailClient):
543
__doc__ = """Use MacOS X's Mail.app for sending email messages.
545
Although it would be nice to use appscript, it's not installed
546
with the shipped Python installations. We instead build an
547
AppleScript and invoke the script using osascript(1). We don't
548
use the _encode_safe() routines as it's not clear what encoding
549
osascript expects the script to be in.
552
_client_commands = ['osascript']
554
def _get_compose_commandline(self, to, subject, attach_path, body=None,
556
"""See ExternalMailClient._get_compose_commandline"""
558
fd, self.temp_file = tempfile.mkstemp(prefix="bzr-send-",
561
os.write(fd, 'tell application "Mail"\n')
562
os.write(fd, 'set newMessage to make new outgoing message\n')
563
os.write(fd, 'tell newMessage\n')
565
os.write(fd, 'make new to recipient with properties'
566
' {address:"%s"}\n' % to)
567
if from_ is not None:
568
# though from_ doesn't actually seem to be used
569
os.write(fd, 'set sender to "%s"\n'
570
% sender.replace('"', '\\"'))
571
if subject is not None:
572
os.write(fd, 'set subject to "%s"\n'
573
% subject.replace('"', '\\"'))
575
# FIXME: would be nice to prepend the body to the
576
# existing content (e.g., preserve signature), but
577
# can't seem to figure out the right applescript
579
os.write(fd, 'set content to "%s\\n\n"\n' %
580
body.replace('"', '\\"').replace('\n', '\\n'))
582
if attach_path is not None:
583
# FIXME: would be nice to first append a newline to
584
# ensure the attachment is on a new paragraph, but
585
# can't seem to figure out the right applescript
587
os.write(fd, 'tell content to make new attachment'
588
' with properties {file name:"%s"}'
589
' at after the last paragraph\n'
590
% self._encode_path(attach_path, 'attachment'))
591
os.write(fd, 'set visible to true\n')
592
os.write(fd, 'end tell\n')
593
os.write(fd, 'end tell\n')
595
os.close(fd) # Just close the handle but do not remove the file.
596
return [self.temp_file]
597
mail_client_registry.register('mail.app', MailApp,
598
help=MailApp.__doc__)
601
175
class DefaultMail(MailClient):
602
__doc__ = """Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
176
"""Default mail handling. Tries XDGEmail (or MAPIClient on Windows),
603
177
falls back to Editor"""
607
179
def _mail_client(self):
608
"""Determine the preferred mail client for this platform"""
609
180
if osutils.supports_mapi():
610
181
return MAPIClient(self.config)
612
183
return XDGEmail(self.config)
614
185
def compose(self, prompt, to, subject, attachment, mime_subtype,
615
extension, basename=None, body=None):
616
"""See MailClient.compose"""
618
188
return self._mail_client().compose(prompt, to, subject,
619
189
attachment, mimie_subtype,
620
extension, basename, body)
621
191
except errors.MailClientNotFound:
622
192
return Editor(self.config).compose(prompt, to, subject,
623
attachment, mimie_subtype, extension, body)
193
attachment, mimie_subtype, extension)
625
def compose_merge_request(self, to, subject, directive, basename=None,
627
"""See MailClient.compose_merge_request"""
195
def compose_merge_request(self, to, subject, directive):
629
197
return self._mail_client().compose_merge_request(to, subject,
630
directive, basename=basename, body=body)
631
199
except errors.MailClientNotFound:
632
200
return Editor(self.config).compose_merge_request(to, subject,
633
directive, basename=basename, body=body)
634
mail_client_registry.register('default', DefaultMail,
635
help=DefaultMail.__doc__)
636
mail_client_registry.default_key = 'default'