From 9899552d3398c0f9dca4540c23174d237c2a7517 Mon Sep 17 00:00:00 2001
From: Magnus Hagander
Date: Tue, 18 Jun 2019 13:41:58 +0200
Subject: [PATCH] Implement email resending in the list archives
This allows a logged-in user to get an email delivered to their mailbox,
thereby making it easy to reply to even if they haven't got it already
(and don't have a MUA capable of handling mbox files).
The email body will go out unmodified (including any list headers that
are stored in the archives, but this does not include for example the
unsubscribe link). Envelope sender is set to one configured in the ini
file, and envelope recipient is set to the email address of the user.
---
.../migrations/0003_message_resend.py | 27 ++++++
django/archives/mailarchives/models.py | 7 ++
.../mailarchives/templates/_message.html | 1 +
.../templates/message_resend.html | 20 +++++
.../templates/resend_complete.html | 17 ++++
django/archives/mailarchives/views.py | 52 +++++++++++-
django/archives/settings.py | 8 +-
django/archives/urls.py | 4 +-
loader/archives.ini.sample | 6 ++
resender/archives_resender.py | 83 +++++++++++++++++++
resender/archives_resender.service.template | 14 ++++
11 files changed, 233 insertions(+), 6 deletions(-)
create mode 100644 django/archives/mailarchives/migrations/0003_message_resend.py
create mode 100644 django/archives/mailarchives/templates/message_resend.html
create mode 100644 django/archives/mailarchives/templates/resend_complete.html
create mode 100755 resender/archives_resender.py
create mode 100644 resender/archives_resender.service.template
diff --git a/django/archives/mailarchives/migrations/0003_message_resend.py b/django/archives/mailarchives/migrations/0003_message_resend.py
new file mode 100644
index 0000000..5461502
--- /dev/null
+++ b/django/archives/mailarchives/migrations/0003_message_resend.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-06-18 09:39
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('mailarchives', '0002_list_permissions'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ResendMessage',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('registeredat', models.DateTimeField(auto_now_add=True)),
+ ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mailarchives.Message')),
+ ('sendto', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/django/archives/mailarchives/models.py b/django/archives/mailarchives/models.py
index 88137f8..a9ca52e 100644
--- a/django/archives/mailarchives/models.py
+++ b/django/archives/mailarchives/models.py
@@ -1,4 +1,5 @@
from django.db import models
+from django.contrib.auth.models import User
# Reason a message was hidden.
# We're intentionally putting the prefix text in the array here, since
@@ -120,6 +121,12 @@ class ListSubscriber(models.Model):
db_table = 'listsubscribers'
+class ResendMessage(models.Model):
+ message = models.ForeignKey(Message, null=False, blank=False)
+ sendto = models.ForeignKey(User, null=False, blank=False)
+ registeredat = models.DateTimeField(null=False, blank=False, auto_now_add=True)
+
+
class ApiClient(models.Model):
apikey = models.CharField(max_length=100, null=False, blank=False)
postback = models.URLField(max_length=500, null=False, blank=False)
diff --git a/django/archives/mailarchives/templates/_message.html b/django/archives/mailarchives/templates/_message.html
index 7dea7f3..c90a80a 100644
--- a/django/archives/mailarchives/templates/_message.html
+++ b/django/archives/mailarchives/templates/_message.html
@@ -33,6 +33,7 @@
Raw Message |
Whole Thread |
Download mbox
+{%if allow_resend %}| Resend email{%endif%}
{% if not show_all %}
diff --git a/django/archives/mailarchives/templates/message_resend.html b/django/archives/mailarchives/templates/message_resend.html
new file mode 100644
index 0000000..0485726
--- /dev/null
+++ b/django/archives/mailarchives/templates/message_resend.html
@@ -0,0 +1,20 @@
+{%extends "page.html"%}
+{%block title%}Resend - {{msg.subject}}{%endblock%}
+
+{%block contents%}
+Resend - {{msg.subject}}
+
+ The below message will be resent to {{request.user.email}},
+ which is the email address of the account you are currently logged in with.
+
+
+
+
+
+
+Message to resend
+{% include '_message.html' with msg=msg lists=lists show_all=True %}
+{%endblock%}
diff --git a/django/archives/mailarchives/templates/resend_complete.html b/django/archives/mailarchives/templates/resend_complete.html
new file mode 100644
index 0000000..9edab34
--- /dev/null
+++ b/django/archives/mailarchives/templates/resend_complete.html
@@ -0,0 +1,17 @@
+{%extends "page.html"%}
+{%block title%}Resend - {{msg.subject}}{%endblock%}
+
+{%block contents%}
+Resend - {{msg.subject}}
+
+ The message {{msg.subject}} with messageid {{msg.messageid}}
+ has been scheduled for resending to {{request.user.email}}.
+
+
+ It will be delivered within a few minutes.
+
+
+
+ Return to message
+
+{%endblock%}
diff --git a/django/archives/mailarchives/views.py b/django/archives/mailarchives/views.py
index 8c82fc5..f19329f 100644
--- a/django/archives/mailarchives/views.py
+++ b/django/archives/mailarchives/views.py
@@ -1,10 +1,11 @@
from django.template import RequestContext
from django.http import HttpResponse, HttpResponseForbidden, Http404
-from django.http import StreamingHttpResponse
+from django.http import StreamingHttpResponse, HttpResponseRedirect
from django.http import HttpResponsePermanentRedirect, HttpResponseNotModified
from django.core.exceptions import PermissionDenied
from django.shortcuts import render, get_object_or_404
from django.utils.http import http_date, parse_http_date_safe
+from django.views.decorators.csrf import csrf_exempt
from django.db import connection, transaction
from django.db.models import Q
from django.conf import settings
@@ -169,7 +170,9 @@ def get_all_groups_and_lists(request, listid=None):
class NavContext(object):
def __init__(self, request, listid=None, listname=None, all_groups=None, expand_groupid=None):
self.request = request
- self.ctx = {}
+ self.ctx = {
+ 'allow_resend': settings.ALLOW_RESEND,
+ }
if all_groups:
groups = copy.deepcopy(all_groups)
@@ -623,6 +626,51 @@ def mbox(request, listname, listname2, mboxyear, mboxmonth):
return _build_mbox(query, params)
+@transaction.atomic
+def resend(request, messageid):
+ if not settings.ALLOW_RESEND:
+ raise PermissionDenied("Access denied.")
+
+ if not (hasattr(request, 'user') and request.user.is_authenticated()):
+ raise ERedirect('%s?next=%s' % (settings.LOGIN_URL, request.path))
+
+ ensure_message_permissions(request, messageid)
+
+ m = get_object_or_404(Message, messageid=messageid)
+ if m.hiddenstatus:
+ raise PermissionDenied("Access denied.")
+
+ if request.method == 'POST':
+ if request.POST.get('resend', None) == '1':
+ ResendMessage(message=m, sendto=request.user).save()
+ connection.cursor().execute("NOTIFY archives_resend")
+ return HttpResponseRedirect('/message-id/resend/{0}/complete'.format(m.messageid))
+
+ lists = List.objects.extra(where=["listid IN (SELECT listid FROM list_threads WHERE threadid=%s)" % m.threadid]).order_by('listname')
+
+ return render_nav(NavContext(request, lists[0].listid, lists[0].listname), 'message_resend.html', {
+ 'msg': m,
+ 'lists': lists,
+ })
+
+
+def resend_complete(request, messageid):
+ if not settings.ALLOW_RESEND:
+ raise PermissionDenied("Access denied.")
+
+ m = get_object_or_404(Message, messageid=messageid)
+ if m.hiddenstatus:
+ raise PermissionDenied("Access denied.")
+
+ lists = List.objects.extra(where=["listid IN (SELECT listid FROM list_threads WHERE threadid=%s)" % m.threadid]).order_by('listname')
+
+ return render_nav(NavContext(request, lists[0].listid, lists[0].listname), 'resend_complete.html', {
+ 'msg': m,
+ 'lists': lists,
+ })
+
+
+@csrf_exempt
def search(request):
if not settings.PUBLIC_ARCHIVES:
# We don't support searching of non-public archives at all at this point.
diff --git a/django/archives/settings.py b/django/archives/settings.py
index 80b990e..5761344 100644
--- a/django/archives/settings.py
+++ b/django/archives/settings.py
@@ -100,6 +100,8 @@ TEMPLATES = [{
INSTALLED_APPS = [
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
'archives.mailarchives',
]
@@ -133,6 +135,7 @@ FORCE_SCRIPT_NAME = ""
SEARCH_CLIENTS = ('127.0.0.1',)
API_CLIENTS = ('127.0.0.1',)
PUBLIC_ARCHIVES = False
+ALLOW_RESEND = False
try:
from .settings_local import *
@@ -140,16 +143,15 @@ except ImportError:
pass
# If this is a non-public site, enable middleware for handling logins etc
-if not PUBLIC_ARCHIVES:
+if ALLOW_RESEND or not PUBLIC_ARCHIVES:
MIDDLEWARE_CLASSES = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
] + MIDDLEWARE_CLASSES
MIDDLEWARE_CLASSES.append('archives.mailarchives.redirecthandler.RedirectMiddleware')
INSTALLED_APPS = [
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
'django.contrib.sessions',
] + INSTALLED_APPS
diff --git a/django/archives/urls.py b/django/archives/urls.py
index 542475d..67dd3e1 100644
--- a/django/archives/urls.py
+++ b/django/archives/urls.py
@@ -34,6 +34,8 @@ urlpatterns = [
url(r'^message-id/flat/(.+)$', archives.mailarchives.views.message_flat),
url(r'^message-id/raw/(.+)$', archives.mailarchives.views.message_raw),
url(r'^message-id/mbox/(.+)$', archives.mailarchives.views.message_mbox),
+ url(r'^message-id/resend/(.+)/complete$', archives.mailarchives.views.resend_complete),
+ url(r'^message-id/resend/(.+)$', archives.mailarchives.views.resend),
url(r'^message-id/attachment/(\d+)/.*$', archives.mailarchives.views.attachment),
url(r'^message-id/legacy/([\w-]+)/(\d+)-(\d+)/msg(\d+).php$', archives.mailarchives.views.legacy),
url(r'^message-id/(.+)$', archives.mailarchives.views.message),
@@ -62,7 +64,7 @@ urlpatterns = [
url(r'^dyncss/(?Pbase|docs).css$', archives.mailarchives.views.dynamic_css),
]
-if not settings.PUBLIC_ARCHIVES:
+if settings.ALLOW_RESEND or not settings.PUBLIC_ARCHIVES:
import archives.auth
urlpatterns += [
diff --git a/loader/archives.ini.sample b/loader/archives.ini.sample
index f921d0c..0959ecd 100644
--- a/loader/archives.ini.sample
+++ b/loader/archives.ini.sample
@@ -3,3 +3,9 @@ connstr=dbname=archives
[varnish]
purgeurl=https://wrigleys.postgresql.org/api/varnish/purge/
+
+[smtp]
+server=localhost:9911
+heloname=localhost
+resender=noreply@example.com
+
diff --git a/resender/archives_resender.py b/resender/archives_resender.py
new file mode 100755
index 0000000..cc08f5a
--- /dev/null
+++ b/resender/archives_resender.py
@@ -0,0 +1,83 @@
+#!/usr/bin/python3 -u
+#
+# archives_resender.py - resend messages to authenticated users
+#
+# This script is intended to be run as a daemon.
+#
+
+
+import os
+import sys
+import select
+import smtplib
+from configparser import ConfigParser
+import psycopg2
+
+
+def process_queue(conn, sender, smtpserver, heloname):
+ with conn.cursor() as curs:
+ curs.execute("SELECT r.id, u.email, m.rawtxt FROM mailarchives_resendmessage r INNER JOIN auth_user u ON u.id=r.sendto_id INNER JOIN messages m ON m.id=r.message_id ORDER BY r.id FOR UPDATE OF r LIMIT 1")
+ ll = curs.fetchall()
+ if len(ll) == 0:
+ conn.rollback()
+ return False
+
+ recipient = ll[0][1]
+ contents = ll[0][2]
+
+ try:
+ # Actually resend! New SMTP connection for each message because we're not sending
+ # that many.
+ smtp = smtplib.SMTP(smtpserver, local_hostname=heloname)
+ smtp.sendmail(sender, recipient, contents)
+ smtp.close()
+ except Exception as e:
+ sys.stderr.write("Error sending email to {0}: {1}\n".format(recipient, e))
+
+ # Fall through and just delete the email, we never make more than one attempt
+
+ curs.execute("DELETE FROM mailarchives_resendmessage WHERE id=%(id)s", {
+ 'id': ll[0][0],
+ })
+ conn.commit()
+ return True
+
+
+if __name__ == "__main__":
+ cfg = ConfigParser()
+ cfg.read(os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), '../loader', 'archives.ini'))
+ if not cfg.has_option('smtp', 'server'):
+ print("Must specify server under smtp in configuration")
+ sys.exit(1)
+ if not cfg.has_option('smtp', 'heloname'):
+ print("Must specify heloname under smtp in configuration")
+ sys.exit(1)
+ if not cfg.has_option('smtp', 'resender'):
+ print("Must specify resender under smtp in configuration")
+ sys.exit(1)
+
+ smtpserver = cfg.get('smtp', 'server')
+ heloname = cfg.get('smtp', 'heloname')
+ sender = cfg.get('smtp', 'resender')
+
+ conn = psycopg2.connect(cfg.get('db', 'connstr') + ' application_name=archives_resender')
+
+ curs = conn.cursor()
+
+ curs.execute("LISTEN archives_resend")
+ conn.commit()
+
+ while True:
+ # Process everything in the queue now
+ while True:
+ if not process_queue(conn, sender, smtpserver, heloname):
+ break
+
+ # Wait for a NOTIFY. Poll every 5 minutes.
+ select.select([conn], [], [], 5 * 60)
+
+ # Eat up all notifications, since we're just going to process
+ # all pending messages until the queue is empty.
+ conn.poll()
+ while conn.notifies:
+ conn.notifies.pop()
diff --git a/resender/archives_resender.service.template b/resender/archives_resender.service.template
new file mode 100644
index 0000000..2abef33
--- /dev/null
+++ b/resender/archives_resender.service.template
@@ -0,0 +1,14 @@
+[Unit]
+Description=pgarchives: remail resender
+After=postgresql.service
+
+[Service]
+ExecStart=/some/where/resender/archives_resendewr.py
+WorkingDirectory=/some/where/resender
+Restart=always
+RestartSec=30
+User=www-data
+Group=www-data
+
+[Install]
+WantedBy=multi-user.target
--
2.39.5