~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

(John Arbash Meinel) Update dirstate._iter_changes to return unicode for all paths (bug #92608)

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright (C) 2007 Canonical Ltd
2
 
#
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.
7
 
#
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.
12
 
#
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
16
 
 
17
 
import errno
18
 
import os
19
 
import subprocess
20
 
import sys
21
 
import tempfile
22
 
 
23
 
import bzrlib
24
 
from bzrlib import (
25
 
    email_message,
26
 
    errors,
27
 
    msgeditor,
28
 
    osutils,
29
 
    urlutils,
30
 
    )
31
 
 
32
 
 
33
 
class MailClient(object):
34
 
    """A mail client that can send messages with attachements."""
35
 
 
36
 
    def __init__(self, config):
37
 
        self.config = config
38
 
 
39
 
    def compose(self, prompt, to, subject, attachment, mime_subtype,
40
 
                extension, basename=None):
41
 
        """Compose (and possibly send) an email message
42
 
 
43
 
        Must be implemented by subclasses.
44
 
 
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
54
 
            type, e.g. ".patch"
55
 
        :param basename: The name to use for the attachment, e.g.
56
 
            "send-nick-3252"
57
 
        """
58
 
        raise NotImplementedError
59
 
 
60
 
    def compose_merge_request(self, to, subject, directive, basename=None):
61
 
        """Compose (and possibly send) a merge request
62
 
 
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
66
 
            a bytestring.
67
 
        :param basename: The name to use for the attachment, e.g.
68
 
            "send-nick-3252"
69
 
        """
70
 
        prompt = self._get_merge_prompt("Please describe these changes:", to,
71
 
                                        subject, directive)
72
 
        self.compose(prompt, to, subject, directive,
73
 
            'x-patch', '.patch', basename)
74
 
 
75
 
    def _get_merge_prompt(self, prompt, to, subject, attachment):
76
 
        """Generate a prompt string.  Overridden by Editor.
77
 
 
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
82
 
        """
83
 
        return ''
84
 
 
85
 
 
86
 
class Editor(MailClient):
87
 
    """DIY mail client that uses commit message editor"""
88
 
 
89
 
    def _get_merge_prompt(self, prompt, to, subject, attachment):
90
 
        """See MailClient._get_merge_prompt"""
91
 
        return (u"%s\n\n"
92
 
                u"To: %s\n"
93
 
                u"Subject: %s\n\n"
94
 
                u"%s" % (prompt, to, subject,
95
 
                         attachment.decode('utf-8', 'replace')))
96
 
 
97
 
    def compose(self, prompt, to, subject, attachment, mime_subtype,
98
 
                extension, basename=None):
99
 
        """See MailClient.compose"""
100
 
        if not to:
101
 
            raise errors.NoMailAddressSpecified()
102
 
        body = msgeditor.edit_commit_message(prompt)
103
 
        if body == '':
104
 
            raise errors.NoMessageSupplied()
105
 
        email_message.EmailMessage.send(self.config,
106
 
                                        self.config.username(),
107
 
                                        to,
108
 
                                        subject,
109
 
                                        body,
110
 
                                        attachment,
111
 
                                        attachment_mime_subtype=mime_subtype)
112
 
 
113
 
 
114
 
class ExternalMailClient(MailClient):
115
 
    """An external mail client."""
116
 
 
117
 
    def _get_client_commands(self):
118
 
        """Provide a list of commands that may invoke the mail client"""
119
 
        if sys.platform == 'win32':
120
 
            import win32utils
121
 
            return [win32utils.get_app_path(i) for i in self._client_commands]
122
 
        else:
123
 
            return self._client_commands
124
 
 
125
 
    def compose(self, prompt, to, subject, attachment, mime_subtype,
126
 
                extension, basename=None):
127
 
        """See MailClient.compose.
128
 
 
129
 
        Writes the attachment to a temporary file, invokes _compose.
130
 
        """
131
 
        if basename is None:
132
 
            basename = 'attachment'
133
 
        pathname = tempfile.mkdtemp(prefix='bzr-mail-')
134
 
        attach_path = osutils.pathjoin(pathname, basename + extension)
135
 
        outfile = open(attach_path, 'wb')
136
 
        try:
137
 
            outfile.write(attachment)
138
 
        finally:
139
 
            outfile.close()
140
 
        self._compose(prompt, to, subject, attach_path, mime_subtype,
141
 
                      extension)
142
 
 
143
 
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
144
 
                extension):
145
 
        """Invoke a mail client as a commandline process.
146
 
 
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
154
 
            the attachment type.
155
 
        """
156
 
        for name in self._get_client_commands():
157
 
            cmdline = [self._encode_path(name, 'executable')]
158
 
            cmdline.extend(self._get_compose_commandline(to, subject,
159
 
                                                         attach_path))
160
 
            try:
161
 
                subprocess.call(cmdline)
162
 
            except OSError, e:
163
 
                if e.errno != errno.ENOENT:
164
 
                    raise
165
 
            else:
166
 
                break
167
 
        else:
168
 
            raise errors.MailClientNotFound(self._client_commands)
169
 
 
170
 
    def _get_compose_commandline(self, to, subject, attach_path):
171
 
        """Determine the commandline to use for composing a message
172
 
 
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
177
 
        """
178
 
        raise NotImplementedError
179
 
 
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
183
 
        with '?'.
184
 
 
185
 
        :param  u:  possible unicode string.
186
 
        :return:    encoded string if u is unicode, u itself otherwise.
187
 
        """
188
 
        if isinstance(u, unicode):
189
 
            return u.encode(bzrlib.user_encoding, 'replace')
190
 
        return u
191
 
 
192
 
    def _encode_path(self, path, kind):
193
 
        """Encode unicode path in user encoding.
194
 
 
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.
200
 
        """
201
 
        if isinstance(path, unicode):
202
 
            try:
203
 
                return path.encode(bzrlib.user_encoding)
204
 
            except UnicodeEncodeError:
205
 
                raise errors.UnableEncodePath(path, kind)
206
 
        return path
207
 
 
208
 
 
209
 
class Evolution(ExternalMailClient):
210
 
    """Evolution mail client."""
211
 
 
212
 
    _client_commands = ['evolution']
213
 
 
214
 
    def _get_compose_commandline(self, to, subject, attach_path):
215
 
        """See ExternalMailClient._get_compose_commandline"""
216
 
        message_options = {}
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))]
225
 
 
226
 
 
227
 
class Mutt(ExternalMailClient):
228
 
    """Mutt mail client."""
229
 
 
230
 
    _client_commands = ['mutt']
231
 
 
232
 
    def _get_compose_commandline(self, to, subject, attach_path):
233
 
        """See ExternalMailClient._get_compose_commandline"""
234
 
        message_options = []
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')])
240
 
        if to is not None:
241
 
            message_options.append(self._encode_safe(to))
242
 
        return message_options
243
 
 
244
 
 
245
 
class Thunderbird(ExternalMailClient):
246
 
    """Mozilla Thunderbird (or Icedove)
247
 
 
248
 
    Note that Thunderbird 1.5 is buggy and does not support setting
249
 
    "to" simultaneously with including a attachment.
250
 
 
251
 
    There is a workaround if no attachment is present, but we always need to
252
 
    send attachments.
253
 
    """
254
 
 
255
 
    _client_commands = ['thunderbird', 'mozilla-thunderbird', 'icedove',
256
 
        '/Applications/Mozilla/Thunderbird.app/Contents/MacOS/thunderbird-bin']
257
 
 
258
 
    def _get_compose_commandline(self, to, subject, attach_path):
259
 
        """See ExternalMailClient._get_compose_commandline"""
260
 
        message_options = {}
261
 
        if to is not None:
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(
267
 
                attach_path)
268
 
        options_list = ["%s='%s'" % (k, v) for k, v in
269
 
                        sorted(message_options.iteritems())]
270
 
        return ['-compose', ','.join(options_list)]
271
 
 
272
 
 
273
 
class KMail(ExternalMailClient):
274
 
    """KDE mail client."""
275
 
 
276
 
    _client_commands = ['kmail']
277
 
 
278
 
    def _get_compose_commandline(self, to, subject, attach_path):
279
 
        """See ExternalMailClient._get_compose_commandline"""
280
 
        message_options = []
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')])
286
 
        if to is not None:
287
 
            message_options.extend([self._encode_safe(to)])
288
 
        return message_options
289
 
 
290
 
 
291
 
class XDGEmail(ExternalMailClient):
292
 
    """xdg-email attempts to invoke the user's preferred mail client"""
293
 
 
294
 
    _client_commands = ['xdg-email']
295
 
 
296
 
    def _get_compose_commandline(self, to, subject, attach_path):
297
 
        """See ExternalMailClient._get_compose_commandline"""
298
 
        if not to:
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')])
306
 
        return commandline
307
 
 
308
 
 
309
 
class EmacsMail(ExternalMailClient):
310
 
    """Call emacsclient to have a mail buffer.
311
 
 
312
 
    This only work for emacs >= 22.1 due to recent -e/--eval support.
313
 
 
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
317
 
    MUA.
318
 
 
319
 
    Users just have to ensure that ``mail-user-agent`` is set according
320
 
    to their tastes.
321
 
    """
322
 
 
323
 
    _client_commands = ['emacsclient']
324
 
 
325
 
    def _prepare_send_function(self):
326
 
        """Write our wrapper function into a temporary file.
327
 
 
328
 
        This temporary file will be loaded at runtime in
329
 
        _get_compose_commandline function.
330
 
 
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.)
337
 
        """
338
 
 
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))
343
 
        (cond
344
 
         ((eq agent 'sendmail-user-agent)
345
 
          (progn
346
 
            (mail-text)
347
 
            (newline)
348
 
            (if (functionp 'etach-attach)
349
 
              (etach-attach file)
350
 
              (mail-attach-file file))))
351
 
         ((or (eq agent 'message-user-agent)(eq agent 'gnus-user-agent))
352
 
          (progn
353
 
            (mml-attach-file file "text/x-patch" "BZR merge" "inline")))
354
 
         ((eq agent 'mew-user-agent)
355
 
          (progn
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)))
363
 
         (t
364
 
          (message "Unhandled MUA, report it on bazaar@lists.canonical.com")))
365
 
      (error "File %s does not exist." file))))
366
 
"""
367
 
 
368
 
        fd, temp_file = tempfile.mkstemp(prefix="emacs-bzr-send-",
369
 
                                         suffix=".el")
370
 
        try:
371
 
            os.write(fd, _defun)
372
 
        finally:
373
 
            os.close(fd) # Just close the handle but do not remove the file.
374
 
        return temp_file
375
 
 
376
 
    def _get_compose_commandline(self, to, subject, attach_path):
377
 
        commandline = ["--eval"]
378
 
 
379
 
        _to = "nil"
380
 
        _subject = "nil"
381
 
 
382
 
        if to is not None:
383
 
            _to = ("\"%s\"" % self._encode_safe(to).replace('"', '\\"'))
384
 
        if subject is not None:
385
 
            _subject = ("\"%s\"" %
386
 
                        self._encode_safe(subject).replace('"', '\\"'))
387
 
 
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)
394
 
 
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)
406
 
 
407
 
        return commandline
408
 
 
409
 
 
410
 
class MAPIClient(ExternalMailClient):
411
 
    """Default Windows mail client launched using MAPI."""
412
 
 
413
 
    def _compose(self, prompt, to, subject, attach_path, mime_subtype,
414
 
                 extension):
415
 
        """See ExternalMailClient._compose.
416
 
 
417
 
        This implementation uses MAPI via the simplemapi ctypes wrapper
418
 
        """
419
 
        from bzrlib.util import simplemapi
420
 
        try:
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,)])
426
 
 
427
 
 
428
 
class DefaultMail(MailClient):
429
 
    """Default mail handling.  Tries XDGEmail (or MAPIClient on Windows),
430
 
    falls back to Editor"""
431
 
 
432
 
    def _mail_client(self):
433
 
        """Determine the preferred mail client for this platform"""
434
 
        if osutils.supports_mapi():
435
 
            return MAPIClient(self.config)
436
 
        else:
437
 
            return XDGEmail(self.config)
438
 
 
439
 
    def compose(self, prompt, to, subject, attachment, mime_subtype,
440
 
                extension, basename=None):
441
 
        """See MailClient.compose"""
442
 
        try:
443
 
            return self._mail_client().compose(prompt, to, subject,
444
 
                                               attachment, mimie_subtype,
445
 
                                               extension, basename)
446
 
        except errors.MailClientNotFound:
447
 
            return Editor(self.config).compose(prompt, to, subject,
448
 
                          attachment, mimie_subtype, extension)
449
 
 
450
 
    def compose_merge_request(self, to, subject, directive, basename=None):
451
 
        """See MailClient.compose_merge_request"""
452
 
        try:
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)