~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/mail_client.py

  • Committer: Naoki INADA
  • Date: 2009-10-29 10:01:19 UTC
  • mto: (4634.97.3 2.0)
  • mto: This revision was merged to the branch mainline in revision 4798.
  • Revision ID: inada-n@klab.jp-20091029100119-uckv9t7ej2qrghw3
import doc-ja rev90

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