+ 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 = uri_section($uri);
+ for my $k (qw(Starttls Debug Compress)) {
+ my $bool = cfg_bool($cfg, "imap.$k", $url) // next;
+ $mic_args->{$sec}->{$k} = $bool;
+ }
+ my $to = cfg_intvl($cfg, 'imap.timeout', $url);
+ $mic_args->{$sec}->{Timeout} = $to if $to;
+ for my $k (qw(pollInterval idleInterval)) {
+ $to = cfg_intvl($cfg, "imap.$k", $url) // next;
+ $self->{imap_opt}->{$sec}->{$k} = $to;
+ }
+ my $k = 'imap.fetchBatchSize';
+ my $bs = $cfg->urlmatch($k, $url) // next;
+ if ($bs =~ /\A([0-9]+)\z/) {
+ $self->{imap_opt}->{$sec}->{batch_size} = $bs;
+ } else {
+ warn "$k=$bs is not an integer\n";
+ }
+ }
+ $mic_args;
+}
+
+sub auth_anon_cb { '' }; # for Mail::IMAPClient::Authcallback
+
+sub mic_for ($$$) { # mic = Mail::IMAPClient
+ my ($self, $url, $mic_args) = @_;
+ my $uri = PublicInbox::URIimap->new($url);
+ require PublicInbox::GitCredential;
+ my $cred = bless {
+ url => $url,
+ protocol => $uri->scheme,
+ host => $uri->host,
+ username => $uri->user,
+ password => $uri->password,
+ }, 'PublicInbox::GitCredential';
+ my $common = $mic_args->{uri_section($uri)} // {};
+ # IMAPClient and Net::Netrc both mishandles `0', so we pass `127.0.0.1'
+ my $host = $cred->{host};
+ $host = '127.0.0.1' if $host eq '0';
+ my $mic_arg = {
+ Port => $uri->port,
+ Server => $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) {
+ $cred->check_netrc unless defined $cred->{password};
+ $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}->{uri_section($uri)} = $mic_arg;
+ } else {
+ warn "E: <$url> LOGIN: $@\n";
+ $mic = undef;
+ }
+ $cred->run($mic ? 'approve' : 'reject') if $cred;
+ $mic;
+}
+
+sub imap_import_msg ($$$$$) {
+ my ($self, $url, $uid, $raw, $flags) = @_;
+ # our target audience expects LF-only, save storage
+ $$raw =~ s/\r\n/\n/sg;
+
+ my $inboxes = $self->{imap}->{$url};
+ if (ref($inboxes)) {
+ for my $ibx (@$inboxes) {
+ my $eml = PublicInbox::Eml->new($$raw);
+ my $x = import_eml($self, $ibx, $eml);
+ }
+ } elsif ($inboxes eq 'watchspam') {
+ # we don't remove unseen messages
+ if ($flags =~ /\\Seen\b/) {
+ local $SIG{__WARN__} = warn_ignore_cb();
+ my $eml = PublicInbox::Eml->new($raw);
+ my $arg = [ $self, $eml, "$url UID:$uid" ];
+ $self->{config}->each_inbox(\&remove_eml_i, $arg);
+ }
+ } else {
+ die "BUG: destination unknown $inboxes";
+ }
+}
+
+sub imap_fetch_all ($$$) {
+ my ($self, $mic, $url) = @_;
+ my $uri = PublicInbox::URIimap->new($url);
+ my $sec = uri_section($uri);
+ my $mbx = $uri->mailbox;
+ $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($url);
+ my ($l_uidval, $l_uid) = $itrk->get_last;
+ $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
+
+ warn "I: $url fetching UID $l_uid:$r_uid\n";
+ $mic->Uid(1); # the default, we hope
+ my $bs = $self->{imap_opt}->{$sec}->{batch_size} // 1;
+ my $req = $mic->imap4rev1 ? 'BODY.PEEK[]' : 'RFC822.PEEK';
+
+ # TODO: FLAGS may be useful for personal use
+ my $key = $req;
+ $key =~ s/\.PEEK//;
+ my ($uids, $batch);
+ my $warn_cb = $SIG{__WARN__} || sub { print STDERR @_ };
+ local $SIG{__WARN__} = sub {
+ my $pfx = ($_[0] // '') =~ /^([A-Z]: )/g ? $1 : '';
+ $batch //= '?';
+ $warn_cb->("$pfx$url UID:$batch\n", @_);
+ };
+ my $err;
+ do {
+ # I wish "UID FETCH $START:*" could work, but:
+ # 1) servers do not need to return results in any order
+ # 2) Mail::IMAPClient doesn't offer a streaming API
+ $uids = $mic->search("UID $l_uid:*") or
+ return "E: $url UID SEARCH $l_uid:* error: $!";
+ return if scalar(@$uids) == 0;
+
+ # RFC 3501 doesn't seem to indicate order of UID SEARCH
+ # responses, so sort it ourselves. Order matters so
+ # IMAPTracker can store the newest UID.
+ @$uids = sort { $a <=> $b } @$uids;
+
+ # Did we actually get new messages?
+ return if $uids->[0] < $l_uid;
+
+ $l_uid = $uids->[-1] + 1; # for next search
+ my $last_uid;
+
+ while (scalar @$uids) {
+ my @batch = splice(@$uids, 0, $bs);
+ $batch = join(',', @batch);
+ local $0 = "UID:$batch $mbx $sec";
+ my $r = $mic->fetch_hash($batch, $req, 'FLAGS');
+ unless ($r) { # network error?
+ $err = "E: $url UID FETCH $batch error: $!";
+ last;
+ }
+ for my $uid (@batch) {
+ # messages get deleted, so holes appear
+ my $per_uid = delete $r->{$uid} // next;
+ my $raw = delete($per_uid->{$key}) // next;
+ my $fl = $per_uid->{FLAGS} // '';
+ imap_import_msg($self, $url, $uid, \$raw, $fl);
+ $last_uid = $uid;
+ last if $self->{quit};
+ }
+ last if $self->{quit};
+ }
+ _done_for_now($self);
+ $itrk->update_last($r_uidval, $last_uid);
+ } until ($err || $self->{quit});
+ $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} || !$mic->IsConnected ||
+ 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, $url, $intvl) = @_;
+ my $uri = PublicInbox::URIimap->new($url);
+ my $sec = uri_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 //= PublicInbox::IMAPClient->new(%$mic_arg);
+ my $err;
+ if ($mic && $mic->IsConnected) {
+ $err = imap_fetch_all($self, $mic, $url);
+ $err //= imap_idle_once($self, $mic, $intvl, $url);
+ } else {
+ $err = "not connected: $!";
+ }
+ if ($err && !$self->{quit}) {
+ warn $err, "\n";
+ $mic = undef;
+ sleep 60 unless $self->{quit};
+ }
+ }
+}
+
+sub watch_atfork_child ($) {
+ my ($self) = @_;
+ delete $self->{idle_pids};
+ delete $self->{poll_pids};
+ delete $self->{opendirs};
+ PublicInbox::DS->Reset;
+ %SIG = (%SIG, %{$self->{sig}}, CHLD => 'DEFAULT');
+ PublicInbox::Sigfd::sig_setmask($self->{oldset});
+}
+
+sub watch_atfork_parent ($) {
+ my ($self) = @_;
+ _done_for_now($self);
+}
+
+sub imap_idle_requeue ($) { # DS::add_timer callback
+ my ($self, $url_intvl) = @{$_[0]};
+ return if $self->{quit};
+ push @{$self->{idle_todo}}, $url_intvl;
+ event_step($self);
+}
+
+sub imap_idle_reap { # PublicInbox::DS::dwaitpid callback
+ my ($self, $pid) = @_;
+ my $url_intvl = delete $self->{idle_pids}->{$pid} or
+ die "BUG: PID=$pid (unknown) reaped: \$?=$?\n";
+
+ my ($url, $intvl) = @$url_intvl;
+ return if $self->{quit};
+ warn "W: PID=$pid on $url died: \$?=$?\n" if $?;
+ PublicInbox::DS::add_timer(60,
+ \&imap_idle_requeue, [ $self, $url_intvl ]);
+}
+
+sub imap_idle_fork ($$) {
+ my ($self, $url_intvl) = @_;
+ my ($url, $intvl) = @$url_intvl;
+ defined(my $pid = fork) or die "fork: $!";
+ if ($pid == 0) {
+ watch_atfork_child($self);
+ watch_imap_idle_1($self, $url, $intvl);
+ _exit(0);
+ }
+ $self->{idle_pids}->{$pid} = $url_intvl;
+ PublicInbox::DS::dwaitpid($pid, \&imap_idle_reap, $self);
+}
+
+sub event_step {
+ my ($self) = @_;
+ return if $self->{quit};
+ my $idle_todo = $self->{idle_todo};
+ if ($idle_todo && @$idle_todo) {
+ watch_atfork_parent($self);
+ while (my $url_intvl = shift(@$idle_todo)) {
+ imap_idle_fork($self, $url_intvl);
+ }
+ }
+ goto(&fs_scan_step) if $self->{mdre};
+}
+
+sub watch_imap_fetch_all ($$) {
+ my ($self, $urls) = @_;
+ for my $url (@$urls) {
+ my $uri = PublicInbox::URIimap->new($url);
+ my $sec = uri_section($uri);
+ my $mic_arg = $self->{mic_arg}->{$sec} or
+ die "BUG: no Mail::IMAPClient->new arg for $sec";
+ my $mic = PublicInbox::IMAPClient->new(%$mic_arg) or next;
+ my $err = imap_fetch_all($self, $mic, $url);
+ last if $self->{quit};
+ warn $err, "\n" if $err;
+ }
+}
+
+sub watch_nntp_fetch_all ($$) {
+ my ($self, $urls) = @_;
+ for my $url (@$urls) {
+ my $uri = uri_new($url);
+ my $sec = uri_section($uri);
+ my $nn_arg = $self->{nn_arg}->{$sec} or
+ die "BUG: no Net::NNTP->new arg for $sec";
+ my $nntp_opt = $self->{nntp_opt}->{$sec};
+ my $nn = nn_new($nn_arg, $nntp_opt, $url);
+ unless ($nn) {
+ warn "E: $url: \$!=$!\n";