]> git.ipfire.org Git - ipfire.org.git/blob - src/backend/messages.py
backend: show checksum on thank-you page
[ipfire.org.git] / src / backend / messages.py
1 #!/usr/bin/python3
2
3 import base64
4 import email
5 import email.mime.multipart
6 import email.mime.text
7 import email.utils
8 import logging
9 import mimetypes
10 import os.path
11 import pynliner
12 import random
13 import smtplib
14 import socket
15 import subprocess
16 import tornado.locale
17 import tornado.template
18
19 from . import accounts
20 from . import misc
21 from . import util
22 from .decorators import *
23
24 # Encode emails in UTF-8 by default
25 email.charset.add_charset("utf-8", email.charset.SHORTEST, email.charset.QP, "utf-8")
26
27 class Messages(misc.Object):
28 @lazy_property
29 def queue(self):
30 return Queue(self.backend)
31
32 @lazy_property
33 def template_loader(self):
34 """
35 Creates a new template loader
36 """
37 templates_dir = self.backend.config.get("global", "templates_dir")
38 assert templates_dir
39
40 # Setup namespace
41 namespace = {
42 "embed_image" : self.embed_image,
43 }
44
45 return tornado.template.Loader(templates_dir, namespace=namespace, autoescape=None)
46
47 def make_recipient(self, recipient):
48 # Use the contact instead of the account
49 if isinstance(recipient, accounts.Account):
50 recipient = recipient.email_to
51
52 # Fall back to pass on strings
53 return recipient
54
55 def make_msgid(self):
56 return email.utils.make_msgid("ipfire", domain="ipfire.org")
57
58 @property
59 def bounce_email_address(self):
60 return self.settings.get("bounce_email_address")
61
62 def _send(self, message, sender=None, priority=0):
63 res = self.db.get("INSERT INTO messages(message, priority) \
64 VALUES(%s, %s) RETURNING id", message, priority)
65
66 logging.debug("Message queued with ID %s" % res.id)
67
68 def send(self, message, priority=0, headers={}):
69 # Convert message from string
70 if not isinstance(message, email.message.Message):
71 message = email.message_from_string(message)
72
73 # Add a message ID if non exsist
74 if not "Message-Id" in message and not "Message-ID" in message:
75 message.add_header("Message-Id", self.make_msgid())
76
77 # Add any headers
78 for k, v in headers:
79 try:
80 message.replace_header(k, v)
81 except KeyError:
82 message.add_header(k, v)
83
84 # Add date if the message doesn't have one already
85 if "Date" not in message:
86 message.add_header("Date", email.utils.formatdate())
87
88 # Send any errors to the bounce address
89 if self.bounce_email_address:
90 message.add_header("Errors-To", "<%s>" % self.bounce_email_address)
91
92 # Send the message
93 self._send(message.as_string(), priority=priority)
94
95 def send_template(self, template_name,
96 sender=None, priority=0, headers={}, **kwargs):
97 """
98 Send a message based on the given template
99 """
100 locale = tornado.locale.get("en_US")
101
102 # Create the required namespace to render the message
103 namespace = {
104 # Generic Stuff
105 "backend" : self.backend,
106
107 # Locale
108 "locale" : locale,
109 "_" : locale.translate,
110 }
111 namespace.update(kwargs)
112
113 # Create an alternating multipart message to show HTML or text
114 message = email.mime.multipart.MIMEMultipart("alternative")
115
116 for extension, mimetype in (("txt", "plain"), ("html", "html")):
117 try:
118 t = self.template_loader.load("%s.%s" % (template_name, extension))
119 except IOError as e:
120 # Ignore if the HTML template does not exist
121 if extension == "html":
122 continue
123
124 # Raise all other exceptions
125 raise e
126
127 # Render the message
128 try:
129 message_part = t.generate(**namespace)
130
131 # Reset the rendered template when it could not be rendered
132 except:
133 self.template_loader.reset()
134 raise
135
136 # Parse the message and extract the header
137 message_part = email.message_from_string(message_part.decode())
138
139 for header in message_part:
140 value = message_part[header]
141
142 # Make sure addresses are properly encoded
143 realname, address = email.utils.parseaddr(value)
144 if realname and address:
145 value = email.utils.formataddr((realname, address))
146
147 try:
148 message.replace_header(header, value)
149 except KeyError:
150 message.add_header(header, value)
151
152 # Inline any CSS
153 if extension == "html":
154 message_part = self._inline_css(message_part)
155
156 # Create a MIMEText object out of it
157 message_part = email.mime.text.MIMEText(
158 message_part.get_payload(), mimetype)
159
160 # Attach the parts to the mime container.
161 # According to RFC2046, the last part of a multipart message
162 # is preferred.
163 message.attach(message_part)
164
165 # Send the message
166 self.send(message, priority=priority, headers=headers)
167
168 # In debug mode, re-compile the templates with every request
169 if self.backend.debug:
170 self.template_loader.reset()
171
172 def _inline_css(self, part):
173 """
174 Inlines any CSS into style attributes
175 """
176 # Fetch the payload
177 payload = part.get_payload()
178
179 # Setup Pynliner
180 p = pynliner.Pynliner().from_string(payload)
181
182 # Run the inlining
183 payload = p.run()
184
185 # Set the payload again
186 part.set_payload(payload)
187
188 return part
189
190 def embed_image(self, path):
191 static_dir = self.backend.config.get("global", "static_dir")
192 assert static_dir
193
194 # Make the path absolute
195 path = os.path.join(static_dir, path)
196
197 # Fetch the mimetype
198 mimetype, encoding = mimetypes.guess_type(path)
199
200 # Read the file
201 with open(path, "rb") as f:
202 data = f.read()
203
204 # Convert data into base64
205 data = base64.b64encode(data)
206
207 # Return everything
208 return "data:%s;base64,%s" % (mimetype, data.decode())
209
210 async def send_cli(self, template, recipient):
211 """
212 Send a test message from the CLI
213 """
214 account = self.backend.accounts.get_by_mail(recipient)
215
216 posts = list(self.backend.blog.get_newest(limit=5))
217
218 kwargs = {
219 "account" : account,
220 "first_name" : account.first_name,
221 "last_name" : account.last_name,
222 "uid" : account.uid,
223 "email" : account.email,
224
225 # Random activation/reset codes
226 "activation_code" : util.random_string(36),
227 "reset_code" : util.random_string(64),
228
229 # The latest blog post
230 "post" : random.choice(posts),
231 }
232
233 return self.send_template(template, **kwargs)
234
235
236 class Queue(misc.Object):
237 @property
238 def messages(self):
239 return self.db.query("SELECT * FROM messages \
240 WHERE time_sent IS NULL \
241 ORDER BY priority DESC, time_created ASC")
242
243 @lazy_property
244 def relay(self):
245 """
246 Connection to the local mail relay
247 """
248 hostname = socket.getfqdn()
249
250 # Open SMTP connection
251 conn = smtplib.SMTP(hostname)
252
253 # Start TLS connection
254 conn.starttls(context=self.backend.ssl_context)
255
256 return conn
257
258 async def send_all(self):
259 # Sends all messages
260 for message in self.messages:
261 self.send(message)
262
263 logging.debug("All messages sent")
264
265 def send(self, message):
266 """
267 Delivers the given message the local mail relay
268 """
269 # Parse the message from what is in the database
270 msg = email.message_from_string(message.message)
271
272 logging.debug("Sending a message %s to: %s" % (
273 msg.get("Subject"), msg.get("To"),
274 ))
275
276 error_messages = []
277 rejected_recipients = {}
278
279 # Try delivering the email
280 try:
281 rejected_recipients = self.relay.send_message(msg)
282
283 except smtplib.SMTPRecipientsRefused as e:
284 rejected_recipients = e.recipients
285
286 except smtplib.SMTPException as e:
287 logging.error("SMTP Exception: %s" % e)
288 error_messages.append("%s" % e)
289
290 # Log all emails that could not be delivered
291 for recipient in rejected_recipients:
292 code, reason = rejected_recipients[recipient]
293
294 error_messages.append("Recipient refused: %s - %s (%s)" % \
295 (recipient, code, reason.decode()))
296
297 if error_messages:
298 self.db.execute("UPDATE messages SET error_message = %s \
299 WHERE id = %s", "; ".join(error_messages), message.id)
300
301 logging.error("Could not send email: %s" % message.id)
302 for line in error_messages:
303 logging.error(line)
304
305 # After the email has been successfully sent, we mark it as such
306 self.db.execute("UPDATE messages SET time_sent = NOW() \
307 WHERE id = %s", message.id)
308
309 def cleanup(self):
310 logging.debug("Cleaning up message queue")
311
312 self.db.execute("DELETE FROM messages \
313 WHERE time_sent IS NOT NULL AND time_sent <= NOW() - '30 day'::interval")