about summary refs log tree commit homepage
path: root/lib/PublicInbox/Unsubscribe.pm
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 /lib/PublicInbox/Unsubscribe.pm
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.
Diffstat (limited to 'lib/PublicInbox/Unsubscribe.pm')
-rw-r--r--lib/PublicInbox/Unsubscribe.pm179
1 files changed, 179 insertions, 0 deletions
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;