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