diff --git a/pgweb/core/migrations/0003_mailtemplate.py b/pgweb/core/migrations/0003_mailtemplate.py new file mode 100644 index 00000000..5c4ab3f6 --- /dev/null +++ b/pgweb/core/migrations/0003_mailtemplate.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.27 on 2020-07-07 15:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_block_oauth'), + ] + + operations = [ + migrations.AddField( + model_name='organisation', + name='mailtemplate', + field=models.CharField(choices=[('default', 'Default template'), ('pgproject', 'PostgreSQL project news')], default='default', max_length=50), + ), + ] diff --git a/pgweb/core/models.py b/pgweb/core/models.py index 63b70a7a..6c2f026d 100644 --- a/pgweb/core/models.py +++ b/pgweb/core/models.py @@ -123,6 +123,12 @@ class OrganisationType(models.Model): return self.typename +_mail_template_choices = ( + ('default', 'Default template'), + ('pgproject', 'PostgreSQL project news'), +) + + class Organisation(TwostateModerateModel): name = models.CharField(max_length=100, null=False, blank=False, unique=True) address = models.TextField(null=False, blank=True) @@ -131,6 +137,7 @@ class Organisation(TwostateModerateModel): phone = models.CharField(max_length=100, null=False, blank=True) orgtype = models.ForeignKey(OrganisationType, null=False, blank=False, verbose_name="Organisation type", on_delete=models.CASCADE) managers = models.ManyToManyField(User, blank=False) + mailtemplate = models.CharField(max_length=50, null=False, blank=False, default='default', choices=_mail_template_choices) lastconfirmed = models.DateTimeField(null=False, blank=False, auto_now_add=True) account_edit_suburl = 'organisations' diff --git a/pgweb/core/templatetags/pgfilters.py b/pgweb/core/templatetags/pgfilters.py index 03a89d3e..212e849e 100644 --- a/pgweb/core/templatetags/pgfilters.py +++ b/pgweb/core/templatetags/pgfilters.py @@ -1,7 +1,9 @@ from django.template.defaultfilters import stringfilter from django import template -import json +from django.template.loader import get_template +import json +import pynliner register = template.Library() @@ -93,3 +95,38 @@ def joinandor(value, andor): value = list(value) return ", ".join([str(x) for x in value[:-1]]) + ' ' + andor + ' ' + str(value[-1]) + + +# CSS inlining (used for HTML email) +@register.tag +class InlineCss(template.Node): + def __init__(self, nodes, arg): + self.nodes = nodes + self.arg = arg + + def render(self, context): + contents = self.nodes.render(context) + path = self.arg.resolve(context, True) + if path is not None: + css = get_template(path).render() + else: + css = '' + + p = pynliner.Pynliner().from_string(contents) + p.with_cssString(css) + return p.run() + + +@register.tag +def inlinecss(parser, token): + nodes = parser.parse(('endinlinecss',)) + + parser.delete_first_token() + + # First part of token is the tagname itself + css = token.split_contents()[1] + + return InlineCss( + nodes, + parser.compile_filter(css), + ) diff --git a/pgweb/core/views.py b/pgweb/core/views.py index 42ae37f1..b0590f9a 100644 --- a/pgweb/core/views.py +++ b/pgweb/core/views.py @@ -392,6 +392,8 @@ def admin_moderate(request, objtype, objid): "The {} with title {}\nhas been approved and is now published.".format(obj._meta.verbose_name, obj.title), modnote, "approved") + if hasattr(obj, 'on_approval'): + obj.on_approval(request) elif modstate == ModerationState.REJECTED: _send_moderation_message(request, obj, diff --git a/pgweb/news/management/commands/news_send_email.py b/pgweb/news/management/commands/news_send_email.py new file mode 100644 index 00000000..99ef669c --- /dev/null +++ b/pgweb/news/management/commands/news_send_email.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# +# Script to send out a news email +# THIS IS FOR TESTING ONLY +# Normal emails are triggered automatically on moderation! +# Note that emails are queued up in the MailQueue model, to be sent asynchronously +# by the sender (or viewed locally). +# +# + +from django.core.management.base import BaseCommand, CommandError + +from pgweb.news.models import NewsArticle +from pgweb.news.util import send_news_email + + +def yesno(prompt): + while True: + r = input(prompt) + if r.lower().startswith('y'): + return True + elif r.lower().startswith('n'): + return False + + +class Command(BaseCommand): + help = 'Test news email' + + def add_arguments(self, parser): + parser.add_argument('id', type=int, help='id of news article to post') + + def handle(self, *args, **options): + try: + news = NewsArticle.objects.get(pk=options['id']) + except NewsArticle.DoesNotExist: + raise CommandError("News article not found.") + + print("Title: {}".format(news.title)) + print("Moderation state: {}".format(news.modstate_string)) + if not yesno('Proceed to send mail for this article?'): + raise CommandError("OK, aborting") + + send_news_email(news) + print("Sent.") diff --git a/pgweb/news/models.py b/pgweb/news/models.py index f7978697..0576afea 100644 --- a/pgweb/news/models.py +++ b/pgweb/news/models.py @@ -3,6 +3,8 @@ from datetime import date from pgweb.core.models import Organisation from pgweb.util.moderation import TristateModerateModel, ModerationState +from .util import send_news_email + class NewsTag(models.Model): urlname = models.CharField(max_length=20, null=False, blank=False, unique=True) @@ -72,3 +74,6 @@ class NewsArticle(TristateModerateModel): def block_edit(self): # Don't allow editing of news articles that have been published return self.modstate in (ModerationState.PENDING, ModerationState.APPROVED) + + def on_approval(self, request): + send_news_email(self) diff --git a/pgweb/news/util.py b/pgweb/news/util.py new file mode 100644 index 00000000..e5e1a1b1 --- /dev/null +++ b/pgweb/news/util.py @@ -0,0 +1,67 @@ +from django.template.loader import get_template +from django.conf import settings + +import os +import hmac +import hashlib + +from pgweb.mailqueue.util import send_simple_mail + + +def _get_contenttype_from_extension(f): + _map = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + } + e = os.path.splitext(f)[1][1:] + if e not in _map: + raise Exception("Unknown extension {}".format(e)) + return _map[e] + + +def send_news_email(news): + # To generate HTML email, pick a template based on the organisation and render it. + + html = get_template('news/mail/{}.html'.format(news.org.mailtemplate)).render({ + 'news': news, + }) + + # Enumerate all files for this template, if any + attachments = [] + basedir = os.path.abspath(os.path.join(settings.PROJECT_ROOT, '../templates/news/mail/img.{}'.format(news.org.mailtemplate))) + if os.path.isdir(basedir): + for f in os.listdir(basedir): + a = { + 'contenttype': '{}; name={}'.format(_get_contenttype_from_extension(f), f), + 'filename': f, + 'disposition': 'inline; filename="{}"'.format(f), + 'id': '<{}>'.format(f), + } + with open(os.path.join(basedir, f), "rb") as f: + a['content'] = f.read() + attachments.append(a) + + # If configured to, add the tags and sign them so that a pglister delivery system can filter + # recipients based on it. + if settings.NEWS_MAIL_TAGKEY: + tagstr = ",".join([t.urlname for t in news.tags.all()]) + h = hmac.new(tagstr.encode('ascii'), settings.NEWS_MAIL_TAGKEY.encode('ascii'), hashlib.sha256) + headers = { + 'X-pglister-tags': tagstr, + 'X-pglister-tagsig': h.hexdigest(), + } + else: + headers = {} + + send_simple_mail( + settings.NEWS_MAIL_SENDER, + settings.NEWS_MAIL_RECEIVER, + news.title, + news.content, + replyto=news.org.email, + sendername="PostgreSQL news", # XXX: Somehow special case based on organisation here as well? + receivername=settings.NEWS_MAIL_RECEIVER_NAME, + htmlbody=html, + attachments=attachments, + headers=headers, + ) diff --git a/pgweb/settings.py b/pgweb/settings.py index b37709a2..89c63e02 100644 --- a/pgweb/settings.py +++ b/pgweb/settings.py @@ -153,6 +153,10 @@ BUGREPORT_EMAIL = "someone@example.com" # Address to pgsql-b BUGREPORT_NOREPLY_EMAIL = "someone-noreply@example.com" # Address to no-reply pgsql-bugs address DOCSREPORT_EMAIL = "someone@example.com" # Address to pgsql-docs list DOCSREPORT_NOREPLY_EMAIL = "someone-noreply@example.com" # Address to no-reply pgsql-docs address +NEWS_MAIL_SENDER = "someone-noreply@example.com" # Address news is sent from +NEWS_MAIL_RECEIVER = "some-announce@example.com" # Address news is sent to +NEWS_MAIL_RECEIVER_NAME = "Some Announcement List" # Name field for sending news +NEWS_MAIL_TAGKEY = "" # Key used to sign tags for pglister delivery FRONTEND_SERVERS = () # A tuple containing the *IP addresses* of all the # varnish frontend servers in use. FTP_MASTERS = () # A tuple containing the *IP addresses* of all machines diff --git a/requirements.txt b/requirements.txt index bc2aae07..2d846dcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ requests-oauthlib==0.4.0 cvss==1.9 pytidylib==0.3.2 pycodestyle==2.4.0 +pynliner==0.8.0 diff --git a/templates/news/mail/base.html b/templates/news/mail/base.html new file mode 100644 index 00000000..934120bc --- /dev/null +++ b/templates/news/mail/base.html @@ -0,0 +1,161 @@ +{%load pgfilters%} +{%comment%} +Base email template, not to be used individually. +Original imported from https://github.com/leemunroe/responsive-html-email-template. +MIT licensed. Local modifications are done, so care needs to be taken on an update. +{%endcomment%} + + + + + + {%block title%}{%endblock%} + + + + + + + + + +
  +
+ +{%comment%} {%endcomment%} + + + +{%comment%} {%endcomment%} + + + +{%comment%} + {%endcomment%} +
+ + + + +
+{%inlinecss "news/mail/inline.css"%} +{%block content%}{%endblock%} +{%endinlinecss%} +
+
+{%comment%} + {%endcomment%} + +{%comment%} + + + +{%endcomment%} +
+
 
+ + diff --git a/templates/news/mail/default.html b/templates/news/mail/default.html new file mode 100644 index 00000000..52c5fa42 --- /dev/null +++ b/templates/news/mail/default.html @@ -0,0 +1,16 @@ +{%extends "news/mail/base.html"%} +{%load markup%} +{%block title%}{{news.title}}{%endblock%} + +{%block content%} +
+

{{news.title}}

+
+{{news.content|markdown}} +{%endblock%} + +{%block footer%} +This email was sent to you from {{news.org}}. It was delivered on their behalf by +the PostgreSQL project. Any questions about the content of the message should be +sent to {{news.org}}. +{%endblock%} diff --git a/templates/news/mail/img.pgproject/slonik.png b/templates/news/mail/img.pgproject/slonik.png new file mode 100644 index 00000000..c127f7ea Binary files /dev/null and b/templates/news/mail/img.pgproject/slonik.png differ diff --git a/templates/news/mail/inline.css b/templates/news/mail/inline.css new file mode 100644 index 00000000..330f83b4 --- /dev/null +++ b/templates/news/mail/inline.css @@ -0,0 +1,38 @@ +h1, +h2, +h3, +h4 { + color: #000000; + font-family: sans-serif; + font-weight: 400; + line-height: 1.4; + margin: 0; + margin-bottom: 30px; +} + +h1 { + font-size: 25px; + font-weight: 300; + text-align: center; +} + +p, +ul, +ol { + font-family: sans-serif; + font-size: 14px; + font-weight: normal; + margin: 0; + margin-bottom: 15px; +} +p li, +ul li, +ol li { + list-style-position: inside; + margin-left: 5px; +} + +a { + color: #3498db; + text-decoration: underline; +} diff --git a/templates/news/mail/pgproject.html b/templates/news/mail/pgproject.html new file mode 100644 index 00000000..afeda5eb --- /dev/null +++ b/templates/news/mail/pgproject.html @@ -0,0 +1,16 @@ +{%extends "news/mail/base.html"%} +{%load markup%} +{%block title%}{{news.title}}{%endblock%} + +{%block content%} +
+ PostgreSQL logo + PostgreSQL logo +

{{news.title}}

+
+{{news.content|markdown}} +{%endblock%} + +{%block footer%} +This email was sent to you from the PostgreSQL project. +{%endblock%}