Add 8bit Content-Transfer-Encoding support.
[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.mime.text import MIMEText as _MIMEText
23 from email.header import Header as _Header
24 from email.utils import formataddr as _formataddr
25 from email.utils import parseaddr as _parseaddr
26 import smtplib as _smtplib
27 import subprocess as _subprocess
28
29 from . import LOG as _LOG
30 from . import config as _config
31 from . import error as _error
32
33
34 def guess_encoding(string, encodings=('US-ASCII', 'UTF-8')):
35     """Find an encoding capable of encoding `string`.
36
37     >>> guess_encoding('alpha', encodings=('US-ASCII', 'UTF-8'))
38     'US-ASCII'
39     >>> guess_encoding('α', encodings=('US-ASCII', 'UTF-8'))
40     'UTF-8'
41     >>> guess_encoding('α', encodings=('US-ASCII', 'ISO-8859-1'))
42     Traceback (most recent call last):
43       ...
44     rss2email.error.NoValidEncodingError: no valid encoding for α in ('US-ASCII', 'ISO-8859-1')
45     """
46     for encoding in encodings:
47         try:
48             string.encode(encoding)
49         except (UnicodeError, LookupError):
50             pass
51         else:
52             return encoding
53     raise _error.NoValidEncodingError(string=string, encodings=encodings)
54
55 def get_message(sender, recipient, subject, body, content_type,
56                 extra_headers=None, config=None, section='DEFAULT'):
57     """Generate a `Message` instance.
58
59     All arguments should be Unicode strings (plain ASCII works as well).
60
61     Only the real name part of sender and recipient addresses may contain
62     non-ASCII characters.
63
64     The email will be properly MIME encoded.
65
66     The charset of the email will be the first one out of the list
67     that can represent all the characters occurring in the email.
68
69     >>> message = get_message(
70     ...     sender='John <jdoe@a.com>', recipient='Ζεύς <z@olympus.org>',
71     ...     subject='Testing',
72     ...     body='Hello, world!\\n',
73     ...     content_type='plain',
74     ...     extra_headers={'Approved': 'joe@bob.org'})
75     >>> print(message.as_string())  # doctest: +REPORT_UDIFF
76     MIME-Version: 1.0
77     Content-Type: text/plain; charset="us-ascii"
78     Content-Transfer-Encoding: 7bit
79     From: John <jdoe@a.com>
80     To: =?utf-8?b?zpbOtc+Nz4I=?= <z@olympus.org>
81     Subject: Testing
82     Approved: joe@bob.org
83     <BLANKLINE>
84     Hello, world!
85     <BLANKLINE>
86     """
87     if config is None:
88         config = _config.CONFIG
89     encodings = [
90         x.strip() for x in config.get(section, 'encodings').split(',')]
91
92     # Split real name (which is optional) and email address parts
93     sender_name,sender_addr = _parseaddr(sender)
94     recipient_name,recipient_addr = _parseaddr(recipient)
95
96     sender_encoding = guess_encoding(sender_name, encodings)
97     recipient_encoding = guess_encoding(recipient_name, encodings)
98     subject_encoding = guess_encoding(subject, encodings)
99     body_encoding = guess_encoding(body, encodings)
100
101     # We must always pass Unicode strings to Header, otherwise it will
102     # use RFC 2047 encoding even on plain ASCII strings.
103     sender_name = str(_Header(sender_name, sender_encoding).encode())
104     recipient_name = str(_Header(recipient_name, recipient_encoding).encode())
105
106     # Make sure email addresses do not contain non-ASCII characters
107     sender_addr.encode('ascii')
108     recipient_addr.encode('ascii')
109
110     # Create the message ('plain' stands for Content-Type: text/plain)
111     message = _MIMEText(body, content_type, body_encoding)
112     message['From'] = _formataddr((sender_name, sender_addr))
113     message['To'] = _formataddr((recipient_name, recipient_addr))
114     message['Subject'] = _Header(subject, subject_encoding)
115     if config.getboolean(section, 'use_8bit'):
116         message['Content-Transfer-Encoding'] = '8bit'
117         message.set_payload(body)
118     if extra_headers:
119         for key,value in extra_headers.items():
120             encoding = guess_encoding(value, encodings)
121             message[key] = _Header(value, encoding)
122     return message
123
124 def smtp_send(sender, recipient, message, config=None, section='DEFAULT'):
125     if config is None:
126         config = _config.CONFIG
127     server = config.get(section, 'smtp-server')
128     _LOG.debug('sending message to {} via {}'.format(recipient, server))
129     ssl = config.getboolean(section, 'smtp-ssl')
130     if ssl:
131         smtp = _smtplib.SMTP_SSL()
132     else:
133         smtp = _smtplib.SMTP()
134         smtp.ehlo()
135     try:
136         smtp.connect(SMTP_SERVER)
137     except KeyboardInterrupt:
138         raise
139     except Exception as e:
140         raise _error.SMTPConnectionError(server=server) from e
141     if config.getboolean(section, 'smtp-auth'):
142         username = config.get(section, 'smtp-username')
143         password = config.get(section, 'smtp-password')
144         try:
145             if not ssl:
146                 smtp.starttls()
147             smtp.login(username, password)
148         except KeyboardInterrupt:
149             raise
150         except Exception as e:
151             raise _error.SMTPAuthenticationError(
152                 server=server, username=username)
153     smtp.send_message(message, sender, [recipient])
154     smtp.quit()
155
156 def sendmail_send(sender, recipient, message, config=None, section='DEFAULT'):
157     if config is None:
158         config = _config.CONFIG
159     _LOG.debug(
160         'sending message to {} via /usr/sbin/sendmail'.format(recipient))
161     try:
162         p = _subprocess.Popen(
163             ['/usr/sbin/sendmail', recipient],
164             stdin=_subprocess.PIPE, stdout=_subprocess.PIPE,
165             stderr=_subprocess.PIPE)
166         stdout,stderr = p.communicate(message.as_string()
167                                       .encode(str(message.get_charset())))
168         status = p.wait()
169         if status:
170             raise _error.SendmailError(
171                 status=status, stdout=stdout, stderr=stderr)
172     except Exception as e:
173         raise _error.SendmailError() from e
174
175 def send(sender, recipient, message, config=None, section='DEFAULT'):
176     if config.getboolean(section, 'use-smtp'):
177         smtp_send(sender, recipient, message)
178     else:
179         sendmail_send(sender, recipient, message)