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%}
+
+
+
+
+
+{%inlinecss "news/mail/inline.css"%}
+{%block content%}{%endblock%}
+{%endinlinecss%}
+ |
+
+
+ |
+
+{%comment%}
+ {%endcomment%}
+
+{%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%}
+
+

+

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