]> git.ipfire.org Git - ipfire.org.git/blobdiff - src/backend/messages.py
backend: show checksum on thank-you page
[ipfire.org.git] / src / backend / messages.py
index 5f90047f197c478571c7b449990b7d84f8daa3ab..53f62baa33bda857dc0beaaee4f1aca3c36ea954 100644 (file)
@@ -1,18 +1,29 @@
 #!/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):
@@ -26,12 +37,17 @@ class Messages(misc.Object):
                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
@@ -43,19 +59,13 @@ class Messages(misc.Object):
        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)
@@ -80,10 +90,10 @@ class Messages(misc.Object):
                        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
                """
@@ -100,45 +110,128 @@ class Messages(misc.Object):
                }
                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
@@ -147,66 +240,74 @@ class Queue(misc.Object):
                        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")