about summary refs log tree commit homepage
path: root/t
diff options
context:
space:
mode:
Diffstat (limited to 't')
-rw-r--r--t/address.t36
-rw-r--r--t/altid.t61
-rw-r--r--t/cgi.t90
-rw-r--r--t/check-www-inbox.perl157
-rw-r--r--t/common.perl21
-rw-r--r--t/config.t29
-rw-r--r--t/config_limiter.t49
-rw-r--r--t/emergency.t53
-rw-r--r--t/feed.t112
-rw-r--r--t/filter.t355
-rw-r--r--t/filter_base.t81
-rw-r--r--t/filter_mirror.t40
-rw-r--r--t/filter_vger.t46
-rw-r--r--t/git-http-backend.psgi25
-rw-r--r--t/git-http-backend.t134
-rw-r--r--t/html_index.t36
-rw-r--r--t/httpd-corner.psgi16
-rw-r--r--t/httpd-corner.t51
-rw-r--r--t/httpd-unix.t4
-rw-r--r--t/httpd.t33
-rw-r--r--t/import.t69
-rw-r--r--t/inbox.t15
-rw-r--r--t/init.t10
-rw-r--r--t/linkify.t56
-rw-r--r--t/mda.t130
-rw-r--r--t/mid.t11
-rw-r--r--t/msg_iter.t44
-rw-r--r--t/nntp.t39
-rw-r--r--t/nntpd.t136
-rw-r--r--t/plack.t127
-rw-r--r--t/precheck.t31
-rw-r--r--t/psgi_attach.t117
-rw-r--r--t/psgi_mount.t78
-rw-r--r--t/psgi_text.t39
-rw-r--r--t/qspawn.t62
-rw-r--r--t/search.t160
-rw-r--r--t/spamcheck_spamc.t49
-rw-r--r--t/spawn.t12
-rw-r--r--t/thread-cycle.t89
-rw-r--r--t/view.t72
-rw-r--r--t/watch_maildir.t126
41 files changed, 2148 insertions, 753 deletions
diff --git a/t/address.t b/t/address.t
new file mode 100644
index 00000000..be0fc5b7
--- /dev/null
+++ b/t/address.t
@@ -0,0 +1,36 @@
+# 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 Test::More;
+use_ok 'PublicInbox::Address';
+
+is_deeply([qw(e@example.com e@example.org)],
+        [PublicInbox::Address::emails('User <e@example.com>, e@example.org')],
+        'address extraction works as expected');
+
+is_deeply([PublicInbox::Address::emails('"ex@example.com" <ex@example.com>')],
+        [qw(ex@example.com)]);
+
+my @names = PublicInbox::Address::names(
+        'User <e@e>, e@e, "John A. Doe" <j@d>, <x@x>');
+is_deeply(['User', 'e', 'John A. Doe', 'x'], \@names,
+        'name extraction works as expected');
+
+@names = PublicInbox::Address::names('"user@example.com" <user@example.com>');
+is_deeply(['user'], \@names, 'address-as-name extraction works as expected');
+
+
+{
+        my $backwards = 'u@example.com (John Q. Public)';
+        @names = PublicInbox::Address::names($backwards);
+        is_deeply(\@names, ['u'], 'backwards name OK');
+        my @emails = PublicInbox::Address::emails($backwards);
+        is_deeply(\@emails, ['u@example.com'], 'backwards emails OK');
+}
+
+
+@names = PublicInbox::Address::names('"Quote Unneeded" <user@example.com>');
+is_deeply(['Quote Unneeded'], \@names, 'extra quotes dropped');
+
+done_testing;
diff --git a/t/altid.t b/t/altid.t
new file mode 100644
index 00000000..887d548f
--- /dev/null
+++ b/t/altid.t
@@ -0,0 +1,61 @@
+# 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 Test::More;
+use File::Temp qw/tempdir/;
+foreach my $mod (qw(DBD::SQLite Search::Xapian)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for altid.t" if $@;
+}
+
+use_ok 'PublicInbox::Msgmap';
+use_ok 'PublicInbox::SearchIdx';
+use_ok 'PublicInbox::Import';
+use_ok 'PublicInbox::Inbox';
+my $tmpdir = tempdir('pi-altid-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $git_dir = "$tmpdir/a.git";
+my $alt_file = "$tmpdir/another-nntp.sqlite3";
+my $altid = [ "serial:gmane:file=$alt_file" ];
+
+{
+        my $mm = PublicInbox::Msgmap->new_file($alt_file, 1);
+        $mm->mid_set(1234, 'a@example.com');
+}
+
+{
+        is(system(qw(git init -q --bare), $git_dir), 0, 'git init ok');
+        my $git = PublicInbox::Git->new($git_dir);
+        my $im = PublicInbox::Import->new($git, 'testbox', 'test@example');
+        $im->add(Email::MIME->create(
+                header => [
+                        From => 'a@example.com',
+                        To => 'b@example.com',
+                        'Content-Type' => 'text/plain',
+                        Subject => 'boo!',
+                        'Message-ID' => '<a@example.com>',
+                ],
+                body => "hello world gmane:666\n",
+        ));
+        $im->done;
+}
+{
+        my $inbox = PublicInbox::Inbox->new({mainrepo=>$git_dir});
+        $inbox->{altid} = $altid;
+        my $rw = PublicInbox::SearchIdx->new($inbox, 1);
+        $rw->index_sync;
+}
+
+{
+        my $ro = PublicInbox::Search->new($git_dir, $altid);
+        my $res = $ro->query("gmane:1234");
+        is($res->{total}, 1, 'got one match');
+        is($res->{msgs}->[0]->mid, 'a@example.com');
+
+        $res = $ro->query("gmane:666");
+        is($res->{total}, 0, 'body did NOT match');
+};
+
+done_testing();
+
+1;
diff --git a/t/cgi.t b/t/cgi.t
index f1a2730c..092ad8c7 100644
--- a/t/cgi.t
+++ b/t/cgi.t
@@ -1,30 +1,27 @@
 # Copyright (C) 2014-2015 all contributors <meta@public-inbox.org>
 # License: AGPLv3 or later (https://www.gnu.org/licenses/agpl-3.0.txt)
+# FIXME: this test is too slow and most non-CGI-requirements
+# should be moved over to things which use test_psgi
 use strict;
 use warnings;
 use Test::More;
 use Email::MIME;
 use File::Temp qw/tempdir/;
 use Cwd;
-use IPC::Run qw/run/;
+eval { require IPC::Run };
+plan skip_all => "missing IPC::Run for t/cgi.t" if $@;
 
 use constant CGI => "blib/script/public-inbox.cgi";
-my $mda = "blib/script/public-inbox-mda";
 my $index = "blib/script/public-inbox-index";
 my $tmpdir = tempdir('pi-cgi-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 my $home = "$tmpdir/pi-home";
 my $pi_home = "$home/.public-inbox";
 my $pi_config = "$pi_home/config";
 my $maindir = "$tmpdir/main.git";
-my $main_bin = getcwd()."/t/main-bin";
-my $main_path = "$main_bin:$ENV{PATH}"; # for spamc ham mock
 my $addr = 'test-public@example.com';
 my $cfgpfx = "publicinbox.test";
 
 {
-        ok(-x "$main_bin/spamc",
-                "spamc ham mock found (run in top of source tree");
-        ok(-x $mda, "$mda is executable");
         is(1, mkdir($home, 0755), "setup ~/ for testing");
         is(1, mkdir($pi_home, 0755), "setup ~/.public-inbox");
         is(0, system(qw(git init -q --bare), $maindir), "git init (main)");
@@ -42,15 +39,18 @@ my $cfgpfx = "publicinbox.test";
         }
 }
 
-my $failbox = "$home/fail.mbox";
-local $ENV{PI_EMERGENCY} = $failbox;
+use_ok 'PublicInbox::Git';
+use_ok 'PublicInbox::Import';
+use_ok 'Email::MIME';
+my $git = PublicInbox::Git->new($maindir);
+my $im = PublicInbox::Import->new($git, 'test', $addr);
+
 {
         local $ENV{HOME} = $home;
-        local $ENV{ORIGINAL_RECIPIENT} = $addr;
 
         # ensure successful message delivery
         {
-                my $simple = Email::Simple->new(<<EOF);
+                my $mime = Email::MIME->new(<<EOF);
 From: Me <me\@example.com>
 To: You <you\@example.com>
 Cc: $addr
@@ -60,16 +60,15 @@ Date: Thu, 01 Jan 1970 00:00:00 +0000
 
 zzzzzz
 EOF
-                my $in = $simple->as_string;
-                run_with_env({PATH => $main_path}, [$mda], \$in);
-                local $ENV{GIT_DIR} = $maindir;
-                my $rev = `git rev-list HEAD`;
+                $im->add($mime);
+                $im->done;
+                my $rev = `git --git-dir=$maindir rev-list HEAD`;
                 like($rev, qr/\A[a-f0-9]{40}/, "good revision committed");
         }
 
         # deliver a reply, too
         {
-                my $reply = Email::Simple->new(<<EOF);
+                my $reply = Email::MIME->new(<<EOF);
 From: You <you\@example.com>
 To: Me <me\@example.com>
 Cc: $addr
@@ -83,10 +82,9 @@ Me wrote:
 
 what?
 EOF
-                my $in = $reply->as_string;
-                run_with_env({PATH => $main_path}, [$mda], \$in);
-                local $ENV{GIT_DIR} = $maindir;
-                my $rev = `git rev-list HEAD`;
+                $im->add($reply);
+                $im->done;
+                my $rev = `git --git-dir=$maindir rev-list HEAD`;
                 like($rev, qr/\A[a-f0-9]{40}/, "good revision committed");
         }
 
@@ -120,7 +118,7 @@ EOF
         like($res->{head}, qr/Status:\s*206/i, "info/refs partial past end OK");
         is($res->{body}, substr($orig, 5), 'partial body OK past end');
 }
-
+use Data::Dumper;
 # atom feeds
 {
         local $ENV{HOME} = $home;
@@ -128,31 +126,16 @@ EOF
         like($res->{body}, qr/<title>test for public-inbox/,
                 "set title in XML feed");
         like($res->{body},
-                qr!http://test\.example\.com/test/blah%40example\.com/!,
+                qr!http://test\.example\.com/test/blah\@example\.com/!,
                 "link id set");
         like($res->{body}, qr/what\?/, "reply included");
 }
 
-# indices
-{
-        local $ENV{HOME} = $home;
-        my $res = cgi_run("/test/");
-        like($res->{head}, qr/Status: 200 OK/, "index returns 200");
-
-        my $idx = cgi_run("/test/index.html");
-        $idx->{body} =~ s!/index.html(\?r=)!/$1!g; # dirty...
-        $idx->{body} = [ split(/\n/, $idx->{body}) ];
-        $res->{body} = [ split(/\n/, $res->{body}) ];
-        is_deeply($res, $idx,
-                '/$LISTNAME/ and /$LISTNAME/index.html are nearly identical');
-        # more checks in t/feed.t
-}
-
 # message-id pages
 {
         local $ENV{HOME} = $home;
         my $slashy_mid = 'slashy/asdf@example.com';
-        my $reply = Email::Simple->new(<<EOF);
+        my $reply = Email::MIME->new(<<EOF);
 From: You <you\@example.com>
 To: Me <me\@example.com>
 Cc: $addr
@@ -162,16 +145,10 @@ Date: Thu, 01 Jan 1970 00:00:01 +0000
 
 slashy
 EOF
-        my $in = $reply->as_string;
-
-        {
-                local $ENV{HOME} = $home;
-                local $ENV{ORIGINAL_RECIPIENT} = $addr;
-                run_with_env({PATH => $main_path}, [$mda], \$in);
-        }
-        local $ENV{GIT_DIR} = $maindir;
+        $im->add($reply);
+        $im->done;
 
-        my $res = cgi_run("/test/slashy%2fasdf%40example.com/raw");
+        my $res = cgi_run("/test/slashy%2fasdf\@example.com/raw");
         like($res->{body}, qr/Message-Id: <\Q$slashy_mid\E>/,
                 "slashy mid raw hit");
 
@@ -188,21 +165,22 @@ EOF
         like($res->{head}, qr/Status: 300 Multiple Choices/, "mid html miss");
 
         $res = cgi_run("/test/blahblah\@example.com/f/");
-        like($res->{body}, qr/\A<html>/, "mid html");
-        like($res->{head}, qr/Status: 200 OK/, "200 response");
-        $res = cgi_run("/test/blahblah\@example.con/f/");
+        like($res->{head}, qr/Status: 301 Moved/, "301 response");
+        like($res->{head},
+                qr!^Location: http://[^/]+/test/blahblah\@example\.com/\r\n!ms,
+                '301 redirect location');
+        $res = cgi_run("/test/blahblah\@example.con/");
         like($res->{head}, qr/Status: 300 Multiple Choices/, "mid html miss");
 
-        $res = cgi_run("/test/");
-        like($res->{body}, qr/slashy%2Fasdf%40example\.com/,
+        $res = cgi_run("/test/new.html");
+        like($res->{body}, qr/slashy%2Fasdf\@example\.com/,
                 "slashy URL generated correctly");
 }
 
 # retrieve thread as an mbox
 {
         local $ENV{HOME} = $home;
-        local $ENV{PATH} = $main_path;
-        my $path = "/test/blahblah%40example.com/t.mbox.gz";
+        my $path = "/test/blahblah\@example.com/t.mbox.gz";
         my $res = cgi_run($path);
         like($res->{head}, qr/^Status: 501 /, "search not-yet-enabled");
         my $indexed = system($index, $maindir) == 0;
@@ -222,7 +200,7 @@ EOF
 
         my $have_xml_feed = eval { require XML::Feed; 1 } if $indexed;
         if ($have_xml_feed) {
-                $path = "/test/blahblah%40example.com/t.atom";
+                $path = "/test/blahblah\@example.com/t.atom";
                 $res = cgi_run($path);
                 like($res->{head}, qr/^Status: 200 /, "atom returned 200");
                 like($res->{head}, qr!^Content-Type: application/atom\+xml!m,
@@ -246,7 +224,7 @@ done_testing();
 sub run_with_env {
         my ($env, @args) = @_;
         my $init = sub { foreach my $k (keys %$env) { $ENV{$k} = $env->{$k} } };
-        run(@args, init => $init);
+        IPC::Run::run(@args, init => $init);
 }
 
 sub cgi_run {
diff --git a/t/check-www-inbox.perl b/t/check-www-inbox.perl
new file mode 100644
index 00000000..4319049c
--- /dev/null
+++ b/t/check-www-inbox.perl
@@ -0,0 +1,157 @@
+#!/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>
+# Parallel WWW checker
+my $usage = "$0 [-j JOBS] [-s SLOW_THRESHOLD] URL_OF_INBOX\n";
+use strict;
+use warnings;
+use File::Temp qw(tempfile);
+use GDBM_File;
+use Getopt::Long qw(:config gnu_getopt no_ignore_case auto_abbrev);
+use IO::Socket;
+use LWP::ConnCache;
+use POSIX qw(:sys_wait_h);
+use Time::HiRes qw(gettimeofday tv_interval);
+use WWW::Mechanize;
+use Data::Dumper;
+my $nproc = 4;
+my $slow = 0.5;
+my %opts = (
+        '-j|jobs=i' => \$nproc,
+        '-s|slow-threshold=f' => \$slow,
+);
+GetOptions(%opts) or die "bad command-line args\n$usage";
+my $root_url = shift or die $usage;
+
+my %workers;
+$SIG{TERM} = sub { exit 0 };
+$SIG{CHLD} = sub {
+        while (1) {
+                my $pid = waitpid(-1, WNOHANG);
+                return if !defined $pid || $pid <= 0;
+                my $p = delete $workers{$pid} || '(unknown)';
+                warn("$pid [$p] exited with $?\n") if $?;
+        }
+};
+
+my @todo = IO::Socket->socketpair(AF_UNIX, SOCK_SEQPACKET, 0);
+die "socketpair failed: $!" unless $todo[1];
+my @done = IO::Socket->socketpair(AF_UNIX, SOCK_SEQPACKET, 0);
+die "socketpair failed: $!" unless $done[1];
+$| = 1;
+
+foreach my $p (1..$nproc) {
+        my $pid = fork;
+        die "fork failed: $!\n" unless defined $pid;
+        if ($pid) {
+                $workers{$pid} = $p;
+        } else {
+                $todo[1]->close;
+                $done[0]->close;
+                worker_loop($todo[0], $done[1]);
+        }
+}
+
+my ($fh, $tmp) = tempfile('www-check-XXXXXXXX',
+                        SUFFIX => '.gdbm', UNLINK => 1, TMPDIR => 1);
+my $gdbm = tie my %seen, 'GDBM_File', $tmp, &GDBM_WRCREAT, 0600;
+defined $gdbm or die "gdbm open failed: $!\n";
+$todo[0]->close;
+$done[1]->close;
+
+my ($rvec, $wvec);
+$todo[1]->blocking(0);
+$done[0]->blocking(0);
+$seen{$root_url} = 1;
+my $ndone = 0;
+my $nsent = 1;
+my @queue = ($root_url);
+my $timeout = $slow * 4;
+while (keys %workers) { # reacts to SIGCHLD
+        $wvec = $rvec = '';
+        my $u;
+        vec($rvec, fileno($done[0]), 1) = 1;
+        if (@queue) {
+                vec($wvec, fileno($todo[1]), 1) = 1;
+        } elsif ($ndone == $nsent) {
+                kill 'TERM', keys %workers;
+                exit;
+        }
+        if (!select($rvec, $wvec, undef, $timeout)) {
+                while (my ($k, $v) = each %seen) {
+                        next if $v == 2;
+                        print "WAIT ($ndone/$nsent) <$k>\n";
+                }
+        }
+        while ($u = shift @queue) {
+                my $s = $todo[1]->send($u, MSG_EOR);
+                if ($!{EAGAIN}) {
+                        unshift @queue, $u;
+                        last;
+                }
+        }
+        my $r;
+        do {
+                $r = $done[0]->recv($u, 65535, 0);
+        } while (!defined $r && $!{EINTR});
+        next unless $u;
+        if ($u =~ s/\ADONE\t//) {
+                $ndone++;
+                $seen{$u} = 2;
+        } else {
+                next if $seen{$u};
+                $seen{$u} = 1;
+                $nsent++;
+                push @queue, $u;
+        }
+}
+
+sub worker_loop {
+        my ($todo_rd, $done_wr) = @_;
+        my $m = WWW::Mechanize->new(autocheck => 0);
+        my $cc = LWP::ConnCache->new;
+        $m->conn_cache($cc);
+        while (1) {
+                $todo_rd->recv(my $u, 65535, 0);
+                next unless $u;
+
+                my $t = [ gettimeofday ];
+                my $r = $m->get($u);
+                $t = tv_interval($t);
+                printf "SLOW %0.06f % 5d %s\n", $t, $$, $u if $t > $slow;
+                my @links;
+                if ($r->is_success) {
+                        my %links = map {
+                                (split('#', $_->URI->abs->as_string))[0] => 1;
+                        } grep {
+                                $_->tag && $_->url !~ /:/
+                        } $m->links;
+                        @links = keys %links;
+                } elsif ($r->code != 300) {
+                        warn "W: ".$r->code . " $u\n"
+                }
+
+                my $s;
+                # blocking
+                foreach my $l (@links, "DONE\t$u") {
+                        next if $l eq '';
+                        do {
+                                $s = $done_wr->send($l, MSG_EOR);
+                        } while (!defined $s && $!{EINTR});
+                        die "$$ send $!\n" unless defined $s;
+                        my $n = length($l);
+                        die "$$ send truncated $s < $n\n" if $s != $n;
+                }
+
+                # make sure the HTML source doesn't screw up terminals
+                # when people curl the source (not remotely an expert
+                # on languages or encodings, here).
+                next if $r->header('Content-Type') !~ m!\btext/html\b!;
+                my $dc = $r->decoded_content;
+                if ($dc =~ /([\x00-\x08\x0d-\x1f\x7f-\x{99999999}]+)/s) {
+                        my $o = $1;
+                        my $c = Dumper($o);
+                        warn "bad: $u $c\n";
+                }
+        }
+}
diff --git a/t/common.perl b/t/common.perl
index bec57699..1251333d 100644
--- a/t/common.perl
+++ b/t/common.perl
@@ -1,18 +1,15 @@
 # Copyright (C) 2015 all contributors <meta@public-inbox.org>
 # License: AGPLv3 or later (https://www.gnu.org/licenses/agpl-3.0.txt)
-require IO::File;
-use POSIX qw/dup/;
 
 sub stream_to_string {
-        my ($cb) = @_;
-        my $headers;
-        my $io = IO::File->new_tmpfile;
-        my $dup = dup($io->fileno);
-        my $response = sub { $headers = \@_, $io };
-        $cb->($response);
-        $io = IO::File->new;
-        $io->fdopen($dup, 'r+');
-        $io->seek(0, 0);
-        $io->read(my $str, ($io->stat)[7]);
+        my ($res) = @_;
+        my $body = $res->[2];
+        my $str = '';
+        while (defined(my $chunk = $body->getline)) {
+                $str .= $chunk;
+        }
+        $body->close;
         $str;
 }
+
+1;
diff --git a/t/config.t b/t/config.t
index 48df0871..073d1d03 100644
--- a/t/config.t
+++ b/t/config.t
@@ -9,10 +9,8 @@ my $tmpdir = tempdir('pi-config-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 
 {
         is(system(qw(git init -q --bare), $tmpdir), 0, "git init successful");
-        {
-                local $ENV{GIT_DIR} = $tmpdir;
-                is(system(qw(git config foo.bar hihi)), 0, "set config");
-        }
+        my @cmd = ('git', "--git-dir=$tmpdir", qw(config foo.bar hihi));
+        is(system(@cmd), 0, "set config");
 
         my $tmp = PublicInbox::Config->new("$tmpdir/config");
 
@@ -28,8 +26,11 @@ my $tmpdir = tempdir('pi-config-XXXXXX', TMPDIR => 1, CLEANUP => 1);
         is_deeply($cfg->lookup('meta@public-inbox.org'), {
                 'mainrepo' => '/home/pi/meta-main.git',
                 'address' => 'meta@public-inbox.org',
+                'domain' => 'public-inbox.org',
+                'url' => 'http://example.com/meta',
                 -primary_address => 'meta@public-inbox.org',
-                'listname' => 'meta',
+                'name' => 'meta',
+                -pi_config => $cfg,
         }, "lookup matches expected output");
 
         is($cfg->lookup('blah@example.com'), undef,
@@ -42,8 +43,24 @@ my $tmpdir = tempdir('pi-config-XXXXXX', TMPDIR => 1, CLEANUP => 1);
                               'test@public-inbox.org'],
                 -primary_address => 'try@public-inbox.org',
                 'mainrepo' => '/home/pi/test-main.git',
-                'listname' => 'test',
+                'domain' => 'public-inbox.org',
+                'name' => 'test',
+                'url' => 'http://example.com/test',
+                -pi_config => $cfg,
         }, "lookup matches expected output for test");
 }
 
+
+{
+        my $cfgpfx = "publicinbox.test";
+        my @altid = qw(serial:gmane:file=a serial:enamg:file=b);
+        my $config = PublicInbox::Config->new({
+                "$cfgpfx.address" => 'test@example.com',
+                "$cfgpfx.mainrepo" => '/path/to/non/existent',
+                "$cfgpfx.altid" => [ @altid ],
+        });
+        my $ibx = $config->lookup_name('test');
+        is_deeply($ibx->{altid}, [ @altid ]);
+}
+
 done_testing();
diff --git a/t/config_limiter.t b/t/config_limiter.t
new file mode 100644
index 00000000..3c7ec557
--- /dev/null
+++ b/t/config_limiter.t
@@ -0,0 +1,49 @@
+# 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 Test::More;
+use PublicInbox::Config;
+my $cfgpfx = "publicinbox.test";
+{
+        my $config = PublicInbox::Config->new({
+                "$cfgpfx.address" => 'test@example.com',
+                "$cfgpfx.mainrepo" => '/path/to/non/existent',
+                "$cfgpfx.httpbackendmax" => 12,
+        });
+        my $ibx = $config->lookup_name('test');
+        my $git = $ibx->git;
+        my $old = "$git";
+        my $lim = $git->{-httpbackend_limiter};
+        ok($lim, 'Limiter exists');
+        is($lim->{max}, 12, 'limiter has expected slots');
+        $ibx->{git} = undef;
+        $git = $ibx->git;
+        isnt($old, "$git", 'got new Git object');
+        is("$git->{-httpbackend_limiter}", "$lim", 'same limiter');
+}
+
+{
+        my $config = PublicInbox::Config->new({
+                'limiter.named.max' => 3,
+                "$cfgpfx.address" => 'test@example.com',
+                "$cfgpfx.mainrepo" => '/path/to/non/existent',
+                "$cfgpfx.httpbackendmax" => 'named',
+        });
+        my $ibx = $config->lookup_name('test');
+        my $git = $ibx->git;
+        ok($git, 'got git object');
+        my $old = "$git";
+        my $lim = $git->{-httpbackend_limiter};
+        ok($lim, 'Limiter exists');
+        is($lim->{max}, 3, 'limiter has expected slots');
+        $git = undef;
+        $ibx->{git} = undef;
+        PublicInbox::Inbox::weaken_task;
+        $git = $ibx->git;
+        isnt($old, "$git", 'got new Git object');
+        is("$git->{-httpbackend_limiter}", "$lim", 'same limiter');
+        is($lim->{max}, 3, 'limiter has expected slots');
+}
+
+done_testing;
diff --git a/t/emergency.t b/t/emergency.t
new file mode 100644
index 00000000..e480338d
--- /dev/null
+++ b/t/emergency.t
@@ -0,0 +1,53 @@
+# 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 Test::More;
+use File::Temp qw/tempdir/;
+my $tmpdir = tempdir('emergency-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+use_ok 'PublicInbox::Emergency';
+
+{
+        my $md = "$tmpdir/a";
+        my $em = PublicInbox::Emergency->new($md);
+        ok(-d $md, 'Maildir a auto-created');
+        my @tmp = <$md/tmp/*>;
+        is(scalar @tmp, 0, 'no temporary files exist, yet');
+        $em->prepare(\"BLAH");
+        @tmp = <$md/tmp/*>;
+        is(scalar @tmp, 1, 'globbed one temporary file');
+        open my $fh, '<', $tmp[0] or die "failed to open: $!";
+        is("BLAH", <$fh>, 'wrote contents to temporary location');
+        my @new = <$md/new/*>;
+        is(scalar @new, 0, 'no new files exist, yet');
+        $em = undef;
+        @tmp = <$md/tmp/*>;
+        is(scalar @tmp, 0, 'temporary file no longer exists');
+        @new = <$md/new/*>;
+        is(scalar @new, 1, 'globbed one new file');
+        open $fh, '<', $new[0] or die "failed to open: $!";
+        is("BLAH", <$fh>, 'wrote contents to new location');
+}
+{
+        my $md = "$tmpdir/b";
+        my $em = PublicInbox::Emergency->new($md);
+        ok(-d $md, 'Maildir b auto-created');
+        my @tmp = <$md/tmp/*>;
+        is(scalar @tmp, 0, 'no temporary files exist, yet');
+        $em->prepare(\"BLAH");
+        @tmp = <$md/tmp/*>;
+        is(scalar @tmp, 1, 'globbed one temporary file');
+        open my $fh, '<', $tmp[0] or die "failed to open: $!";
+        is("BLAH", <$fh>, 'wrote contents to temporary location');
+        my @new = <$md/new/*>;
+        is(scalar @new, 0, 'no new files exist, yet');
+        is(sysread($em->fh, my $buf, 9), 4, 'read file handle exposed');
+        is($buf, 'BLAH', 'got expected data');
+        $em->abort;
+        @tmp = <$md/tmp/*>;
+        is(scalar @tmp, 0, 'temporary file no longer exists');
+        @new = <$md/new/*>;
+        is(scalar @new , 0, 'new file no longer exists');
+}
+
+done_testing();
diff --git a/t/feed.t b/t/feed.t
index 73b7d0bb..19a9ba09 100644
--- a/t/feed.t
+++ b/t/feed.t
@@ -3,10 +3,12 @@
 use strict;
 use warnings;
 use Test::More;
-use Email::Simple;
+use Email::MIME;
 use PublicInbox::Feed;
+use PublicInbox::Git;
+use PublicInbox::Import;
 use PublicInbox::Config;
-use IPC::Run qw/run/;
+use PublicInbox::Inbox;
 use File::Temp qw/tempdir/;
 my $have_xml_feed = eval { require XML::Feed; 1 };
 require 't/common.perl';
@@ -15,15 +17,45 @@ sub string_feed {
         stream_to_string(PublicInbox::Feed::generate($_[0]));
 }
 
+# ensure we are compatible with existing ssoma installations which
+# do not use fast-import.  We can probably remove this in 2018
+my %SSOMA;
+sub rand_use ($) {
+        return 0 if $ENV{FAST};
+        eval { require IPC::Run };
+        return 0 if $@;
+        my $cmd = $_[0];
+        my $x = $SSOMA{$cmd};
+        unless ($x) {
+                $x = -1;
+                foreach my $p (split(':', $ENV{PATH})) {
+                        -x "$p/$cmd" or next;
+                        $x = 1;
+                        last;
+                }
+                $SSOMA{$cmd} = $x;
+        }
+        return if $x < 0;
+        ($x > 0 && (int(rand(10)) % 2) == 1);
+}
+
 my $tmpdir = tempdir('pi-feed-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 my $git_dir = "$tmpdir/gittest";
+my $ibx = PublicInbox::Inbox->new({
+        address => 'test@example',
+        name => 'testbox',
+        mainrepo => $git_dir,
+        url => 'http://example.com/test',
+});
+my $git = $ibx->git;
+my $im = PublicInbox::Import->new($git, $ibx->{name}, 'test@example');
 
 {
         is(0, system(qw(git init -q --bare), $git_dir), "git init");
         local $ENV{GIT_DIR} = $git_dir;
 
         foreach my $i (1..6) {
-                my $simple = Email::Simple->new(<<EOF);
+                my $mime = Email::MIME->new(<<EOF);
 From: ME <me\@example.com>
 To: U <u\@example.com>
 Message-Id: <$i\@example.com>
@@ -53,10 +85,16 @@ msg $i
 
 keep me
 EOF
-                my $str = $simple->as_string;
-                run(['ssoma-mda', $git_dir], \$str) or
-                        die "mda failed: $?\n";
+                if (rand_use('ssoma-mda')) {
+                        $im->done;
+                        my $str = $mime->as_string;
+                        IPC::Run::run(['ssoma-mda', $git_dir], \$str) or
+                                die "mda failed: $?\n";
+                } else {
+                        like($im->add($mime), qr/\A:\d+/, 'added');
+                }
         }
+        $im->done;
 }
 
 # spam check
@@ -64,7 +102,7 @@ EOF
         # check initial feed
         {
                 my $feed = string_feed({
-                        git_dir => $git_dir,
+                        -inbox => $ibx,
                         max => 3
                 });
                 SKIP: {
@@ -72,13 +110,11 @@ EOF
                         my $p = XML::Feed->parse(\$feed);
                         is($p->format, "Atom", "parsed atom feed");
                         is(scalar $p->entries, 3, "parsed three entries");
-                        is($p->id, 'mailto:public-inbox@example.com',
+                        is($p->id, 'mailto:test@example',
                                 "id is set to default");
                 }
 
-                unlike($feed, qr/drop me/, "long quoted text dropped");
-                like($feed, qr!/\d%40example\.com/f/#q!,
-                        "/f/ url generated for long quoted text");
+                like($feed, qr/drop me/, "long quoted text kept");
                 like($feed, qr/inline me here/, "short quoted text kept");
                 like($feed, qr/keep me/, "unquoted text saved");
         }
@@ -86,13 +122,7 @@ EOF
         # add a new spam message
         my $spam;
         {
-                my $pid = open(my $pipe, "|-");
-                defined $pid or die "fork/pipe failed: $!\n";
-                if ($pid == 0) {
-                        exec("ssoma-mda", $git_dir);
-                }
-
-                $spam = Email::Simple->new(<<EOF);
+                $spam = Email::MIME->new(<<EOF);
 From: SPAMMER <spammer\@example.com>
 To: U <u\@example.com>
 Message-Id: <this-is-spam\@example.com>
@@ -100,14 +130,20 @@ Subject: SPAM!!!!!!!!
 Date: Thu, 01 Jan 1970 00:00:00 +0000
 
 EOF
-                print $pipe $spam->as_string or die "print failed: $!\n";
-                close $pipe or die "close pipe failed: $!\n";
+                if (rand_use('ssoma-mda')) {
+                        my $str = $spam->as_string;
+                        IPC::Run::run(['ssoma-mda', $git_dir], \$str) or
+                                die "mda failed: $?\n";
+                } else {
+                        $im->add($spam);
+                        $im->done;
+                }
         }
 
         # check spam shows up
         {
                 my $spammy_feed = string_feed({
-                        git_dir => $git_dir,
+                        -inbox => $ibx,
                         max => 3
                 });
                 SKIP: {
@@ -120,18 +156,18 @@ EOF
         }
 
         # nuke spam
-        {
+        if (rand_use('ssoma-rm')) {
                 my $spam_str = $spam->as_string;
-                run(["ssoma-rm", $git_dir], \$spam_str) or
+                IPC::Run::run(["ssoma-rm", $git_dir], \$spam_str) or
                                 die "ssoma-rm failed: $?\n";
+        } else {
+                $im->remove($spam);
+                $im->done;
         }
 
         # spam no longer shows up
         {
-                my $feed = string_feed({
-                        git_dir => $git_dir,
-                        max => 3
-                });
+                my $feed = string_feed({ -inbox => $ibx, max => 3 });
                 SKIP: {
                         skip 'XML::Feed missing', 2 unless $have_xml_feed;
                         my $p = XML::Feed->parse(\$feed);
@@ -142,26 +178,4 @@ EOF
         }
 }
 
-# check pi_config
-{
-        foreach my $addr (('a@example.com'), ['a@example.com','b@localhost']) {
-                my $feed = string_feed({
-                        git_dir => $git_dir,
-                        max => 3,
-                        listname => 'asdf',
-                        pi_config => bless({
-                                'publicinbox.asdf.address' => $addr,
-                        }, 'PublicInbox::Config'),
-                });
-                SKIP: {
-                        skip 'XML::Feed missing', 3 unless $have_xml_feed;
-                        my $p = XML::Feed->parse(\$feed);
-                        is($p->id, 'mailto:a@example.com',
-                                "ID is set correctly");
-                        is($p->format, "Atom", "parsed atom feed");
-                        is(scalar $p->entries, 3, "parsed three entries");
-                }
-        }
-}
-
 done_testing();
diff --git a/t/filter.t b/t/filter.t
deleted file mode 100644
index 80a7c123..00000000
--- a/t/filter.t
+++ /dev/null
@@ -1,355 +0,0 @@
-# Copyright (C) 2013-2015 all contributors <meta@public-inbox.org>
-# License: AGPLv3 or later (https://www.gnu.org/licenses/agpl-3.0.txt)
-use strict;
-use warnings;
-use Test::More;
-use Email::MIME;
-use PublicInbox::Filter;
-
-sub count_body_parts {
-        my ($bodies, $part) = @_;
-        my $body = $part->body_raw;
-        $body =~ s/\A\s*//;
-        $body =~ s/\s*\z//;
-        $bodies->{$body} ||= 0;
-        $bodies->{$body}++;
-}
-
-# multipart/alternative: HTML and quoted-printable, keep the plain-text
-{
-        my $html_body = "<html><body>hi</body></html>";
-        my $parts = [
-                Email::MIME->create(
-                        attributes => {
-                                content_type => 'text/html; charset=UTF-8',
-                                encoding => 'base64',
-                        },
-                        body => $html_body,
-                ),
-                Email::MIME->create(
-                        attributes => {
-                                content_type => 'text/plain',
-                                encoding => 'quoted-printable',
-                        },
-                        body => 'hi = "bye"',
-                )
-        ];
-        my $email = Email::MIME->create(
-                header_str => [
-                  From => 'a@example.com',
-                  Subject => 'blah',
-                  'Content-Type' => 'multipart/alternative'
-                ],
-                parts => $parts,
-        );
-        is(1, PublicInbox::Filter->run($email), "run was a success");
-        my $parsed = Email::MIME->new($email->as_string);
-        is("text/plain", $parsed->header("Content-Type"));
-        is(scalar $parsed->parts, 1, "HTML part removed");
-        my %bodies;
-        $parsed->walk_parts(sub {
-                my ($part) = @_;
-                return if $part->subparts; # walk_parts already recurses
-                count_body_parts(\%bodies, $part);
-        });
-        is(scalar keys %bodies, 1, "one bodies");
-        is($bodies{"hi =3D \"bye\"="}, 1, "QP text part unchanged");
-        $parsed->walk_parts(sub {
-                my ($part) = @_;
-                my $b = $part->body;
-                $b =~ s/\s*\z//;
-                is($b, "hi = \"bye\"", "decoded body matches");
-        });
-}
-
-# plain-text email is passed through unchanged
-{
-        my $s = Email::MIME->create(
-                header => [
-                        From => 'a@example.com',
-                        To => 'b@example.com',
-                        'Content-Type' => 'text/plain',
-                        Subject => 'this is a subject',
-                ],
-                body => "hello world\n",
-        );
-        is(1, PublicInbox::Filter->run($s), "run was a success");
-}
-
-# convert single-part HTML to plain-text
-{
-        my $s = Email::MIME->create(
-                header => [
-                        From => 'a@example.com',
-                        To => 'b@example.com',
-                        'Content-Type' => 'text/html',
-                        Subject => 'HTML only badness',
-                ],
-                body => "<html><body>bad body\r\n</body></html>\n",
-        );
-        is(1, PublicInbox::Filter->run($s), "run was a success");
-        unlike($s->as_string, qr/<html>/, "HTML removed");
-        is("text/plain", $s->header("Content-Type"),
-                "content-type changed");
-        like($s->body, qr/\A\s*bad body\s*\z/, "body");
-        unlike($s->body, qr/\r/, "body has no cr");
-        like($s->header("X-Content-Filtered-By"),
-                qr/PublicInbox::Filter/, "XCFB header added");
-}
-
-# multipart/alternative: HTML and plain-text, keep the plain-text
-{
-        my $html_body = "<html><body>hi</body></html>";
-        my $parts = [
-                Email::MIME->create(
-                        attributes => {
-                                content_type => 'text/html; charset=UTF-8',
-                                encoding => 'base64',
-                        },
-                        body => $html_body,
-                ),
-                Email::MIME->create(
-                        attributes => {
-                                content_type => 'text/plain',
-                        },
-                        body=> 'hi',
-                )
-        ];
-        my $email = Email::MIME->create(
-                header_str => [
-                  From => 'a@example.com',
-                  Subject => 'blah',
-                  'Content-Type' => 'multipart/alternative'
-                ],
-                parts => $parts,
-        );
-        is(1, PublicInbox::Filter->run($email), "run was a success");
-        my $parsed = Email::MIME->new($email->as_string);
-        is("text/plain", $parsed->header("Content-Type"));
-        is(scalar $parsed->parts, 1, "HTML part removed");
-        my %bodies;
-        $parsed->walk_parts(sub {
-                my ($part) = @_;
-                return if $part->subparts; # walk_parts already recurses
-                count_body_parts(\%bodies, $part);
-        });
-        is(scalar keys %bodies, 1, "one bodies");
-        is($bodies{"hi"}, 1, "plain text part unchanged");
-}
-
-# multi-part plain-text-only
-{
-        my $parts = [
-                Email::MIME->create(
-                        attributes => { content_type => 'text/plain', },
-                        body => 'hi',
-                ),
-                Email::MIME->create(
-                        attributes => { content_type => 'text/plain', },
-                        body => 'bye',
-                )
-        ];
-        my $email = Email::MIME->create(
-                header_str => [ From => 'a@example.com', Subject => 'blah' ],
-                parts => $parts,
-        );
-        is(1, PublicInbox::Filter->run($email), "run was a success");
-        my $parsed = Email::MIME->new($email->as_string);
-        is(scalar $parsed->parts, 2, "still 2 parts");
-        my %bodies;
-        $parsed->walk_parts(sub {
-                my ($part) = @_;
-                return if $part->subparts; # walk_parts already recurses
-                count_body_parts(\%bodies, $part);
-        });
-        is(scalar keys %bodies, 2, "two bodies");
-        is($bodies{"bye"}, 1, "bye part exists");
-        is($bodies{"hi"}, 1, "hi part exists");
-        is($parsed->header("X-Content-Filtered-By"), undef,
-                "XCFB header unset");
-}
-
-# multi-part HTML, several HTML parts
-{
-        my $parts = [
-                Email::MIME->create(
-                        attributes => {
-                                content_type => 'text/html',
-                                encoding => 'base64',
-                        },
-                        body => '<html><body>b64 body</body></html>',
-                ),
-                Email::MIME->create(
-                        attributes => {
-                                content_type => 'text/html',
-                                encoding => 'quoted-printable',
-                        },
-                        body => '<html><body>qp body</body></html>',
-                )
-        ];
-        my $email = Email::MIME->create(
-                header_str => [ From => 'a@example.com', Subject => 'blah' ],
-                parts => $parts,
-        );
-        is(1, PublicInbox::Filter->run($email), "run was a success");
-        my $parsed = Email::MIME->new($email->as_string);
-        is(scalar $parsed->parts, 2, "still 2 parts");
-        my %bodies;
-        $parsed->walk_parts(sub {
-                my ($part) = @_;
-                return if $part->subparts; # walk_parts already recurses
-                count_body_parts(\%bodies, $part);
-        });
-        is(scalar keys %bodies, 2, "two body parts");
-        is($bodies{"b64 body"}, 1, "base64 part converted");
-        is($bodies{"qp body"}, 1, "qp part converted");
-        like($parsed->header("X-Content-Filtered-By"), qr/PublicInbox::Filter/,
-             "XCFB header added");
-}
-
-# plain-text with image attachments, kill images
-{
-        my $parts = [
-                Email::MIME->create(
-                        attributes => { content_type => 'text/plain' },
-                        body => 'see image',
-                ),
-                Email::MIME->create(
-                        attributes => {
-                                content_type => 'image/jpeg',
-                                filename => 'scary.jpg',
-                                encoding => 'base64',
-                        },
-                        body => 'bad',
-                )
-        ];
-        my $email = Email::MIME->create(
-                header_str => [ From => 'a@example.com', Subject => 'blah' ],
-                parts => $parts,
-        );
-        is(1, PublicInbox::Filter->run($email), "run was a success");
-        my $parsed = Email::MIME->new($email->as_string);
-        is(scalar $parsed->parts, 1, "image part removed");
-        my %bodies;
-        $parsed->walk_parts(sub {
-                my ($part) = @_;
-                return if $part->subparts; # walk_parts already recurses
-                count_body_parts(\%bodies, $part);
-        });
-        is(scalar keys %bodies, 1, "one body");
-        is($bodies{'see image'}, 1, 'original body exists');
-        like($parsed->header("X-Content-Filtered-By"), qr/PublicInbox::Filter/,
-             "XCFB header added");
-}
-
-# all bad
-{
-        my $parts = [
-                Email::MIME->create(
-                        attributes => {
-                                content_type => 'image/jpeg',
-                                filename => 'scary.jpg',
-                                encoding => 'base64',
-                        },
-                        body => 'bad',
-                ),
-                Email::MIME->create(
-                        attributes => {
-                                content_type => 'text/plain',
-                                filename => 'scary.exe',
-                                encoding => 'base64',
-                        },
-                        body => 'bad',
-                ),
-        ];
-        my $email = Email::MIME->create(
-                header_str => [ From => 'a@example.com', Subject => 'blah' ],
-                parts => $parts,
-        );
-        is(0, PublicInbox::Filter->run($email),
-                "run signaled to stop delivery");
-        my $parsed = Email::MIME->new($email->as_string);
-        is(scalar $parsed->parts, 1, "bad parts removed");
-        my %bodies;
-        $parsed->walk_parts(sub {
-                my ($part) = @_;
-                return if $part->subparts; # walk_parts already recurses
-                count_body_parts(\%bodies, $part);
-        });
-        is(scalar keys %bodies, 1, "one body");
-        is($bodies{"all attachments scrubbed by PublicInbox::Filter"}, 1,
-           "attachment scrubber left its mark");
-        like($parsed->header("X-Content-Filtered-By"), qr/PublicInbox::Filter/,
-             "XCFB header added");
-}
-
-{
-        my $s = Email::MIME->create(
-                header => [
-                        From => 'a@example.com',
-                        To => 'b@example.com',
-                        'Content-Type' => 'test/pain',
-                        Subject => 'this is a subject',
-                ],
-                body => "hello world\n",
-        );
-        is(0, PublicInbox::Filter->run($s), "run was a failure");
-        like($s->as_string, qr/scrubbed/, "scrubbed message");
-}
-
-{
-        my $s = Email::MIME->create(
-                header => [
-                        From => 'a@example.com',
-                        To => 'b@example.com',
-                        'Content-Type' => 'text/plain',
-                        'Mail-Followup-To' => 'c@example.com',
-                        Subject => 'mfttest',
-                ],
-                body => "mft\n",
-        );
-
-        is('c@example.com', $s->header("Mail-Followup-To"),
-                "mft set correctly");
-        is(1, PublicInbox::Filter->run($s), "run succeeded for mft");
-        is(undef, $s->header("Mail-Followup-To"), "mft stripped");
-}
-
-# multi-part with application/octet-stream
-{
-        my $os = 'application/octet-stream';
-        my $parts = [
-                Email::MIME->create(
-                        attributes => { content_type => $os },
-                        body => <<EOF
-#include <stdio.h>
-int main(void)
-{
-        printf("Hello world\\n");
-        return 0;
-}
-
-/* some folks like ^L */
-EOF
-                ),
-                Email::MIME->create(
-                        attributes => {
-                                filename => 'zero.data',
-                                encoding => 'base64',
-                                content_type => $os,
-                        },
-                        body => ("\0" x 4096),
-                )
-        ];
-        my $email = Email::MIME->create(
-                header_str => [ From => 'a@example.com', Subject => 'blah' ],
-                parts => $parts,
-        );
-        is(1, PublicInbox::Filter->run($email), "run was a success");
-        my $parsed = Email::MIME->new($email->as_string);
-        is(scalar $parsed->parts, 1, "only one remaining part");
-        like($parsed->header("X-Content-Filtered-By"),
-                qr/PublicInbox::Filter/, "XCFB header added");
-}
-
-done_testing();
diff --git a/t/filter_base.t b/t/filter_base.t
new file mode 100644
index 00000000..ee5c7307
--- /dev/null
+++ b/t/filter_base.t
@@ -0,0 +1,81 @@
+# 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 Test::More;
+use Email::MIME;
+use_ok 'PublicInbox::Filter::Base';
+
+{
+        my $f = PublicInbox::Filter::Base->new;
+        ok($f, 'created stock object');
+        ok(defined $f->{reject_suffix}, 'rejected suffix redefined');
+        is(ref($f->{reject_suffix}), 'Regexp', 'reject_suffix should be a RE');
+}
+
+{
+        my $f = PublicInbox::Filter::Base->new(reject_suffix => undef);
+        ok($f, 'created base object q/o reject_suffix');
+        ok(!defined $f->{reject_suffix}, 'reject_suffix not defined');
+}
+
+{
+        my $f = PublicInbox::Filter::Base->new;
+        my $html_body = "<html><body>hi</body></html>";
+        my $parts = [
+                Email::MIME->create(
+                        attributes => {
+                                content_type => 'text/xhtml; charset=UTF-8',
+                                encoding => 'base64',
+                        },
+                        body => $html_body,
+                ),
+                Email::MIME->create(
+                        attributes => {
+                                content_type => 'text/plain',
+                                encoding => 'quoted-printable',
+                        },
+                        body => 'hi = "bye"',
+                )
+        ];
+        my $email = Email::MIME->create(
+                header_str => [
+                  From => 'a@example.com',
+                  Subject => 'blah',
+                  'Content-Type' => 'multipart/alternative'
+                ],
+                parts => $parts,
+        );
+        is($f->delivery($email), 100, "xhtml rejected");
+}
+
+{
+        my $f = PublicInbox::Filter::Base->new;
+        my $parts = [
+                Email::MIME->create(
+                        attributes => {
+                                content_type => 'application/vnd.ms-excel',
+                                encoding => 'base64',
+                        },
+                        body => 'junk',
+                ),
+                Email::MIME->create(
+                        attributes => {
+                                content_type => 'text/plain',
+                                encoding => 'quoted-printable',
+                        },
+                        body => 'junk',
+                )
+        ];
+        my $email = Email::MIME->create(
+                header_str => [
+                  From => 'a@example.com',
+                  Subject => 'blah',
+                  'Content-Type' => 'multipart/mixed'
+                ],
+                parts => $parts,
+        );
+        is($f->delivery($email), 100, 'proprietary format rejected on glob');
+}
+
+done_testing();
diff --git a/t/filter_mirror.t b/t/filter_mirror.t
new file mode 100644
index 00000000..01be282e
--- /dev/null
+++ b/t/filter_mirror.t
@@ -0,0 +1,40 @@
+# 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 Test::More;
+use Email::MIME;
+use_ok 'PublicInbox::Filter::Mirror';
+
+my $f = PublicInbox::Filter::Mirror->new;
+ok($f, 'created PublicInbox::Filter::Mirror object');
+{
+        my $html_body = "<html><body>hi</body></html>";
+        my $parts = [
+                Email::MIME->create(
+                        attributes => {
+                                content_type => 'text/html; charset=UTF-8',
+                                encoding => 'base64',
+                        },
+                        body => $html_body,
+                ),
+                Email::MIME->create(
+                        attributes => {
+                                content_type => 'text/plain',
+                                encoding => 'quoted-printable',
+                        },
+                        body => 'hi = "bye"',
+                )
+        ];
+        my $email = Email::MIME->create(
+                header_str => [
+                  From => 'a@example.com',
+                  Subject => 'blah',
+                  'Content-Type' => 'multipart/alternative'
+                ],
+                parts => $parts,
+        );
+        is($f->ACCEPT, $f->delivery($email), 'accept any trash that comes');
+}
+
+done_testing();
diff --git a/t/filter_vger.t b/t/filter_vger.t
new file mode 100644
index 00000000..83a4c9ee
--- /dev/null
+++ b/t/filter_vger.t
@@ -0,0 +1,46 @@
+# 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 Test::More;
+use Email::MIME;
+use_ok 'PublicInbox::Filter::Vger';
+
+my $f = PublicInbox::Filter::Vger->new;
+ok($f, 'created PublicInbox::Filter::Vger object');
+{
+        my $lkml = <<'EOF';
+From: foo@example.com
+Subject: test
+
+keep this
+--
+To unsubscribe from this list: send the line "unsubscribe linux-kernel" in
+the body of a message to majordomo@vger.kernel.org
+More majordomo info at  http://vger.kernel.org/majordomo-info.html
+Please read the FAQ at  http://www.tux.org/lkml/
+EOF
+
+        my $mime = Email::MIME->new($lkml);
+        $mime = $f->delivery($mime);
+        is("keep this\n", $mime->body, 'normal message filtered OK');
+}
+
+{
+        my $no_nl = <<'EOF';
+From: foo@example.com
+Subject: test
+
+OSX users :P--
+To unsubscribe from this list: send the line "unsubscribe git" in
+the body of a message to majordomo@vger.kernel.org
+More majordomo info at  http://vger.kernel.org/majordomo-info.html
+EOF
+
+        my $mime = Email::MIME->new($no_nl);
+        $mime = $f->delivery($mime);
+        is('OSX users :P', $mime->body, 'missing trailing LF in original OK');
+}
+
+
+done_testing();
diff --git a/t/git-http-backend.psgi b/t/git-http-backend.psgi
new file mode 100644
index 00000000..66f41505
--- /dev/null
+++ b/t/git-http-backend.psgi
@@ -0,0 +1,25 @@
+#!/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 PublicInbox::GitHTTPBackend;
+use PublicInbox::Git;
+use Plack::Builder;
+use BSD::Resource qw(getrusage);
+my $git_dir = $ENV{GIANT_GIT_DIR} or die 'GIANT_GIT_DIR not defined in env';
+my $git = PublicInbox::Git->new($git_dir);
+builder {
+        enable 'Head';
+        sub {
+                my ($env) = @_;
+                if ($env->{PATH_INFO} =~ m!\A/(.+)\z!s) {
+                        PublicInbox::GitHTTPBackend::serve($env, $git, $1);
+                } else {
+                        my $ru = getrusage();
+                        my $b = $ru->maxrss . "\n";
+                        [ 200, [ qw(Content-Type text/plain Content-Length),
+                                 length($b) ], [ $b ] ]
+                }
+        }
+}
diff --git a/t/git-http-backend.t b/t/git-http-backend.t
new file mode 100644
index 00000000..e506e772
--- /dev/null
+++ b/t/git-http-backend.t
@@ -0,0 +1,134 @@
+# 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 Test::More;
+use File::Temp qw/tempdir/;
+use IO::Socket;
+use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD);
+use Socket qw(SO_KEEPALIVE IPPROTO_TCP TCP_NODELAY);
+use POSIX qw(dup2 setsid);
+use Cwd qw(getcwd);
+
+my $git_dir = $ENV{GIANT_GIT_DIR};
+plan 'skip_all' => 'GIANT_GIT_DIR not defined' unless $git_dir;
+foreach my $mod (qw(Danga::Socket BSD::Resource
+                        Plack::Util Plack::Builder
+                        HTTP::Date HTTP::Status Net::HTTP)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for git-http-backend.t" if $@;
+}
+my $psgi = getcwd()."/t/git-http-backend.psgi";
+my $tmpdir = tempdir('pi-git-http-backend-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $err = "$tmpdir/stderr.log";
+my $out = "$tmpdir/stdout.log";
+my $httpd = 'blib/script/public-inbox-httpd';
+my %opts = (
+        LocalAddr => '127.0.0.1',
+        ReuseAddr => 1,
+        Proto => 'tcp',
+        Type => SOCK_STREAM,
+        Listen => 1024,
+);
+my $sock = IO::Socket::INET->new(%opts);
+my $host = $sock->sockhost;
+my $port = $sock->sockport;
+my $pid;
+END { kill 'TERM', $pid if defined $pid };
+
+my $get_maxrss = sub {
+        my $http = Net::HTTP->new(Host => "$host:$port");
+        ok($http, 'Net::HTTP object created for maxrss');
+        $http->write_request(GET => '/');
+        my ($code, $mess, %h) = $http->read_response_headers;
+        is($code, 200, 'success reading maxrss');
+        my $n = $http->read_entity_body(my $buf, 256);
+        ok(defined $n, 'read response body');
+        like($buf, qr/\A\d+\n\z/, 'got memory response');
+        ok(int($buf) > 0, 'got non-zero memory response');
+        int($buf);
+};
+
+{
+        ok($sock, 'sock created');
+        $pid = fork;
+        if ($pid == 0) { # pretend to be systemd
+                fcntl($sock, F_SETFD, 0);
+                dup2(fileno($sock), 3) or die "dup2 failed: $!\n";
+                $ENV{LISTEN_PID} = $$;
+                $ENV{LISTEN_FDS} = 1;
+                $ENV{TEST_CHUNK} = '1';
+                exec $httpd, "--stdout=$out", "--stderr=$err", $psgi;
+                die "FAIL: $!\n";
+        }
+        ok(defined $pid, 'forked httpd process successfully');
+}
+my $mem_a = $get_maxrss->();
+
+SKIP: {
+        my $max = 0;
+        my $pack;
+        my $glob = "$git_dir/objects/pack/pack-*.pack";
+        foreach my $f (glob($glob)) {
+                my $n = -s $f;
+                if ($n > $max) {
+                        $max = $n;
+                        $pack = $f;
+                }
+        }
+        skip "no packs found in $git_dir" unless defined $pack;
+        if ($pack !~ m!(/objects/pack/pack-[a-f0-9]{40}.pack)\z!) {
+                skip "bad pack name: $pack";
+        }
+        my $url = $1;
+        my $http = Net::HTTP->new(Host => "$host:$port");
+        ok($http, 'Net::HTTP object created');
+        $http->write_request(GET => $url);
+        my ($code, $mess, %h) = $http->read_response_headers;
+        is(200, $code, 'got 200 success for pack');
+        is($max, $h{'Content-Length'}, 'got expected Content-Length for pack');
+        foreach my $i (1..3) {
+                sleep 1;
+                my $diff = $get_maxrss->() - $mem_a;
+                note "${diff}K memory increase after $i seconds";
+                ok($diff < 1024, 'no bloating caused by slow dumb client');
+        }
+}
+
+{
+        my $c = fork;
+        if ($c == 0) {
+                setsid();
+                exec qw(git clone -q --mirror), "http://$host:$port/",
+                        "$tmpdir/mirror.git";
+                die "Failed start git clone: $!\n";
+        }
+        select(undef, undef, undef, 0.1);
+        foreach my $i (1..10) {
+                is(1, kill('STOP', -$c), 'signaled clone STOP');
+                sleep 1;
+                ok(kill('CONT', -$c), 'continued clone');
+                my $diff = $get_maxrss->() - $mem_a;
+                note "${diff}K memory increase after $i seconds";
+                ok($diff < 2048, 'no bloating caused by slow smart client');
+        }
+        ok(kill('CONT', -$c), 'continued clone');
+        is($c, waitpid($c, 0), 'reaped wayward slow clone');
+        is($?, 0, 'clone did not error out');
+        note 'clone done, fsck-ing clone result...';
+        is(0, system("git", "--git-dir=$tmpdir/mirror.git",
+                        qw(fsck --no-progress)),
+                'fsck did not report corruption');
+
+        my $diff = $get_maxrss->() - $mem_a;
+        note "${diff}K memory increase after smart clone";
+        ok($diff < 2048, 'no bloating caused by slow smart client');
+}
+
+{
+        ok(kill('TERM', $pid), 'killed httpd');
+        $pid = undef;
+        waitpid(-1, 0);
+}
+
+done_testing();
diff --git a/t/html_index.t b/t/html_index.t
index adbadaf4..f29b442d 100644
--- a/t/html_index.t
+++ b/t/html_index.t
@@ -3,11 +3,22 @@
 use strict;
 use warnings;
 use Test::More;
-use Email::Simple;
+use Email::MIME;
 use PublicInbox::Feed;
+use PublicInbox::Git;
+use PublicInbox::Import;
+use PublicInbox::Inbox;
 use File::Temp qw/tempdir/;
 my $tmpdir = tempdir('pi-http-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 my $git_dir = "$tmpdir/gittest";
+my $ibx = PublicInbox::Inbox->new({
+        address => 'test@example',
+        name => 'tester',
+        mainrepo => $git_dir,
+        url => 'http://example.com/test',
+});
+my $git = $ibx->git;
+my $im = PublicInbox::Import->new($git, 'tester', 'test@example');
 
 # setup
 {
@@ -15,19 +26,13 @@ my $git_dir = "$tmpdir/gittest";
         my $prev = "";
 
         foreach my $i (1..6) {
-                local $ENV{GIT_DIR} = $git_dir;
-                my $pid = open(my $pipe, "|-");
-                defined $pid or die "fork/pipe failed: $!\n";
-                if ($pid == 0) {
-                        exec("ssoma-mda", $git_dir);
-                }
                 my $mid = "<$i\@example.com>";
                 my $mid_line = "Message-ID: $mid";
                 if ($prev) {
                         $mid_line .= "In-Reply-To: $prev";
                 }
                 $prev = $mid;
-                my $simple = Email::Simple->new(<<EOF);
+                my $mime = Email::MIME->new(<<EOF);
 From: ME <me\@example.com>
 To: U <u\@example.com>
 $mid_line
@@ -43,20 +48,9 @@ msg $i
 
 keep me
 EOF
-                print $pipe $simple->as_string or die "print failed: $!\n";
-                close $pipe or die "close pipe failed: $!\n";
+                like($im->add($mime), qr/\A:\d+\z/, 'inserted message');
         }
-}
-
-# check HTML index
-{
-        use IO::File;
-        my $cb = PublicInbox::Feed::generate_html_index({
-                git_dir => $git_dir,
-                max => 3
-        });
-        require 't/common.perl';
-        like(stream_to_string($cb), qr/html/, "feed is valid HTML :)");
+        $im->done;
 }
 
 done_testing();
diff --git a/t/httpd-corner.psgi b/t/httpd-corner.psgi
index da8a2ee8..ed1f92c0 100644
--- a/t/httpd-corner.psgi
+++ b/t/httpd-corner.psgi
@@ -30,6 +30,7 @@ my $app = sub {
                         return sub {
                                 open my $f, '<', $fifo or
                                                 die "open $fifo: $!\n";
+                                local $/ = "\n";
                                 my @r = <$f>;
                                 $_[0]->([200, $h, \@r ]);
                         };
@@ -38,6 +39,7 @@ my $app = sub {
                                 my $fh = $_[0]->([200, $h]);
                                 open my $f, '<', $fifo or
                                                 die "open $fifo: $!\n";
+                                local $/ = "\n";
                                 while (defined(my $l = <$f>)) {
                                         $fh->write($l);
                                 }
@@ -56,6 +58,20 @@ my $app = sub {
                         $fh->write($buf);
                         $fh->close;
                 }
+        } elsif ($path eq '/empty') {
+                $code = 200;
+        } elsif ($path eq '/getline-die') {
+                $code = 200;
+                $body = Plack::Util::inline_object(
+                        getline => sub { die 'GETLINE FAIL' },
+                        close => sub { die 'CLOSE FAIL' },
+                );
+        } elsif ($path eq '/close-die') {
+                $code = 200;
+                $body = Plack::Util::inline_object(
+                        getline => sub { undef },
+                        close => sub { die 'CLOSE FAIL' },
+                );
         }
 
         [ $code, $h, $body ]
diff --git a/t/httpd-corner.t b/t/httpd-corner.t
index b64f334a..8a0337c2 100644
--- a/t/httpd-corner.t
+++ b/t/httpd-corner.t
@@ -5,8 +5,9 @@
 use strict;
 use warnings;
 use Test::More;
+use Time::HiRes qw(gettimeofday tv_interval);
 
-foreach my $mod (qw(Plack::Util Plack::Request Plack::Builder Danga::Socket
+foreach my $mod (qw(Plack::Util Plack::Builder Danga::Socket
                         HTTP::Date HTTP::Status)) {
         eval "require $mod";
         plan skip_all => "$mod missing for httpd-corner.t" if $@;
@@ -85,6 +86,30 @@ my $spawn_httpd = sub {
 }
 
 {
+        my $conn = conn_for($sock, 'getline-die');
+        $conn->write("GET /getline-die HTTP/1.1\r\nHost: example.com\r\n\r\n");
+        ok($conn->read(my $buf, 8192), 'read some response');
+        like($buf, qr!HTTP/1\.1 200\b[^\r]*\r\n!, 'got some sort of header');
+        is($conn->read(my $nil, 8192), 0, 'read EOF');
+        $conn = undef;
+        my $after = capture($err);
+        is(scalar(grep(/GETLINE FAIL/, @$after)), 1, 'failure logged');
+        is(scalar(grep(/CLOSE FAIL/, @$after)), 1, 'body->close not called');
+}
+
+{
+        my $conn = conn_for($sock, 'close-die');
+        $conn->write("GET /close-die HTTP/1.1\r\nHost: example.com\r\n\r\n");
+        ok($conn->read(my $buf, 8192), 'read some response');
+        like($buf, qr!HTTP/1\.1 200\b[^\r]*\r\n!, 'got some sort of header');
+        is($conn->read(my $nil, 8192), 0, 'read EOF');
+        $conn = undef;
+        my $after = capture($err);
+        is(scalar(grep(/GETLINE FAIL/, @$after)), 0, 'getline not failed');
+        is(scalar(grep(/CLOSE FAIL/, @$after)), 1, 'body->close not called');
+}
+
+{
         my $conn = conn_for($sock, 'excessive header');
         $SIG{PIPE} = 'IGNORE';
         $conn->write("GET /callback HTTP/1.0\r\n");
@@ -218,7 +243,6 @@ my $check_self = sub {
 
 SKIP: {
         use POSIX qw(dup2);
-        use IO::File;
         my $have_curl = 0;
         foreach my $p (split(':', $ENV{PATH})) {
                 -x "$p/curl" or next;
@@ -230,7 +254,7 @@ SKIP: {
         my $url = 'http://' . $sock->sockhost . ':' . $sock->sockport . '/sha1';
         my ($r, $w);
         pipe($r, $w) or die "pipe: $!";
-        my $tout = IO::File->new_tmpfile or die "new_tmpfile: $!";
+        open(my $tout, '+>', undef) or die "open temporary file: $!";
         my $pid = fork;
         defined $pid or die "fork: $!";
         my @cmd = (qw(curl --tcp-nodelay --no-buffer -T- -HExpect: -sS), $url);
@@ -275,6 +299,18 @@ SKIP: {
 }
 
 {
+        my $conn = conn_for($sock, 'no TCP_CORK on empty body');
+        $conn->write("GET /empty HTTP/1.1\r\nHost:example.com\r\n\r\n");
+        my $buf = '';
+        my $t0 = [ gettimeofday ];
+        until ($buf =~ /\r\n\r\n/s) {
+                $conn->sysread($buf, 4096, length($buf));
+        }
+        my $elapsed = tv_interval($t0, [ gettimeofday ]);
+        ok($elapsed < 0.190, 'no 200ms TCP cork delay on empty body');
+}
+
+{
         my $conn = conn_for($sock, 'graceful termination during slow request');
         $conn->write("PUT /sha1 HTTP/1.0\r\n");
         delay();
@@ -476,4 +512,13 @@ SKIP: {
 
 done_testing();
 
+sub capture {
+        my ($f) = @_;
+        open my $fh, '+<', $f or die "failed to open $f: $!\n";
+        local $/ = "\n";
+        my @r = <$fh>;
+        truncate($fh, 0) or die "truncate failed on $f: $!\n";
+        \@r
+}
+
 1;
diff --git a/t/httpd-unix.t b/t/httpd-unix.t
index 00adf13c..4b0f116e 100644
--- a/t/httpd-unix.t
+++ b/t/httpd-unix.t
@@ -5,7 +5,7 @@ use strict;
 use warnings;
 use Test::More;
 
-foreach my $mod (qw(Plack::Util Plack::Request Plack::Builder Danga::Socket
+foreach my $mod (qw(Plack::Util Plack::Builder Danga::Socket
                         HTTP::Date HTTP::Status)) {
         eval "require $mod";
         plan skip_all => "$mod missing for httpd-unix.t" if $@;
@@ -54,6 +54,7 @@ ok(-S $unix, 'UNIX socket was bound by -httpd');
 sub check_sock ($) {
         my ($unix) = @_;
         my $sock = IO::Socket::UNIX->new(Peer => $unix, Type => SOCK_STREAM);
+        warn "E: $! connecting to $unix\n" unless defined $sock;
         ok($sock, 'client UNIX socket connected');
         ok($sock->write("GET /host-port HTTP/1.0\r\n\r\n"),
                 'wrote req to server');
@@ -103,6 +104,7 @@ SKIP: {
 
         ok(-f "$tmpdir/pid", 'pid file written');
         open my $fh, '<', "$tmpdir/pid" or die "open failed: $!";
+        local $/ = "\n";
         my $rpid = <$fh>;
         chomp $rpid;
         like($rpid, qr/\A\d+\z/s, 'pid file looks like a pid');
diff --git a/t/httpd.t b/t/httpd.t
index 28f507db..c2e73608 100644
--- a/t/httpd.t
+++ b/t/httpd.t
@@ -4,7 +4,7 @@ use strict;
 use warnings;
 use Test::More;
 
-foreach my $mod (qw(Plack::Util Plack::Request Plack::Builder Danga::Socket
+foreach my $mod (qw(Plack::Util Plack::Builder Danga::Socket
                         HTTP::Date HTTP::Status)) {
         eval "require $mod";
         plan skip_all => "$mod missing for httpd.t" if $@;
@@ -14,24 +14,16 @@ use Cwd qw/getcwd/;
 use IO::Socket;
 use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD);
 use Socket qw(SO_KEEPALIVE IPPROTO_TCP TCP_NODELAY);
-use IPC::Run;
 
 # FIXME: too much setup
 my $tmpdir = tempdir('pi-httpd-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 my $home = "$tmpdir/pi-home";
 my $err = "$tmpdir/stderr.log";
 my $out = "$tmpdir/stdout.log";
-my $pi_home = "$home/.public-inbox";
-my $pi_config = "$pi_home/config";
 my $maindir = "$tmpdir/main.git";
-my $main_bin = getcwd()."/t/main-bin";
-my $main_path = "$main_bin:$ENV{PATH}"; # for spamc ham mock
 my $group = 'test-httpd';
 my $addr = $group . '@example.com';
 my $cfgpfx = "publicinbox.$group";
-my $failbox = "$home/fail.mbox";
-local $ENV{PI_EMERGENCY} = $failbox;
-my $mda = 'blib/script/public-inbox-mda';
 my $httpd = 'blib/script/public-inbox-httpd';
 my $init = 'blib/script/public-inbox-init';
 
@@ -44,6 +36,9 @@ my %opts = (
 );
 my $sock = IO::Socket::INET->new(%opts);
 my $pid;
+use_ok 'PublicInbox::Git';
+use_ok 'PublicInbox::Import';
+use_ok 'Email::MIME';
 END { kill 'TERM', $pid if defined $pid };
 {
         local $ENV{HOME} = $home;
@@ -52,8 +47,7 @@ END { kill 'TERM', $pid if defined $pid };
 
         # ensure successful message delivery
         {
-                local $ENV{ORIGINAL_RECIPIENT} = $addr;
-                my $in = <<EOF;
+                my $mime = Email::MIME->new(<<EOF);
 From: Me <me\@example.com>
 To: You <you\@example.com>
 Cc: $addr
@@ -63,9 +57,11 @@ Date: Thu, 01 Jan 1970 06:06:06 +0000
 
 nntp
 EOF
-                local $ENV{PATH} = $main_path;
-                IPC::Run::run([$mda], \$in);
-                is(0, $?, 'ran MDA correctly');
+                $mime->header_set('List-Id', "<$addr>");
+                my $git = PublicInbox::Git->new($maindir);
+                my $im = PublicInbox::Import->new($git, 'test', $addr);
+                $im->add($mime);
+                $im->done($mime);
         }
         ok($sock, 'sock created');
         $! = 0;
@@ -104,7 +100,16 @@ EOF
 
         is(system(qw(git clone -q --mirror),
                         "http://$host:$port/$group", "$tmpdir/clone.git"),
+                0, 'smart clone successful');
+
+        # ensure dumb cloning works, too:
+        is(system('git', "--git-dir=$maindir",
+                qw(config http.uploadpack false)),
+                0, 'disable http.uploadpack');
+        is(system(qw(git clone -q --mirror),
+                        "http://$host:$port/$group", "$tmpdir/dumb.git"),
                 0, 'clone successful');
+
         ok(kill('TERM', $pid), 'killed httpd');
         $pid = undef;
         waitpid(-1, 0);
diff --git a/t/import.t b/t/import.t
new file mode 100644
index 00000000..73f92adb
--- /dev/null
+++ b/t/import.t
@@ -0,0 +1,69 @@
+# 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 Test::More;
+use Email::MIME;
+use PublicInbox::Git;
+use PublicInbox::Import;
+use File::Temp qw/tempdir/;
+my $dir = tempdir('pi-import-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+
+is(system(qw(git init -q --bare), $dir), 0, 'git init successful');
+my $git = PublicInbox::Git->new($dir);
+
+my $im = PublicInbox::Import->new($git, 'testbox', 'test@example');
+my $mime = Email::MIME->create(
+        header => [
+                From => 'a@example.com',
+                To => 'b@example.com',
+                'Content-Type' => 'text/plain',
+                Subject => 'this is a subject',
+                'Message-ID' => '<a@example.com>',
+        ],
+        body => "hello world\n",
+);
+like($im->add($mime), qr/\A:\d+\z/, 'added one message');
+$im->done;
+my @revs = $git->qx(qw(rev-list HEAD));
+is(scalar @revs, 1, 'one revision created');
+
+$mime->header_set('Message-ID', '<b@example.com>');
+$mime->header_set('Subject', 'msg2');
+like($im->add($mime, sub { $mime }), qr/\A:\d+\z/, 'added 2nd message');
+$im->done;
+@revs = $git->qx(qw(rev-list HEAD));
+is(scalar @revs, 2, '2 revisions exist');
+
+is($im->add($mime), undef, 'message only inserted once');
+$im->done;
+@revs = $git->qx(qw(rev-list HEAD));
+is(scalar @revs, 2, '2 revisions exist');
+
+foreach my $c ('c'..'z') {
+        $mime->header_set('Message-ID', "<$c\@example.com>");
+        $mime->header_set('Subject', "msg - $c");
+        like($im->add($mime), qr/\A:\d+\z/, "added $c message");
+}
+$im->done;
+@revs = $git->qx(qw(rev-list HEAD));
+is(scalar @revs, 26, '26 revisions exist after mass import');
+my ($mark, $msg) = $im->remove($mime);
+like($mark, qr/\A:\d+\z/, 'got mark');
+is(ref($msg), 'Email::MIME', 'got old message deleted');
+
+is(undef, $im->remove($mime), 'remove is idempotent');
+
+# mismatch on identical Message-ID
+$mime->header_set('Message-ID', '<a@example.com>');
+($mark, $msg) = $im->remove($mime);
+is($mark, 'MISMATCH', 'mark == MISMATCH on mismatch');
+is($msg->header('Message-ID'), '<a@example.com>', 'Message-ID matches');
+isnt($msg->header('Subject'), $mime->header('Subject'), 'subject mismatch');
+
+$mime->header_set('Message-Id', '<failcheck@example.com>');
+is($im->add($mime, sub { undef }), undef, 'check callback fails');
+is($im->remove($mime), undef, 'message not added, so not removed');
+
+$im->done;
+done_testing();
diff --git a/t/inbox.t b/t/inbox.t
new file mode 100644
index 00000000..9909dc53
--- /dev/null
+++ b/t/inbox.t
@@ -0,0 +1,15 @@
+# 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 Test::More;
+use_ok 'PublicInbox::Inbox';
+my $x = PublicInbox::Inbox->new({url => '//example.com/test/'});
+is($x->base_url, 'https://example.com/test/', 'expanded protocol-relative');
+$x = PublicInbox::Inbox->new({url => 'http://example.com/test'});
+is($x->base_url, 'http://example.com/test/', 'added trailing slash');
+
+$x = PublicInbox::Inbox->new({});
+is($x->base_url, undef, 'undef base_url allowed');
+
+done_testing();
diff --git a/t/init.t b/t/init.t
index b78fbaf5..f87dc225 100644
--- a/t/init.t
+++ b/t/init.t
@@ -13,10 +13,16 @@ use constant pi_init => 'blib/script/public-inbox-init';
         my $cfgfile = "$ENV{PI_DIR}/config";
         my @cmd = (pi_init, 'blist', "$tmpdir/blist",
                    qw(http://example.com/blist blist@example.com));
-        is(system(@cmd), 0, 'public-inbox-init failed');
+        is(system(@cmd), 0, 'public-inbox-init OK');
 
         ok(-e $cfgfile, "config exists, now");
-        is(system(@cmd), 0, 'public-inbox-init failed (idempotent)');
+        is(system(@cmd), 0, 'public-inbox-init OK (idempotent)');
+
+        chmod 0666, $cfgfile or die "chmod failed: $!";
+        @cmd = (pi_init, 'clist', "$tmpdir/clist",
+                   qw(http://example.com/clist clist@example.com));
+        is(system(@cmd), 0, 'public-inbox-init clist OK');
+        is((stat($cfgfile))[2] & 07777, 0666, "permissions preserved");
 }
 
 done_testing();
diff --git a/t/linkify.t b/t/linkify.t
index 586691ae..99acf17d 100644
--- a/t/linkify.t
+++ b/t/linkify.t
@@ -23,4 +23,60 @@ use PublicInbox::Linkify;
         is($s, qq(<a\nhref="$u">$u</a>;), 'trailing semicolon not in URL');
 }
 
+{
+        my $l = PublicInbox::Linkify->new;
+        my $u = 'http://example.com/url-with-(parens)';
+        my $s = "hello $u world";
+        $s = $l->linkify_1($s);
+        $s = $l->linkify_2($s);
+        is($s, qq(hello <a\nhref="$u">$u</a> world), 'URL preserved');
+
+        $u .= "?query=a";
+        $s = "hello $u world";
+        $s = $l->linkify_1($s);
+        $s = $l->linkify_2($s);
+        is($s, qq(hello <a\nhref="$u">$u</a> world), 'query preserved');
+
+        $u .= "#fragment";
+        $s = "hello $u world";
+        $s = $l->linkify_1($s);
+        $s = $l->linkify_2($s);
+        is($s, qq(hello <a\nhref="$u">$u</a> world),
+          'query + fragment preserved');
+
+        $u = "http://example.com/";
+        $s = "hello $u world";
+        $s = $l->linkify_1($s);
+        $s = $l->linkify_2($s);
+        is($s, qq(hello <a\nhref="$u">$u</a> world), "root URL preserved");
+
+        $u = "http://example.com/#fragment";
+        $s = "hello $u world";
+        $s = $l->linkify_1($s);
+        $s = $l->linkify_2($s);
+        is($s, qq(hello <a\nhref="$u">$u</a> world), "root + fragment");
+}
+
+# Markdown compatibility
+{
+        my $l = PublicInbox::Linkify->new;
+        my $u = 'http://example.com/';
+        my $s = "[markdown]($u)";
+        $s = $l->linkify_1($s);
+        $s = $l->linkify_2($s);
+        is($s, qq![markdown](<a\nhref="$u">$u</a>)!, 'Markdown-compatible');
+
+        $s = qq![markdown]($u "title")!;
+        $s = $l->linkify_1($s);
+        $s = $l->linkify_2($s);
+        is($s, qq![markdown](<a\nhref="$u">$u</a> "title")!,
+                'Markdown title compatible');
+
+        $s = qq![markdown]($u).!;
+        $s = $l->linkify_1($s);
+        $s = $l->linkify_2($s);
+        is($s, qq![markdown](<a\nhref="$u">$u</a>).!,
+                'Markdown-compatible end of sentence');
+}
+
 done_testing();
diff --git a/t/mda.t b/t/mda.t
index 03bad871..886b4cb9 100644
--- a/t/mda.t
+++ b/t/mda.t
@@ -4,10 +4,11 @@ use strict;
 use warnings;
 use Test::More;
 use Email::MIME;
-use Email::Filter;
 use File::Temp qw/tempdir/;
 use Cwd;
-use IPC::Run qw(run);
+use PublicInbox::MID qw(mid2path);
+eval { require IPC::Run };
+plan skip_all => "missing IPC::Run for t/mda.t" if $@;
 
 my $mda = "blib/script/public-inbox-mda";
 my $learn = "blib/script/public-inbox-learn";
@@ -22,7 +23,7 @@ my $fail_bin = getcwd()."/t/fail-bin";
 my $fail_path = "$fail_bin:$ENV{PATH}"; # for spamc spam mock
 my $addr = 'test-public@example.com';
 my $cfgpfx = "publicinbox.test";
-my $failbox = "$home/fail.mbox";
+my $faildir = "$home/faildir/";
 my $mime;
 
 {
@@ -47,14 +48,19 @@ my $mime;
 
 local $ENV{GIT_COMMITTER_NAME} = eval {
         use PublicInbox::MDA;
+        use PublicInbox::Address;
         use Encode qw/encode/;
         my $mbox = 't/utf8.mbox';
         open(my $fh, '<', $mbox) or die "failed to open mbox: $mbox\n";
         my $str = eval { local $/; <$fh> };
         close $fh;
-        my $msg = Email::Filter->new(data => $str);
-        $msg = Email::MIME->new($msg->simple->as_string);
-        my ($author, $email, $date) = PublicInbox::MDA->author_info($msg);
+        my $msg = Email::MIME->new($str);
+
+        my $from = $msg->header('From');
+        my ($author) = PublicInbox::Address::names($from);
+        my ($email) = PublicInbox::Address::emails($from);
+        my $date = $msg->header('Date');
+
         is('El&#233;anor',
                 encode('us-ascii', my $tmp = $author, Encode::HTMLCREF),
                 'HTML conversion is correct');
@@ -67,7 +73,7 @@ die $@ if $@;
 
 {
         my $good_rev;
-        local $ENV{PI_EMERGENCY} = $failbox;
+        local $ENV{PI_EMERGENCY} = $faildir;
         local $ENV{HOME} = $home;
         local $ENV{ORIGINAL_RECIPIENT} = $addr;
         my $simple = Email::Simple->new(<<EOF);
@@ -84,12 +90,11 @@ EOF
         # ensure successful message delivery
         {
                 local $ENV{PATH} = $main_path;
-                run([$mda], \$in);
-                local $ENV{GIT_DIR} = $maindir;
-                my $rev = `git rev-list HEAD`;
+                IPC::Run::run([$mda], \$in);
+                my $rev = `git --git-dir=$maindir rev-list HEAD`;
                 like($rev, qr/\A[a-f0-9]{40}/, "good revision committed");
                 chomp $rev;
-                my $cmt = `git cat-file commit $rev`;
+                my $cmt = `git --git-dir=$maindir cat-file commit $rev`;
                 like($cmt, qr/^author Me <me\@example\.com> 0 \+0000\n/m,
                         "author info set correctly");
                 like($cmt, qr/^committer test <test-public\@example\.com>/m,
@@ -99,13 +104,14 @@ EOF
 
         # ensure failures work, fail with bad spamc
         {
-                ok(!-e $failbox, "nothing in PI_EMERGENCY before");
+                my @prev = <$faildir/new/*>;
+                is(scalar @prev, 0 , "nothing in PI_EMERGENCY before");
                 local $ENV{PATH} = $fail_path;
-                run([$mda], \$in);
-                local $ENV{GIT_DIR} = $maindir;
-                my @revs = `git rev-list HEAD`;
+                IPC::Run::run([$mda], \$in);
+                my @revs = `git --git-dir=$maindir rev-list HEAD`;
                 is(scalar @revs, 1, "bad revision not committed");
-                ok(-s $failbox > 0, "PI_EMERGENCY is written to");
+                my @new = <$faildir/new/*>;
+                is(scalar @new, 1, "PI_EMERGENCY is written to");
         }
 
         fail_bad_header($good_rev, "bad recipient", <<"");
@@ -155,7 +161,7 @@ Date: deadbeef
 
 # spam training
 {
-        local $ENV{PI_EMERGENCY} = $failbox;
+        local $ENV{PI_EMERGENCY} = $faildir;
         local $ENV{HOME} = $home;
         local $ENV{ORIGINAL_RECIPIENT} = $addr;
         local $ENV{PATH} = $main_path;
@@ -173,23 +179,25 @@ EOF
 
         {
                 # deliver the spam message, first
-                run([$mda], \$in);
-                my $msg = `ssoma cat $mid $maindir`;
+                IPC::Run::run([$mda], \$in);
+                my $path = mid2path($mid);
+                my $msg = `git --git-dir=$maindir cat-file blob HEAD:$path`;
                 like($msg, qr/\Q$mid\E/, "message delivered");
 
                 # now train it
                 local $ENV{GIT_AUTHOR_EMAIL} = 'trainer@example.com';
                 local $ENV{GIT_COMMITTER_EMAIL} = 'trainer@example.com';
-                run([$learn, "spam"], \$msg);
+                local $ENV{GIT_COMMITTER_NAME} = undef;
+                IPC::Run::run([$learn, "spam"], \$msg);
                 is($?, 0, "no failure from learning spam");
-                run([$learn, "spam"], \$msg);
+                IPC::Run::run([$learn, "spam"], \$msg);
                 is($?, 0, "no failure from learning spam idempotently");
         }
 }
 
 # train ham message
 {
-        local $ENV{PI_EMERGENCY} = $failbox;
+        local $ENV{PI_EMERGENCY} = $faildir;
         local $ENV{HOME} = $home;
         local $ENV{ORIGINAL_RECIPIENT} = $addr;
         local $ENV{PATH} = $main_path;
@@ -210,11 +218,12 @@ EOF
         local $ENV{GIT_AUTHOR_EMAIL} = 'trainer@example.com';
         local $ENV{GIT_COMMITTER_EMAIL} = 'trainer@example.com';
 
-        run([$learn, "ham"], \$in);
+        IPC::Run::run([$learn, "ham"], \$in);
         is($?, 0, "learned ham without failure");
-        my $msg = `ssoma cat $mid $maindir`;
+        my $path = mid2path($mid);
+        my $msg = `git --git-dir=$maindir cat-file blob HEAD:$path`;
         like($msg, qr/\Q$mid\E/, "ham message delivered");
-        run([$learn, "ham"], \$in);
+        IPC::Run::run([$learn, "ham"], \$in);
         is($?, 0, "learned ham idempotently ");
 
         # ensure trained email is filtered, too
@@ -249,81 +258,28 @@ EOF
 
         {
                 $in = $mime->as_string;
-                run([$learn, "ham"], \$in);
+                IPC::Run::run([$learn, "ham"], \$in);
                 is($?, 0, "learned ham without failure");
-                $msg = `ssoma cat $mid $maindir`;
+                my $path = mid2path($mid);
+                $msg = `git --git-dir=$maindir cat-file blob HEAD:$path`;
                 like($msg, qr/<\Q$mid\E>/, "ham message delivered");
                 unlike($msg, qr/<html>/i, '<html> filtered');
         }
 }
 
-# faildir - emergency destination is maildir
-{
-        my $faildir= "$home/faildir/";
-        local $ENV{PI_EMERGENCY} = $faildir;
-        local $ENV{HOME} = $home;
-        local $ENV{ORIGINAL_RECIPIENT} = $addr;
-        local $ENV{PATH} = $fail_path;
-        my $in = <<EOF;
-From: Faildir <faildir\@example.com>
-To: You <you\@example.com>
-Cc: $addr
-Message-ID: <faildir\@example.com>
-Subject: faildir subject
-Date: Thu, 01 Jan 1970 00:00:00 +0000
-
-EOF
-        run([$mda], \$in);
-        ok(-d $faildir, "emergency exists");
-        my @new = glob("$faildir/new/*");
-        is(scalar(@new), 1, "message delivered");
-        is(unlink(@new), 1, "removed emergency message");
-
-        local $ENV{PATH} = $main_path;
-        $in = <<EOF;
-From: Faildir <faildir\@example.com>
-To: $addr
-Content-Type: text/html
-Message-ID: <faildir\@example.com>
-Subject: faildir subject
-Date: Thu, 01 Jan 1970 00:00:00 +0000
-
-<html><body>bad</body></html>
-EOF
-        my $out = '';
-        my $err = '';
-        run([$mda], \$in, \$out, \$err);
-        isnt($?, 0, "mda exited with failure");
-        is(length $out, 0, 'nothing in stdout');
-        isnt(length $err, 0, 'error message in stderr');
-
-        @new = glob("$faildir/new/*");
-        is(scalar(@new), 0, "new message did not show up");
-
-        # reject multipart again
-        $in = $mime->as_string;
-        $err = '';
-        run([$mda], \$in, \$out, \$err);
-        isnt($?, 0, "mda exited with failure");
-        is(length $out, 0, 'nothing in stdout');
-        isnt(length $err, 0, 'error message in stderr');
-        @new = glob("$faildir/new/*");
-        is(scalar(@new), 0, "new message did not show up");
-}
-
 done_testing();
 
 sub fail_bad_header {
         my ($good_rev, $msg, $in) = @_;
-        open my $fh, '>', $failbox or die "failed to open $failbox: $!\n";
-        close $fh or die "failed to close $failbox: $!\n";
+        my @f = glob("$faildir/*/*");
+        unlink @f if @f;
         my ($out, $err) = ("", "");
         local $ENV{PATH} = $main_path;
-        run([$mda], \$in, \$out, \$err);
-        local $ENV{GIT_DIR} = $maindir;
-        my $rev = `git rev-list HEAD`;
+        IPC::Run::run([$mda], \$in, \$out, \$err);
+        my $rev = `git --git-dir=$maindir rev-list HEAD`;
         chomp $rev;
         is($rev, $good_rev, "bad revision not commited ($msg)");
-        ok(-s $failbox > 0, "PI_EMERGENCY is written to ($msg)");
+        @f = glob("$faildir/*/*");
+        is(scalar @f, 1, "faildir written to");
         [ $in, $out, $err ];
 }
diff --git a/t/mid.t b/t/mid.t
new file mode 100644
index 00000000..b0af8386
--- /dev/null
+++ b/t/mid.t
@@ -0,0 +1,11 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use Test::More;
+use PublicInbox::MID qw(mid_escape);
+
+is(mid_escape('foo!@(bar)'), 'foo!@(bar)');
+is(mid_escape('foo%!@(bar)'), 'foo%25!@(bar)');
+is(mid_escape('foo%!@(bar)'), 'foo%25!@(bar)');
+
+done_testing();
+1;
diff --git a/t/msg_iter.t b/t/msg_iter.t
new file mode 100644
index 00000000..7ade6e41
--- /dev/null
+++ b/t/msg_iter.t
@@ -0,0 +1,44 @@
+# 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 Test::More;
+use Email::MIME;
+use_ok('PublicInbox::MsgIter');
+
+{
+        my $parts = [ Email::MIME->create(body => "a\n"),
+                        Email::MIME->create(body => "b\n") ];
+        my $mime = Email::MIME->create(parts => $parts,
+                                header_str => [ From => 'root@localhost' ]);
+        my @parts;
+        msg_iter($mime, sub {
+                my ($part, $level, @ex) = @{$_[0]};
+                my $s = $part->body_str;
+                $s =~ s/\s+//s;
+                push @parts, [ $s, $level, @ex ];
+        });
+        is_deeply(\@parts, [ [ qw(a 1 1) ], [ qw(b 1 2) ] ], 'order is fine');
+}
+
+{
+        my $parts = [ Email::MIME->create(body => 'a'),
+                        Email::MIME->create(body => 'b') ];
+        $parts = [ Email::MIME->create(parts => $parts,
+                                header_str => [ From => 'sub@localhost' ]),
+                        Email::MIME->create(body => 'sig') ];
+        my $mime = Email::MIME->create(parts => $parts,
+                                header_str => [ From => 'root@localhost' ]);
+        my @parts;
+        msg_iter($mime, sub {
+                my ($part, $level, @ex) = @{$_[0]};
+                my $s = $part->body_str;
+                $s =~ s/\s+//s;
+                push @parts, [ $s, $level, @ex ];
+        });
+        is_deeply(\@parts, [ [qw(a 2 1 1)], [qw(b 2 1 2)], [qw(sig 1 2)] ],
+                'nested part shows up properly');
+}
+
+done_testing();
+1;
diff --git a/t/nntp.t b/t/nntp.t
index 388620e5..7500d6b9 100644
--- a/t/nntp.t
+++ b/t/nntp.t
@@ -11,6 +11,7 @@ foreach my $mod (qw(DBD::SQLite Search::Xapian Danga::Socket)) {
 }
 
 use_ok 'PublicInbox::NNTP';
+use_ok 'PublicInbox::Inbox';
 
 {
         sub quote_str {
@@ -95,4 +96,42 @@ use_ok 'PublicInbox::NNTP';
         }
 }
 
+{ # test setting NNTP headers in HEAD and ARTICLE requests
+        require Email::MIME;
+        my $u = 'https://example.com/a/';
+        my $ng = PublicInbox::Inbox->new({ name => 'test',
+                                        mainrepo => 'test.git',
+                                        address => 'a@example.com',
+                                        -primary_address => 'a@example.com',
+                                        newsgroup => 'test',
+                                        domain => 'example.com',
+                                        url => '//example.com/a'});
+        is($ng->base_url, $u, 'URL expanded');
+        my $mid = 'a@b';
+        my $mime = Email::MIME->new("Message-ID: <$mid>\r\n\r\n");
+        PublicInbox::NNTP::set_nntp_headers($mime->header_obj, $ng, 1, $mid);
+        is_deeply([ $mime->header('Message-ID') ], [ "<$mid>" ],
+                'Message-ID unchanged');
+        is_deeply([ $mime->header('Archived-At') ], [ "<${u}a\@b/>" ],
+                'Archived-At: set');
+        is_deeply([ $mime->header('List-Archive') ], [ "<$u>" ],
+                'List-Archive: set');
+        is_deeply([ $mime->header('List-Post') ], [ '<mailto:a@example.com>' ],
+                'List-Post: set');
+        is_deeply([ $mime->header('Newsgroups') ], [ 'test' ],
+                'Newsgroups: set');
+        is_deeply([ $mime->header('Xref') ], [ 'example.com test:1' ],
+                'Xref: set');
+
+        $ng->{-base_url} = 'http://mirror.example.com/m/';
+        PublicInbox::NNTP::set_nntp_headers($mime->header_obj, $ng, 2, $mid);
+        is_deeply([ $mime->header('Message-ID') ], [ "<$mid>" ],
+                'Message-ID unchanged');
+        is_deeply([ $mime->header('Archived-At') ],
+                [ "<${u}a\@b/>", '<http://mirror.example.com/m/a@b/>' ],
+                'Archived-At: appended');
+        is_deeply([ $mime->header('Xref') ], [ 'example.com test:2' ],
+                'Old Xref: clobbered');
+}
+
 done_testing();
diff --git a/t/nntpd.t b/t/nntpd.t
index d597855b..ad50a64f 100644
--- a/t/nntpd.t
+++ b/t/nntpd.t
@@ -16,26 +16,18 @@ use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD);
 use Socket qw(SO_KEEPALIVE IPPROTO_TCP TCP_NODELAY);
 use File::Temp qw/tempdir/;
 use Net::NNTP;
-use IPC::Run qw(run);
 
 my $tmpdir = tempdir('pi-nntpd-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 my $home = "$tmpdir/pi-home";
 my $err = "$tmpdir/stderr.log";
 my $out = "$tmpdir/stdout.log";
-my $pi_home = "$home/.public-inbox";
-my $pi_config = "$pi_home/config";
 my $maindir = "$tmpdir/main.git";
-my $main_bin = getcwd()."/t/main-bin";
-my $main_path = "$main_bin:$ENV{PATH}"; # for spamc ham mock
 my $group = 'test-nntpd';
 my $addr = $group . '@example.com';
-my $cfgpfx = "publicinbox.$group";
-my $failbox = "$home/fail.mbox";
-local $ENV{PI_EMERGENCY} = $failbox;
-my $mda = 'blib/script/public-inbox-mda';
 my $nntpd = 'blib/script/public-inbox-nntpd';
 my $init = 'blib/script/public-inbox-init';
-my $index = 'blib/script/public-inbox-index';
+use_ok 'PublicInbox::Import';
+use_ok 'PublicInbox::Git';
 
 my %opts = (
         LocalAddr => '127.0.0.1',
@@ -46,29 +38,38 @@ my %opts = (
 );
 my $sock = IO::Socket::INET->new(%opts);
 my $pid;
+my $len;
 END { kill 'TERM', $pid if defined $pid };
 {
         local $ENV{HOME} = $home;
         system($init, $group, $maindir, 'http://example.com/', $addr);
+        is(system(qw(git config), "--file=$home/.public-inbox/config",
+                        "publicinbox.$group.newsgroup", $group),
+                0, 'enabled newsgroup');
+        my $len;
 
         # ensure successful message delivery
         {
-                local $ENV{ORIGINAL_RECIPIENT} = $addr;
-                my $simple = Email::Simple->new(<<EOF);
-From: Me <me\@example.com>
-To: You <you\@example.com>
+                my $mime = Email::MIME->new(<<EOF);
+To: =?utf-8?Q?El=C3=A9anor?= <you\@example.com>
+From: =?utf-8?Q?El=C3=A9anor?= <me\@example.com>
 Cc: $addr
 Message-Id: <nntp\@example.com>
-Subject: hihi
+Content-Type: text/plain; charset=utf-8
+Subject: Testing for =?utf-8?Q?El=C3=A9anor?=
 Date: Thu, 01 Jan 1970 06:06:06 +0000
+Content-Transfer-Encoding: 8bit
 
-nntp
+This is a test message for El\xc3\xa9anor
 EOF
-                my $in = $simple->as_string;
-                local $ENV{PATH} = $main_path;
-                IPC::Run::run([$mda], \$in);
-                is(0, $?, 'ran MDA correctly');
-                is(0, system($index, $maindir), 'indexed git dir');
+                $mime->header_set('List-Id', "<$addr>");
+                $len = length($mime->as_string);
+                my $git = PublicInbox::Git->new($maindir);
+                my $im = PublicInbox::Import->new($git, 'test', $addr);
+                $im->add($mime);
+                $im->done;
+                my $s = PublicInbox::SearchIdx->new($maindir, 1);
+                $s->index_sync;
         }
 
         ok($sock, 'sock created');
@@ -106,10 +107,10 @@ EOF
         my $mid = '<nntp@example.com>';
         my %xhdr = (
                 'message-id' => $mid,
-                'subject' => 'hihi',
+                subject => "Testing for El\xc3\xa9anor",
                 'date' => 'Thu, 01 Jan 1970 06:06:06 +0000',
-                'from' => 'Me <me@example.com>',
-                'to' => 'You <you@example.com>',
+                'from' => "El\xc3\xa9anor <me\@example.com>",
+                'to' => "El\xc3\xa9anor <you\@example.com>",
                 'cc' => $addr,
                 'xref' => "example.com $group:1"
         );
@@ -119,6 +120,18 @@ EOF
         is($buf, "201 server ready - post via email\r\n", 'got greeting');
         $s->autoflush(1);
 
+        syswrite($s, "NEWGROUPS\t19990424 000000 \033GMT\007\r\n");
+        is(0, sysread($s, $buf, 4096), 'GOT EOF on cntrl');
+
+        $s = IO::Socket::INET->new(%opts);
+        sysread($s, $buf, 4096);
+        is($buf, "201 server ready - post via email\r\n", 'got greeting');
+        $s->autoflush(1);
+
+        syswrite($s, "NEWGROUPS 19990424 000000 GMT\r\n");
+        $buf = read_til_dot($s);
+        like($buf, qr/\A231 list of /, 'newgroups OK');
+
         while (my ($k, $v) = each %xhdr) {
                 is_deeply($n->xhdr("$k $mid"), { $mid => $v },
                           "XHDR $k by message-id works");
@@ -126,14 +139,11 @@ EOF
                           "$k by article number works");
                 is_deeply($n->xhdr("$k 1-"), { 1 => $v },
                           "$k by article range works");
-                next;
                 $buf = '';
                 syswrite($s, "HDR $k $mid\r\n");
-                do {
-                        sysread($s, $buf, 4096, length($buf));
-                } until ($buf =~ /\r\n\.\r\n\z/);
+                $buf = read_til_dot($s);
                 my @r = split("\r\n", $buf);
-                like($r[0], qr/\A224 /, '224 response for HDR');
+                like($r[0], qr/\A225 /, '225 response for HDR');
                 is($r[1], "0 $v", 'got expected response for HDR');
         }
 
@@ -146,34 +156,42 @@ EOF
         }
 
         is_deeply($n->xover('1-'), {
-                '1' => ['hihi',
-                        'Me <me@example.com>',
+                '1' => ["Testing for El\xc3\xa9anor",
+                        "El\xc3\xa9anor <me\@example.com>",
                         'Thu, 01 Jan 1970 06:06:06 +0000',
                         '<nntp@example.com>',
                         '',
-                        '202',
+                        $len,
                         '1' ] }, "XOVER range works");
 
         is_deeply($n->xover('1'), {
-                '1' => ['hihi',
-                        'Me <me@example.com>',
+                '1' => ["Testing for El\xc3\xa9anor",
+                        "El\xc3\xa9anor <me\@example.com>",
                         'Thu, 01 Jan 1970 06:06:06 +0000',
                         '<nntp@example.com>',
                         '',
-                        '202',
+                        $len,
                         '1' ] }, "XOVER by article works");
 
+        is_deeply($n->head(1), $n->head('<nntp@example.com>'), 'HEAD OK');
+        is_deeply($n->body(1), $n->body('<nntp@example.com>'), 'BODY OK');
+        is($n->body(1)->[0], "This is a test message for El\xc3\xa9anor\n",
+                'body really matches');
+        my $art = $n->article(1);
+        is(ref($art), 'ARRAY', 'got array for ARTICLE');
+        is_deeply($art, $n->article('<nntp@example.com>'), 'ARTICLE OK');
+        is($n->article(999), undef, 'non-existent num');
+        is($n->article('<non-existent@example>'), undef, 'non-existent mid');
+
         {
                 syswrite($s, "OVER $mid\r\n");
-                $buf = '';
-                do {
-                        sysread($s, $buf, 4096, length($buf));
-                } until ($buf =~ /\r\n\.\r\n\z/);
+                $buf = read_til_dot($s);
                 my @r = split("\r\n", $buf);
                 like($r[0], qr/^224 /, 'got 224 response for OVER');
-                is($r[1], "0\thihi\tMe <me\@example.com>\t" .
+                is($r[1], "0\tTesting for El\xc3\xa9anor\t" .
+                        "El\xc3\xa9anor <me\@example.com>\t" .
                         "Thu, 01 Jan 1970 06:06:06 +0000\t" .
-                        "$mid\t\t202\t1", 'OVER by Message-ID works');
+                        "$mid\t\t$len\t1", 'OVER by Message-ID works');
                 is($r[2], '.', 'correctly terminated response');
         }
 
@@ -185,7 +203,20 @@ EOF
                  'XHDR on invalid header returns empty');
 
         {
-                syswrite($s, "HDR List-id 1-\r\n");
+                my $t0 = time;
+                my $date = $n->date;
+                my $t1 = time;
+                ok($date >= $t0, 'valid date after start');
+                ok($date <= $t1, 'valid date before stop');
+        }
+
+        {
+                setsockopt($s, IPPROTO_TCP, TCP_NODELAY, 1);
+                syswrite($s, 'HDR List-id 1-');
+                select(undef, undef, undef, 0.15);
+                ok(kill('TERM', $pid), 'killed nntpd');
+                select(undef, undef, undef, 0.15);
+                syswrite($s, "\r\n");
                 $buf = '';
                 do {
                         sysread($s, $buf, 4096, length($buf));
@@ -196,11 +227,26 @@ EOF
                 is(scalar @r, 1, 'only one response line');
         }
 
-        ok(kill('TERM', $pid), 'killed nntpd');
-        $pid = undef;
-        waitpid(-1, 0);
+        $n = $s = undef;
+        is($pid, waitpid($pid, 0), 'nntpd exited successfully');
+        my $eout = eval {
+                local $/;
+                open my $fh, '<', $err or die "open $err failed: $!";
+                <$fh>;
+        };
+        is($?, 0, 'no error in exited process');
+        unlike($eout, qr/wide/i, 'no Wide character warnings');
 }
 
 done_testing();
 
+sub read_til_dot {
+        my ($s) = @_;
+        my $buf = '';
+        do {
+                sysread($s, $buf, 4096, length($buf));
+        } until ($buf =~ /\r\n\.\r\n\z/);
+        $buf;
+}
+
 1;
diff --git a/t/plack.t b/t/plack.t
index 568f09f0..1d624589 100644
--- a/t/plack.t
+++ b/t/plack.t
@@ -5,36 +5,23 @@ use warnings;
 use Test::More;
 use Email::MIME;
 use File::Temp qw/tempdir/;
-use Cwd;
-use IPC::Run qw/run/;
 my $psgi = "examples/public-inbox.psgi";
-my $mda = "blib/script/public-inbox-mda";
 my $tmpdir = tempdir('pi-plack-XXXXXX', TMPDIR => 1, CLEANUP => 1);
-my $home = "$tmpdir/pi-home";
-my $pi_home = "$home/.public-inbox";
-my $pi_config = "$pi_home/config";
+my $pi_config = "$tmpdir/config";
 my $maindir = "$tmpdir/main.git";
-my $main_bin = getcwd()."/t/main-bin";
-my $main_path = "$main_bin:$ENV{PATH}"; # for spamc ham mock
 my $addr = 'test-public@example.com';
 my $cfgpfx = "publicinbox.test";
-my $failbox = "$home/fail.mbox";
-local $ENV{PI_EMERGENCY} = $failbox;
-my @mods = qw(HTTP::Request::Common Plack::Request Plack::Test
-        Mail::Thread URI::Escape);
+my @mods = qw(HTTP::Request::Common Plack::Test URI::Escape);
 foreach my $mod (@mods) {
         eval "require $mod";
         plan skip_all => "$mod missing for plack.t" if $@;
 }
+use_ok 'PublicInbox::Import';
+use_ok 'PublicInbox::Git';
 
 foreach my $mod (@mods) { use_ok $mod; }
 {
         ok(-f $psgi, "psgi example file found");
-        ok(-x "$main_bin/spamc",
-                "spamc ham mock found (run in top of source tree");
-        ok(-x $mda, "$mda is executable");
-        is(1, mkdir($home, 0755), "setup ~/ for testing");
-        is(1, mkdir($pi_home, 0755), "setup ~/.public-inbox");
         is(0, system(qw(git init -q --bare), $maindir), "git init (main)");
         open my $fh, '>', "$maindir/description" or die "open: $!\n";
         print $fh "test for public-inbox\n";
@@ -42,18 +29,17 @@ foreach my $mod (@mods) { use_ok $mod; }
         my %cfg = (
                 "$cfgpfx.address" => $addr,
                 "$cfgpfx.mainrepo" => $maindir,
+                "$cfgpfx.url" => 'http://example.com/test/',
+                "$cfgpfx.newsgroup" => 'inbox.test',
         );
         while (my ($k,$v) = each %cfg) {
                 is(0, system(qw(git config --file), $pi_config, $k, $v),
                         "setup $k");
         }
 
-        local $ENV{HOME} = $home;
-        local $ENV{ORIGINAL_RECIPIENT} = $addr;
-
         # ensure successful message delivery
         {
-                my $simple = Email::Simple->new(<<EOF);
+                my $mime = Email::MIME->new(<<EOF);
 From: Me <me\@example.com>
 To: You <you\@example.com>
 Cc: $addr
@@ -63,13 +49,38 @@ Date: Thu, 01 Jan 1970 00:00:00 +0000
 
 zzzzzz
 EOF
-                my $in = $simple->as_string;
-                run_with_env({PATH => $main_path}, [$mda], \$in);
-                local $ENV{GIT_DIR} = $maindir;
-                my $rev = `git rev-list HEAD`;
+                my $git = PublicInbox::Git->new($maindir);
+                my $im = PublicInbox::Import->new($git, 'test', $addr);
+                $im->add($mime);
+                $im->done;
+                my $rev = `git --git-dir="$maindir" rev-list HEAD`;
                 like($rev, qr/\A[a-f0-9]{40}/, "good revision committed");
         }
-        my $app = require $psgi;
+        my $app = eval {
+                local $ENV{PI_CONFIG} = $pi_config;
+                require $psgi;
+        };
+
+        test_psgi($app, sub {
+                my ($cb) = @_;
+                foreach my $u (qw(robots.txt favicon.ico .well-known/foo)) {
+                        my $res = $cb->(GET("http://example.com/$u"));
+                        is($res->code, 404, "$u is missing");
+                }
+        });
+
+        # redirect with newsgroup
+        test_psgi($app, sub {
+                my ($cb) = @_;
+                my $from = 'http://example.com/inbox.test';
+                my $to = 'http://example.com/test/';
+                my $res = $cb->(GET($from));
+                is($res->code, 301, 'newsgroup name is permanent redirect');
+                is($to, $res->header('Location'), 'redirect location matches');
+                $from .= '/';
+                is($res->code, 301, 'newsgroup name/ is permanent redirect');
+                is($to, $res->header('Location'), 'redirect location matches');
+        });
 
         # redirect with trailing /
         test_psgi($app, sub {
@@ -85,7 +96,7 @@ EOF
         foreach my $t (qw(t T)) {
                 test_psgi($app, sub {
                         my ($cb) = @_;
-                        my $u = $pfx . "/blah%40example.com/$t";
+                        my $u = $pfx . "/blah\@example.com/$t";
                         my $res = $cb->(GET($u));
                         is(301, $res->code, "redirect for missing /");
                         my $location = $res->header('Location');
@@ -96,11 +107,11 @@ EOF
         foreach my $t (qw(f)) {
                 test_psgi($app, sub {
                         my ($cb) = @_;
-                        my $u = $pfx . "/blah%40example.com/$t";
+                        my $u = $pfx . "/blah\@example.com/$t";
                         my $res = $cb->(GET($u));
-                        is(301, $res->code, "redirect for missing /");
+                        is(301, $res->code, "redirect for legacy /f");
                         my $location = $res->header('Location');
-                        like($location, qr!/\Q$t\E/\z!,
+                        like($location, qr!/blah\@example\.com/\z!,
                                 'redirected with missing /');
                 });
         }
@@ -108,11 +119,11 @@ EOF
         test_psgi($app, sub {
                 my ($cb) = @_;
                 my $atomurl = 'http://example.com/test/new.atom';
-                my $res = $cb->(GET('http://example.com/test/'));
+                my $res = $cb->(GET('http://example.com/test/new.html'));
                 is(200, $res->code, 'success response received');
-                like($res->content, qr!href="\Q$atomurl\E"!,
+                like($res->content, qr!href="new\.atom"!,
                         'atom URL generated');
-                like($res->content, qr!href="blah%40example\.com/"!,
+                like($res->content, qr!href="blah\@example\.com/"!,
                         'index generated');
         });
 
@@ -121,23 +132,29 @@ EOF
                 my $res = $cb->(GET($pfx . '/atom.xml'));
                 is(200, $res->code, 'success response received for atom');
                 like($res->content,
-                        qr!link\s+href="\Q$pfx\E/blah%40example\.com/"!s,
+                        qr!link\s+href="\Q$pfx\E/blah\@example\.com/"!s,
                         'atom feed generated correct URL');
         });
 
-        foreach my $t (('', 'f/')) {
-                test_psgi($app, sub {
-                        my ($cb) = @_;
-                        my $path = "/blah%40example.com/$t";
-                        my $res = $cb->(GET($pfx . $path));
-                        is(200, $res->code, "success for $path");
-                        like($res->content, qr!<title>hihi - Me</title>!,
-                                "HTML returned");
-                });
-        }
         test_psgi($app, sub {
                 my ($cb) = @_;
-                my $res = $cb->(GET($pfx . '/blah%40example.com/raw'));
+                my $path = '/blah@example.com/';
+                my $res = $cb->(GET($pfx . $path));
+                is(200, $res->code, "success for $path");
+                like($res->content, qr!<title>hihi - Me</title>!,
+                        "HTML returned");
+
+                $path .= 'f/';
+                $res = $cb->(GET($pfx . $path));
+                is(301, $res->code, "redirect for $path");
+                my $location = $res->header('Location');
+                like($location, qr!/blah\@example\.com/\z!,
+                        '/$MESSAGE_ID/f/ redirected to /$MESSAGE_ID/');
+        });
+
+        test_psgi($app, sub {
+                my ($cb) = @_;
+                my $res = $cb->(GET($pfx . '/blah@example.com/raw'));
                 is(200, $res->code, 'success response received for /*/raw');
                 like($res->content, qr!^From !sm, "mbox returned");
         });
@@ -146,47 +163,41 @@ EOF
         foreach my $t (qw(m f)) {
                 test_psgi($app, sub {
                         my ($cb) = @_;
-                        my $res = $cb->(GET($pfx . "/$t/blah%40example.com.txt"));
+                        my $res = $cb->(GET($pfx . "/$t/blah\@example.com.txt"));
                         is(301, $res->code, "redirect for old $t .txt link");
                         my $location = $res->header('Location');
-                        like($location, qr!/blah%40example\.com/raw\z!,
+                        like($location, qr!/blah\@example\.com/raw\z!,
                                 ".txt redirected to /raw");
                 });
         }
 
         my %umap = (
                 'm' => '',
-                'f' => 'f/',
+                'f' => '',
                 't' => 't/',
         );
         while (my ($t, $e) = each %umap) {
                 test_psgi($app, sub {
                         my ($cb) = @_;
-                        my $res = $cb->(GET($pfx . "/$t/blah%40example.com.html"));
+                        my $res = $cb->(GET($pfx . "/$t/blah\@example.com.html"));
                         is(301, $res->code, "redirect for old $t .html link");
                         my $location = $res->header('Location');
                         like($location,
-                                qr!/blah%40example\.com/$e(?:#u)?\z!,
+                                qr!/blah\@example\.com/$e(?:#u)?\z!,
                                 ".html redirected to new location");
                 });
         }
         foreach my $sfx (qw(mbox mbox.gz)) {
                 test_psgi($app, sub {
                         my ($cb) = @_;
-                        my $res = $cb->(GET($pfx . "/t/blah%40example.com.$sfx"));
+                        my $res = $cb->(GET($pfx . "/t/blah\@example.com.$sfx"));
                         is(301, $res->code, 'redirect for old thread link');
                         my $location = $res->header('Location');
                         like($location,
-                             qr!/blah%40example\.com/t\.mbox(?:\.gz)?\z!,
+                             qr!/blah\@example\.com/t\.mbox(?:\.gz)?\z!,
                              "$sfx redirected to /mbox.gz");
                 });
         }
 }
 
 done_testing();
-
-sub run_with_env {
-        my ($env, @args) = @_;
-        my $init = sub { foreach my $k (keys %$env) { $ENV{$k} = $env->{$k} } };
-        run(@args, init => $init);
-}
diff --git a/t/precheck.t b/t/precheck.t
index 3f2c5d5b..0c3ce1c4 100644
--- a/t/precheck.t
+++ b/t/precheck.t
@@ -4,32 +4,46 @@ use strict;
 use warnings;
 use Test::More;
 use Email::Simple;
-use Email::Filter;
 use PublicInbox::MDA;
 
 sub do_checks {
         my ($s) = @_;
 
-        my $f = Email::Filter->new(data => $s->as_string);
-
         my $recipient = 'foo@example.com';
-        ok(!PublicInbox::MDA->precheck($f, $recipient),
+        ok(!PublicInbox::MDA->precheck($s, $recipient),
                 "wrong ORIGINAL_RECIPIENT rejected");
 
         $recipient = 'b@example.com';
-        ok(PublicInbox::MDA->precheck($f, $recipient),
+        ok(PublicInbox::MDA->precheck($s, $recipient),
                 "ORIGINAL_RECIPIENT in To: is OK");
 
         $recipient = 'c@example.com';
-        ok(PublicInbox::MDA->precheck($f, $recipient),
+        ok(PublicInbox::MDA->precheck($s, $recipient),
                 "ORIGINAL_RECIPIENT in Cc: is OK");
 
         $recipient = [ 'c@example.com', 'd@example.com' ];
-        ok(PublicInbox::MDA->precheck($f, $recipient),
+        ok(PublicInbox::MDA->precheck($s, $recipient),
                 "alias list is OK");
 }
 
 {
+        my $s = Email::Simple->create(
+                header => [
+                        From => 'abc@example.com',
+                        To => 'abc@example.com',
+                        Cc => 'c@example.com, another-list@example.com',
+                        'Content-Type' => 'text/plain',
+                        Subject => 'list is fine',
+                        'Message-ID' => '<MID@host>',
+                        Date => 'Wed, 09 Apr 2014 01:28:34 +0000',
+                ],
+                body => "hello world\n",
+        );
+        my $addr = [ 'c@example.com', 'd@example.com' ];
+        ok(PublicInbox::MDA->precheck($s, $addr), 'Cc list is OK');
+}
+
+{
         do_checks(Email::Simple->create(
                 header => [
                         From => 'a@example.com',
@@ -72,8 +86,7 @@ sub do_checks {
                 ],
                 body => "hello world\n",
         );
-        my $f = Email::Filter->new(data => $s->as_string);
-        ok(!PublicInbox::MDA->precheck($f, $recipient),
+        ok(!PublicInbox::MDA->precheck($s, $recipient),
                 "missing From: is rejected");
 }
 
diff --git a/t/psgi_attach.t b/t/psgi_attach.t
new file mode 100644
index 00000000..0d20b7f7
--- /dev/null
+++ b/t/psgi_attach.t
@@ -0,0 +1,117 @@
+# 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 Test::More;
+use Email::MIME;
+use File::Temp qw/tempdir/;
+my $tmpdir = tempdir('psgi-attach-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $maindir = "$tmpdir/main.git";
+my $addr = 'test-public@example.com';
+my $cfgpfx = "publicinbox.test";
+my @mods = qw(HTTP::Request::Common Plack::Test URI::Escape);
+foreach my $mod (@mods) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for plack.t" if $@;
+}
+use_ok $_ foreach @mods;
+use PublicInbox::Import;
+use PublicInbox::Git;
+use PublicInbox::Config;
+use PublicInbox::WWW;
+use_ok 'PublicInbox::WwwAttach';
+use Plack::Builder;
+my $config = PublicInbox::Config->new({
+        "$cfgpfx.address" => $addr,
+        "$cfgpfx.mainrepo" => $maindir,
+});
+is(0, system(qw(git init -q --bare), $maindir), "git init (main)");
+my $git = PublicInbox::Git->new($maindir);
+my $im = PublicInbox::Import->new($git, 'test', $addr);
+
+{
+        open my $fh, '<', '/dev/urandom' or die "unable to open urandom: $!\n";
+        sysread($fh, my $buf, 8);
+        is(8, length($buf), 'read some random data');
+        my $qp = "abcdef=g\n==blah\n";
+        my $b64 = 'b64'.$buf."\n";
+        my $txt = "plain\ntext\npass\nthrough\n";
+        my $dot = "dotfile\n";
+        my $parts = [
+                Email::MIME->create(
+                        attributes => {
+                                filename => 'queue-pee',
+                                content_type => 'text/plain',
+                                encoding => 'quoted-printable'
+                        },
+                        body => $qp),
+                Email::MIME->create(
+                        attributes => {
+                                filename => 'bayce-sixty-four',
+                                content_type => 'appication/octet-stream',
+                                encoding => 'base64',
+                        },
+                        body => $b64),
+                Email::MIME->create(
+                        attributes => {
+                                filename => 'noop.txt',
+                                content_type => 'text/plain',
+                        },
+                        body => $txt),
+                Email::MIME->create(
+                        attributes => {
+                                filename => '.dotfile',
+                                content_type => 'text/plain',
+                        },
+                        body => $dot),
+        ];
+        my $mime = Email::MIME->create(
+                parts => $parts,
+                header_str => [ From => 'root@z', 'Message-Id' => '<Z@B>',
+                        Subject => 'hi']
+        );
+        $mime = $mime->as_string;
+        $mime =~ s/\r\n/\n/g; # normalize to LF only
+        $mime = Email::MIME->new($mime);
+        $im->add($mime);
+        $im->done;
+
+        my $www = PublicInbox::WWW->new($config);
+        test_psgi(sub { $www->call(@_) }, sub {
+                my ($cb) = @_;
+                my $res;
+                $res = $cb->(GET('/test/Z%40B/'));
+                my @href = ($res->content =~ /^href="([^"]+)"/gms);
+                @href = grep(/\A[\d\.]+-/, @href);
+                is_deeply([qw(1-queue-pee 2-bayce-sixty-four 3-noop.txt
+                                4-a.txt)],
+                        \@href, 'attachment links generated');
+
+                $res = $cb->(GET('/test/Z%40B/1-queue-pee'));
+                my $qp_res = $res->content;
+                ok(length($qp_res) >= length($qp), 'QP length is close');
+                like($qp_res, qr/\n\z/s, 'trailing newline exists');
+                # is(index($qp_res, $qp), 0, 'QP trailing newline is there');
+                $qp_res =~ s/\r\n/\n/g;
+                is(index($qp_res, $qp), 0, 'QP trailing newline is there');
+
+                $res = $cb->(GET('/test/Z%40B/2-base-sixty-four'));
+                is(quotemeta($res->content), quotemeta($b64),
+                        'Base64 matches exactly');
+
+                $res = $cb->(GET('/test/Z%40B/3-noop.txt'));
+                my $txt_res = $res->content;
+                ok(length($txt_res) >= length($txt),
+                        'plain text almost matches');
+                like($txt_res, qr/\n\z/s, 'trailing newline exists in text');
+                is(index($txt_res, $txt), 0, 'plain text not truncated');
+
+                $res = $cb->(GET('/test/Z%40B/4-a.txt'));
+                my $dot_res = $res->content;
+                ok(length($dot_res) >= length($dot), 'dot almost matches');
+                $res = $cb->(GET('/test/Z%40B/4-any-filename.txt'));
+                is($res->content, $dot_res, 'user-specified filename is OK');
+
+        });
+}
+done_testing();
diff --git a/t/psgi_mount.t b/t/psgi_mount.t
new file mode 100644
index 00000000..4a515c6a
--- /dev/null
+++ b/t/psgi_mount.t
@@ -0,0 +1,78 @@
+# 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 Test::More;
+use Email::MIME;
+use File::Temp qw/tempdir/;
+my $tmpdir = tempdir('psgi-path-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $maindir = "$tmpdir/main.git";
+my $addr = 'test-public@example.com';
+my $cfgpfx = "publicinbox.test";
+my @mods = qw(HTTP::Request::Common Plack::Test URI::Escape);
+foreach my $mod (@mods) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for plack.t" if $@;
+}
+use_ok $_ foreach @mods;
+use PublicInbox::Import;
+use PublicInbox::Git;
+use PublicInbox::Config;
+use PublicInbox::WWW;
+use Plack::Builder;
+use Plack::App::URLMap;
+my $config = PublicInbox::Config->new({
+        "$cfgpfx.address" => $addr,
+        "$cfgpfx.mainrepo" => $maindir,
+});
+is(0, system(qw(git init -q --bare), $maindir), "git init (main)");
+my $git = PublicInbox::Git->new($maindir);
+my $im = PublicInbox::Import->new($git, 'test', $addr);
+{
+        my $mime = Email::MIME->new(<<EOF);
+From: Me <me\@example.com>
+To: You <you\@example.com>
+Cc: $addr
+Message-Id: <blah\@example.com>
+Subject: hihi
+Date: Thu, 01 Jan 1970 00:00:00 +0000
+
+zzzzzz
+EOF
+        $im->add($mime);
+        $im->done;
+}
+
+my $www = PublicInbox::WWW->new($config);
+my $app = builder {
+        enable 'Head';
+        mount '/a' => builder { sub { $www->call(@_) } };
+        mount '/b' => builder { sub { $www->call(@_) } };
+};
+
+test_psgi($app, sub {
+        my ($cb) = @_;
+        my $res;
+        # Atom feed:
+        $res = $cb->(GET('/a/test/new.atom'));
+        like($res->content, qr!\bhttp://[^/]+/a/test/!,
+                'URLs which exist in Atom feed are mount-aware');
+        unlike($res->content, qr!\b\Qhttp://[^/]+/test/\E!,
+                'No URLs which are not mount-aware');
+
+        # redirects
+        $res = $cb->(GET('/a/test/blah%40example.com/'));
+        is($res->code, 200, 'OK with URLMap mount');
+        $res = $cb->(GET('/a/test/blah%40example.com/raw'));
+        is($res->code, 200, 'OK with URLMap mount');
+        $res = $cb->(GET('/a/test/m/blah%40example.com.html'));
+        is($res->header('Location'),
+                'http://localhost/a/test/blah@example.com/',
+                'redirect functions properly under mount');
+
+        $res = $cb->(GET('/test/blah%40example.com/'));
+        is($res->code, 404, 'intentional 404 with URLMap mount');
+
+});
+
+done_testing();
diff --git a/t/psgi_text.t b/t/psgi_text.t
new file mode 100644
index 00000000..bf565f83
--- /dev/null
+++ b/t/psgi_text.t
@@ -0,0 +1,39 @@
+# 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 Test::More;
+use Email::MIME;
+use File::Temp qw/tempdir/;
+my $tmpdir = tempdir('psgi-text-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $maindir = "$tmpdir/main.git";
+my $addr = 'test-public@example.com';
+my $cfgpfx = "publicinbox.test";
+my @mods = qw(HTTP::Request::Common Plack::Test URI::Escape);
+foreach my $mod (@mods) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for psgi_text.t" if $@;
+}
+use_ok $_ foreach @mods;
+use PublicInbox::Import;
+use PublicInbox::Git;
+use PublicInbox::Config;
+use PublicInbox::WWW;
+use_ok 'PublicInbox::WwwText';
+use Plack::Builder;
+my $config = PublicInbox::Config->new({
+        "$cfgpfx.address" => $addr,
+        "$cfgpfx.mainrepo" => $maindir,
+});
+is(0, system(qw(git init -q --bare), $maindir), "git init (main)");
+my $www = PublicInbox::WWW->new($config);
+
+test_psgi(sub { $www->call(@_) }, sub {
+        my ($cb) = @_;
+        my $res;
+        $res = $cb->(GET('/test/_/text/help/'));
+        like($res->content, qr!<title>public-inbox help.*</title>!,
+                'default help');
+});
+
+done_testing();
diff --git a/t/qspawn.t b/t/qspawn.t
new file mode 100644
index 00000000..9c42e100
--- /dev/null
+++ b/t/qspawn.t
@@ -0,0 +1,62 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use Test::More;
+use_ok 'PublicInbox::Qspawn';
+
+my $limiter = PublicInbox::Qspawn::Limiter->new(1);
+{
+        my $x = PublicInbox::Qspawn->new([qw(true)]);
+        my $run = 0;
+        $x->start($limiter, sub {
+                my ($rpipe) = @_;
+                is(0, sysread($rpipe, my $buf, 1), 'read zero bytes');
+                ok(!$x->finish, 'no error on finish');
+                $run = 1;
+        });
+        is($run, 1, 'callback ran alright');
+}
+
+{
+        my $x = PublicInbox::Qspawn->new([qw(false)]);
+        my $run = 0;
+        $x->start($limiter, sub {
+                my ($rpipe) = @_;
+                is(0, sysread($rpipe, my $buf, 1), 'read zero bytes from false');
+                my $err = $x->finish;
+                is($err, 256, 'error on finish');
+                $run = 1;
+        });
+        is($run, 1, 'callback ran alright');
+}
+
+foreach my $cmd ([qw(sleep 1)], [qw(sh -c), 'sleep 1; false']) {
+        my $s = PublicInbox::Qspawn->new($cmd);
+        my @run;
+        $s->start($limiter, sub {
+                my ($rpipe) = @_;
+                push @run, 'sleep';
+                is(0, sysread($rpipe, my $buf, 1), 'read zero bytes');
+        });
+        my $n = 0;
+        my @t = map {
+                my $i = $n++;
+                my $x = PublicInbox::Qspawn->new([qw(true)]);
+                $x->start($limiter, sub {
+                        my ($rpipe) = @_;
+                        push @run, $i;
+                });
+                [$x, $i]
+        } (0..2);
+
+        if ($cmd->[-1] =~ /false\z/) {
+                ok($s->finish, 'got error on false after sleep');
+        } else {
+                ok(!$s->finish, 'no error on sleep');
+        }
+        ok(!$_->[0]->finish, "true $_->[1] succeeded") foreach @t;
+        is_deeply([qw(sleep 0 1 2)], \@run, 'ran in order');
+}
+
+done_testing();
+
+1;
diff --git a/t/search.t b/t/search.t
index bbf1a89f..eed9c9b6 100644
--- a/t/search.t
+++ b/t/search.t
@@ -33,13 +33,14 @@ ok($@, "exception raised on non-existent DB");
 }
 
 my $rw = PublicInbox::SearchIdx->new($git_dir, 1);
-my $ro = PublicInbox::Search->new($git_dir);
+$rw->_xdb_acquire;
+$rw->_xdb_release;
 $rw = undef;
+my $ro = PublicInbox::Search->new($git_dir);
 my $rw_commit = sub {
-        $rw->{xdb}->commit_transaction if $rw;
-        $rw = undef;
+        $rw->{xdb}->commit_transaction if $rw && $rw->{xdb};
         $rw = PublicInbox::SearchIdx->new($git_dir, 1);
-        $rw->{xdb}->begin_transaction;
+        $rw->_xdb_acquire->begin_transaction;
 };
 
 {
@@ -85,6 +86,7 @@ my $rw_commit = sub {
                         'Message-ID' => '<last@s>',
                         From => 'John Smith <js@example.com>',
                         To => 'list@example.com',
+                        Cc => 'foo@example.com',
                 ],
                 body => "goodbye forever :<\n");
 
@@ -121,19 +123,19 @@ sub filter_mids {
                 is($res->{total}, 0, "path variant `$p' does not match");
         }
 
-        $res = $ro->query('subject:(Hello world)');
+        $res = $ro->query('s:(Hello world)');
         @res = filter_mids($res);
-        is_deeply(\@res, \@exp, 'got expected results for subject:() match');
+        is_deeply(\@res, \@exp, 'got expected results for s:() match');
 
-        $res = $ro->query('subject:"Hello world"');
+        $res = $ro->query('s:"Hello world"');
         @res = filter_mids($res);
-        is_deeply(\@res, \@exp, 'got expected results for subject:"" match');
+        is_deeply(\@res, \@exp, 'got expected results for s:"" match');
 
-        $res = $ro->query('subject:"Hello world"', {limit => 1});
+        $res = $ro->query('s:"Hello world"', {limit => 1});
         is(scalar @{$res->{msgs}}, 1, "limit works");
         my $first = $res->{msgs}->[0];
 
-        $res = $ro->query('subject:"Hello world"', {offset => 1});
+        $res = $ro->query('s:"Hello world"', {offset => 1});
         is(scalar @{$res->{msgs}}, 1, "offset works");
         my $second = $res->{msgs}->[0];
 
@@ -179,7 +181,7 @@ sub filter_mids {
         $rw_commit->();
         $ro->reopen;
 
-        # Subject:
+        # subject
         my $res = $ro->query('ghost');
         my @exp = sort qw(ghost-message@s ghost-reply@s);
         my @res = filter_mids($res);
@@ -274,10 +276,11 @@ sub filter_mids {
 
 # circular references
 {
+        my $s = 'foo://'. ('Circle' x 15).'/foo';
         my $doc_id = $rw->add_message(Email::MIME->create(
+                header => [ Subject => $s ],
                 header_str => [
                         Date => 'Sat, 02 Oct 2010 00:00:01 +0000',
-                        Subject => 'Circle',
                         'Message-ID' => '<circle@a>',
                         'References' => '<circle@a>',
                         'In-Reply-To' => '<circle@a>',
@@ -289,6 +292,139 @@ sub filter_mids {
         my $smsg = $rw->lookup_message('circle@a');
         $smsg->ensure_metadata;
         is($smsg->references, '', "no references created");
+        my $msg = PublicInbox::SearchMsg->load_doc($smsg->{doc});
+        is($s, $msg->subject, 'long subject not rewritten');
+}
+
+{
+        my $str = eval {
+                my $mbox = 't/utf8.mbox';
+                open(my $fh, '<', $mbox) or die "failed to open mbox: $mbox\n";
+                local $/;
+                <$fh>
+        };
+        $str =~ s/\AFrom [^\n]+\n//s;
+        my $mime = Email::MIME->new($str);
+        my $doc_id = $rw->add_message($mime);
+        ok($doc_id > 0, 'message indexed doc_id with UTF-8');
+        my $smsg = $rw->lookup_message('testmessage@example.com');
+        my $msg = PublicInbox::SearchMsg->load_doc($smsg->{doc});
+
+        is($mime->header('Subject'), $msg->subject, 'UTF-8 subject preserved');
+}
+
+{
+        my $res = $ro->query('d:19931002..20101002');
+        ok(scalar @{$res->{msgs}} > 0, 'got results within range');
+        $res = $ro->query('d:20101003..');
+        is(scalar @{$res->{msgs}}, 0, 'nothing after 20101003');
+        $res = $ro->query('d:..19931001');
+        is(scalar @{$res->{msgs}}, 0, 'nothing before 19931001');
+}
+
+# names and addresses
+{
+        my $res = $ro->query('t:list@example.com');
+        is(scalar @{$res->{msgs}}, 6, 'searched To: successfully');
+        foreach my $smsg (@{$res->{msgs}}) {
+                like($smsg->to, qr/\blist\@example\.com\b/, 'to appears');
+        }
+
+        $res = $ro->query('tc:list@example.com');
+        is(scalar @{$res->{msgs}}, 6, 'searched To+Cc: successfully');
+        foreach my $smsg (@{$res->{msgs}}) {
+                my $tocc = join("\n", $smsg->to, $smsg->cc);
+                like($tocc, qr/\blist\@example\.com\b/, 'tocc appears');
+        }
+
+        foreach my $pfx ('tcf:', 'c:') {
+                $res = $ro->query($pfx . 'foo@example.com');
+                is(scalar @{$res->{msgs}}, 1,
+                        "searched $pfx successfully for Cc:");
+                foreach my $smsg (@{$res->{msgs}}) {
+                        like($smsg->cc, qr/\bfoo\@example\.com\b/,
+                                'cc appears');
+                }
+        }
+
+        foreach my $pfx ('', 'tcf:', 'f:') {
+                $res = $ro->query($pfx . 'Laggy');
+                is(scalar @{$res->{msgs}}, 1,
+                        "searched $pfx successfully for From:");
+                foreach my $smsg (@{$res->{msgs}}) {
+                        like($smsg->from, qr/Laggy Sender/,
+                                "From appears with $pfx");
+                }
+        }
+}
+
+{
+        $rw_commit->();
+        $ro->reopen;
+        my $res = $ro->query('b:hello');
+        is(scalar @{$res->{msgs}}, 0, 'no match on body search only');
+        $res = $ro->query('bs:smith');
+        is(scalar @{$res->{msgs}}, 0,
+                'no match on body+subject search for From');
+
+        $res = $ro->query('q:theatre');
+        is(scalar @{$res->{msgs}}, 1, 'only one quoted body');
+        like($res->{msgs}->[0]->from, qr/\AQuoter/, 'got quoted body');
+
+        $res = $ro->query('nq:theatre');
+        is(scalar @{$res->{msgs}}, 1, 'only one non-quoted body');
+        like($res->{msgs}->[0]->from, qr/\ANon-Quoter/, 'got non-quoted body');
+
+        foreach my $pfx (qw(b: bs:)) {
+                $res = $ro->query($pfx . 'theatre');
+                is(scalar @{$res->{msgs}}, 2, "searched both bodies for $pfx");
+                like($res->{msgs}->[0]->from, qr/\ANon-Quoter/,
+                        "non-quoter first for $pfx");
+        }
+}
+
+{
+        my $part1 = Email::MIME->create(
+                 attributes => {
+                     content_type => 'text/plain',
+                     disposition  => 'attachment',
+                     charset => 'US-ASCII',
+                     encoding => 'quoted-printable',
+                     filename => 'attached_fart.txt',
+                 },
+                 body_str => 'inside the attachment',
+        );
+        my $part2 = Email::MIME->create(
+                 attributes => {
+                     content_type => 'text/plain',
+                     disposition  => 'attachment',
+                     charset => 'US-ASCII',
+                     encoding => 'quoted-printable',
+                     filename => 'part_deux.txt',
+                 },
+                 body_str => 'inside another',
+        );
+        my $amsg = Email::MIME->create(
+                header_str => [
+                        Subject => 'see attachment',
+                        'Message-ID' => '<file@attached>',
+                        From => 'John Smith <js@example.com>',
+                        To => 'list@example.com',
+                ],
+                parts => [ $part1, $part2 ],
+        );
+        ok($rw->add_message($amsg), 'added attachment');
+        $rw_commit->();
+        $ro->reopen;
+        my $n = $ro->query('n:attached_fart.txt');
+        is(scalar @{$n->{msgs}}, 1, 'got result for n:');
+        my $res = $ro->query('part_deux.txt');
+        is(scalar @{$res->{msgs}}, 1, 'got result without n:');
+        is($n->{msgs}->[0]->mid, $res->{msgs}->[0]->mid,
+                'same result with and without');
+        my $txt = $ro->query('"inside another"');
+        is($txt->{msgs}->[0]->mid, $res->{msgs}->[0]->mid,
+                'search inside text attachments works');
 }
 
 done_testing();
diff --git a/t/spamcheck_spamc.t b/t/spamcheck_spamc.t
new file mode 100644
index 00000000..65ac5c2e
--- /dev/null
+++ b/t/spamcheck_spamc.t
@@ -0,0 +1,49 @@
+# 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 Test::More;
+use Cwd;
+use Email::Simple;
+use IO::File;
+use File::Temp qw/tempdir/;
+use Fcntl qw(:DEFAULT SEEK_SET);
+my $tmpdir = tempdir('spamcheck_spamc-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+
+use_ok 'PublicInbox::Spamcheck::Spamc';
+my $spamc = PublicInbox::Spamcheck::Spamc->new;
+$spamc->{checkcmd} = [qw(cat)];
+
+{
+        open my $fh, '+>', "$tmpdir/file" or die "open failed: $!";
+        ok(!$spamc->spamcheck($fh), 'empty '.ref($fh));
+}
+ok(!$spamc->spamcheck(IO::File->new_tmpfile), 'IO::File->new_tmpfile');
+
+my $dst = '';
+my $src = <<'EOF';
+Date: Thu, 01 Jan 1970 00:00:00 +0000
+To: <e@example.com>
+From: <e@example.com>
+Subject: test
+Message-ID: <testmessage@example.com>
+
+EOF
+ok($spamc->spamcheck(Email::Simple->new($src), \$dst), 'Email::Simple works');
+is($dst, $src, 'input == output');
+
+$dst = '';
+$spamc->{checkcmd} = ['sh', '-c', 'cat; false'];
+ok(!$spamc->spamcheck(Email::Simple->new($src), \$dst), 'Failed check works');
+is($dst, $src, 'input == output for spammy example');
+
+for my $l (qw(ham spam)) {
+        my $file = "$tmpdir/$l.out";
+        $spamc->{$l.'cmd'} = ['tee', $file ];
+        my $method = $l.'learn';
+        ok($spamc->$method(Email::Simple->new($src)), "$method OK");
+        open my $fh, '<', $file or die "failed to open $file: $!";
+        is(eval { local $/, <$fh> }, $src, "$l command ran alright");
+}
+
+done_testing();
diff --git a/t/spawn.t b/t/spawn.t
index d52b6465..0f756462 100644
--- a/t/spawn.t
+++ b/t/spawn.t
@@ -70,6 +70,15 @@ use PublicInbox::Spawn qw(which spawn popen_rd);
         is(sysread($fh, $buf, 6), 6, 'sysread got 6 bytes');
         is($buf, "hello\n", 'tied gets works');
         is(sysread($fh, $buf, 6), 0, 'sysread got EOF');
+        $? = 1;
+        close $fh;
+        is($?, 0, '$? set properly');
+}
+
+{
+        my $fh = popen_rd([qw(false)]);
+        close $fh;
+        isnt($?, 0, '$? set properly: '.$?);
 }
 
 {
@@ -78,8 +87,9 @@ use PublicInbox::Spawn qw(which spawn popen_rd);
         is(kill(0, $pid), 1, 'child process is running');
         ok(!defined(sysread($fh, my $buf, 1)) && $!{EAGAIN},
            'sysread returned quickly with EAGAIN');
-        is(kill(15, $pid), 1, 'child process killed early');
+        is(kill(9, $pid), 1, 'child process killed early');
         is(waitpid($pid, 0), $pid, 'child process reapable');
+        isnt($?, 0, '$? set properly: '.$?);
 }
 
 done_testing();
diff --git a/t/thread-cycle.t b/t/thread-cycle.t
new file mode 100644
index 00000000..b0844490
--- /dev/null
+++ b/t/thread-cycle.t
@@ -0,0 +1,89 @@
+# 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 Test::More;
+use_ok('PublicInbox::SearchMsg');
+use_ok('PublicInbox::SearchThread');
+use Email::Simple;
+my $mt = eval {
+        require Mail::Thread;
+        no warnings 'once';
+        $Mail::Thread::nosubject = 1;
+        $Mail::Thread::noprune = 1;
+};
+my @check;
+my @msgs = map {
+        my $msg = $_;
+        $msg->{references} =~ s/\s+/ /sg if $msg->{references};
+        my $simple = Email::Simple->create(header => [
+                'Message-Id' => "<$msg->{mid}>",
+                'References' => $msg->{references},
+        ]);
+        push @check, $simple;
+        bless $msg, 'PublicInbox::SearchMsg'
+} (
+
+# data from t/testbox-6 in Mail::Thread 2.55:
+        { mid => '20021124145312.GA1759@nlin.net' },
+        { mid => 'slrnau448m.7l4.markj+0111@cloaked.freeserve.co.uk',
+          references => '<20021124145312.GA1759@nlin.net>',
+        },
+        { mid => '15842.10677.577458.656565@jupiter.akutech-local.de',
+          references => '<20021124145312.GA1759@nlin.net>
+                        <slrnau448m.7l4.markj+0111@cloaked.freeserve.co.uk>',
+        },
+        { mid => '20021125171807.GK8236@somanetworks.com',
+          references => '<20021124145312.GA1759@nlin.net>
+                        <slrnau448m.7l4.markj+0111@cloaked.freeserve.co.uk>
+                        <15842.10677.577458.656565@jupiter.akutech-local.de>',
+        },
+        { mid => '15843.12163.554914.469248@jupiter.akutech-local.de',
+          references => '<20021124145312.GA1759@nlin.net>
+                        <slrnau448m.7l4.markj+0111@cloaked.freeserve.co.uk>
+                        <15842.10677.577458.656565@jupiter.akutech-local.de>
+                        <E18GPHf-0000zp-00@cloaked.freeserve.co.uk>',
+        },
+        { mid => 'E18GPHf-0000zp-00@cloaked.freeserve.co.uk',
+          references => '<20021124145312.GA1759@nlin.net>
+                        <slrnau448m.7l4.markj+0111@cloaked.freeserve.co.uk>
+                        <15842.10677.577458.656565@jupiter.akutech-local.de>'
+        }
+);
+
+my $st = thread_to_s(\@msgs);
+
+SKIP: {
+        skip 'Mail::Thread missing', 1 unless $mt;
+        $mt = Mail::Thread->new(@check);
+        $mt->thread;
+        $mt->order(sub { sort { $a->messageid cmp $b->messageid } @_ });
+        my $check = '';
+
+        my @q = map { (0, $_) } $mt->rootset;
+        while (@q) {
+                my $level = shift @q;
+                my $node = shift @q or next;
+                $check .= (" "x$level) . $node->messageid . "\n";
+                unshift @q, $level + 1, $node->child, $level, $node->next;
+        }
+        is($check, $st, 'Mail::Thread output matches');
+}
+
+done_testing();
+
+sub thread_to_s {
+        my $th = PublicInbox::SearchThread->new(shift);
+        $th->thread;
+        $th->order(sub { [ sort { $a->{id} cmp $b->{id} } @{$_[0]} ] });
+        my $st = '';
+        my @q = map { (0, $_) } @{$th->{rootset}};
+        while (@q) {
+                my $level = shift @q;
+                my $node = shift @q or next;
+                $st .= (" "x$level). "$node->{id}\n";
+                my $cl = $level + 1;
+                unshift @q, map { ($cl, $_) } @{$node->{children}};
+        }
+        $st;
+}
diff --git a/t/view.t b/t/view.t
index 568ab303..46fbe410 100644
--- a/t/view.t
+++ b/t/view.t
@@ -4,7 +4,46 @@ use strict;
 use warnings;
 use Test::More;
 use Email::MIME;
-use PublicInbox::View;
+use Plack::Util;
+use_ok 'PublicInbox::View';
+
+my @q = (
+        'foo@bar', 'foo@bar',
+        'a b', "'a b'",
+        "a'b", "'a'\\''b'",
+);
+while (@q) {
+        my $input = shift @q;
+        my $expect = shift @q;
+        my $res = PublicInbox::View::squote_maybe($input);
+        is($res, $expect, "quote $input => $res");
+}
+
+# FIXME: make this test less fragile
+my $ctx = {
+        env => { HTTP_HOST => 'example.com', 'psgi.url_scheme' => 'http' },
+        -inbox => Plack::Util::inline_object(
+                name => 'test',
+                search => sub { undef },
+                base_url => sub { 'http://example.com/' },
+                cloneurl => sub {[]},
+                nntp_url => sub {[]},
+                description => sub { '' }),
+};
+$ctx->{-inbox}->{-primary_address} = 'test@example.com';
+
+sub msg_html ($) {
+        my ($mime) = @_;
+
+        my $s = '';
+        my $r = PublicInbox::View::msg_html($ctx, $mime);
+        my $body = $r->[2];
+        while (defined(my $buf = $body->getline)) {
+                $s .= $buf;
+        }
+        $body->close;
+        $s;
+}
 
 # plain text
 {
@@ -41,26 +80,12 @@ EOF
                 body => $body,
         )->as_string;
         my $mime = Email::MIME->new($s);
-        my $html = PublicInbox::View::msg_html(undef, $mime);
+        my $html = msg_html($mime);
 
         # ghetto tests
-        like($html, qr!<a\nhref="\.\./raw"!s, "raw link present");
+        like($html, qr!<a\nhref="raw"!s, "raw link present");
         like($html, qr/hello world\b/, "body present");
         like($html, qr/&gt; keep this inline/, "short quoted text is inline");
-        like($html, qr/<a\nid=[^>]+><\/a>&gt; Long and wordy/,
-                "long quoted text is anchored");
-
-        # short page
-        my $pfx = "../hello%40example.com/f/";
-        $mime = Email::MIME->new($s);
-        my $short = PublicInbox::View::msg_html(undef, $mime, $pfx);
-        like($short, qr!<a\nhref="\.\./hello%40example\.com/f/!s,
-                "MID link present");
-        like($short, qr/\n&gt; keep this inline/,
-                "short quoted text is inline");
-        like($short, qr/<a\nhref="\Q$pfx\E#[^>]+>Long and wordy/,
-                "long quoted text is made into a link");
-        ok(length($short) < length($html), "short page is shorter");
 }
 
 # multipart crap
@@ -85,8 +110,8 @@ EOF
                 parts => $parts,
         );
 
-        my $html = PublicInbox::View::msg_html(undef, $mime);
-        like($html, qr/hi\n-+ part #2 -+\nbye\n/, "multipart split");
+        my $html = msg_html($mime);
+        like($html, qr/hi\n.*-- Attachment #2.*\nbye\n/s, "multipart split");
 }
 
 # multipart email with attached patch
@@ -114,8 +139,8 @@ EOF
                 parts => $parts,
         );
 
-        my $html = PublicInbox::View::msg_html(undef, $mime);
-        like($html, qr!see attached patch\n-+ foo\.patch -+\n--- a/file\n!,
+        my $html = msg_html($mime);
+        like($html, qr!.*Attachment #2: foo\.patch --!,
                 "parts split with filename");
 }
 
@@ -140,15 +165,18 @@ EOF
         );
 
         my $orig = $mime->body_raw;
-        my $html = PublicInbox::View::msg_html(undef, $mime);
+        my $html = msg_html($mime);
         like($orig, qr/hi =3D bye=/, "our test used QP correctly");
         like($html, qr/\bhi = bye\b/, "HTML output decoded QP");
 }
 
 {
         use PublicInbox::MID qw/id_compress/;
+
+        # n.b: this is probably invalid since we dropped CGI for PSGI:
         like(id_compress('foo%bar@wtf'), qr/\A[a-f0-9]{40}\z/,
                 "percent always converted to sha1 to workaround buggy httpds");
+
         is(id_compress('foobar-wtf'), 'foobar-wtf',
                 'regular ID not compressed');
 }
diff --git a/t/watch_maildir.t b/t/watch_maildir.t
new file mode 100644
index 00000000..3969c80d
--- /dev/null
+++ b/t/watch_maildir.t
@@ -0,0 +1,126 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use Test::More;
+use File::Temp qw/tempdir/;
+use Email::MIME;
+use Cwd;
+use PublicInbox::Config;
+my @mods = qw(Filesys::Notify::Simple);
+foreach my $mod (@mods) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for watch_maildir.t" if $@;
+}
+
+my $tmpdir = tempdir('watch_maildir-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $git_dir = "$tmpdir/test.git";
+my $maildir = "$tmpdir/md";
+my $spamdir = "$tmpdir/spam";
+use_ok 'PublicInbox::WatchMaildir';
+use_ok 'PublicInbox::Emergency';
+my $cfgpfx = "publicinbox.test";
+my $addr = 'test-public@example.com';
+is(system(qw(git init -q --bare), $git_dir), 0, 'initialized git dir');
+
+my $msg = <<EOF;
+From: user\@example.com
+To: $addr
+Subject: spam
+Message-Id: <a\@b.com>
+Date: Sat, 18 Jun 2016 00:00:00 +0000
+
+something
+EOF
+PublicInbox::Emergency->new($maildir)->prepare(\$msg);
+ok(POSIX::mkfifo("$maildir/cur/fifo", 0777));
+my $sem = PublicInbox::Emergency->new($spamdir); # create dirs
+
+my $config = PublicInbox::Config->new({
+        "$cfgpfx.address" => $addr,
+        "$cfgpfx.mainrepo" => $git_dir,
+        "$cfgpfx.watch" => "maildir:$maildir",
+        "$cfgpfx.filter" => 'PublicInbox::Filter::Vger',
+        "publicinboxlearn.watchspam" => "maildir:$spamdir",
+});
+
+PublicInbox::WatchMaildir->new($config)->scan;
+my $git = PublicInbox::Git->new($git_dir);
+my @list = $git->qx(qw(rev-list refs/heads/master));
+is(scalar @list, 1, 'one revision in rev-list');
+
+my $write_spam = sub {
+        is(scalar glob("$spamdir/new/*"), undef, 'no spam existing');
+        $sem->prepare(\$msg);
+        $sem->commit;
+        my @new = glob("$spamdir/new/*");
+        is(scalar @new, 1);
+        my @p = split(m!/+!, $new[0]);
+        ok(link($new[0], "$spamdir/cur/".$p[-1].":2,S"));
+        is(unlink($new[0]), 1);
+};
+$write_spam->();
+is(unlink(glob("$maildir/new/*")), 1, 'unlinked old spam');
+PublicInbox::WatchMaildir->new($config)->scan;
+@list = $git->qx(qw(rev-list refs/heads/master));
+is(scalar @list, 2, 'two revisions in rev-list');
+@list = $git->qx(qw(ls-tree -r --name-only refs/heads/master));
+is(scalar @list, 0, 'tree is empty');
+
+# check with scrubbing
+{
+        $msg .= qq(--
+To unsubscribe from this list: send the line "unsubscribe git" in
+the body of a message to majordomo\@vger.kernel.org
+More majordomo info at  http://vger.kernel.org/majordomo-info.html\n);
+        PublicInbox::Emergency->new($maildir)->prepare(\$msg);
+        PublicInbox::WatchMaildir->new($config)->scan;
+        @list = $git->qx(qw(ls-tree -r --name-only refs/heads/master));
+        is(scalar @list, 1, 'tree has one file');
+        my $mref = $git->cat_file('HEAD:'.$list[0]);
+        like($$mref, qr/something\n\z/s, 'message scrubbed on import');
+
+        is(unlink(glob("$maildir/new/*")), 1, 'unlinked spam');
+        $write_spam->();
+        PublicInbox::WatchMaildir->new($config)->scan;
+        @list = $git->qx(qw(ls-tree -r --name-only refs/heads/master));
+        is(scalar @list, 0, 'tree is empty');
+        @list = $git->qx(qw(rev-list refs/heads/master));
+        is(scalar @list, 4, 'four revisions in rev-list');
+}
+
+{
+        my $fail_bin = getcwd()."/t/fail-bin";
+        ok(-x "$fail_bin/spamc", "mock spamc exists");
+        my $fail_path = "$fail_bin:$ENV{PATH}"; # for spamc ham mock
+        local $ENV{PATH} = $fail_path;
+        PublicInbox::Emergency->new($maildir)->prepare(\$msg);
+        $config->{'publicinboxwatch.spamcheck'} = 'spamc';
+        {
+                local $SIG{__WARN__} = sub {}; # quiet spam check warning
+                PublicInbox::WatchMaildir->new($config)->scan;
+        }
+        @list = $git->qx(qw(ls-tree -r --name-only refs/heads/master));
+        is(scalar @list, 0, 'tree has no files spamc checked');
+        is(unlink(glob("$maildir/new/*")), 1);
+}
+
+{
+        my $main_bin = getcwd()."/t/main-bin";
+        ok(-x "$main_bin/spamc", "mock spamc exists");
+        my $main_path = "$main_bin:$ENV{PATH}"; # for spamc ham mock
+        local $ENV{PATH} = $main_path;
+        PublicInbox::Emergency->new($maildir)->prepare(\$msg);
+        $config->{'publicinboxwatch.spamcheck'} = 'spamc';
+        @list = $git->qx(qw(ls-tree -r --name-only refs/heads/master));
+        PublicInbox::WatchMaildir->new($config)->scan;
+        @list = $git->qx(qw(ls-tree -r --name-only refs/heads/master));
+        is(scalar @list, 1, 'tree has one file after spamc checked');
+
+        # XXX: workaround some weird caching/memoization in cat-file,
+        # shouldn't be an issue in real-world use, though...
+        $git = PublicInbox::Git->new($git_dir);
+
+        my $mref = $git->cat_file('refs/heads/master:'.$list[0]);
+        like($$mref, qr/something\n\z/s, 'message scrubbed on import');
+}
+
+done_testing;