~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

  • Committer: Canonical.com Patch Queue Manager
  • Date: 2008-03-16 14:01:20 UTC
  • mfrom: (3280.2.5 integration)
  • Revision ID: pqm@pqm.ubuntu.com-20080316140120-i3yq8yr1l66m11h7
Start 1.4 development

Show diffs side-by-side

added added

removed removed

Lines of Context:
12
12
#
13
13
# You should have received a copy of the GNU General Public License
14
14
# along with this program; if not, write to the Free Software
15
 
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
 
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
16
16
 
17
17
import errno
18
18
import os
19
19
import subprocess
20
20
import sys
21
21
import tempfile
22
 
import urllib
23
22
 
24
23
import bzrlib
25
24
from bzrlib import (
28
27
    msgeditor,
29
28
    osutils,
30
29
    urlutils,
31
 
    registry
32
30
    )
33
31
 
34
 
mail_client_registry = registry.Registry()
35
 
 
36
32
 
37
33
class MailClient(object):
38
34
    """A mail client that can send messages with attachements."""
41
37
        self.config = config
42
38
 
43
39
    def compose(self, prompt, to, subject, attachment, mime_subtype,
44
 
                extension, basename=None, body=None):
 
40
                extension, basename=None):
45
41
        """Compose (and possibly send) an email message
46
42
 
47
43
        Must be implemented by subclasses.
61
57
        """
62
58
        raise NotImplementedError
63
59
 
64
 
    def compose_merge_request(self, to, subject, directive, basename=None,
65
 
                              body=None):
 
60
    def compose_merge_request(self, to, subject, directive, basename=None):
66
61
        """Compose (and possibly send) a merge request
67
62
 
68
63
        :param to: The address to send the request to
75
70
        prompt = self._get_merge_prompt("Please describe these changes:", to,
76
71
                                        subject, directive)
77
72
        self.compose(prompt, to, subject, directive,
78
 
            'x-patch', '.patch', basename, body)
 
73
            'x-patch', '.patch', basename)
79
74
 
80
75
    def _get_merge_prompt(self, prompt, to, subject, attachment):
81
76
        """Generate a prompt string.  Overridden by Editor.
91
86
class Editor(MailClient):
92
87
    """DIY mail client that uses commit message editor"""
93
88
 
94
 
    supports_body = True
95
 
 
96
89
    def _get_merge_prompt(self, prompt, to, subject, attachment):
97
90
        """See MailClient._get_merge_prompt"""
98
91
        return (u"%s\n\n"
102
95
                         attachment.decode('utf-8', 'replace')))
103
96
 
104
97
    def compose(self, prompt, to, subject, attachment, mime_subtype,
105
 
                extension, basename=None, body=None):
 
98
                extension, basename=None):
106
99
        """See MailClient.compose"""
107
100
        if not to:
108
101
            raise errors.NoMailAddressSpecified()
109
 
        body = msgeditor.edit_commit_message(prompt, start_message=body)
 
102
        body = msgeditor.edit_commit_message(prompt)
110
103
        if body == '':
111
104
            raise errors.NoMessageSupplied()
112
105
        email_message.EmailMessage.send(self.config,
116
109
                                        body,
117
110
                                        attachment,
118
111
                                        attachment_mime_subtype=mime_subtype)
119
 
mail_client_registry.register('editor', Editor,
120
 
                              help=Editor.__doc__)
121
 
 
122
 
 
123
 
class BodyExternalMailClient(MailClient):
124
 
 
125
 
    supports_body = True
 
112
 
 
113
 
 
114
class ExternalMailClient(MailClient):
 
115
    """An external mail client."""
126
116
 
127
117
    def _get_client_commands(self):
128
118
        """Provide a list of commands that may invoke the mail client"""
133
123
            return self._client_commands
134
124
 
135
125
    def compose(self, prompt, to, subject, attachment, mime_subtype,
136
 
                extension, basename=None, body=None):
 
126
                extension, basename=None):
137
127
        """See MailClient.compose.
138
128
 
139
129
        Writes the attachment to a temporary file, invokes _compose.
140
130
        """
141
131
        if basename is None:
142
132
            basename = 'attachment'
143
 
        pathname = osutils.mkdtemp(prefix='bzr-mail-')
 
133
        pathname = tempfile.mkdtemp(prefix='bzr-mail-')
144
134
        attach_path = osutils.pathjoin(pathname, basename + extension)
145
135
        outfile = open(attach_path, 'wb')
146
136
        try:
147
137
            outfile.write(attachment)
148
138
        finally:
149
139
            outfile.close()
150
 
        if body is not None:
151
 
            kwargs = {'body': body}
152
 
        else:
153
 
            kwargs = {}
154
140
        self._compose(prompt, to, subject, attach_path, mime_subtype,
155
 
                      extension, **kwargs)
 
141
                      extension)
156
142
 
157
143
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
158
 
                extension, body=None):
 
144
                extension):
159
145
        """Invoke a mail client as a commandline process.
160
146
 
161
147
        Overridden by MAPIClient.
169
155
        """
170
156
        for name in self._get_client_commands():
171
157
            cmdline = [self._encode_path(name, 'executable')]
172
 
            if body is not None:
173
 
                kwargs = {'body': body}
174
 
            else:
175
 
                kwargs = {}
176
158
            cmdline.extend(self._get_compose_commandline(to, subject,
177
 
                                                         attach_path,
178
 
                                                         **kwargs))
 
159
                                                         attach_path))
179
160
            try:
180
161
                subprocess.call(cmdline)
181
162
            except OSError, e:
186
167
        else:
187
168
            raise errors.MailClientNotFound(self._client_commands)
188
169
 
189
 
    def _get_compose_commandline(self, to, subject, attach_path, body):
 
170
    def _get_compose_commandline(self, to, subject, attach_path):
190
171
        """Determine the commandline to use for composing a message
191
172
 
192
173
        Implemented by various subclasses
205
186
        :return:    encoded string if u is unicode, u itself otherwise.
206
187
        """
207
188
        if isinstance(u, unicode):
208
 
            return u.encode(osutils.get_user_encoding(), 'replace')
 
189
            return u.encode(bzrlib.user_encoding, 'replace')
209
190
        return u
210
191
 
211
192
    def _encode_path(self, path, kind):
219
200
        """
220
201
        if isinstance(path, unicode):
221
202
            try:
222
 
                return path.encode(osutils.get_user_encoding())
 
203
                return path.encode(bzrlib.user_encoding)
223
204
            except UnicodeEncodeError:
224
205
                raise errors.UnableEncodePath(path, kind)
225
206
        return path
226
207
 
227
208
 
228
 
class ExternalMailClient(BodyExternalMailClient):
229
 
    """An external mail client."""
230
 
 
231
 
    supports_body = False
232
 
 
233
 
 
234
 
class Evolution(BodyExternalMailClient):
 
209
class Evolution(ExternalMailClient):
235
210
    """Evolution mail client."""
236
211
 
237
212
    _client_commands = ['evolution']
238
213
 
239
 
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
214
    def _get_compose_commandline(self, to, subject, attach_path):
240
215
        """See ExternalMailClient._get_compose_commandline"""
241
216
        message_options = {}
242
217
        if subject is not None:
243
218
            message_options['subject'] = subject
244
219
        if attach_path is not None:
245
220
            message_options['attach'] = attach_path
246
 
        if body is not None:
247
 
            message_options['body'] = body
248
221
        options_list = ['%s=%s' % (k, urlutils.escape(v)) for (k, v) in
249
222
                        sorted(message_options.iteritems())]
250
223
        return ['mailto:%s?%s' % (self._encode_safe(to or ''),
251
224
            '&'.join(options_list))]
252
 
mail_client_registry.register('evolution', Evolution,
253
 
                              help=Evolution.__doc__)
254
225
 
255
226
 
256
227
class Mutt(ExternalMailClient):
267
238
            message_options.extend(['-a',
268
239
                self._encode_path(attach_path, 'attachment')])
269
240
        if to is not None:
270
 
            message_options.extend(['--', self._encode_safe(to)])
 
241
            message_options.append(self._encode_safe(to))
271
242
        return message_options
272
 
mail_client_registry.register('mutt', Mutt,
273
 
                              help=Mutt.__doc__)
274
 
 
275
 
 
276
 
class Thunderbird(BodyExternalMailClient):
 
243
 
 
244
 
 
245
class Thunderbird(ExternalMailClient):
277
246
    """Mozilla Thunderbird (or Icedove)
278
247
 
279
248
    Note that Thunderbird 1.5 is buggy and does not support setting
284
253
    """
285
254
 
286
255
    _client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
287
 
        '/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin',
288
 
        '/Applications/Thunderbird.app/Contents/MacOS/thunderbird-bin']
 
256
        '/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin']
289
257
 
290
 
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
258
    def _get_compose_commandline(self, to, subject, attach_path):
291
259
        """See ExternalMailClient._get_compose_commandline"""
292
260
        message_options = {}
293
261
        if to is not None:
297
265
        if attach_path is not None:
298
266
            message_options['attachment'] = urlutils.local_path_to_url(
299
267
                attach_path)
300
 
        if body is not None:
301
 
            options_list = ['body=%s' % urllib.quote(self._encode_safe(body))]
302
 
        else:
303
 
            options_list = []
304
 
        options_list.extend(["%s='%s'" % (k, v) for k, v in
305
 
                        sorted(message_options.iteritems())])
 
268
        options_list = ["%s='%s'" % (k, v) for k, v in
 
269
                        sorted(message_options.iteritems())]
306
270
        return ['-compose', ','.join(options_list)]
307
 
mail_client_registry.register('thunderbird', Thunderbird,
308
 
                              help=Thunderbird.__doc__)
309
271
 
310
272
 
311
273
class KMail(ExternalMailClient):
324
286
        if to is not None:
325
287
            message_options.extend([self._encode_safe(to)])
326
288
        return message_options
327
 
mail_client_registry.register('kmail', KMail,
328
 
                              help=KMail.__doc__)
329
 
 
330
 
 
331
 
class Claws(ExternalMailClient):
332
 
    """Claws mail client."""
333
 
 
334
 
    _client_commands = ['claws-mail']
335
 
 
336
 
    def _get_compose_commandline(self, to, subject, attach_path):
337
 
        """See ExternalMailClient._get_compose_commandline"""
338
 
        compose_url = ['mailto:']
339
 
        if to is not None:
340
 
            compose_url.append(self._encode_safe(to))
341
 
        compose_url.append('?')
342
 
        if subject is not None:
343
 
            # Don't use urllib.quote_plus because Claws doesn't seem
344
 
            # to recognise spaces encoded as "+".
345
 
            compose_url.append(
346
 
                'subject=%s' % urllib.quote(self._encode_safe(subject)))
347
 
        # Collect command-line options.
348
 
        message_options = ['--compose', ''.join(compose_url)]
349
 
        if attach_path is not None:
350
 
            message_options.extend(
351
 
                ['--attach', self._encode_path(attach_path, 'attachment')])
352
 
        return message_options
353
 
mail_client_registry.register('claws', Claws,
354
 
                              help=Claws.__doc__)
355
 
 
356
 
 
357
 
class XDGEmail(BodyExternalMailClient):
 
289
 
 
290
 
 
291
class XDGEmail(ExternalMailClient):
358
292
    """xdg-email attempts to invoke the user's preferred mail client"""
359
293
 
360
294
    _client_commands = ['xdg-email']
361
295
 
362
 
    def _get_compose_commandline(self, to, subject, attach_path, body=None):
 
296
    def _get_compose_commandline(self, to, subject, attach_path):
363
297
        """See ExternalMailClient._get_compose_commandline"""
364
298
        if not to:
365
299
            raise errors.NoMailAddressSpecified()
369
303
        if attach_path is not None:
370
304
            commandline.extend(['--attach',
371
305
                self._encode_path(attach_path, 'attachment')])
372
 
        if body is not None:
373
 
            commandline.extend(['--body', self._encode_safe(body)])
374
 
        return commandline
375
 
mail_client_registry.register('xdg-email', XDGEmail,
376
 
                              help=XDGEmail.__doc__)
377
 
 
378
 
 
379
 
class EmacsMail(ExternalMailClient):
380
 
    """Call emacsclient to have a mail buffer.
381
 
 
382
 
    This only work for emacs >= 22.1 due to recent -e/--eval support.
383
 
 
384
 
    The good news is that this implementation will work with all mail
385
 
    agents registered against ``mail-user-agent``. So there is no need
386
 
    to instantiate ExternalMailClient for each and every GNU Emacs
387
 
    MUA.
388
 
 
389
 
    Users just have to ensure that ``mail-user-agent`` is set according
390
 
    to their tastes.
391
 
    """
392
 
 
393
 
    _client_commands = ['emacsclient']
394
 
 
395
 
    def _prepare_send_function(self):
396
 
        """Write our wrapper function into a temporary file.
397
 
 
398
 
        This temporary file will be loaded at runtime in
399
 
        _get_compose_commandline function.
400
 
 
401
 
        This function does not remove the file.  That's a wanted
402
 
        behaviour since _get_compose_commandline won't run the send
403
 
        mail function directly but return the eligible command line.
404
 
        Removing our temporary file here would prevent our sendmail
405
 
        function to work.  (The file is deleted by some elisp code
406
 
        after being read by Emacs.)
407
 
        """
408
 
 
409
 
        _defun = r"""(defun bzr-add-mime-att (file)
410
 
  "Attach FILE to a mail buffer as a MIME attachment."
411
 
  (let ((agent mail-user-agent))
412
 
    (if (and file (file-exists-p file))
413
 
        (cond
414
 
         ((eq agent 'sendmail-user-agent)
415
 
          (progn
416
 
            (mail-text)
417
 
            (newline)
418
 
            (if (functionp 'etach-attach)
419
 
              (etach-attach file)
420
 
              (mail-attach-file file))))
421
 
         ((or (eq agent 'message-user-agent)
422
 
              (eq agent 'gnus-user-agent)
423
 
              (eq agent 'mh-e-user-agent))
424
 
          (progn
425
 
            (mml-attach-file file "text/x-patch" "BZR merge" "inline")))
426
 
         ((eq agent 'mew-user-agent)
427
 
          (progn
428
 
            (mew-draft-prepare-attachments)
429
 
            (mew-attach-link file (file-name-nondirectory file))
430
 
            (let* ((nums (mew-syntax-nums))
431
 
                   (syntax (mew-syntax-get-entry mew-encode-syntax nums)))
432
 
              (mew-syntax-set-cd syntax "BZR merge")
433
 
              (mew-encode-syntax-print mew-encode-syntax))
434
 
            (mew-header-goto-body)))
435
 
         (t
436
 
          (message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
437
 
      (error "File %s does not exist." file))))
438
 
"""
439
 
 
440
 
        fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
441
 
                                         suffix=".el")
442
 
        try:
443
 
            os.write(fd, _defun)
444
 
        finally:
445
 
            os.close(fd) # Just close the handle but do not remove the file.
446
 
        return temp_file
447
 
 
448
 
    def _get_compose_commandline(self, to, subject, attach_path):
449
 
        commandline = ["--eval"]
450
 
 
451
 
        _to = "nil"
452
 
        _subject = "nil"
453
 
 
454
 
        if to is not None:
455
 
            _to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
456
 
        if subject is not None:
457
 
            _subject = ("\"%s\"" %
458
 
                        self._encode_safe(subject).replace('"', '\\"'))
459
 
 
460
 
        # Funcall the default mail composition function
461
 
        # This will work with any mail mode including default mail-mode
462
 
        # User must tweak mail-user-agent variable to tell what function
463
 
        # will be called inside compose-mail.
464
 
        mail_cmd = "(compose-mail %s %s)" % (_to, _subject)
465
 
        commandline.append(mail_cmd)
466
 
 
467
 
        # Try to attach a MIME attachment using our wrapper function
468
 
        if attach_path is not None:
469
 
            # Do not create a file if there is no attachment
470
 
            elisp = self._prepare_send_function()
471
 
            lmmform = '(load "%s")' % elisp
472
 
            mmform  = '(bzr-add-mime-att "%s")' % \
473
 
                self._encode_path(attach_path, 'attachment')
474
 
            rmform = '(delete-file "%s")' % elisp
475
 
            commandline.append(lmmform)
476
 
            commandline.append(mmform)
477
 
            commandline.append(rmform)
478
 
 
479
 
        return commandline
480
 
mail_client_registry.register('emacsclient', EmacsMail,
481
 
                              help=EmacsMail.__doc__)
482
 
 
483
 
 
484
 
class MAPIClient(BodyExternalMailClient):
 
306
        return commandline
 
307
 
 
308
 
 
309
class MAPIClient(ExternalMailClient):
485
310
    """Default Windows mail client launched using MAPI."""
486
311
 
487
312
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
488
 
                 extension, body=None):
 
313
                 extension):
489
314
        """See ExternalMailClient._compose.
490
315
 
491
316
        This implementation uses MAPI via the simplemapi ctypes wrapper
492
317
        """
493
318
        from bzrlib.util import simplemapi
494
319
        try:
495
 
            simplemapi.SendMail(to or '', subject or '', body or '',
496
 
                                attach_path)
 
320
            simplemapi.SendMail(to or '', subject or '', '', attach_path)
497
321
        except simplemapi.MAPIError, e:
498
322
            if e.code != simplemapi.MAPI_USER_ABORT:
499
323
                raise errors.MailClientNotFound(['MAPI supported mail client'
500
324
                                                 ' (error %d)' % (e.code,)])
501
 
mail_client_registry.register('mapi', MAPIClient,
502
 
                              help=MAPIClient.__doc__)
503
325
 
504
326
 
505
327
class DefaultMail(MailClient):
514
336
            return XDGEmail(self.config)
515
337
 
516
338
    def compose(self, prompt, to, subject, attachment, mime_subtype,
517
 
                extension, basename=None, body=None):
 
339
                extension, basename=None):
518
340
        """See MailClient.compose"""
519
341
        try:
520
342
            return self._mail_client().compose(prompt, to, subject,
521
343
                                               attachment, mimie_subtype,
522
 
                                               extension, basename, body)
 
344
                                               extension, basename)
523
345
        except errors.MailClientNotFound:
524
346
            return Editor(self.config).compose(prompt, to, subject,
525
 
                          attachment, mimie_subtype, extension, body)
 
347
                          attachment, mimie_subtype, extension)
526
348
 
527
 
    def compose_merge_request(self, to, subject, directive, basename=None,
528
 
                              body=None):
 
349
    def compose_merge_request(self, to, subject, directive):
529
350
        """See MailClient.compose_merge_request"""
530
351
        try:
531
352
            return self._mail_client().compose_merge_request(to, subject,
532
 
                    directive, basename=basename, body=body)
 
353
                                                             directive)
533
354
        except errors.MailClientNotFound:
534
355
            return Editor(self.config).compose_merge_request(to, subject,
535
 
                          directive, basename=basename, body=body)
536
 
mail_client_registry.register('default', DefaultMail,
537
 
                              help=DefaultMail.__doc__)
538
 
mail_client_registry.default_key = 'default'
 
356
                          directive)