user/dev discussion of public-inbox itself
 help / color / mirror / code / Atom feed
From: Eric Wong <e@yhbt.net>
To: meta@public-inbox.org
Subject: [PATCH 08/34] watch: preliminary IMAP support
Date: Sat, 27 Jun 2020 10:03:34 +0000	[thread overview]
Message-ID: <20200627100400.9871-9-e@yhbt.net> (raw)
In-Reply-To: <20200627100400.9871-1-e@yhbt.net>

Only servers with IDLE are supported, for now.  Polling will
be needed since users may need to watch many inboxes with
a few active connections due to IMAP server limitations.
---
 MANIFEST                        |   1 +
 lib/PublicInbox/WatchMaildir.pm | 493 ++++++++++++++++++++++++++++----
 t/imapd.t                       |  43 +++
 t/watch_imap.t                  |  21 ++
 4 files changed, 504 insertions(+), 54 deletions(-)
 create mode 100644 t/watch_imap.t

diff --git a/MANIFEST b/MANIFEST
index ffd79c1f1b3..161b6cddbe0 100644
--- a/MANIFEST
+++ b/MANIFEST
@@ -347,6 +347,7 @@ t/v2reindex.t
 t/v2writable.t
 t/view.t
 t/watch_filter_rubylang.t
+t/watch_imap.t
 t/watch_maildir.t
 t/watch_maildir_v2.t
 t/watch_multiple_headers.t
diff --git a/lib/PublicInbox/WatchMaildir.pm b/lib/PublicInbox/WatchMaildir.pm
index c1f3c5c258f..fea7d5ef9ee 100644
--- a/lib/PublicInbox/WatchMaildir.pm
+++ b/lib/PublicInbox/WatchMaildir.pm
@@ -11,6 +11,8 @@ use PublicInbox::InboxWritable;
 use File::Temp 0.19 (); # 0.19 for ->newdir
 use PublicInbox::Filter::Base qw(REJECT);
 use PublicInbox::Spamcheck;
+use PublicInbox::DS qw(now);
+use POSIX qw(_exit WNOHANG);
 *mime_from_path = \&PublicInbox::InboxWritable::mime_from_path;
 
 sub compile_watchheaders ($) {
@@ -39,7 +41,8 @@ sub compile_watchheaders ($) {
 sub new {
 	my ($class, $config) = @_;
 	my (%mdmap, @mdir, $spamc);
-	my %uniq;
+	my %uniq; # directory => count
+	my %imap; # url => [inbox objects] or 'watchspam'
 
 	# "publicinboxwatch" is the documented namespace
 	# "publicinboxlearn" is legacy but may be supported
@@ -55,6 +58,8 @@ sub new {
 				push @mdir, $cur;
 				$uniq{$cur}++;
 				$mdmap{$cur} = 'watchspam';
+			} elsif (my $url = imap_url($dir)) {
+				$imap{$url} = 'watchspam';
 			} else {
 				warn "unsupported $k=$dir\n";
 			}
@@ -73,33 +78,34 @@ sub new {
 		my $watch = $ibx->{watch} or return;
 		if (is_maildir($watch)) {
 			compile_watchheaders($ibx);
-			my $new = "$watch/new";
-			my $cur = "$watch/cur";
-			my $ws = $mdmap{$cur};
-			if ($ws && !ref($ws) && $ws eq 'watchspam') {
-				warn <<EOF;
-E: $cur is a spam folder and cannot be used for `$ibx->{name}' input
-EOF
-				return; # onto next inbox
-			}
+			my ($new, $cur) = ("$watch/new", "$watch/cur");
+			return if is_watchspam($cur, $mdmap{$cur}, $ibx);
 			push @mdir, $new unless $uniq{$new}++;
 			push @mdir, $cur unless $uniq{$cur}++;
 			push @{$mdmap{$new} ||= []}, $ibx;
 			push @{$mdmap{$cur} ||= []}, $ibx;
+		} elsif (my $url = imap_url($watch)) {
+			return if is_watchspam($url, $imap{$url}, $ibx);
+			compile_watchheaders($ibx);
+			push @{$imap{$url} ||= []}, $ibx;
 		} else {
 			warn "watch unsupported: $k=$watch\n";
 		}
 	});
-	return unless @mdir;
+	return unless scalar(@mdir) || scalar(keys %imap);
 
-	my $mdre = join('|', map { quotemeta($_) } @mdir);
-	$mdre = qr!\A($mdre)/!;
+	my $mdre;
+	if (@mdir) {
+		$mdre = join('|', map { quotemeta($_) } @mdir);
+		$mdre = qr!\A($mdre)/!;
+	}
 	bless {
 		spamcheck => $spamcheck,
 		mdmap => \%mdmap,
 		mdir => \@mdir,
 		mdre => $mdre,
 		config => $config,
+		imap => scalar keys %imap ? \%imap : undef,
 		importers => {},
 		opendirs => {}, # dirname => dirhandle (in progress scans)
 	}, $class;
@@ -126,28 +132,51 @@ sub _try_fsn_paths {
 	_done_for_now($self);
 }
 
+sub remove_eml_i { # each_inbox callback
+	my ($ibx, $arg) = @_;
+	my ($self, $eml, $loc) = @$arg;
+	eval {
+		my $im = _importer_for($self, $ibx);
+		$im->remove($eml, 'spam');
+		if (my $scrub = $ibx->filter($im)) {
+			my $scrubbed = $scrub->scrub($eml, 1);
+			$scrubbed or return;
+			$scrubbed == REJECT() and return;
+			$im->remove($scrubbed, 'spam');
+		}
+	};
+	warn "error removing spam at: $loc from $ibx->{name}: $@\n" if $@;
+}
+
 sub _remove_spam {
 	my ($self, $path) = @_;
 	# path must be marked as (S)een
 	$path =~ /:2,[A-R]*S[T-Za-z]*\z/ or return;
-	my $mime = mime_from_path($path) or return;
-	$self->{config}->each_inbox(sub {
-		my ($ibx) = @_;
-		eval {
-			my $im = _importer_for($self, $ibx);
-			$im->remove($mime, 'spam');
-			if (my $scrub = $ibx->filter($im)) {
-				my $scrubbed = $scrub->scrub($mime, 1);
-				$scrubbed or return;
-				$scrubbed == REJECT() and return;
-				$im->remove($scrubbed, 'spam');
-			}
-		};
-		if ($@) {
-			warn "error removing spam at: ", $path,
-				" from ", $ibx->{name}, ': ', $@, "\n";
+	my $eml = mime_from_path($path) or return;
+	$self->{config}->each_inbox(\&remove_eml_i, [ $self, $eml, $path ]);
+}
+
+sub import_eml ($$$) {
+	my ($self, $ibx, $eml) = @_;
+	my $im = _importer_for($self, $ibx);
+
+	# any header match means it's eligible for the inbox:
+	if (my $watch_hdrs = $ibx->{-watchheaders}) {
+		my $ok;
+		my $hdr = $eml->header_obj;
+		for my $wh (@$watch_hdrs) {
+			my @v = $hdr->header_raw($wh->[0]);
+			$ok = grep(/$wh->[1]/, @v) and last;
 		}
-	})
+		return unless $ok;
+	}
+
+	if (my $scrub = $ibx->filter($im)) {
+		my $ret = $scrub->scrub($eml) or return;
+		$ret == REJECT() and return;
+		$eml = $ret;
+	}
+	$im->add($eml, $self->{spamcheck});
 }
 
 sub _try_path {
@@ -172,32 +201,29 @@ sub _try_path {
 		$warn_cb->(@_);
 	};
 	foreach my $ibx (@$inboxes) {
-		my $mime = mime_from_path($path) or next;
-		my $im = _importer_for($self, $ibx);
-
-		# any header match means it's eligible for the inbox:
-		if (my $watch_hdrs = $ibx->{-watchheaders}) {
-			my $ok;
-			my $hdr = $mime->header_obj;
-			for my $wh (@$watch_hdrs) {
-				my @v = $hdr->header_raw($wh->[0]);
-				$ok = grep(/$wh->[1]/, @v) and last;
-			}
-			next unless $ok;
-		}
-
-		if (my $scrub = $ibx->filter($im)) {
-			my $ret = $scrub->scrub($mime) or next;
-			$ret == REJECT() and next;
-			$mime = $ret;
-		}
-		$im->add($mime, $self->{spamcheck});
+		my $eml = mime_from_path($path) or next;
+		import_eml($self, $ibx, $eml);
 	}
 }
 
-sub quit { trigger_scan($_[0], 'quit') }
+sub quit {
+	my ($self) = @_;
+	trigger_scan($self, 'quit') or $self->{quit} = 1;
+	if (my $imap_pid = $self->{-imap_pid}) {
+		kill('QUIT', $imap_pid);
+	}
+	if (my $idle_pids = $self->{idle_pids}) {
+		kill('QUIT', $_) for (keys %$idle_pids);
+	}
+	if (my $idle_mic = $self->{idle_mic}) {
+		eval { $idle_mic->done };
+		warn "IDLE DONE error: $@\n" if $@;
+		eval { $idle_mic->disconnect };
+		warn "IDLE LOGOUT error: $@\n" if $@;
+	}
+}
 
-sub watch {
+sub watch_fs {
 	my ($self) = @_;
 	my $scan = File::Temp->newdir("public-inbox-watch.$$.scan.XXXXXX",
 					TMPDIR => 1);
@@ -205,14 +231,355 @@ sub watch {
 	my $re = qr!\A$scandir/!;
 	my $cb = sub { _try_fsn_paths($self, $re, \@_) };
 
-	# lazy load here, we may support watching via IMAP IDLE
-	# in the future...
 	eval { require Filesys::Notify::Simple } or
 		die "Filesys::Notify::Simple is currently required for $0\n";
 	my $fsn = Filesys::Notify::Simple->new([@{$self->{mdir}}, $scandir]);
 	$fsn->wait($cb) until $self->{quit};
 }
 
+# returns the git config section name, e.g [imap "imaps://user@example.com"]
+# without the mailbox, so we can share connections between different inboxes
+sub imap_section ($) {
+	my ($uri) = @_;
+	$uri->scheme . '://' . $uri->authority;
+}
+
+sub cfg_intvl ($$) {
+	my ($cfg, $key) = @_;
+	defined(my $v = $cfg->{lc($key)}) or return;
+	$v =~ /\A[0-9]+\z/s and return $v + 0;
+	if (ref($v) eq 'ARRAY') {
+		$v = join(', ', @$v);
+		warn "W: $key has multiple values: $v\nW: $key ignored\n";
+	} else {
+		warn "W: $key=$v is not an integer value in seconds\n";
+	}
+}
+
+# flesh out common IMAP-specific data structures
+sub imap_common_init ($) {
+	my ($self) = @_;
+	my $cfg = $self->{config};
+	my $mic_args = {}; # scheme://authority => Mail:IMAPClient arg
+	for my $url (sort keys %{$self->{imap}}) {
+		my $uri = PublicInbox::URIimap->new($url);
+		my $sec = imap_section($uri);
+		for my $k (qw(Starttls Debug Compress)) {
+			my $key = lc("imap.$sec.$k");
+			defined(my $orig = $cfg->{$key}) or next;
+			my $v = PublicInbox::Config::_git_config_bool($orig);
+			if (defined($v)) {
+				$mic_args->{$sec}->{$k} = $v;
+			} else {
+				warn "W: $key=$orig is not boolean\n";
+			}
+		}
+		my $to = cfg_intvl($cfg, "imap.$sec.Timeout");
+		$mic_args->{$sec}->{Timeout} = $to if $to;
+		$to = cfg_intvl($cfg, "imap.$sec.PollInterval");
+		$self->{imap_opt}->{$sec}->{poll_intvl} = $to if $to;
+		$to = cfg_intvl($cfg, "imap.$sec.IdleInterval");
+		$self->{imap_opt}->{$sec}->{idle_intvl} = $to if $to;
+	}
+	$mic_args;
+}
+
+sub auth_anon_cb { '' }; # for Mail::IMAPClient::Authcallback
+
+sub mic_for ($$$) { # mic = Mail::IMAPClient
+	my ($self, $uri, $mic_args) = @_;
+	my $url = $uri->as_string;
+	my $cred = {
+		url => $url,
+		protocol => $uri->scheme,
+		host => $uri->host,
+		username => $uri->user,
+		password => $uri->password,
+	};
+	my $common = $mic_args->{imap_section($uri)} // {};
+	my $host = $cred->{host};
+	my $mic_arg = {
+		Port => $uri->port,
+		# IMAPClient mishandles `0', so we pass `127.0.0.1'
+		Server => $host eq '0' ? '127.0.0.1' : $host,
+		Ssl => $uri->scheme eq 'imaps',
+		Keepalive => 1, # SO_KEEPALIVE
+		%$common, # may set Starttls, Compress, Debug ....
+	};
+	my $mic = PublicInbox::IMAPClient->new(%$mic_arg) or
+		die "E: <$url> new: $@\n";
+
+	# default to using STARTTLS if it's available, but allow
+	# it to be disabled since I usually connect to localhost
+	if (!$mic_arg->{Ssl} && !defined($mic_arg->{Starttls}) &&
+			$mic->has_capability('STARTTLS') &&
+			$mic->can('starttls')) {
+		$mic->starttls or die "E: <$url> STARTTLS: $@\n";
+	}
+
+	# do we even need credentials?
+	if (!defined($cred->{username}) &&
+			$mic->has_capability('AUTH=ANONYMOUS')) {
+		$cred = undef;
+	}
+	if ($cred) {
+		Git::credential($cred, 'fill'); # may prompt user here
+		$mic->User($mic_arg->{User} = $cred->{username});
+		$mic->Password($mic_arg->{Password} = $cred->{password});
+	} else { # AUTH=ANONYMOUS
+		$mic->Authmechanism($mic_arg->{Authmechanism} = 'ANONYMOUS');
+		$mic->Authcallback($mic_arg->{Authcallback} = \&auth_anon_cb);
+	}
+	if ($mic->login && $mic->IsAuthenticated) {
+		# success! keep IMAPClient->new arg in case we get disconnected
+		$self->{mic_arg}->{imap_section($uri)} = $mic_arg;
+	} else {
+		warn "E: <$url> LOGIN: $@\n";
+		$mic = undef;
+	}
+	Git::credential($cred, $mic ? 'approve' : 'reject') if $cred;
+	$mic;
+}
+
+sub imap_start ($) {
+	my ($self) = @_;
+	eval { require PublicInbox::IMAPClient } or
+		die "Mail::IMAPClient is required for IMAP:\n$@\n";
+	eval { require Git } or
+		die "Git (Perl module) is required for IMAP:\n$@\n";
+	eval { require PublicInbox::IMAPTracker } or
+		die "DBD::SQLite is required for IMAP\n:$@\n";
+
+	my $mic_args = imap_common_init($self);
+	# make sure we can connect and cache the credentials in memory
+	$self->{mic_arg} = {}; # schema://authority => IMAPClient->new args
+	my $mics = $self->{mics} = {}; # schema://authority => IMAPClient obj
+	for my $url (sort keys %{$self->{imap}}) {
+		my $uri = PublicInbox::URIimap->new($url);
+		$mics->{imap_section($uri)} //= mic_for($self, $uri, $mic_args);
+	}
+}
+
+sub imap_fetch_all ($$$) {
+	my ($self, $mic, $uri) = @_;
+	my $sec = imap_section($uri);
+	my $mbx = $uri->mailbox;
+	my $url = $uri->as_string;
+	$mic->Clear(1); # trim results history
+	$mic->examine($mbx) or return "E: EXAMINE $mbx ($sec) failed: $!";
+	my ($r_uidval, $r_uidnext);
+	for ($mic->Results) {
+		/^\* OK \[UIDVALIDITY ([0-9]+)\].*/ and $r_uidval = $1;
+		/^\* OK \[UIDNEXT ([0-9]+)\].*/ and $r_uidnext = $1;
+		last if $r_uidval && $r_uidnext;
+	}
+	$r_uidval //= $mic->uidvalidity($mbx) //
+		return "E: $url cannot get UIDVALIDITY";
+	$r_uidnext //= $mic->uidnext($mbx) //
+		return "E: $url cannot get UIDNEXT";
+	my $itrk = PublicInbox::IMAPTracker->new;
+	my ($l_uidval, $l_uid) = $itrk->get_last($url);
+	$l_uidval //= $r_uidval; # first time
+	$l_uid //= 1;
+	if ($l_uidval != $r_uidval) {
+		return "E: $url UIDVALIDITY mismatch\n".
+			"E: local=$l_uidval != remote=$r_uidval";
+	}
+	my $r_uid = $r_uidnext - 1;
+	if ($l_uid != 1 && $l_uid > $r_uid) {
+		return "E: $url local UID exceeds remote ($l_uid > $r_uid)\n".
+			"E: $url strangely, UIDVALIDLITY matches ($l_uidval)\n";
+	}
+	return if $l_uid >= $r_uid; # nothing to do
+
+	$mic->Uid(1); # the default, we hope
+	my $req = $mic->imap4rev1 ? 'BODY.PEEK[]' : 'RFC822.PEEK';
+	my $key = $req;
+	$key =~ s/\.PEEK//;
+	my $inboxes = $self->{imap}->{$url};
+	warn "I: $url fetching $l_uid..$r_uid\n";
+	my $uid = -1;
+	my $warn_cb = $SIG{__WARN__} || sub { print STDERR @_ };
+	local $SIG{__WARN__} = sub {
+		$warn_cb->("$url UID:$uid\n");
+		$warn_cb->(@_);
+	};
+	my $err;
+	$itrk->{dbh}->begin_work;
+	for my $u ($l_uid..$r_uid) {
+		$uid = $u;
+		local $0 = "UID:$uid $mbx $sec";
+		my $r = $mic->fetch_hash($uid, $req);
+		unless ($r) { # network error?
+			$err = "E: $url UID FETCH $uid error: $!\n";
+			last;
+		}
+
+		# messages get deleted, so holes appear
+		defined(my $raw = delete $r->{$uid}->{$key}) or next;
+
+		# our target audience expects LF-only, save storage
+		$raw =~ s/\r\n/\n/sg;
+
+		if (ref($inboxes)) {
+			for my $ibx (@$inboxes) {
+				my $eml = PublicInbox::Eml->new($raw);
+				my $x = import_eml($self, $ibx, $eml);
+			}
+		} elsif ($inboxes eq 'watchspam') {
+			my $eml = PublicInbox::Eml->new($raw);
+			my $arg = [ $self, $eml, "$uri UID:$uid" ];
+			$self->{config}->each_inbox(\&remove_eml_i, $arg);
+		} else {
+			die "BUG: destination unknown $inboxes";
+		}
+		$itrk->update_last($url, $r_uidval, $uid);
+		last if $self->{quit};
+	}
+	_done_for_now($self);
+	$itrk->{dbh}->commit;
+	$err;
+}
+
+sub imap_idle_once ($$$$) {
+	my ($self, $mic, $intvl, $url) = @_;
+	my $i = $intvl //= (29 * 60);
+	my $end = now() + $intvl;
+	warn "I: $url idling for ${intvl}s\n";
+	local $0 = "IDLE $0";
+	unless ($mic->idle) {
+		return if $self->{quit};
+		return "E: IDLE failed on $url: $!";
+	}
+	$self->{idle_mic} = $mic; # for ->quit
+	my @res;
+	until ($self->{quit} || grep(/^\* [0-9]+ EXISTS/, @res) || $i <= 0) {
+		@res = $mic->idle_data($i);
+		$i = $end - now();
+	}
+	delete $self->{idle_mic};
+	unless ($self->{quit}) {
+		$mic->IsConnected or return "E: IDLE disconnected on $url";
+		$mic->done or return "E: IDLE DONE failed on $url: $!";
+	}
+	undef;
+}
+
+# idles on a single URI
+sub watch_imap_idle_1 ($$$) {
+	my ($self, $uri, $intvl) = @_;
+	my $sec = imap_section($uri);
+	my $mic_arg = $self->{mic_arg}->{$sec} or
+			die "BUG: no Mail::IMAPClient->new arg for $sec";
+	my $mic;
+	local $0 = $uri->mailbox." $sec";
+	until ($self->{quit}) {
+		$mic //= delete($self->{mics}->{$sec}) //
+				PublicInbox::IMAPClient->new(%$mic_arg);
+		my $err = imap_fetch_all($self, $mic, $uri);
+		$err //= imap_idle_once($self, $mic, $intvl, $uri->as_string);
+		if ($err && !$self->{quit}) {
+			warn $err, "\n";
+			$mic = undef;
+			sleep 60 unless $self->{quit};
+		}
+	}
+}
+
+sub watch_imap_idle_all ($$) {
+	my ($self, $idle) = @_; # $idle = [[ uri1, intvl1 ], [ uri2, intvl2 ]]
+	$self->{mics} = {}; # going to be forking, so disconnect
+	my $idle_pids = $self->{idle_pids} = {};
+	until ($self->{quit}) {
+		while (my $uri_intvl = shift @$idle) {
+			my ($uri, $intvl) = @$uri_intvl;
+			defined(my $pid = fork) or die "fork: $!";
+			if ($pid == 0) {
+				delete $self->{idle_pids};
+				watch_imap_idle_1($self, $uri, $intvl);
+				_exit(0);
+			}
+			$idle_pids->{$pid} = $uri_intvl;
+		}
+		my $pid = waitpid(-1, 0) or next;
+		if ($pid < 0) {
+			warn "W: no idling children: $!";
+			if (@$idle) {
+				sleep 60;
+			} else {
+				warn "W: nothing to respawn, quitting IDLE\n";
+				last;
+			}
+		}
+		if (my $uri_intvl = delete $idle_pids->{$pid}) {
+			my ($uri, $intvl) = @$uri_intvl;
+			my $url = $uri->as_string;
+			if ($? || !$self->{quit}) {
+				warn "W: PID=$pid on $url died: \$?=$?\n";
+			}
+			push @$idle, $uri_intvl;
+		} else {
+			warn "W: PID=$pid (unknown) reaped: \$?=$?\n";
+		}
+	}
+
+	# tear it all down
+	kill('QUIT', $_) for (keys %$idle_pids);
+	while (scalar keys %$idle_pids) {
+		if (my $pid = waitpid(-1, WNOHANG)) {
+			if ($pid < 0) {
+				warn "E: no children? $! (PIDs: ",
+					join(', ', keys %$idle_pids),")\n";
+				last;
+			} else {
+				delete $idle_pids->{$pid};
+			}
+		} else { # signals aren't that reliable w/o signalfd/kevent
+			sleep 1;
+			kill('QUIT', $_) for (keys %$idle_pids);
+		}
+	}
+}
+
+sub watch_imap ($) {
+	my ($self) = @_;
+	my $idle = []; # [ [ uri1, intvl1 ], [uri2, intvl2] ];
+	my $poll = {}; # intvl_seconds => [ uri1, uri2 ]
+	for my $url (keys %{$self->{imap}}) {
+		my $uri = PublicInbox::URIimap->new($url);
+		my $sec = imap_section($uri);
+		my $mic = $self->{mics}->{$sec};
+		my $intvl = $self->{imap_opt}->{$sec}->{poll_intvl};
+		if ($mic->has_capability('IDLE') && !$intvl) {
+			$intvl = $self->{imap_opt}->{$sec}->{idle_intvl};
+			push @$idle, [ $uri, $intvl // () ];
+		} else {
+			push @{$poll->{$intvl || 120}}, $uri;
+		}
+	}
+	my $nr_poll = scalar keys %$poll;
+	if (scalar @$idle && !$nr_poll) { # multiple idlers, need fork
+		watch_imap_idle_all($self, $idle);
+	}
+	# TODO: polling
+}
+
+sub watch {
+	my ($self) = @_;
+	if ($self->{mdre} && $self->{imap}) {
+		defined(my $pid = fork) or die "fork: $!";
+		if ($pid == 0) {
+			imap_start($self);
+			goto &watch_imap;
+		}
+		$self->{-imap_pid} = $pid;
+	} elsif ($self->{imap}) {
+		imap_start($self);
+		goto &watch_imap;
+	}
+	goto &watch_fs;
+}
+
 sub trigger_scan {
 	my ($self, $base) = @_;
 	my $dir = $self->{scandir} or return;
@@ -296,4 +663,22 @@ sub is_maildir {
 	$_[0];
 }
 
+sub is_watchspam {
+	my ($cur, $ws, $ibx) = @_;
+	if ($ws && !ref($ws) && $ws eq 'watchspam') {
+		warn <<EOF;
+E: $cur is a spam folder and cannot be used for `$ibx->{name}' input
+EOF
+		return 1;
+	}
+	undef;
+}
+
+sub imap_url {
+	my ($url) = @_;
+	require PublicInbox::URIimap;
+	my $uri = PublicInbox::URIimap->new($url);
+	$uri ? $uri->canonical->as_string : undef;
+}
+
 1;
diff --git a/t/imapd.t b/t/imapd.t
index ffa195d57ac..3f31743df2e 100644
--- a/t/imapd.t
+++ b/t/imapd.t
@@ -440,6 +440,45 @@ ok($mic->logout, 'logged out');
 	like(<$c>, qr/\Atagonly BAD Error in IMAP command/, 'tag-only line');
 }
 
+{
+	use_ok 'PublicInbox::WatchMaildir';
+	use_ok 'PublicInbox::InboxIdle';
+	my $home = "$tmpdir/watch_home";
+	mkdir $home or BAIL_OUT $!;
+	mkdir "$home/.public-inbox" or BAIL_OUT $!;
+	local $ENV{HOME} = $home;
+	my $name = 'watchimap';
+	my $addr = "i1\@example.com";
+	my $url = "http://example.com/i1";
+	my $inboxdir = "$tmpdir/watchimap";
+	my $cmd = ['-init', '-V2', '-Lbasic', $name, $inboxdir, $url, $addr];
+	my ($ihost, $iport) = ($sock->sockhost, $sock->sockport);
+	my $imapurl = "imap://$ihost:$iport/inbox.i1.0";
+	run_script($cmd) or BAIL_OUT("init $name");
+	xsys(qw(git config), "--file=$home/.public-inbox/config",
+			"publicinbox.$name.watch",
+			$imapurl) == 0 or BAIL_OUT "git config $?";
+	my $cfg = PublicInbox::Config->new;
+	PublicInbox::DS->Reset;
+	my $ii = PublicInbox::InboxIdle->new($cfg);
+	my $cb = sub { PublicInbox::DS->SetPostLoopCallback(sub {}) };
+	my $obj = bless \$cb, 'InboxWakeup';
+	$cfg->each_inbox(sub { $_[0]->subscribe_unlock('ident', $obj) });
+	open my $err, '+>', undef or BAIL_OUT $!;
+	my $w = start_script(['-watch'], undef, { 2 => $err });
+	PublicInbox::DS->EventLoop;
+	diag 'inbox unlocked';
+	$w->kill;
+	$w->join;
+	is($?, 0, 'no error in exited -watch process');
+	$cfg->each_inbox(sub { shift->unsubscribe_unlock('ident') });
+	$ii->close;
+	PublicInbox::DS->Reset;
+	seek($err, 0, 0);
+	my @err = grep(!/^I:/, <$err>);
+	is(@err, 0, 'no warnings/errors from -watch'.join(' ', @err));
+}
+
 $td->kill;
 $td->join;
 is($?, 0, 'no error in exited process');
@@ -449,3 +488,7 @@ unlike($eout, qr/wide/i, 'no Wide character warnings');
 unlike($eout, qr/uninitialized/i, 'no uninitialized warnings');
 
 done_testing;
+
+package InboxWakeup;
+use strict;
+sub on_inbox_unlock { ${$_[0]}->() }
diff --git a/t/watch_imap.t b/t/watch_imap.t
new file mode 100644
index 00000000000..9433bb6f443
--- /dev/null
+++ b/t/watch_imap.t
@@ -0,0 +1,21 @@
+# Copyright (C) 2020 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use Test::More;
+use PublicInbox::Config;
+# see t/imapd*.t for tests against a live IMAP server
+
+use_ok 'PublicInbox::WatchMaildir';
+my $cfg = PublicInbox::Config->new(\<<EOF);
+publicinbox.i.address=i\@example.com
+publicinbox.i.inboxdir=/nonexistent
+publicinbox.i.watch=imap://example.com/INBOX.a
+publicinboxlearn.watchspam=imap://example.com/INBOX.spam
+EOF
+my $watch = PublicInbox::WatchMaildir->new($cfg);
+is($watch->{imap}->{'imap://example.com/INBOX.a'}->[0]->{name}, 'i',
+	'watched an inbox');
+is($watch->{imap}->{'imap://example.com/INBOX.spam'}, 'watchspam',
+	'watched spam folder');
+
+done_testing;

  parent reply	other threads:[~2020-06-27 10:04 UTC|newest]

Thread overview: 46+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2020-06-27 10:03 [PATCH 00/34] watch: add IMAP and NNTP support Eric Wong
2020-06-27 10:03 ` [PATCH 01/34] inboxwritable: ensure ssoma.lock exists on init Eric Wong
2020-06-27 10:03 ` [PATCH 02/34] inbox: warn on ->on_inbox_unlock exception Eric Wong
2020-06-27 10:03 ` [PATCH 03/34] IMAPTracker: Add a helper to track our place in reading imap mailboxes Eric Wong
2020-06-27 10:03 ` [PATCH 04/34] imaptracker: use ~/.local/share/public-inbox/imap.sqlite3 Eric Wong
2020-06-27 10:03 ` [PATCH 05/34] watchmaildir: hoist out compile_watchheaders Eric Wong
2020-06-27 10:03 ` [PATCH 06/34] watchmaildir: fix check for spam vs ham inbox conflicts Eric Wong
2020-06-27 10:03 ` [PATCH 07/34] URI IMAP support Eric Wong
2020-06-27 10:03 ` Eric Wong [this message]
2020-06-27 10:03 ` [PATCH 09/34] kqnotify|fake_inotify: detect Maildir write ops Eric Wong
2020-06-27 10:03 ` [PATCH 10/34] watch: remove Filesys::Notify::Simple dependency Eric Wong
2020-06-27 10:03 ` [PATCH 11/34] watch: use signalfd for Maildir watching Eric Wong
2020-06-27 19:05   ` Kyle Meyer
2020-06-27 22:32     ` Eric Wong
2020-06-27 10:03 ` [PATCH 12/34] ds: remove fields.pm usage Eric Wong
2020-06-27 10:03 ` [PATCH 13/34] watch: wire up IMAP IDLE reapers to DS Eric Wong
2020-06-27 10:03 ` [PATCH 14/34] watch: support IMAP polling Eric Wong
2020-06-27 10:03 ` [PATCH 15/34] config: support ->urlmatch method for -watch Eric Wong
2020-06-27 10:03 ` [PATCH 16/34] watch: stop importers before forking Eric Wong
2020-06-27 10:03 ` [PATCH 17/34] watch: use UID SEARCH to avoid empty UID FETCH Eric Wong
2020-06-27 10:03 ` [PATCH 18/34] ds: add_timer: allow passing arg to callback Eric Wong
2020-06-27 10:03 ` [PATCH 19/34] imaptracker: add {url} field to reduce args Eric Wong
2020-06-27 10:03 ` [PATCH 20/34] imaptracker: drop {dbname} field Eric Wong
2020-06-27 10:03 ` [PATCH 21/34] watch: avoid long transaction to IMAPTracker Eric Wong
2020-06-27 10:03 ` [PATCH 22/34] watch: support imap.fetchBatchSize parameter Eric Wong
2020-06-27 10:03 ` [PATCH 23/34] watch: imap: be quiet about disconnecting on quit Eric Wong
2020-06-27 10:03 ` [PATCH 24/34] watch: support multiple watch: directives per-inbox Eric Wong
2020-06-27 10:03 ` [PATCH 25/34] watch: remove {mdir} array Eric Wong
2020-06-27 10:03 ` [PATCH 26/34] watch: just use ->urlmatch Eric Wong
2020-06-27 10:03 ` [PATCH 27/34] testcommon: $ENV{TAIL} supports non-@ARGV redirects Eric Wong
2020-06-27 10:03 ` [PATCH 28/34] watch: add NNTP support Eric Wong
2020-06-27 19:06   ` Kyle Meyer
2020-06-27 10:03 ` [PATCH 29/34] watch: show user-specified URL consistently Eric Wong
2020-06-27 10:03 ` [PATCH 30/34] watch: enable autoflush for STDOUT and STDERR Eric Wong
2020-06-27 10:03 ` [PATCH 31/34] watch: use our own "git credential" wrapper Eric Wong
2020-06-27 10:03 ` [PATCH 32/34] watch: support ~/.netrc via Net::Netrc Eric Wong
2020-06-27 10:03 ` [PATCH 33/34] imaptracker: use flock(2) around writes Eric Wong
2020-06-27 10:04 ` [PATCH 34/34] watch: simplify internal structures Eric Wong
2020-06-29 10:34 ` [PATCH 0/5] watch: Maildir fixes Eric Wong
2020-06-29 10:34   ` [PATCH 1/5] watch: check for duplicates in ->over before spamcheck Eric Wong
2020-06-29 10:34   ` [PATCH 2/5] watch: show path for warnings from spam messages Eric Wong
2020-06-29 10:34   ` [PATCH 3/5] watch: ensure SIGCHLD works in forked children Eric Wong
2020-06-29 10:34   ` [PATCH 4/5] spawn: unblock SIGCHLD in subprocess Eric Wong
2020-07-07  6:17     ` [PATCH 6/5] t/spawn: fix test reliability Eric Wong
2020-06-29 10:34   ` [PATCH 5/5] watch: make waitpid() synchronous for Maildir scans Eric Wong
2020-06-29 10:37     ` Eric Wong

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

  List information: https://public-inbox.org/README

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20200627100400.9871-9-e@yhbt.net \
    --to=e@yhbt.net \
    --cc=meta@public-inbox.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
Code repositories for project(s) associated with this public inbox

	https://80x24.org/public-inbox.git

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for read-only IMAP folder(s) and NNTP newsgroup(s).