Support sending HTML emails
authorMichael Tremer <michael.tremer@ipfire.org>
Wed, 20 Nov 2019 16:28:13 +0000 (16:28 +0000)
committerMichael Tremer <michael.tremer@ipfire.org>
Wed, 20 Nov 2019 16:29:23 +0000 (16:29 +0000)
Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
.gitignore
Makefile.am
src/backend/base.py
src/backend/messages.py
src/templates/messages/base.html [new file with mode: 0644]
src/templates/messages/main.scss [new file with mode: 0644]

index 2d0f70a..777242a 100644 (file)
@@ -12,6 +12,7 @@
 /src/static/favicon.ico
 /src/static/img/apple-touch-icon-*-precomposed.png
 /src/systemd/ipfire.org-webapp-*.service
+/src/templates/messages/main.css
 /ipfire.org.conf.sample
 .DS_Store
 Makefile
index 05b95d9..3250973 100644 (file)
@@ -206,6 +206,12 @@ templates_location_DATA = \
 
 templates_locationdir = $(templatesdir)/location
 
+templates_messages_DATA = \
+       src/templates/messages/base.html \
+       src/templates/messages/main.css
+
+templates_messagesdir = $(templatesdir)/messages
+
 templates_mirrors_DATA = \
        src/templates/mirrors/index.html \
        src/templates/mirrors/mirror.html
index 80c5099..1578a01 100644 (file)
@@ -102,6 +102,7 @@ class Backend(object):
                        "launch-campaigns"  : self.campaigns.launch_manually,
                        "run-campaigns"     : self.campaigns.run,
                        "scan-files"        : self.releases.scan_files,
+                       "send-message"      : self.messages.send_cli,
                        "send-all-messages" : self.messages.queue.send_all,
                        "test-blacklist"    : self.geoip.test_blacklist,
                        "test-ldap"         : self.accounts.test_ldap,
index 28b717c..8063ce3 100644 (file)
@@ -1,8 +1,8 @@
 #!/usr/bin/python3
 
 import email
-import email.charset
-import email.mime.nonmultipart
+import email.mime.multipart
+import email.mime.text
 import email.utils
 import logging
 import subprocess
@@ -104,37 +104,46 @@ class Messages(misc.Object):
                }
                namespace.update(kwargs)
 
-               # Create a non-multipart message
-               message = email.mime.nonmultipart.MIMENonMultipart(
-                       "text", "plain", charset="utf-8",
-               )
+               # Create an alternating multipart message to show HTML or text
+               message = email.mime.multipart.MIMEMultipart("alternative")
 
-               # Load template
-               t = self.template_loader.load("%s.txt" % template_name)
-
-               # 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)
+
+                       # Reset the rendered template when it could not be rendered
+                       except:
+                               self.template_loader.reset()
+                               raise
+
+                       # Parse the message and extract the header
+                       message_part = email.message_from_string(message_part.decode())
+
+                       for header in message_part:
+                               try:
+                                       message.replace_header(header, message_part[header])
+                               except KeyError:
+                                       message.add_header(header, message_part[header])
 
-               # Do not encode emails in base64
-               charset = email.charset.Charset("utf-8")
-               charset.body_encoding = email.charset.QP
+                       # Create a MIMEText object out of it
+                       message_part = email.mime.text.MIMEText(
+                               message_part.get_payload(), mimetype)
 
-               # Set payload
-               message.set_payload(message_part.get_payload(), charset=charset)
+                       # 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)
@@ -143,6 +152,15 @@ class Messages(misc.Object):
                if self.backend.debug:
                        self.template_loader.reset()
 
+       async def send_cli(self, template, recipient):
+               """
+                       Send a test message from the CLI
+               """
+               account = self.backend.accounts.get_by_mail(recipient)
+
+               return self.send_template(template, recipients=[recipient,],
+                       account=account)
+
 
 class Queue(misc.Object):
        @property
diff --git a/src/templates/messages/base.html b/src/templates/messages/base.html
new file mode 100644 (file)
index 0000000..959a314
--- /dev/null
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html>
+       <head>
+               <base href="https://www.ipfire.org/">
+               <meta name="viewport" content="width=device-width">
+               <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+               <title>{% block title %}{% end block %}</title>
+               <style media="all" type="text/css">
+                       {% include "main.css" %}
+               </style>
+       </head>
+
+       <body>
+               <span class="preheader">{% block preheader %}{% end preheader %}</span>
+               <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
+                       <tr>
+                               <td class="container">
+                                       <div class="content">
+                                               {% block container %}
+                                                       <table role="presentation" class="main">
+                                                               <tr>
+                                                                       <td class="logo">
+                                                                               <img src="/static/img/ipfire-tux.png" alt="{{ _("IPFire Logo") }}">
+                                                                       </td>
+                                                               </tr>
+                                                               <tr>
+                                                                       <td class="wrapper">
+                                                                               <table role="presentation" border="0" cellpadding="0" cellspacing="0">
+                                                                                       <tr>
+                                                                                               <td>
+                                                                                                       {% block content %}{% end block %}
+                                                                                               </td>
+                                                                                       </tr>
+                                                                               </table>
+                                                                       </td>
+                                                               </tr>
+                                                       </table>
+                                               {% end block %}
+
+                                               <div class="footer">
+                                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
+                                                               <tr>
+                                                                       <td class="content-block">
+                                                                               <span class="apple-link">{{ _("The IPFire Project" )}}</span>
+
+                                                                               <br>
+
+                                                                               {% block footer %}
+                                                                                       {{ _("Don't like these emails?") }}
+                                                                                       <a href="https://people.ipfire.org/">{{ _("Unsubscribe") }}</a>.
+                                                                               {% end block %}
+                                                                       </td>
+                                                               </tr>
+                                                       </table>
+                                               </div>
+                                       </div>
+                               </td>
+                       </tr>
+               </table>
+       </body>
+</html>
diff --git a/src/templates/messages/main.scss b/src/templates/messages/main.scss
new file mode 100644 (file)
index 0000000..97c84f3
--- /dev/null
@@ -0,0 +1,340 @@
+@import "../../scss/variables";
+
+@import "../../bootstrap/scss/functions";
+@import "../../bootstrap/scss/variables";
+
+
+// Use font sizes in px
+$font-size-base:            18px;
+$small-font-size:           12px;
+
+$h1-font-size:              48px;
+$h2-font-size:                         40px;
+$h3-font-size:                         36px;
+$h4-font-size:                         32px;
+$headings-margin-bottom:       20px;
+
+$paragraph-margin-bottom:      14px;
+
+// Resets
+img {
+       border: none;
+       -ms-interpolation-mode: bicubic;
+       max-width: 100%;
+}
+
+body {
+       background-color: $body-bg;
+       font-family: $font-family-sans-serif;
+       -webkit-font-smoothing: antialiased;
+       font-size: $font-size-base;
+       line-height: $line-height-base;
+       margin: 0;
+       padding: 0;
+       -ms-text-size-adjust: 100%;
+       -webkit-text-size-adjust: 100%;
+}
+
+table {
+       border-collapse: separate;
+       mso-table-lspace: 0pt;
+       mso-table-rspace: 0pt;
+       width: 100%;
+
+       td {
+               font-family: $font-family-sans-serif;
+               font-size: $font-size-base;
+               vertical-align: top;
+       }
+}
+
+// Basic Styling
+
+.body {
+       background-color: $body-bg;
+       width: 100%;
+}
+
+/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
+.container {
+       display: block;
+       margin: 0 auto !important;
+
+       // Center the container
+       max-width: 580px;
+       padding: 10px;
+       width: 580px;
+}
+
+/* This should also be a block element, so that it will fill 100% of the .container */
+.content {
+       box-sizing: border-box;
+       display: block;
+       margin: 0 auto;
+       max-width: 580px;
+       padding: 10px;
+}
+
+// Headers, Footers, Containers
+
+.main {
+       background: $white;
+       color: $dark;
+       border-radius: $card-border-radius;
+       width: 100%;
+
+       .logo {
+               text-align: center;
+
+               img {
+                       height: 196px;
+                       padding: 24px 0 12px 0;
+               }
+       }
+}
+
+.wrapper {
+       box-sizing: border-box;
+       padding: 20px;
+}
+
+.content-block {
+       padding-bottom: 10px;
+       padding-top: 10px;
+}
+
+.footer {
+       clear: both;
+       margin-top: 10px;
+       text-align: center;
+       width: 100%;
+
+       td, p, span, a {
+               color: $light;
+               font-size: $small-font-size;
+               text-align: center;
+       }
+}
+
+// Typography
+
+h1, h2, h3, h4 {
+       color: $dark;
+       font-family: $font-family-sans-serif;
+       font-weight: $headings-font-weight;
+       line-height: $headings-line-height;
+       margin: 0;
+       margin-bottom: $headings-margin-bottom;
+}
+
+h1 {
+       font-size: $h1-font-size;
+       text-align: center;
+       text-transform: capitalize;
+}
+
+p, ul, ol {
+       font-family: $font-family-sans-serif;
+       font-size: $font-size-base;
+       font-weight: normal;
+       margin: 0;
+       margin-bottom: $paragraph-margin-bottom;
+}
+
+a {
+       color: $link-color;
+       text-decoration: underline;
+}
+
+// Buttons
+
+.btn {
+       box-sizing: border-box;
+       width: 100%;
+
+       > tbody > tr > td {
+               padding-bottom: 15px;
+       }
+
+       table {
+               width: 100%;
+
+               td {
+                       background-color: #ffffff;
+                       border-radius: $btn-border-radius;
+                       text-align: center;
+               }
+       }
+
+       a {
+               width: 100%;
+               background-color: #ffffff;
+               border: 1px solid $link-color;
+               border-radius: $btn-border-radius;
+               box-sizing: border-box;
+               color: $link-color;
+               cursor: pointer;
+               display: inline-block;
+               font-size: $font-size-base;
+               font-weight: $btn-font-weight;
+               margin: 0;
+               padding: $btn-padding-y $btn-padding-x;
+               text-decoration: none;
+               text-transform: uppercase;
+       }
+}
+
+.btn-primary {
+       table td {
+               background-color: $link-color;
+       }
+
+       a {
+               background-color: $link-color;
+               border-color: $link-color;
+               color: #ffffff;
+       }
+}
+
+// Other
+
+.align-center {
+       text-align: center;
+}
+
+.align-right {
+       text-align: right;
+}
+
+.align-left {
+       text-align: left;
+}
+
+.clear {
+       clear: both;
+}
+
+.mt-0 {
+       margin-top: 0;
+}
+
+.mb-0 {
+       margin-bottom: 0;
+}
+
+.preheader {
+       color: transparent;
+       display: none;
+       height: 0;
+       max-height: 0;
+       max-width: 0;
+       opacity: 0;
+       overflow: hidden;
+       mso-hide: all;
+       visibility: hidden;
+       width: 0;
+}
+
+.powered-by a {
+       text-decoration: none;
+}
+
+hr {
+       border: 0;
+       border-bottom: $hr-border-width solid $hr-border-color;
+       margin: 20px 0;
+}
+
+// Make this all mobile-friendly
+
+@media only screen and (max-width: 620px) {
+       table[class=body] {
+               h1 {
+                       font-size: $h1-font-size !important;
+                       margin-bottom: $headings-margin-bottom !important;
+               }
+
+               p, ul, ol, td, span, a {
+                       font-size: $font-size-base !important;
+               }
+
+               .wrapper, .article {
+                       padding: 10px !important;
+               }
+
+               .content {
+                       padding: 0 !important;
+               }
+
+               .container {
+                       padding: 0 !important;
+                       width: 100% !important;
+               }
+
+               .main {
+                       border-left-width: 0 !important;
+                       border-radius: 0 !important;
+                       border-right-width: 0 !important;
+               }
+
+               .img-responsive {
+                       height: auto !important;
+                       max-width: 100% !important;
+                       width: auto !important;
+               }
+       }
+}
+
+// Hack for Dark Mode
+
+@media (prefers-dark-interface) {
+       .body {
+               background-color: none;
+       }
+
+       .main {
+               background: $white !important;
+               color: $dark !important;
+       }
+}
+
+// Hack for Outlook
+
+@media all {
+       .ExternalClass {
+               width: 100%;
+
+               &, p, span, font, td, div {
+                       line-height: 100%;
+               }
+       }
+
+       .apple-link a {
+               color: inherit !important;
+               font-family: inherit !important;
+               font-size: inherit !important;
+               font-weight: inherit !important;
+               line-height: inherit !important;
+               text-decoration: none !important;
+       }
+
+       #MessageViewBody a {
+               color: inherit;
+               text-decoration: none;
+               font-size: inherit;
+               font-family: inherit;
+               font-weight: inherit;
+               line-height: inherit;
+       }
+
+       .btn-primary {
+               table td:hover {
+                       background-color: $link-hover-color !important;
+               }
+
+               a:hover {
+                       background-color: $link-hover-color !important;
+                       border-color: $link-hover-color !important;
+               }
+       }
+}