forked from ThePalaceProject/library-registry
-
Notifications
You must be signed in to change notification settings - Fork 0
/
emailer.py
282 lines (239 loc) · 10.4 KB
/
emailer.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
import logging
import os
from email import charset
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from smtplib import SMTP
from config import CannotLoadConfiguration, CannotSendEmail
# Set up an encoding/decoding between UTF-8 and quoted-printable.
# Otherwise, the bodies of email messages will be encoded with base64
# and they'll be hard to read. This way, only the non-ASCII characters
# need to be encoded.
charset.add_charset("utf-8", charset.QP, charset.QP, "utf-8")
class Emailer:
"""A class for sending small amounts of email."""
log = logging.getLogger("Emailer")
# Goal and setting names for the ExternalIntegration.
GOAL = "email"
PORT = "port"
FROM_ADDRESS = "from_address"
FROM_NAME = "from_name"
DEFAULT_FROM_NAME = "Library Simplified registry support"
ENV_RECIPIENT_OVERRIDE_ADDRESS = "EMAILER_RECIPIENT_OVERRIDE"
# Constants for different types of email.
ADDRESS_DESIGNATED = "address_designated"
ADDRESS_NEEDS_CONFIRMATION = "address_needs_confirmation"
EMAIL_TYPES = [ADDRESS_DESIGNATED, ADDRESS_NEEDS_CONFIRMATION]
DEFAULT_ADDRESS_DESIGNATED_SUBJECT = (
"This address designated as the %(rel_desc)s for %(library)s"
)
DEFAULT_ADDRESS_NEEDS_CONFIRMATION_SUBJECT = (
"Confirm the %(rel_desc)s for %(library)s"
)
DEFAULT_ADDRESS_DESIGNATED_TEMPLATE = (
"This email address, %(to_address)s, has been registered with the Library Simplified library registry "
"as the %(rel_desc)s for the library %(library)s (%(library_web_url)s)."
"\n\n"
"If this is obviously wrong (for instance, you don't work at a public library), please accept our "
"apologies and contact the Library Simplified support address at %(from_address)s -- something has gone wrong."
"\n\n"
"If you do work at a public library, but you're not sure what this means, please speak to a technical point "
"of contact at your library, or contact the Library Simplified support address at %(from_address)s."
)
NEEDS_CONFIRMATION_ADDITION = (
"If you do know what this means, you should also know that you're not quite done. We need to confirm that "
"you actually meant to use this email address for this purpose. If everything looks right, please "
"visit this link:"
"\n\n"
"%(confirmation_link)s"
"\n\n"
"The link will expire in about a day. If the link expires, just re-register your library with the library "
"registry, and a fresh confirmation email like this will be sent out."
)
BODIES = {
ADDRESS_DESIGNATED: DEFAULT_ADDRESS_DESIGNATED_TEMPLATE,
ADDRESS_NEEDS_CONFIRMATION: DEFAULT_ADDRESS_DESIGNATED_TEMPLATE
+ "\n\n"
+ NEEDS_CONFIRMATION_ADDITION,
}
SUBJECTS = {
ADDRESS_DESIGNATED: DEFAULT_ADDRESS_DESIGNATED_SUBJECT,
ADDRESS_NEEDS_CONFIRMATION: DEFAULT_ADDRESS_NEEDS_CONFIRMATION_SUBJECT,
}
# We use this to catch templates that contain variables we won't
# be able to fill in. This doesn't include from_address and to_address,
# which are filled in separately.
KNOWN_TEMPLATE_KEYS = [
"rel_desc",
"library",
"library_web_url",
"confirmation_link",
"to_address",
"from_address",
]
def __init__(
self,
smtp_username,
smtp_password,
smtp_host,
smtp_port,
from_name,
from_address,
templates,
):
config_errors = []
required_parameters = (
"smtp_username",
"smtp_password",
"smtp_host",
"smtp_port",
"from_name",
"from_address",
)
for param_name in required_parameters:
if not locals()[param_name]:
config_errors.append(param_name)
if config_errors:
msg = "Emailer instantiated with missing params: " + ", ".join(
config_errors
)
raise CannotLoadConfiguration(msg)
self.smtp_username = smtp_username
self.smtp_password = smtp_password
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.from_name = from_name
self.from_address = from_address
self.templates = templates
self.recipient_address_override = os.environ.get(
self.ENV_RECIPIENT_OVERRIDE_ADDRESS, None
)
# Make sure the templates don't contain any template values we can't handle.
test_template_values = {key: "value" for key in self.KNOWN_TEMPLATE_KEYS}
for template in list(self.templates.values()):
try:
template.body("from address", "to address", **test_template_values)
except Exception as e:
m = f"Template '{template.subject_template}'/'{template.body_template}' contains unrecognized key: {e}"
raise CannotLoadConfiguration(m)
def send(self, email_type: str, to_address: str, smtp_class=SMTP, **kwargs):
"""Generate an email from a template and send it.
If we are overriding the email address, we send to and set the
"To:" header to the overriding address. However, we keep the
original `to_address` in the message body, so it is clear on
whose behalf the email is being sent.
:param email_type: The name of the template to use.
:param to_address: Addressee of the email.
:param smtp_class: Use this class for the SMTP protocol client.
:param kwargs: Arguments to use when generating the email from
a template.
"""
if email_type not in self.templates:
raise ValueError("No such email template: %s" % email_type)
template = self.templates[email_type]
from_header = f"{self.from_name} <{self.from_address}>"
kwargs["from_address"] = self.from_address
# Check to see if we have an alternative recipient, unless this is a test email.
recipient = (
self._effective_recipient(default=to_address)
if email_type != "test"
else to_address
)
kwargs["to_address"] = to_address
body = template.body(from_header, to_header=recipient, **kwargs)
self.log.info(
"Sending email of type {!r} to {!r}{}".format(
email_type,
recipient,
f" on behalf of {to_address!r}" if recipient != to_address else "",
)
)
try:
self._send_email(recipient, body, smtp_class)
except Exception as exc:
raise CannotSendEmail(exc)
def _effective_recipient(self, default: str = None) -> str:
"""Override the recipient's email address, when applicable."""
return self.recipient_address_override or default
def _send_email(self, to_address, body, smtp_class=SMTP):
"""Actually send an email."""
smtp = smtp_class(host=self.smtp_host, port=self.smtp_port)
smtp.connect(self.smtp_host, self.smtp_port)
smtp.starttls()
smtp.login(self.smtp_username, self.smtp_password)
smtp.sendmail(self.from_address, to_address, body)
smtp.quit()
@classmethod
def from_sitewide_integration(cls, _db):
"""Create an Emailer from a site-wide email integration.
:param _db: A database connection
"""
integration = cls._sitewide_integration(_db)
host = integration.url
port = integration.setting(cls.PORT).int_value or 587
from_address = integration.setting(cls.FROM_ADDRESS).value
from_name = integration.setting(cls.FROM_NAME).value or cls.DEFAULT_FROM_NAME
email_templates = {}
for email_type in cls.EMAIL_TYPES:
subject = (
integration.setting(email_type + "_subject").value
or cls.SUBJECTS[email_type]
)
body = (
integration.setting(email_type + "_body").value
or cls.BODIES[email_type]
)
template = EmailTemplate(subject, body)
email_templates[email_type] = template
return cls(
smtp_username=integration.username,
smtp_password=integration.password,
smtp_host=host,
smtp_port=port,
from_name=from_name,
from_address=from_address,
templates=email_templates,
)
@classmethod
def _sitewide_integration(cls, _db):
"""Find the ExternalIntegration for the emailer."""
from model import ExternalIntegration
qu = _db.query(ExternalIntegration).filter(ExternalIntegration.goal == cls.GOAL)
integrations = qu.all()
if not integrations:
raise CannotLoadConfiguration("No email integration is configured.")
return None
if len(integrations) > 1:
# If there are multiple integrations configured, none of
# them can be the 'site-wide' configuration.
raise CannotLoadConfiguration("Multiple email integrations are configured")
[integration] = integrations
return integration
class EmailTemplate:
"""A template for email messages."""
def __init__(self, subject_template, body_template):
self.subject_template = subject_template
self.body_template = body_template
def body(self, from_header, to_header, **kwargs):
"""
Generate the complete body of the email message, including headers.
:param from_header: Originating address to use in From: header.
:param to_header: Destination address to use in To: header.
:param kwargs: Arguments to use when filling out the template.
"""
message = MIMEMultipart("mixed")
message["From"] = from_header
message["To"] = to_header
message["Subject"] = Header(self.subject_template % kwargs, "utf-8")
# This might look ugly, because %(from_address)s in a template
# is expected to be an unadorned email address, whereas this
# might look like '"Name" <email>', but it's better than
# nothing.
for k, v in (("to_address", to_header), ("from_address", from_header)):
if k not in kwargs:
kwargs[k] = v
payload = self.body_template % kwargs
text_part = MIMEText(payload, "plain", "utf-8")
message.attach(text_part)
return message.as_string()