Implement email resending in the list archives
authorMagnus Hagander <magnus@hagander.net>
Tue, 18 Jun 2019 11:41:58 +0000 (13:41 +0200)
committerMagnus Hagander <magnus@hagander.net>
Tue, 18 Jun 2019 11:44:15 +0000 (13:44 +0200)
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.

django/archives/mailarchives/migrations/0003_message_resend.py [new file with mode: 0644]
django/archives/mailarchives/models.py
django/archives/mailarchives/templates/_message.html
django/archives/mailarchives/templates/message_resend.html [new file with mode: 0644]
django/archives/mailarchives/templates/resend_complete.html [new file with mode: 0644]
django/archives/mailarchives/views.py
django/archives/settings.py
django/archives/urls.py
loader/archives.ini.sample
resender/archives_resender.py [new file with mode: 0755]
resender/archives_resender.service.template [new file with mode: 0644]

diff --git a/django/archives/mailarchives/migrations/0003_message_resend.py b/django/archives/mailarchives/migrations/0003_message_resend.py
new file mode 100644 (file)
index 0000000..5461502
--- /dev/null
@@ -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)),
+            ],
+        ),
+    ]
index 88137f8b8ee276c8e28ec99397466741541cb4ac..a9ca52e9d9b40bc61cdb6f685952a691e8e411b5 100644 (file)
@@ -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)
index 7dea7f3d64fb86745559128fbb97f11e965bd83e..c90a80afea418fc4800ae81bb517978fa56f7a4d 100644 (file)
@@ -33,6 +33,7 @@
      <a href="/message-id/raw/{{msg.messageid|urlencode}}">Raw Message</a> |
      <a href="/message-id/flat/{{msg.messageid|urlencode}}">Whole Thread</a> |
      <a href="/message-id/mbox/{{msg.messageid|urlencode}}">Download mbox</a>
+{%if allow_resend %}| <a href="/message-id/resend/{{msg.messageid|urlencode}}">Resend email</a>{%endif%}
    </td>
  </tr>
  {% 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 (file)
index 0000000..0485726
--- /dev/null
@@ -0,0 +1,20 @@
+{%extends "page.html"%}
+{%block title%}Resend - {{msg.subject}}{%endblock%}
+
+{%block contents%}
+<h1 class="subject">Resend - {{msg.subject}}</h1>
+<p>
+  The below message will be resent to <em>{{request.user.email}}</em>,
+  which is the email address of the account you are currently logged in with.
+</p>
+
+<p>
+  <form method="post" action="/message-id/resend/{{msg.messageid|urlencode}}">{% csrf_token %}
+    <input type="hidden" name="resend" value="1">
+    <input type="submit" value="Resend this message" class="btn btn-primary">
+  </form>
+</p>
+
+<h4>Message to resend</h4>
+{% 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 (file)
index 0000000..9edab34
--- /dev/null
@@ -0,0 +1,17 @@
+{%extends "page.html"%}
+{%block title%}Resend - {{msg.subject}}{%endblock%}
+
+{%block contents%}
+<h1 class="subject">Resend - {{msg.subject}}</h1>
+<p>
+  The message <em>{{msg.subject}}</em> with messageid <em>{{msg.messageid}}</em>
+  has been scheduled for resending to <em>{{request.user.email}}</em>.
+</p>
+<p>
+  It will be delivered within a few minutes.
+</p>
+
+<p>
+  <a class="btn btn-primary" href="/message-id/{{msg.messageid|urlencode}}">Return to message</a>
+</p>
+{%endblock%}
index 8c82fc5ba9d9c37fe5449d900468fdb73034369b..f19329f479dd5d4233d8256b68416aabcea64781 100644 (file)
@@ -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.
index 80b990e9743f807f54a973d078b54f0e48684d7d..5761344c8adba127edb02f223a703b2120f19c44 100644 (file)
@@ -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
 
index 542475dc263f7df81ef60616dda918658b5261a3..67dd3e14d269c5f0576efcbcb2da670e46f4a0c1 100644 (file)
@@ -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/(?P<css>base|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 += [
index f921d0cf7d98feabd45ec34857fcea05ec89364b..0959ecd9aae12e331aa203e922f1ce5877bc2473 100644 (file)
@@ -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 (executable)
index 0000000..cc08f5a
--- /dev/null
@@ -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 (file)
index 0000000..2abef33
--- /dev/null
@@ -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