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