about summary refs log tree commit homepage
diff options
context:
space:
mode:
authorEric Wong <e@80x24.org>2016-05-18 01:23:05 +0000
committerEric Wong <e@80x24.org>2016-05-20 21:33:56 +0000
commit1f29b33d3f71b8a40f5ae76bf20b95618b518654 (patch)
tree974ac5d70d601a454b92bf824e03e8f23b6c6daa
parentd6cdb106f27abed5d05da035c95e106939fbe3b2 (diff)
downloadpublic-inbox-1f29b33d3f71b8a40f5ae76bf20b95618b518654.tar.gz
While public-inbox is intended primarily for archival,
SMTP list subscriptions are still in use in most places
and users are likely to want a good unsubscribe mechanism.

HTTP (or HTTPS) links in the List-Unsubscribe header are
often preferable since some users may use an incorrect
email address for mailto: links.

Thus, it is useful to provide an example which generates an
HTTPS link for users to click on.  The default .psgi requires
a POST confirmation (as destructive actions with GET are
considered bad practice).  However, the "confirm" parameter
may be disabled for a true "one-click" unsubscribe.

The generated URLs are hopefully short enough and both shell
and highlighting-friendly to reduce copy+paste errors.
-rw-r--r--examples/README.unsubscribe40
-rw-r--r--examples/unsubscribe-milter.socket10
-rw-r--r--examples/unsubscribe-milter@.service24
-rw-r--r--examples/unsubscribe-psgi.socket11
-rw-r--r--examples/unsubscribe-psgi@.service20
-rw-r--r--examples/unsubscribe.milter139
-rw-r--r--examples/unsubscribe.psgi68
-rw-r--r--lib/PublicInbox/Unsubscribe.pm179
8 files changed, 491 insertions, 0 deletions
diff --git a/examples/README.unsubscribe b/examples/README.unsubscribe
new file mode 100644
index 00000000..7c41067c
--- /dev/null
+++ b/examples/README.unsubscribe
@@ -0,0 +1,40 @@
+Unsubscribe endpoints for mlmmj users (and possibly Mailman, too)
+
+* examples/unsubscribe.milter filters outgoing messages
+  and appends an HTTPS URL to the List-Unsubscribe header.
+  This List-Unsubscribe header should point to the PSGI
+  described below.
+  Currently, this is only active for a whitelist of test
+  addresses in /etc/unsubscribe-milter.whitelist
+  with one email address per line.
+
+* examples/unsubscribe.psgi is a PSGI which needs to run
+  as the mlmmj user with permission to run mlmmj-unsub.
+  This depends on the PublicInbox::Unsubscribe module
+  which may be extracted from the rest of public-inbox.
+  It is strongly recommended to NOT run the rest of the
+  public-inbox WWW code in the same process as this PSGI.
+  (The public-inbox WWW code will never need write
+   permissions to anything besides stderr).
+
+* Both the .milter and .psgi examples are bundled with
+  systemd service and socket activation examples.
+  AFAIK no other PSGI server besides public-inbox-httpd
+  supports systemd socket activation.
+
+To wire up the milter for postfix, I use the following
+in /etc/postfix/main.cf:
+
+  # Milter configuration
+  milter_default_action = accept
+  milter_protocol = 2
+
+  # other milters may be chained here (e.g. opendkim)
+  # chroot users will need to adjust this path
+  smtpd_milters = local:/var/spool/postfix/unsubscribe/unsubscribe.sock
+
+  # This is not needed for mlmmj since mlmmj uses SMTP:
+  # non_smtpd_milters = local:/var/spool/postfix/unsubscribe/unsubscribe.sock
+
+Copyright (C) 2016 all contributors <meta@public-inbox.org>
+License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
diff --git a/examples/unsubscribe-milter.socket b/examples/unsubscribe-milter.socket
new file mode 100644
index 00000000..bfaa97a1
--- /dev/null
+++ b/examples/unsubscribe-milter.socket
@@ -0,0 +1,10 @@
+# ==> /etc/systemd/system/unsubscribe-milter.socket <==
+[Unit]
+Description = unsubscribe.milter socket
+
+[Socket]
+ListenStream = /var/spool/postfix/unsubscribe/unsubscribe.sock
+Service = unsubscribe-milter@1.service
+
+[Install]
+WantedBy = sockets.target
diff --git a/examples/unsubscribe-milter@.service b/examples/unsubscribe-milter@.service
new file mode 100644
index 00000000..98e3d478
--- /dev/null
+++ b/examples/unsubscribe-milter@.service
@@ -0,0 +1,24 @@
+# ==> /etc/systemd/system/unsubscribe-milter@.service <==
+# The '@' is to allow multiple simultaneous services to start
+# and share the same socket so new code can be cycled in
+# without downtime
+
+[Unit]
+Description = unsubscribe milter %i
+Wants = unsubscribe-milter.socket
+After = unsubscribe-milter.socket
+
+[Service]
+# First 8 bytes is for the key, next 8 bytes is for the IV
+# using Blowfish.  We want as short URLs as possible to avoid
+# copy+paste errors
+# umask 077 && dd if=/dev/urandom bs=16 count=1 of=.unsubscribe.key
+ExecStart = /usr/local/sbin/unsubscribe.milter /home/mlmmj/.unsubscribe.key
+Sockets = unsubscribe-milter.socket
+
+# the corresponding PSGI app needs permissions to modify the
+# mlmmj spool, so we might as well use the same user since
+User = mlmmj
+
+[Install]
+WantedBy = multi-user.target
diff --git a/examples/unsubscribe-psgi.socket b/examples/unsubscribe-psgi.socket
new file mode 100644
index 00000000..e7ab797b
--- /dev/null
+++ b/examples/unsubscribe-psgi.socket
@@ -0,0 +1,11 @@
+# ==> /etc/systemd/system/unsubscribe-psgi.socket <==
+[Unit]
+Description = unsubscribe PSGI socket
+
+[Socket]
+# Forward to the PSGI using nginx or similar
+ListenStream = /run/unsubscribe-psgi.sock
+Service = unsubscribe-psgi@1.service
+
+[Install]
+WantedBy = sockets.target
diff --git a/examples/unsubscribe-psgi@.service b/examples/unsubscribe-psgi@.service
new file mode 100644
index 00000000..f588886b
--- /dev/null
+++ b/examples/unsubscribe-psgi@.service
@@ -0,0 +1,20 @@
+# ==> /etc/systemd/system/unsubscribe-psgi@.service <==
+# The '@' is to allow multiple simultaneous services to start
+# and share the same socket so new code can be cycled in
+# without downtime
+
+[Unit]
+Description = unsubscribe PSGI %i
+Wants = unsubscribe-psgi.socket
+After = unsubscribe-psgi.socket
+
+[Service]
+# any PSGI server ought to work,
+# but public-inbox-httpd supports socket activation like unsubscribe.milter
+ExecStart = /usr/local/bin/public-inbox-httpd /etc/unsubscribe.psgi
+Sockets = unsubscribe-psgi.socket
+# we need to modify the mlmmj spool
+User = mlmmj
+
+[Install]
+WantedBy = multi-user.target
diff --git a/examples/unsubscribe.milter b/examples/unsubscribe.milter
new file mode 100644
index 00000000..e1936386
--- /dev/null
+++ b/examples/unsubscribe.milter
@@ -0,0 +1,139 @@
+#!/usr/bin/perl -w
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Sendmail::PMilter qw(:all);
+use IO::Socket;
+use Crypt::CBC;
+use MIME::Base64 qw(encode_base64url);
+
+my $key_file = shift @ARGV or die "Usage: $0 KEY_FILE\n";
+open my $fh, '<', $key_file or die "failed to open $key_file\n";
+my ($key, $iv);
+if (read($fh, $key, 8) != 8 || read($fh, $iv, 8) != 8 ||
+                        read($fh, my $end, 8) != 0) {
+        die "KEY_FILE must be 16 bytes\n";
+}
+
+# these parameters were chosen to generate shorter parameters
+# to reduce the possibility of copy+paste errors
+my $crypt = Crypt::CBC->new(-key => $key,
+                        -iv => $iv,
+                        -header => 'none',
+                        -cipher => 'Blowfish');
+$fh = $iv = $key = undef;
+
+my %cbs;
+$cbs{connect} = sub {
+        my ($ctx) = @_;
+        eval { $ctx->setpriv({ header => {}, envrcpt => {} }) };
+        warn $@ if $@;
+        SMFIS_CONTINUE;
+};
+
+$cbs{envrcpt} = sub {
+        my ($ctx, $addr) = @_;
+        eval {
+                $addr =~ tr!<>!!d;
+                $ctx->getpriv->{envrcpt}->{$addr} = 1;
+        };
+        warn $@ if $@;
+        SMFIS_CONTINUE;
+};
+
+$cbs{header} = sub {
+        my ($ctx, $k, $v) = @_;
+        eval {
+                my $k_ = lc $k;
+                if ($k_ eq 'list-unsubscribe') {
+                        my $header = $ctx->getpriv->{header} ||= {};
+                        my $ary = $header->{$k_} ||= [];
+
+                        # we create placeholders in case there are
+                        # multiple headers of the same name
+                        my $cur = [];
+                        push @$ary, $cur;
+
+                        # This relies on mlmmj convention:
+                        #        $LIST+unsubscribe@$DOMAIN
+                        if ($v =~ /\A<mailto:([^@]+)\+unsubscribe@([^>]+)>\z/) {
+                                @$cur = ($k, $v, $1, $2);
+
+                        # Mailman convention:
+                        #        $LIST-request@$DOMAIN?subject=unsubscribe
+                        } elsif ($v =~ /\A<mailto:([^@]+)-request@
+                                        ([^\?]+)\?subject=unsubscribe>\z/x) {
+                                # @$cur = ($k, $v, $1, $2);
+                        }
+                }
+        };
+        warn $@ if $@;
+        SMFIS_CONTINUE;
+};
+
+# only whitelist a few users for testing:
+my $whitelist = '/etc/unsubscribe-milter.whitelist';
+my %TEST_WHITELIST = map { $_ => 1 } eval {
+                open my $fh, '<', $whitelist or
+                        die "Failed to open $whitelist: $!";
+                local $/ = "\n";
+                chomp(my @lines = (<$fh>));
+                @lines;
+        };
+die "No whitelist at $whitelist\n" unless scalar keys %TEST_WHITELIST;
+
+$cbs{eom} = sub {
+        my ($ctx) = @_;
+        eval {
+                my $priv = $ctx->getpriv;
+                $ctx->setpriv({ header => {}, envrcpt => {} });
+                my @rcpt = keys %{$priv->{envrcpt}};
+
+                # one recipient, one unique HTTP(S) URL
+                return SMFIS_CONTINUE if @rcpt != 1;
+                return SMFIS_CONTINUE unless $TEST_WHITELIST{$rcpt[0]};
+
+                my $unsub = $priv->{header}->{'list-unsubscribe'} || [];
+                my $n = 0;
+                foreach my $u (@$unsub) {
+                        # Milter indices are 1-based,
+                        # not 0-based like Perl arrays
+                        my $index = ++$n;
+                        my ($k, $v, $list, $domain) = @$u;
+
+                        next unless $k && $v && $list && $domain;
+                        my $u = $crypt->encrypt($rcpt[0]);
+                        $u = encode_base64url($u);
+                        $v .= ",\n <https://$domain/u/$u/$list>";
+
+                        $ctx->chgheader($k, $index, $v);
+                }
+        };
+        warn $@ if $@;
+        SMFIS_CONTINUE;
+};
+
+my $milter = Sendmail::PMilter->new;
+
+# Try to inherit a socket from systemd or similar:
+my $fds = $ENV{LISTEN_FDS};
+if ($fds && (($ENV{LISTEN_PID} || 0) == $$)) {
+        die "$0 can only listen on one FD\n" if $fds != 1;
+        my $start_fd = 3;
+        my $s = IO::Socket->new_from_fd($start_fd, 'r') or
+                die "inherited bad FD from LISTEN_FDS: $!\n";
+        $milter->set_socket($s);
+} else {
+        # fall back to binding a socket:
+        my $sock = 'unix:/var/spool/postfix/unsubscribe/unsubscribe.sock';
+        $milter->set_listen(1024);
+        my $umask = umask 0000;
+        $milter->setconn($sock);
+        umask $umask;
+}
+
+$milter->register('unsubscribe', \%cbs, SMFI_CURR_ACTS);
+my $dispatcher = Sendmail::PMilter::prefork_dispatcher(max_children => 2);
+$milter->set_dispatcher($dispatcher);
+$milter->main();
diff --git a/examples/unsubscribe.psgi b/examples/unsubscribe.psgi
new file mode 100644
index 00000000..82e186b1
--- /dev/null
+++ b/examples/unsubscribe.psgi
@@ -0,0 +1,68 @@
+#!/usr/bin/perl -w
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
+# This should not require any other PublicInbox code, but may use
+# PublicInbox::Config if ~/.public-inbox/config exists or
+# PI_CONFIG is pointed to an appropriate location
+use strict;
+use Plack::Builder;
+use PublicInbox::Unsubscribe;
+my $app = PublicInbox::Unsubscribe->new(
+        pi_config => eval { # optional, for pointing out archives
+                require PublicInbox::Config;
+                # uses ~/.public-inbox/config by default,
+                # can override with PI_CONFIG or here since
+                # I run this .psgi as the mlmmj user while the
+                # public-inbox-mda code which actually writes to
+                # the archives runs as a different user.
+                PublicInbox::Config->new('/home/pi/.public-inbox/config')
+        },
+        code_url => 'git://80x24.org/public-inbox.git', # change if you fork
+        owner_email => 'BOFH@example.com',
+        confirm => 1,
+
+        # First 8 bytes is for the key, next 8 bytes is for the IV
+        # using Blowfish.  We want as short URLs as possible to avoid
+        # copy+paste errors
+        # umask 077 && dd if=/dev/urandom bs=16 count=1 of=.unsubscribe.key
+        key_file => '/home/mlmmj/.unsubscribe.key',
+
+        # this runs as whatever user has perms to run /usr/bin/mlmmj-unsub
+        # users of other mailing lists.  Returns '' on success.
+        unsubscribe => sub {
+                my ($user_addr, $list_addr) = @_;
+
+                # map list_addr to mlmmj spool, I use:
+                # /home/mlmmj/spool/$LIST here
+                my ($list, $domain) = split('@', $list_addr, 2);
+                my $spool = "/home/mlmmj/spool/$list";
+
+                return "Invalid list: $list" unless -d $spool;
+
+                # -c to send a confirmation email, -s is important
+                # in case a user is click-happy and clicks twice.
+                my @cmd = (qw(/usr/bin/mlmmj-unsub -c -s),
+                                '-L', $spool, '-a', $user_addr);
+
+                # we don't know which version they're subscribed to,
+                # try both non-digest and digest
+                my $normal = system(@cmd);
+                my $digest = system(@cmd, '-d');
+
+                # success if either succeeds:
+                return '' if ($normal == 0 || $digest == 0);
+
+                # missing executable or FS error,
+                # otherwise -s always succeeds, right?
+                return 'Unknown error, contact admin';
+        },
+);
+
+builder {
+        mount '/u' => builder {
+                eval { enable 'Deflater' }; # optional
+                eval { enable 'ReverseProxy' }; # optional
+                enable 'Head';
+                sub { $app->call(@_) };
+        };
+};
diff --git a/lib/PublicInbox/Unsubscribe.pm b/lib/PublicInbox/Unsubscribe.pm
new file mode 100644
index 00000000..1f5ce315
--- /dev/null
+++ b/lib/PublicInbox/Unsubscribe.pm
@@ -0,0 +1,179 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+#
+# Standalone PSGI app to handle HTTP(s) unsubscribe links generated
+# by milters like examples/unsubscribe.milter to mailing lists.
+#
+# This does not depend on any other modules in the PublicInbox::*
+# and ought to be usable with any mailing list software.
+package PublicInbox::Unsubscribe;
+use strict;
+use warnings;
+use Crypt::CBC;
+use Plack::Util;
+use MIME::Base64 qw(decode_base64url);
+my $CODE_URL = 'git://80x24.org/public-inbox.git';
+my @CT_HTML = ('Content-Type', 'text/html; charset=UTF-8');
+
+sub new {
+        my ($class, %opt) = @_;
+        my $key_file = $opt{key_file};
+        defined $key_file or die "`key_file' needed";
+        open my $fh, '<', $key_file or die
+                "failed to open key_file=$key_file: $!\n";
+        my ($key, $iv);
+        if (read($fh, $key, 8) != 8 || read($fh, $iv, 8) != 8 ||
+                                read($fh, my $end, 8) != 0) {
+                die "key_file must be 16 bytes\n";
+        }
+
+        # these parameters were chosen to generate shorter parameters
+        # to reduce the possibility of copy+paste errors
+        my $cipher = Crypt::CBC->new(-key => $key,
+                        -iv => $iv,
+                        -header => 'none',
+                        -cipher => 'Blowfish');
+
+        my $e = $opt{owner_email} or die "`owner_email' not specified\n";
+        my $unsubscribe = $opt{unsubscribe} or
+                die "`unsubscribe' callback not given\n";
+
+        bless {
+                pi_config => $opt{pi_config}, # PublicInbox::Config
+                owner_email => $opt{owner_email},
+                cipher => $cipher,
+                unsubscribe => $unsubscribe,
+                contact => qq(<a\nhref="mailto:$e">$e</a>),
+                code_url => $opt{code_url} || $CODE_URL,
+                confirm => $opt{confirm},
+        }, $class;
+}
+
+# entry point for PSGI
+sub call {
+        my ($self, $env) = @_;
+        my $m = $env->{REQUEST_METHOD};
+        if ($m eq 'GET' || $m eq 'HEAD') {
+                $self->{confirm} ? confirm_prompt($self, $env)
+                                 : finalize_unsub($self, $env);
+        } elsif ($m eq 'POST') {
+                finalize_unsub($self, $env);
+        } else {
+                r($self, 405,
+                        Plack::Util::encode_html($m).' method not allowed');
+        }
+}
+
+sub _user_list_addr {
+        my ($self, $env) = @_;
+        my ($blank, $u, $list) = split('/', $env->{PATH_INFO});
+
+        if (!defined $u || $u eq '') {
+                return r($self, 400, 'Bad request',
+                        'Missing encrypted email address in path component');
+        }
+        if (!defined $list && $list eq '') {
+                return r($self, 400, 'Bad request',
+                        'Missing mailing list name in path component');
+        }
+        my $user = eval { $self->{cipher}->decrypt(decode_base64url($u)) };
+        if (!defined $user) {
+                my $err = quotemeta($@);
+                my $errors = $env->{'psgi.errors'};
+                $errors->print("error decrypting: $u\n");
+                $errors->print("$_\n") for split("\n", $err);
+                return r($self, 400, 'Bad request', "Failed to decrypt: $u");
+        }
+
+        # The URLs are too damn long if we have the encrypted domain
+        # name in the query string
+        if (index($list, '@') < 0) {
+                my $host = (split(':', $env->{HTTP_HOST}))[0];
+                $list .= '@'.$host;
+        }
+        ($user, $list);
+}
+
+sub confirm_prompt { # on GET
+        my ($self, $env) = @_;
+        my ($user_addr, $list_addr) = _user_list_addr($self, $env);
+        return $user_addr if ref $user_addr;
+
+        my $xl = Plack::Util::encode_html($list_addr);
+        my $xu = Plack::Util::encode_html($user_addr);
+        my @body = (
+                "Confirmation required to remove", '',
+                "\t$xu", '',
+                "from the mailing list at", '',
+                "\t$xl", '',
+                'You will get one last email once you hit "Confirm" below:',
+                qq(</pre><form\nmethod=post\naction="">) .
+                qq(<input\ntype=submit\nvalue="Confirm" />) .
+                '</form><pre>');
+
+        push @body, archive_info($self, $env, $list_addr);
+
+        r($self, 200, "Confirm unsubscribe for $xl", @body);
+}
+
+sub finalize_unsub { # on POST
+        my ($self, $env) = @_;
+        my ($user_addr, $list_addr) = _user_list_addr($self, $env);
+        return $user_addr if ref $user_addr;
+
+        my @archive = archive_info($self, $env, $list_addr);
+        if (my $err = $self->{unsubscribe}->($user_addr, $list_addr)) {
+                return r($self, 500, Plack::Util::encode_html($err), @archive);
+        }
+
+        my $xl = Plack::Util::encode_html($list_addr);
+        r($self, 200, "Unsubscribed from $xl",
+                'You may get one final goodbye message', @archive);
+}
+
+sub r {
+        my ($self, $code, $title, @body) = @_;
+        [ $code, [ @CT_HTML ], [
+                "<html><head><title>$title</title></head><body><pre>".
+                join("\n", "<b>$title</b>\n", @body) . '</pre><hr />'.
+                "<pre>This page is available under AGPL-3.0+\n" .
+                "git clone $self->{code_url}\n" .
+                qq(Email $self->{contact} if you have any questions).
+                '</pre></body></html>'
+        ] ];
+}
+
+sub archive_info {
+        my ($self, $env, $list_addr) = @_;
+        my $archive_url = $self->{archive_urls}->{$list_addr};
+
+        unless ($archive_url) {
+                if (my $config = $self->{pi_config}) {
+                        # PublicInbox::Config::lookup
+                        my $inbox = $config->lookup($list_addr);
+                        # PublicInbox::Inbox::base_url
+                        $archive_url = $inbox->base_url if $inbox;
+                }
+        }
+
+        # protocol-relative URL:  "//example.com/" => "https://example.com/"
+        if ($archive_url =~ m!\A//!) {
+                $archive_url = "$env->{'psgi.url_scheme'}:$archive_url";
+        }
+
+        # maybe there are other places where we could map
+        # list_addr => archive_url without ~/.public-inbox/config
+        if ($archive_url) {
+                $archive_url = Plack::Util::encode_html($archive_url);
+                ('',
+                'HTML and git clone-able archives are available at:',
+                qq(<a\nhref="$archive_url">$archive_url</a>))
+        } else {
+                ('',
+                'There ought to be archives for this list,',
+                'but unfortunately the admin did not configure '.
+                __PACKAGE__. ' to show you the URL');
+        }
+}
+
+1;