email: Fix typo '\\n' -> '\n' in _flatten docstring.
[rss2email.git] / rss2email / email.py
1 # -*- encoding: utf-8 -*-
2 #
3 # Copyright (C) 2012-2013 W. Trevor King <wking@tremily.us>
4 #
5 # This file is part of rss2email.
6 #
7 # rss2email is free software: you can redistribute it and/or modify it under
8 # the terms of the GNU General Public License as published by the Free Software
9 # Foundation, either version 2 of the License, or (at your option) version 3 of
10 # the License.
11 #
12 # rss2email is distributed in the hope that it will be useful, but WITHOUT ANY
13 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License along with
17 # rss2email.  If not, see <http://www.gnu.org/licenses/>.
18
19 """Email message generation and dispatching
20 """
21
22 from email.charset import Charset as _Charset
23 import email.encoders as _email_encoders
24 from email.generator import BytesGenerator as _BytesGenerator
25 from email.header import Header as _Header
26 from email.mime.text import MIMEText as _MIMEText
27 from email.utils import formataddr as _formataddr
28 from email.utils import parseaddr as _parseaddr
29 import io as _io
30 import smtplib as _smtplib
31 import subprocess as _subprocess
32
33 from . import LOG as _LOG
34 from . import config as _config
35 from . import error as _error
36
37
38 def guess_encoding(string, encodings=('US-ASCII', 'UTF-8')):
39     """Find an encoding capable of encoding `string`.
40
41     >>> guess_encoding('alpha', encodings=('US-ASCII', 'UTF-8'))
42     'US-ASCII'
43     >>> guess_encoding('α', encodings=('US-ASCII', 'UTF-8'))
44     'UTF-8'
45     >>> guess_encoding('α', encodings=('US-ASCII', 'ISO-8859-1'))
46     Traceback (most recent call last):
47       ...
48     rss2email.error.NoValidEncodingError: no valid encoding for α in ('US-ASCII', 'ISO-8859-1')
49     """
50     for encoding in encodings:
51         try:
52             string.encode(encoding)
53         except (UnicodeError, LookupError):
54             pass
55         else:
56             return encoding
57     raise _error.NoValidEncodingError(string=string, encodings=encodings)
58
59 def get_message(sender, recipient, subject, body, content_type,
60                 extra_headers=None, config=None, section='DEFAULT'):
61     """Generate a `Message` instance.
62
63     All arguments should be Unicode strings (plain ASCII works as well).
64
65     Only the real name part of sender and recipient addresses may contain
66     non-ASCII characters.
67
68     The email will be properly MIME encoded.
69
70     The charset of the email will be the first one out of the list
71     that can represent all the characters occurring in the email.
72
73     >>> message = get_message(
74     ...     sender='John <jdoe@a.com>', recipient='Ζεύς <z@olympus.org>',
75     ...     subject='Testing',
76     ...     body='Hello, world!\\n',
77     ...     content_type='plain',
78     ...     extra_headers={'Approved': 'joe@bob.org'})
79     >>> print(message.as_string())  # doctest: +REPORT_UDIFF
80     MIME-Version: 1.0
81     Content-Type: text/plain; charset="us-ascii"
82     Content-Transfer-Encoding: 7bit
83     From: John <jdoe@a.com>
84     To: =?utf-8?b?zpbOtc+Nz4I=?= <z@olympus.org>
85     Subject: Testing
86     Approved: joe@bob.org
87     <BLANKLINE>
88     Hello, world!
89     <BLANKLINE>
90     """
91     if config is None:
92         config = _config.CONFIG
93     if section not in config.sections():
94         section = 'DEFAULT'
95     encodings = [
96         x.strip() for x in config.get(section, 'encodings').split(',')]
97
98     # Split real name (which is optional) and email address parts
99     sender_name,sender_addr = _parseaddr(sender)
100     recipient_name,recipient_addr = _parseaddr(recipient)
101
102     sender_encoding = guess_encoding(sender_name, encodings)
103     recipient_encoding = guess_encoding(recipient_name, encodings)
104     subject_encoding = guess_encoding(subject, encodings)
105     body_encoding = guess_encoding(body, encodings)
106
107     # We must always pass Unicode strings to Header, otherwise it will
108     # use RFC 2047 encoding even on plain ASCII strings.
109     sender_name = str(_Header(sender_name, sender_encoding).encode())
110     recipient_name = str(_Header(recipient_name, recipient_encoding).encode())
111
112     # Make sure email addresses do not contain non-ASCII characters
113     sender_addr.encode('ascii')
114     recipient_addr.encode('ascii')
115
116     # Create the message ('plain' stands for Content-Type: text/plain)
117     message = _MIMEText(body, content_type, body_encoding)
118     message['From'] = _formataddr((sender_name, sender_addr))
119     message['To'] = _formataddr((recipient_name, recipient_addr))
120     message['Subject'] = _Header(subject, subject_encoding)
121     if config.getboolean(section, 'use-8bit'):
122         del message['Content-Transfer-Encoding']
123         charset = _Charset(body_encoding)
124         charset.body_encoding = _email_encoders.encode_7or8bit
125         message.set_payload(body.encode(body_encoding), charset=charset)
126     if extra_headers:
127         for key,value in extra_headers.items():
128             encoding = guess_encoding(value, encodings)
129             message[key] = _Header(value, encoding)
130     return message
131
132 def smtp_send(sender, recipient, message, config=None, section='DEFAULT'):
133     if config is None:
134         config = _config.CONFIG
135     server = config.get(section, 'smtp-server')
136     _LOG.debug('sending message to {} via {}'.format(recipient, server))
137     ssl = config.getboolean(section, 'smtp-ssl')
138     if ssl:
139         smtp = _smtplib.SMTP_SSL()
140     else:
141         smtp = _smtplib.SMTP()
142         smtp.ehlo()
143     try:
144         smtp.connect(SMTP_SERVER)
145     except KeyboardInterrupt:
146         raise
147     except Exception as e:
148         raise _error.SMTPConnectionError(server=server) from e
149     if config.getboolean(section, 'smtp-auth'):
150         username = config.get(section, 'smtp-username')
151         password = config.get(section, 'smtp-password')
152         try:
153             if not ssl:
154                 smtp.starttls()
155             smtp.login(username, password)
156         except KeyboardInterrupt:
157             raise
158         except Exception as e:
159             raise _error.SMTPAuthenticationError(
160                 server=server, username=username)
161     smtp.send_message(message, sender, [recipient])
162     smtp.quit()
163
164 def _flatten(message):
165     r"""Flatten an email.message.Message to bytes
166
167     >>> import rss2email.config
168     >>> config = rss2email.config.Config()
169     >>> config.read_dict(rss2email.config.CONFIG)
170
171     Here's a 7-bit, base64 version:
172
173     >>> message = get_message(
174     ...     sender='John <jdoe@a.com>', recipient='Ζεύς <z@olympus.org>',
175     ...     subject='Homage',
176     ...     body="You're great, Ζεύς!\n",
177     ...     content_type='plain',
178     ...     config=config)
179     >>> for line in _flatten(message).split(b'\n'):
180     ...     print(line)  # doctest: +REPORT_UDIFF
181     b'MIME-Version: 1.0'
182     b'Content-Type: text/plain; charset="utf-8"'
183     b'Content-Transfer-Encoding: base64'
184     b'From: John <jdoe@a.com>'
185     b'To: =?utf-8?b?zpbOtc+Nz4I=?= <z@olympus.org>'
186     b'Subject: Homage'
187     b''
188     b'WW91J3JlIGdyZWF0LCDOls61z43PgiEK'
189     b''
190
191     Here's an 8-bit version:
192
193     >>> config.set('DEFAULT', 'use-8bit', str(True))
194     >>> message = get_message(
195     ...     sender='John <jdoe@a.com>', recipient='Ζεύς <z@olympus.org>',
196     ...     subject='Homage',
197     ...     body="You're great, Ζεύς!\n",
198     ...     content_type='plain',
199     ...     config=config)
200     >>> for line in _flatten(message).split(b'\n'):
201     ...     print(line)  # doctest: +REPORT_UDIFF
202     b'MIME-Version: 1.0'
203     b'Content-Type: text/plain; charset="utf-8"'
204     b'From: John <jdoe@a.com>'
205     b'To: =?utf-8?b?zpbOtc+Nz4I=?= <z@olympus.org>'
206     b'Subject: Homage'
207     b'Content-Transfer-Encoding: 8bit'
208     b''
209     b"You're great, \xce\x96\xce\xb5\xcf\x8d\xcf\x82!"
210     b''
211
212     Here's an 8-bit version in UTF-16:
213
214     >>> config.set('DEFAULT', 'encodings', 'US-ASCII, UTF-16-LE')
215     >>> message = get_message(
216     ...     sender='John <jdoe@a.com>', recipient='Ζεύς <z@olympus.org>',
217     ...     subject='Homage',
218     ...     body="You're great, Ζεύς!\n",
219     ...     content_type='plain',
220     ...     config=config)
221     >>> for line in _flatten(message).split(b'\n'):
222     ...     print(line)  # doctest: +REPORT_UDIFF
223     b'MIME-Version: 1.0'
224     b'Content-Type: text/plain; charset="utf-16-le"'
225     b'From: John <jdoe@a.com>'
226     b'To: =?utf-8?b?zpbOtc+Nz4I=?= <z@olympus.org>'
227     b'Subject: Homage'
228     b'Content-Transfer-Encoding: 8bit'
229     b''
230     b"\x00Y\x00o\x00u\x00'\x00r\x00e\x00 \x00g\x00r\x00e\x00a\x00t\x00,\x00 \x00\x96\x03\xb5\x03\xcd\x03\xc2\x03!\x00\n\x00"
231     """
232     bytesio = _io.BytesIO()
233     generator = _BytesGenerator(bytesio)  # use policies for Python >=3.3
234     generator.flatten(message)
235     return bytesio.getvalue()
236
237 def sendmail_send(sender, recipient, message, config=None, section='DEFAULT'):
238     if config is None:
239         config = _config.CONFIG
240     message_bytes = _flatten(message)
241     sendmail = config.get(section, 'sendmail')
242     _LOG.debug(
243         'sending message to {} via {}'.format(recipient, sendmail))
244     try:
245         p = _subprocess.Popen(
246             [sendmail, '-f', sender, recipient],
247             stdin=_subprocess.PIPE, stdout=_subprocess.PIPE,
248             stderr=_subprocess.PIPE)
249         stdout,stderr = p.communicate(message_bytes)
250         status = p.wait()
251         if status:
252             raise _error.SendmailError(
253                 status=status, stdout=stdout, stderr=stderr)
254     except Exception as e:
255         raise _error.SendmailError() from e
256
257 def send(sender, recipient, message, config=None, section='DEFAULT'):
258     if config.getboolean(section, 'use-smtp'):
259         smtp_send(sender, recipient, message)
260     else:
261         sendmail_send(sender, recipient, message)