source: main/waeup.kofa/trunk/src/waeup/kofa/smtp.py @ 14400

Last change on this file since 14400 was 12836, checked in by Henrik Bettermann, 9 years ago

Fix docstrings as slanged by Sphinx.

  • Property svn:keywords set to Id
File size: 7.0 KB
Line 
1## $Id: smtp.py 12836 2015-03-31 17:03:08Z henrik $
2##
3## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""
19Email (SMTP) services for Kofa.
20
21Note About Encodings
22--------------------
23
24All functions in this module expect any raw strings passed in to be
25encoded 'utf-8' (if you pass in unicode objects instead, this is not a
26problem).
27
28This is because we cannot easily tell from a raw string (it is in fact
29only a byte stream) what encoding it has. In latin-1 and utf-8, for
30instance, there exist some chars (byte values) that have different
31meanings in both encodings. If we see such a char in a byte stream:
32what is it meant to represent? The respective character from latin1 or
33the one from utf-8?
34
35We therefore interpret all internally used raw strings to be encoded as
36utf-8.
37
38The functions in here nevertheless try hard to produce output (mail
39parts, headers, etc.) encoded in the least complex manner. For
40instance if you pass in some address or mail body that is
41representable (correctly) as ASCII or latin-1, we will turn the text
42into that encoding (given, you passed it in as utf-8) to stay as
43compatible as possible with old mailers that do not understand utf-8.
44
45"""
46import grok
47import logging
48from email.Header import Header
49from email.Utils import formataddr
50from email.mime.text import MIMEText
51from zope.component import getUtility
52from zope.sendmail.interfaces import IMailDelivery
53from waeup.kofa.interfaces import IMailService
54
55
56#: The hardcoded from address. Must not by yahoo.com.
57FROM_ADDRESS = 'no-reply@waeup.org'
58
59
60class DefaultMailService(grok.GlobalUtility):
61    """Returns a :class:`zope.sendmail.IMailDelivery`.
62
63    Searches a site from current request (if applicable) and returns
64    the mail delivery set for this site or a fake mailer that does not
65    really send mail (for testing, evaluating, etc.).
66    """
67    grok.implements(IMailService)
68
69    def __call__(self):
70        name = 'No email service'
71        site = grok.getSite()
72        if site is not None:
73            config = site['configuration']
74            name = getattr(config, 'smtp_mailer', name)
75        return getUtility(IMailDelivery, name=name)
76
77
78class FakeSMTPDelivery(grok.GlobalUtility):
79    """A fake mail delivery for testing etc.
80
81    Instead of sending real mails, this mailer only logs received
82    messages to the ``test.smtp`` logger.
83    """
84    grok.implements(IMailDelivery)
85    grok.name('No email service')
86
87    def send(self, fromaddr, toaddrs, message):
88        logger = logging.getLogger('test.smtp')
89        rcpts = ', '.join([x.decode('utf-8') for x in toaddrs])
90        logger.info(
91            u"Sending email from %s to %s:" % (
92                fromaddr.decode('utf-8'), rcpts))
93        logger.info(u"Message:")
94        for line in message.split('\n'):
95            logger.info(u"msg: " + line.decode('utf-8'))
96        return 'fake-message-id@example.com'
97
98CHARSETS = ('US-ASCII', 'ISO-8859-1', 'UTF-8')
99
100
101def encode_header_item(item):
102    """Turns `item`, a string into an SMTP header part string.
103
104    Encodings are checked carefully (we try to encode as ASCII,
105    Latin-1 and UTF-8 in that order).
106
107    If `item` is not a basestring, `None` is returned.
108    """
109    if not isinstance(item, basestring):
110        return None
111    if not isinstance(item, unicode):
112        item = unicode(item, 'utf-8')
113    return str(Header(item, 'iso-8859-1'))  # try ascii, then latin1, utf-8
114
115
116def encode_address(addr, name=u''):
117    """Turn email address parts into a single valid email string.
118
119    The given email address and the name are turned into a single
120    (byte stream) string, suitable for use with ``To:`` or ``From:``
121    headers in emails.
122
123    Any encodings to a mailer-readable format are performed.
124
125    Preferred input format is unicode, although also raw strings (byte
126    streams) work as long as they are decodable from UTF-8.
127
128    That means: if you pass in non-unicode string, take care to
129    deliver utf-8 encoded strings (or plain ASCII).
130
131    Returns a single (raw) string like "My Name <my@sample.com>".
132    """
133    addr = encode_header_item(addr)
134    name = encode_header_item(name)
135    return formataddr((name, addr))
136
137
138def encode_body(text):
139    """Build MIME message part from text.
140
141    You can pass unicode objects or simple strings as text.
142
143    If the input is a simple string, this string is expected
144    to be encoded 'utf-8'!
145
146    Returns a MIMEText object.
147    """
148    if not isinstance(text, unicode):
149        text = unicode(text, 'utf-8')
150    charset = CHARSETS[-1]  # fallback
151    for charset in CHARSETS:
152        try:
153            text = text.encode(charset)
154        except UnicodeError:
155            pass  # try next encoding
156        else:
157            break
158    return MIMEText(text, 'plain', charset)
159
160
161def send_mail(from_name, from_addr, rcpt_name, rcpt_addrs,
162              subject, body, config=None, cc=False):
163    """Send mail.
164
165    Use `IMailService` to send a mail with the parameters
166    delivered.
167
168    Please note: the `from_addr` given will be used for the reply-to
169    (and cc) field only. It will _not_ be used for the `from` field,
170    as yahoo does not allow non-yahoo servers to deliver mail with
171    ``@yahoo.com`` in the from-field.
172
173    The from-address set here will be: `FROM_ADDRESS` as set above.
174
175    ``cc`` tells whether we want the from-address to be CCed. This is
176    not the case by default as we easily act as an open relay
177    otherwise.
178
179    XXX: The hard-coded from-address should be changable or be
180         determined smarter by looking up a FQDN or similar.
181
182    """
183    # format message
184    rcpt_addrs = rcpt_addrs.replace(' ', '').split(',')
185    body_to = ''
186    for email in rcpt_addrs:
187        body_to += '%s, ' % encode_address(email, rcpt_name)
188    body = encode_body(body)
189    sender_addr = encode_address(FROM_ADDRESS, from_name)
190    reply_addr = encode_address(from_addr, from_name)
191    body["From"] = sender_addr
192    body["To"] = body_to.strip(', ')
193    if cc:
194        body["Cc"] = reply_addr
195    body["Reply-To"] = reply_addr
196    body["Subject"] = encode_header_item(subject)
197
198    mailer = getUtility(IMailService)
199    try:
200        email_admin = grok.getSite()['configuration'].email_admin
201        if from_addr != email_admin:
202            rcpt_addrs += [from_addr]
203    except TypeError:
204        # In tests we might not have a site object
205        rcpt_addrs += [from_addr]
206        pass
207    result = mailer().send(FROM_ADDRESS, rcpt_addrs, body.as_string())
208    return result
Note: See TracBrowser for help on using the repository browser.