#!/usr/bin/python3
+import base64
import email
-import email.charset
-import email.mime.nonmultipart
+import email.mime.multipart
+import email.mime.text
import email.utils
import logging
+import mimetypes
+import os.path
+import pynliner
+import random
+import smtplib
+import socket
import subprocess
-import tornado.gen
+import tornado.locale
import tornado.template
from . import accounts
from . import misc
+from . import util
from .decorators import *
+# Encode emails in UTF-8 by default
+email.charset.add_charset("utf-8", email.charset.SHORTEST, email.charset.QP, "utf-8")
+
class Messages(misc.Object):
@lazy_property
def queue(self):
templates_dir = self.backend.config.get("global", "templates_dir")
assert templates_dir
- return tornado.template.Loader(templates_dir, autoescape=None)
+ # Setup namespace
+ namespace = {
+ "embed_image" : self.embed_image,
+ }
+
+ return tornado.template.Loader(templates_dir, namespace=namespace, autoescape=None)
def make_recipient(self, recipient):
# Use the contact instead of the account
if isinstance(recipient, accounts.Account):
- recipient = "%s <%s>" % (recipient, recipient.email)
+ recipient = recipient.email_to
# Fall back to pass on strings
return recipient
def bounce_email_address(self):
return self.settings.get("bounce_email_address")
- def _send(self, recipients, message, sender=None, priority=0):
- if not recipients:
- raise ValueError("Empty list of recipients")
-
- # Format recipients
- recipients = [self.make_recipient(r) for r in recipients]
-
- res = self.db.get("INSERT INTO messages(message, priority, envelope_recipients) \
- VALUES(%s, %s, %s) RETURNING id", message, priority, recipients)
+ def _send(self, message, sender=None, priority=0):
+ res = self.db.get("INSERT INTO messages(message, priority) \
+ VALUES(%s, %s) RETURNING id", message, priority)
logging.debug("Message queued with ID %s" % res.id)
- def send(self, recipients, message, priority=None, headers={}):
+ def send(self, message, priority=0, headers={}):
# Convert message from string
if not isinstance(message, email.message.Message):
message = email.message_from_string(message)
message.add_header("Errors-To", "<%s>" % self.bounce_email_address)
# Send the message
- self._send(recipients, message.as_string(), priority=priority)
+ self._send(message.as_string(), priority=priority)
- def send_template(self, template_name, recipients,
- sender=None, priority=None, headers={}, **kwargs):
+ def send_template(self, template_name,
+ sender=None, priority=0, headers={}, **kwargs):
"""
Send a message based on the given template
"""
}
namespace.update(kwargs)
- # Create a non-multipart message
- message = email.mime.nonmultipart.MIMENonMultipart(
- "text", "plain", charset="utf-8",
- )
-
- # Load template
- t = self.template_loader.load("%s.txt" % template_name)
+ # Create an alternating multipart message to show HTML or text
+ message = email.mime.multipart.MIMEMultipart("alternative")
- # Render the message
- try:
- message_part = t.generate(**namespace)
+ for extension, mimetype in (("txt", "plain"), ("html", "html")):
+ try:
+ t = self.template_loader.load("%s.%s" % (template_name, extension))
+ except IOError as e:
+ # Ignore if the HTML template does not exist
+ if extension == "html":
+ continue
- # Reset the rendered template when it could not be rendered
- except:
- self.template_loader.reset()
- raise
+ # Raise all other exceptions
+ raise e
- # Parse the message and extract the header
- message_part = email.message_from_string(message_part.decode())
- for k, v in list(message_part.items()):
+ # Render the message
try:
- message.replace_header(k, v)
- except KeyError:
- message.add_header(k, v)
+ message_part = t.generate(**namespace)
- # Do not encode emails in base64
- charset = email.charset.Charset("utf-8")
- charset.body_encoding = email.charset.QP
+ # Reset the rendered template when it could not be rendered
+ except:
+ self.template_loader.reset()
+ raise
- # Set payload
- message.set_payload(message_part.get_payload(), charset=charset)
+ # Parse the message and extract the header
+ message_part = email.message_from_string(message_part.decode())
+
+ for header in message_part:
+ value = message_part[header]
+
+ # Make sure addresses are properly encoded
+ realname, address = email.utils.parseaddr(value)
+ if realname and address:
+ value = email.utils.formataddr((realname, address))
+
+ try:
+ message.replace_header(header, value)
+ except KeyError:
+ message.add_header(header, value)
+
+ # Inline any CSS
+ if extension == "html":
+ message_part = self._inline_css(message_part)
+
+ # Create a MIMEText object out of it
+ message_part = email.mime.text.MIMEText(
+ message_part.get_payload(), mimetype)
+
+ # Attach the parts to the mime container.
+ # According to RFC2046, the last part of a multipart message
+ # is preferred.
+ message.attach(message_part)
# Send the message
- self.send(recipients, message, priority=priority, headers=headers)
+ self.send(message, priority=priority, headers=headers)
# In debug mode, re-compile the templates with every request
if self.backend.debug:
self.template_loader.reset()
+ def _inline_css(self, part):
+ """
+ Inlines any CSS into style attributes
+ """
+ # Fetch the payload
+ payload = part.get_payload()
+
+ # Setup Pynliner
+ p = pynliner.Pynliner().from_string(payload)
+
+ # Run the inlining
+ payload = p.run()
+
+ # Set the payload again
+ part.set_payload(payload)
+
+ return part
+
+ def embed_image(self, path):
+ static_dir = self.backend.config.get("global", "static_dir")
+ assert static_dir
+
+ # Make the path absolute
+ path = os.path.join(static_dir, path)
+
+ # Fetch the mimetype
+ mimetype, encoding = mimetypes.guess_type(path)
+
+ # Read the file
+ with open(path, "rb") as f:
+ data = f.read()
+
+ # Convert data into base64
+ data = base64.b64encode(data)
+
+ # Return everything
+ return "data:%s;base64,%s" % (mimetype, data.decode())
+
+ async def send_cli(self, template, recipient):
+ """
+ Send a test message from the CLI
+ """
+ account = self.backend.accounts.get_by_mail(recipient)
+
+ posts = list(self.backend.blog.get_newest(limit=5))
+
+ kwargs = {
+ "account" : account,
+ "first_name" : account.first_name,
+ "last_name" : account.last_name,
+ "uid" : account.uid,
+ "email" : account.email,
+
+ # Random activation/reset codes
+ "activation_code" : util.random_string(36),
+ "reset_code" : util.random_string(64),
+
+ # The latest blog post
+ "post" : random.choice(posts),
+ }
+
+ return self.send_template(template, **kwargs)
+
class Queue(misc.Object):
@property
WHERE time_sent IS NULL \
ORDER BY priority DESC, time_created ASC")
- @tornado.gen.coroutine
- def send_all(self):
+ @lazy_property
+ def relay(self):
+ """
+ Connection to the local mail relay
+ """
+ hostname = socket.getfqdn()
+
+ # Open SMTP connection
+ conn = smtplib.SMTP(hostname)
+
+ # Start TLS connection
+ conn.starttls(context=self.backend.ssl_context)
+
+ return conn
+
+ async def send_all(self):
# Sends all messages
for message in self.messages:
- self._sendmail(message)
+ self.send(message)
logging.debug("All messages sent")
- def _sendmail(self, message):
+ def send(self, message):
"""
- Delivers the given message to sendmail.
+ Delivers the given message the local mail relay
"""
- try:
- # Parse the message from what is in the database
- msg = email.message_from_string(message.message)
+ # Parse the message from what is in the database
+ msg = email.message_from_string(message.message)
- logging.debug("Sending a message %s to: %s" % (
- msg.get("Subject"), ", ".join(message.envelope_recipients)
- ))
+ logging.debug("Sending a message %s to: %s" % (
+ msg.get("Subject"), msg.get("To"),
+ ))
- # Make sendmail command line
- cmd = [
- "/usr/sbin/sendmail",
+ error_messages = []
+ rejected_recipients = {}
- # Don't treat a single line with . as end of input
- "-oi",
-
- # Envelope Sender
- "-f", msg.get("From") or "no-reply@ipfire.org",
- ]
-
- # Envelope Recipients
- cmd += message.envelope_recipients
+ # Try delivering the email
+ try:
+ rejected_recipients = self.relay.send_message(msg)
- # Run sendmail and pipe the email in
- p = subprocess.Popen(cmd, bufsize=0, close_fds=True,
- stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ except smtplib.SMTPRecipientsRefused as e:
+ rejected_recipients = e.recipients
- stdout, stderr = p.communicate(message.message.encode("utf-8"))
+ except smtplib.SMTPException as e:
+ logging.error("SMTP Exception: %s" % e)
+ error_messages.append("%s" % e)
- # Wait until sendmail has finished
- p.wait()
+ # Log all emails that could not be delivered
+ for recipient in rejected_recipients:
+ code, reason = rejected_recipients[recipient]
- if p.returncode:
- self.db.execute("UPDATE messages SET error_message = %s \
- WHERE id = %s", stdout, message.id)
+ error_messages.append("Recipient refused: %s - %s (%s)" % \
+ (recipient, code, reason.decode()))
- logging.error("Could not send mail: %s" % stdout)
+ if error_messages:
+ self.db.execute("UPDATE messages SET error_message = %s \
+ WHERE id = %s", "; ".join(error_messages), message.id)
- # Raise all exceptions
- except:
- raise
+ logging.error("Could not send email: %s" % message.id)
+ for line in error_messages:
+ logging.error(line)
- else:
- # After the email has been successfully sent, we mark it as such
- self.db.execute("UPDATE messages SET time_sent = NOW() \
- WHERE id = %s", message.id)
+ # After the email has been successfully sent, we mark it as such
+ self.db.execute("UPDATE messages SET time_sent = NOW() \
+ WHERE id = %s", message.id)
def cleanup(self):
logging.debug("Cleaning up message queue")
self.db.execute("DELETE FROM messages \
- WHERE time_sent IS NOT NULL AND time_sent >= NOW() + '1 day'::interval")
+ WHERE time_sent IS NOT NULL AND time_sent <= NOW() - '30 day'::interval")