public-inbox.git  about / heads / tags
an "archives first" approach to mailing lists
blob 8c682012585c0baf65438389f4647ef6bf106711 5799 bytes (raw)
$ git show HEAD:examples/unsubscribe.milter	# shows this blob on the CLI

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
 
#!/usr/bin/perl -w
# Copyright (C) all contributors <meta@public-inbox.org>
# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
use strict;
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";
}

# optionally support unique mailto: subject in List-Unsubscribe,
# requires a custom rule in front of mlmmj, see __END__
my $unique_mailto = $ENV{UNIQUE_MAILTO};

# 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 $allow_domains = '/etc/unsubscribe-milter.allow_domains';
my $ALLOW_DOMAINS;
if (open my $fh, '<', $allow_domains) {
	local $/ = "\n";
	chomp(my @l = <$fh>);
	die "close: $!" unless eof($fh) && close($fh);
	my %l = map { lc($_) => 1 } @l;
	$ALLOW_DOMAINS = \%l;
} else {
	warn <<EOM;
W: open $allow_domains: $! (all domains allowed)
W: all mlmmj-looking messages will have List-Unsubscribe added,
W: this is probably not what you want.
EOM
}

# only allow users hitting SMTP server locally:
# Is a config file necessary?  Regexps are ugly for IP addresses
# but Net::Patricia (or similar) seems like overkill.  Ugly it is:
my @ALLOW_ADDR = (qr/\A::1\z/, qr/\A127\./);
my $ALLOW_ADDR = join('|', @ALLOW_ADDR);

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;
};

# We don't want people unsubscribing archivers:
sub archive_addr {
	my ($addr) = @_;
	return 1 if ($addr =~ /\@m\.gmane(?:-mx)?\.org\z/);
	return 1 if ($addr eq 'archive@mail-archive.com');
	0
}

$cbs{eom} = sub {
	my ($ctx) = @_;
	eval {
		my $priv = $ctx->getpriv;
		$ctx->setpriv({ header => {}, envrcpt => {} });

		# XXX my postfix (3.5.18-0+deb11u1) + Sendmail::PMilter
		# instance doesn't seem to get {client_addr}, but
		# {daemon_addr} seems to make sense since I only want it
		# to apply to users connecting to postfix locally:
		if ($ALLOW_ADDR) {
			my $x = $ctx->getsymval('{daemon_addr}');
			return SMFIS_CONTINUE if $x && $x !~ /$ALLOW_ADDR/;
		}

		# one recipient, one unique HTTP(S) URL
		my @rcpt = keys %{$priv->{envrcpt}};
		return SMFIS_CONTINUE if @rcpt != 1;
		if ($ALLOW_DOMAINS) {
			my $addr = $ctx->getsymval('{mail_addr}');
			my (undef, $d) = split /\@/, $addr;
			return SMFIS_CONTINUE if !$ALLOW_DOMAINS->{$d};
		}
		return SMFIS_CONTINUE if archive_addr(lc($rcpt[0]));

		my $unsub = $priv->{header}->{'list-unsubscribe'} || [];
		my $n = 0;
		my $added;
		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);
			if ($unique_mailto) {
				# $u needs to be in the Subject: header since
				# +$EXTENSION is case-insensitive
				my $s = "subject=$u";
				$v = "<mailto:$list+unique-unsub\@$domain?$s>";
			}
			$v .= ",\n <https://$domain/u/$u/$list>";

			$ctx->chgheader($k, $index, $v);
			$added = 1;
		}
		# RFC 8058
		$added and $ctx->addheader('List-Unsubscribe-Post',
					'List-Unsubscribe=One-Click');
	};
	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);
$milter->main();
__END__
# TMPMSG comes from dc-dlvr, it's populated before the above runs:
# TMPMSG=$(mktemp -t dc-dlvr.orig.$USER.XXXXXX || exit 1)
# cat >$TMPMSG

# I use something like this in front of mlmmj for UNIQUE_MAILTO
# $EXTENSION and $ORIGINAL_RECIPIENT are set by postfix, $list
# is a local mapping of addresses to mailing list names.
case $ORIGINAL_RECIPIENT in
foo+*) list=foo ;;
# ...
esac

case $EXTENSION in
unique-unsub)
	u="$(formail -z -c -x Subject <$TMPMSG)"
	d=$(expr "$ORIGINAL_RECIPIENT" : '^.*@\(.*\)')

	# forward this to the unsubscribe.psgi service
	curl -sSf https://$d/u/$u/$list >/dev/null
	exit
	;;
esac
/usr/bin/mlmmj-receive -L /path/to/mlmmj-spool/$list <"$TMPMSG"
exit

git clone https://public-inbox.org/public-inbox.git
git clone http://7fh6tueqddpjyxjmgtdiueylzoqt6pt7hec3pukyptlmohoowvhde4yd.onion/public-inbox.git