]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/NetReader.pm
net_reader: support (imap|nntp).proxy in config file
[public-inbox.git] / lib / PublicInbox / NetReader.pm
1 # Copyright (C) 2021 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3
4 # common reader code for IMAP and NNTP (and maybe JMAP)
5 package PublicInbox::NetReader;
6 use strict;
7 use v5.10.1;
8 use parent qw(Exporter PublicInbox::IPC);
9 use PublicInbox::Eml;
10 use PublicInbox::Config;
11 our %IMAPflags2kw = map {; "\\\u$_" => $_ } qw(seen answered flagged draft);
12 $IMAPflags2kw{'$Forwarded'} = 'forwarded';  # RFC 5550
13
14 our @EXPORT = qw(uri_section imap_uri nntp_uri);
15
16 sub ndump {
17         require Data::Dumper;
18         Data::Dumper->new(\@_)->Useqq(1)->Terse(1)->Dump;
19 }
20
21 # returns the git config section name, e.g [imap "imaps://user@example.com"]
22 # without the mailbox, so we can share connections between different inboxes
23 sub uri_section ($) {
24         my ($uri) = @_;
25         $uri->scheme . '://' . $uri->authority;
26 }
27
28 sub socks_args ($) {
29         my ($val) = @_;
30         return if ($val // '') eq '';
31         if ($val =~ m!\Asocks5h:// (?: \[ ([^\]]+) \] | ([^:/]+) )
32                                         (?::([0-9]+))?/*\z!ix) {
33                 my ($h, $p) = ($1 // $2, $3 + 0);
34                 $h = '127.0.0.1' if $h eq '0';
35                 eval { require IO::Socket::Socks } or die <<EOM;
36 IO::Socket::Socks missing for socks5h://$h:$p
37 EOM
38                 return { ProxyAddr => $h, ProxyPort => $p };
39         }
40         die "$val not understood (only socks5h:// is supported)\n";
41 }
42
43 sub mic_new ($$$$) {
44         my ($self, $mic_arg, $sec, $uri) = @_;
45         my %socks;
46         my $sa = $self->{imap_opt}->{$sec}->{-proxy_cfg} || $self->{-proxy_cli};
47         if ($sa) {
48                 my %opt = %$sa;
49                 $opt{ConnectAddr} = delete $mic_arg->{Server};
50                 $opt{ConnectPort} = delete $mic_arg->{Port};
51                 $socks{Socket} = IO::Socket::Socks->new(%opt) or die
52                         "E: <$$uri> ".eval('$IO::Socket::Socks::SOCKS_ERROR');
53         }
54         PublicInbox::IMAPClient->new(%$mic_arg, %socks);
55 }
56
57 sub auth_anon_cb { '' }; # for Mail::IMAPClient::Authcallback
58
59 # mic_for may prompt the user and store auth info, prepares mic_get
60 sub mic_for ($$$$) { # mic = Mail::IMAPClient
61         my ($self, $url, $mic_args, $lei) = @_;
62         require PublicInbox::URIimap;
63         my $uri = PublicInbox::URIimap->new($url);
64         require PublicInbox::GitCredential;
65         my $cred = bless {
66                 url => $url,
67                 protocol => $uri->scheme,
68                 host => $uri->host,
69                 username => $uri->user,
70                 password => $uri->password,
71         }, 'PublicInbox::GitCredential';
72         my $sec = uri_section($uri);
73         my $common = $mic_args->{$sec} // {};
74         # IMAPClient and Net::Netrc both mishandles `0', so we pass `127.0.0.1'
75         my $host = $cred->{host};
76         $host = '127.0.0.1' if $host eq '0';
77         my $mic_arg = {
78                 Port => $uri->port,
79                 Server => $host,
80                 Ssl => $uri->scheme eq 'imaps',
81                 Keepalive => 1, # SO_KEEPALIVE
82                 %$common, # may set Starttls, Compress, Debug ....
83         };
84         require PublicInbox::IMAPClient;
85         my $mic = mic_new($self, $mic_arg, $sec, $uri) or
86                         die "E: <$url> new: $@\n";
87         # default to using STARTTLS if it's available, but allow
88         # it to be disabled since I usually connect to localhost
89         if (!$mic_arg->{Ssl} && !defined($mic_arg->{Starttls}) &&
90                         $mic->has_capability('STARTTLS') &&
91                         $mic->can('starttls')) {
92                 $mic->starttls or die "E: <$url> STARTTLS: $@\n";
93         }
94
95         # do we even need credentials?
96         if (!defined($cred->{username}) &&
97                         $mic->has_capability('AUTH=ANONYMOUS')) {
98                 $cred = undef;
99         }
100         if ($cred) {
101                 $cred->check_netrc unless defined $cred->{password};
102                 $cred->fill($lei); # may prompt user here
103                 $mic->User($mic_arg->{User} = $cred->{username});
104                 $mic->Password($mic_arg->{Password} = $cred->{password});
105         } else { # AUTH=ANONYMOUS
106                 $mic->Authmechanism($mic_arg->{Authmechanism} = 'ANONYMOUS');
107                 $mic_arg->{Authcallback} = 'auth_anon_cb';
108                 $mic->Authcallback(\&auth_anon_cb);
109         }
110         my $err;
111         if ($mic->login && $mic->IsAuthenticated) {
112                 # success! keep IMAPClient->new arg in case we get disconnected
113                 $self->{mic_arg}->{$sec} = $mic_arg;
114         } else {
115                 $err = "E: <$url> LOGIN: $@\n";
116                 if ($cred && defined($cred->{password})) {
117                         $err =~ s/\Q$cred->{password}\E/*******/g;
118                 }
119                 $mic = undef;
120         }
121         $cred->run($mic ? 'approve' : 'reject') if $cred;
122         if ($err) {
123                 $lei ? $lei->fail($err) : warn($err);
124         }
125         $mic;
126 }
127
128 # Net::NNTP doesn't support CAPABILITIES, yet
129 sub try_starttls ($) {
130         my ($host) = @_;
131         return if $host =~ /\.onion\z/s;
132         return if $host =~ /\A127\.[0-9]+\.[0-9]+\.[0-9]+\z/s;
133         return if $host eq '::1';
134         1;
135 }
136
137 sub nn_new ($$$) {
138         my ($nn_arg, $nntp_opt, $uri) = @_;
139         my $nn;
140         if (defined $nn_arg->{ProxyAddr}) {
141                 require PublicInbox::NetNNTPSocks;
142                 eval { $nn = PublicInbox::NetNNTPSocks->new_socks(%$nn_arg) };
143                 die "E: <$uri> $@\n" if $@;
144         } else {
145                 $nn = Net::NNTP->new(%$nn_arg) or die "E: <$uri> new: $!\n";
146         }
147
148         # default to using STARTTLS if it's available, but allow
149         # it to be disabled for localhost/VPN users
150         if (!$nn_arg->{SSL} && $nn->can('starttls')) {
151                 if (!defined($nntp_opt->{starttls}) &&
152                                 try_starttls($nn_arg->{Host})) {
153                         # soft fail by default
154                         $nn->starttls or warn <<"";
155 W: <$uri> STARTTLS tried and failed (not requested)
156
157                 } elsif ($nntp_opt->{starttls}) {
158                         # hard fail if explicitly configured
159                         $nn->starttls or die <<"";
160 E: <$uri> STARTTLS requested and failed
161
162                 }
163         } elsif ($nntp_opt->{starttls}) {
164                 $nn->can('starttls') or
165                         die "E: <$uri> Net::NNTP too old for STARTTLS\n";
166                 $nn->starttls or die <<"";
167 E: <$uri> STARTTLS requested and failed
168
169         }
170         $nn;
171 }
172
173 sub nn_for ($$$$) { # nn = Net::NNTP
174         my ($self, $uri, $nn_args, $lei) = @_;
175         my $sec = uri_section($uri);
176         my $nntp_opt = $self->{nntp_opt}->{$sec} //= {};
177         my $host = $uri->host;
178         # Net::NNTP and Net::Netrc both mishandle `0', so we pass `127.0.0.1'
179         $host = '127.0.0.1' if $host eq '0';
180         my $cred;
181         my ($u, $p);
182         if (defined(my $ui = $uri->userinfo)) {
183                 require PublicInbox::GitCredential;
184                 $cred = bless {
185                         url => $sec,
186                         protocol => $uri->scheme,
187                         host => $host,
188                 }, 'PublicInbox::GitCredential';
189                 ($u, $p) = split(/:/, $ui, 2);
190                 ($cred->{username}, $cred->{password}) = ($u, $p);
191                 $cred->check_netrc unless defined $p;
192         }
193         my $common = $nn_args->{$sec} // {};
194         my $nn_arg = {
195                 Port => $uri->port,
196                 Host => $host,
197                 SSL => $uri->secure, # snews == nntps
198                 %$common, # may Debug ....
199         };
200         my $sa = $self->{-proxy_cli};
201         %$nn_arg = (%$nn_arg, %$sa) if $sa;
202         my $nn = nn_new($nn_arg, $nntp_opt, $uri);
203         if ($cred) {
204                 $cred->fill($lei); # may prompt user here
205                 if ($nn->authinfo($u, $p)) {
206                         push @{$nntp_opt->{-postconn}}, [ 'authinfo', $u, $p ];
207                 } else {
208                         warn "E: <$uri> AUTHINFO $u XXXX failed\n";
209                         $nn = undef;
210                 }
211         }
212
213         if ($nntp_opt->{compress}) {
214                 # https://rt.cpan.org/Ticket/Display.html?id=129967
215                 if ($nn->can('compress')) {
216                         if ($nn->compress) {
217                                 push @{$nntp_opt->{-postconn}}, [ 'compress' ];
218                         } else {
219                                 warn "W: <$uri> COMPRESS failed\n";
220                         }
221                 } else {
222                         delete $nntp_opt->{compress};
223                         warn <<"";
224 W: <$uri> COMPRESS not supported by Net::NNTP
225 W: see https://rt.cpan.org/Ticket/Display.html?id=129967 for updates
226
227                 }
228         }
229
230         $self->{nn_arg}->{$sec} = $nn_arg;
231         $cred->run($nn ? 'approve' : 'reject') if $cred;
232         $nn;
233 }
234
235 sub imap_uri {
236         my ($url) = @_;
237         require PublicInbox::URIimap;
238         my $uri = PublicInbox::URIimap->new($url);
239         $uri ? $uri->canonical : undef;
240 }
241
242 my %IS_NNTP = (news => 1, snews => 1, nntp => 1, nntps => 1);
243 sub nntp_uri {
244         my ($url) = @_;
245         require PublicInbox::URInntps;
246         my $uri = PublicInbox::URInntps->new($url);
247         $uri && $IS_NNTP{$uri->scheme} && $uri->group ? $uri->canonical : undef;
248 }
249
250 sub cfg_intvl ($$$) {
251         my ($cfg, $key, $url) = @_;
252         my $v = $cfg->urlmatch($key, $url) // return;
253         $v =~ /\A[0-9]+(?:\.[0-9]+)?\z/s and return $v + 0;
254         if (ref($v) eq 'ARRAY') {
255                 $v = join(', ', @$v);
256                 warn "W: $key has multiple values: $v\nW: $key ignored\n";
257         } else {
258                 warn "W: $key=$v is not a numeric value in seconds\n";
259         }
260 }
261
262 sub cfg_bool ($$$) {
263         my ($cfg, $key, $url) = @_;
264         my $orig = $cfg->urlmatch($key, $url) // return;
265         my $bool = $cfg->git_bool($orig);
266         warn "W: $key=$orig for $url is not boolean\n" unless defined($bool);
267         $bool;
268 }
269
270 # flesh out common IMAP-specific data structures
271 sub imap_common_init ($;$) {
272         my ($self, $lei) = @_;
273         return unless $self->{imap_order};
274         $self->{quiet} = 1 if $lei && $lei->{opt}->{quiet};
275         eval { require PublicInbox::IMAPClient } or
276                 die "Mail::IMAPClient is required for IMAP:\n$@\n";
277         ($lei || eval { require PublicInbox::IMAPTracker }) or
278                 die "DBD::SQLite is required for IMAP\n:$@\n";
279         require PublicInbox::URIimap;
280         my $cfg = $self->{pi_cfg} // $lei->_lei_cfg;
281         my $mic_args = {}; # scheme://authority => Mail:IMAPClient arg
282         for my $uri (@{$self->{imap_order}}) {
283                 my $sec = uri_section($uri);
284                 for my $k (qw(Starttls Debug Compress)) {
285                         my $bool = cfg_bool($cfg, "imap.$k", $$uri) // next;
286                         $mic_args->{$sec}->{$k} = $bool;
287                 }
288                 my $to = cfg_intvl($cfg, 'imap.timeout', $$uri);
289                 $mic_args->{$sec}->{Timeout} = $to if $to;
290                 my $sa = socks_args($cfg->urlmatch('imap.Proxy', $$uri));
291                 $self->{imap_opt}->{$sec}->{-proxy_cfg} = $sa if $sa;
292                 for my $k (qw(pollInterval idleInterval)) {
293                         $to = cfg_intvl($cfg, "imap.$k", $$uri) // next;
294                         $self->{imap_opt}->{$sec}->{$k} = $to;
295                 }
296                 my $k = 'imap.fetchBatchSize';
297                 my $bs = $cfg->urlmatch($k, $$uri) // next;
298                 if ($bs =~ /\A([0-9]+)\z/) {
299                         $self->{imap_opt}->{$sec}->{batch_size} = $bs;
300                 } else {
301                         warn "$k=$bs is not an integer\n";
302                 }
303         }
304         # make sure we can connect and cache the credentials in memory
305         $self->{mic_arg} = {}; # schema://authority => IMAPClient->new args
306         my $mics = {}; # schema://authority => IMAPClient obj
307         for my $uri (@{$self->{imap_order}}) {
308                 my $sec = uri_section($uri);
309                 my $mic = $mics->{$sec} //=
310                                 mic_for($self, "$sec/", $mic_args, $lei) //
311                                 die "Unable to continue\n";
312                 next unless $self->isa('PublicInbox::NetWriter');
313                 my $dst = $uri->mailbox // next;
314                 next if $mic->exists($dst); # already exists
315                 $mic->create($dst) or die "CREATE $dst failed <$uri>: $@";
316         }
317         $mics;
318 }
319
320 # flesh out common NNTP-specific data structures
321 sub nntp_common_init ($;$) {
322         my ($self, $lei) = @_;
323         return unless $self->{nntp_order};
324         $self->{quiet} = 1 if $lei && $lei->{opt}->{quiet};
325         eval { require Net::NNTP } or
326                 die "Net::NNTP is required for NNTP:\n$@\n";
327         ($lei || eval { require PublicInbox::IMAPTracker }) or
328                 die "DBD::SQLite is required for NNTP\n:$@\n";
329         my $cfg = $self->{pi_cfg} // $lei->_lei_cfg;
330         my $nn_args = {}; # scheme://authority => Net::NNTP->new arg
331         for my $uri (@{$self->{nntp_order}}) {
332                 my $sec = uri_section($uri);
333                 my $args = $nn_args->{$sec} //= {};
334
335                 # Debug and Timeout are passed to Net::NNTP->new
336                 my $v = cfg_bool($cfg, 'nntp.Debug', $$uri);
337                 $args->{Debug} = $v if defined $v;
338                 my $to = cfg_intvl($cfg, 'nntp.Timeout', $$uri);
339                 $args->{Timeout} = $to if $to;
340                 my $sa = socks_args($cfg->urlmatch('nntp.Proxy', $$uri));
341                 %$args = (%$args, %$sa) if $sa;
342
343                 # Net::NNTP post-connect commands
344                 for my $k (qw(starttls compress)) {
345                         $v = cfg_bool($cfg, "nntp.$k", $$uri) // next;
346                         $self->{nntp_opt}->{$sec}->{$k} = $v;
347                 }
348
349                 # -watch internal option
350                 for my $k (qw(pollInterval)) {
351                         $to = cfg_intvl($cfg, "nntp.$k", $$uri) // next;
352                         $self->{nntp_opt}->{$sec}->{$k} = $to;
353                 }
354         }
355         # make sure we can connect and cache the credentials in memory
356         $self->{nn_arg} = {}; # schema://authority => Net::NNTP->new args
357         my %nn; # schema://authority => Net::NNTP object
358         for my $uri (@{$self->{nntp_order}}) {
359                 my $sec = uri_section($uri);
360                 $nn{$sec} //= nn_for($self, $uri, $nn_args, $lei);
361         }
362         \%nn; # for optional {nn_cached}
363 }
364
365 sub add_url {
366         my ($self, $arg) = @_;
367         my $uri;
368         if ($uri = imap_uri($arg)) {
369                 push @{$self->{imap_order}}, $uri;
370         } elsif ($uri = nntp_uri($arg)) {
371                 push @{$self->{nntp_order}}, $uri;
372         } else {
373                 push @{$self->{unsupported_url}}, $arg;
374         }
375 }
376
377 sub errors {
378         my ($self, $lei) = @_;
379         if (my $u = $self->{unsupported_url}) {
380                 return "Unsupported URL(s): @$u";
381         }
382         if ($self->{imap_order}) {
383                 eval { require PublicInbox::IMAPClient } or
384                         die "Mail::IMAPClient is required for IMAP:\n$@\n";
385         }
386         if ($self->{nntp_order}) {
387                 eval { require Net::NNTP } or
388                         die "Net::NNTP is required for NNTP:\n$@\n";
389         }
390         my $sa = socks_args($lei ? $lei->{opt}->{proxy} : undef);
391         $self->{-proxy_cli} = $sa if $sa;
392         undef;
393 }
394
395 sub _imap_do_msg ($$$$$) {
396         my ($self, $url, $uid, $raw, $flags) = @_;
397         # our target audience expects LF-only, save storage
398         $$raw =~ s/\r\n/\n/sg;
399         my $kw = [];
400         for my $f (split(/ /, $flags)) {
401                 if (my $k = $IMAPflags2kw{$f}) {
402                         push @$kw, $k;
403                 } elsif ($f eq "\\Recent") { # not in JMAP
404                 } elsif ($f eq "\\Deleted") { # not in JMAP
405                         return;
406                 } elsif ($self->{verbose}) {
407                         warn "# unknown IMAP flag $f <$url/;UID=$uid>\n";
408                 }
409         }
410         @$kw = sort @$kw; # for all UI/UX purposes
411         my ($eml_cb, @args) = @{$self->{eml_each}};
412         $eml_cb->($url, $uid, $kw, PublicInbox::Eml->new($raw), @args);
413 }
414
415 sub run_commit_cb ($) {
416         my ($self) = @_;
417         my $cmt_cb_args = $self->{on_commit} or return;
418         my ($cb, @args) = @$cmt_cb_args;
419         $cb->(@args);
420 }
421
422 sub _itrk_last ($$;$) {
423         my ($self, $uri, $r_uidval) = @_;
424         return (undef, undef, $r_uidval) unless $self->{incremental};
425         my ($itrk, $l_uid, $l_uidval);
426         if (defined(my $lms = $self->{-lms_ro})) { # LeiMailSync or 0
427                 $uri->uidvalidity($r_uidval) if defined $r_uidval;
428                 my $x;
429                 $l_uid = ($lms && ($x = $lms->location_stats($$uri))) ?
430                                 $x->{'uid.max'} : undef;
431                 # itrk remains undef, lei/store worker writes to
432                 # mail_sync.sqlite3
433         } else {
434                 $itrk = PublicInbox::IMAPTracker->new($$uri);
435                 ($l_uidval, $l_uid) = $itrk->get_last($$uri);
436         }
437         ($itrk, $l_uid, $l_uidval //= $r_uidval);
438 }
439
440 sub _imap_fetch_all ($$$) {
441         my ($self, $mic, $orig_uri) = @_;
442         my $sec = uri_section($orig_uri);
443         my $mbx = $orig_uri->mailbox;
444         $mic->Clear(1); # trim results history
445         $mic->examine($mbx) or return "E: EXAMINE $mbx ($sec) failed: $!";
446         my ($r_uidval, $r_uidnext);
447         for ($mic->Results) {
448                 /^\* OK \[UIDVALIDITY ([0-9]+)\].*/ and $r_uidval = $1;
449                 /^\* OK \[UIDNEXT ([0-9]+)\].*/ and $r_uidnext = $1;
450                 last if $r_uidval && $r_uidnext;
451         }
452         $r_uidval //= $mic->uidvalidity($mbx) //
453                 return "E: $orig_uri cannot get UIDVALIDITY";
454         $r_uidnext //= $mic->uidnext($mbx) //
455                 return "E: $orig_uri cannot get UIDNEXT";
456         my $expect = $orig_uri->uidvalidity // $r_uidval;
457         return <<EOF if $expect != $r_uidval;
458 E: $orig_uri UIDVALIDITY mismatch (got $r_uidval)
459 EOF
460
461         my $uri = $orig_uri->clone;
462         my ($itrk, $l_uid, $l_uidval) = _itrk_last($self, $uri, $r_uidval);
463         return <<EOF if $l_uidval != $r_uidval;
464 E: $uri UIDVALIDITY mismatch
465 E: local=$l_uidval != remote=$r_uidval
466 EOF
467         $uri->uidvalidity($r_uidval);
468         $l_uid //= 0;
469         my $r_uid = $r_uidnext - 1;
470         return <<EOF if $l_uid > $r_uid;
471 E: $uri local UID exceeds remote ($l_uid > $r_uid)
472 E: $uri strangely, UIDVALIDLITY matches ($l_uidval)
473 EOF
474         return if $l_uid >= $r_uid; # nothing to do
475         $l_uid ||= 1;
476         my ($mod, $shard) = @{$self->{shard_info} // []};
477         unless ($self->{quiet}) {
478                 my $m = $mod ? " [(UID % $mod) == $shard]" : '';
479                 warn "# $uri fetching UID $l_uid:$r_uid$m\n";
480         }
481         $mic->Uid(1); # the default, we hope
482         my $bs = $self->{imap_opt}->{$sec}->{batch_size} // 1;
483         my $req = $mic->imap4rev1 ? 'BODY.PEEK[]' : 'RFC822.PEEK';
484         my $key = $req;
485         $key =~ s/\.PEEK//;
486         my ($uids, $batch);
487         my $err;
488         do {
489                 # I wish "UID FETCH $START:*" could work, but:
490                 # 1) servers do not need to return results in any order
491                 # 2) Mail::IMAPClient doesn't offer a streaming API
492                 unless ($uids = $mic->search("UID $l_uid:*")) {
493                         return if $!{EINTR} && $self->{quit};
494                         return "E: $uri UID SEARCH $l_uid:* error: $!";
495                 }
496                 return if scalar(@$uids) == 0;
497
498                 # RFC 3501 doesn't seem to indicate order of UID SEARCH
499                 # responses, so sort it ourselves.  Order matters so
500                 # IMAPTracker can store the newest UID.
501                 @$uids = sort { $a <=> $b } @$uids;
502
503                 # Did we actually get new messages?
504                 return if $uids->[0] < $l_uid;
505
506                 $l_uid = $uids->[-1] + 1; # for next search
507                 my $last_uid;
508                 my $n = $self->{max_batch};
509
510                 @$uids = grep { ($_ % $mod) == $shard } @$uids if $mod;
511                 while (scalar @$uids) {
512                         my @batch = splice(@$uids, 0, $bs);
513                         $batch = join(',', @batch);
514                         local $0 = "UID:$batch $mbx $sec";
515                         my $r = $mic->fetch_hash($batch, $req, 'FLAGS');
516                         unless ($r) { # network error?
517                                 last if $!{EINTR} && $self->{quit};
518                                 $err = "E: $uri UID FETCH $batch error: $!";
519                                 last;
520                         }
521                         for my $uid (@batch) {
522                                 # messages get deleted, so holes appear
523                                 my $per_uid = delete $r->{$uid} // next;
524                                 my $raw = delete($per_uid->{$key}) // next;
525                                 _imap_do_msg($self, $$uri, $uid, \$raw,
526                                                 $per_uid->{FLAGS});
527                                 $last_uid = $uid;
528                                 last if $self->{quit};
529                         }
530                         last if $self->{quit};
531                 }
532                 run_commit_cb($self);
533                 $itrk->update_last($r_uidval, $last_uid) if $itrk;
534         } until ($err || $self->{quit});
535         $err;
536 }
537
538 # uses cached auth info prepared by mic_for
539 sub mic_get {
540         my ($self, $uri) = @_;
541         my $sec = uri_section($uri);
542         # see if caller saved result of imap_common_init
543         my $cached = $self->{mics_cached};
544         if ($cached) {
545                 my $mic = $cached->{$sec};
546                 return $mic if $mic && $mic->IsConnected;
547                 delete $cached->{$sec};
548         }
549         my $mic_arg = $self->{mic_arg}->{$sec} or
550                         die "BUG: no Mail::IMAPClient->new arg for $sec";
551         if (defined(my $cb_name = $mic_arg->{Authcallback})) {
552                 if (ref($cb_name) ne 'CODE') {
553                         $mic_arg->{Authcallback} = $self->can($cb_name);
554                 }
555         }
556         my $mic = mic_new($self, $mic_arg, $sec, $uri);
557         $cached //= {}; # invalid placeholder if no cache enabled
558         $mic && $mic->IsConnected ? ($cached->{$sec} = $mic) : undef;
559 }
560
561 sub imap_each {
562         my ($self, $url, $eml_cb, @args) = @_;
563         my $uri = ref($url) ? $url : PublicInbox::URIimap->new($url);
564         my $sec = uri_section($uri);
565         local $0 = $uri->mailbox." $sec";
566         my $mic = mic_get($self, $uri);
567         my $err;
568         if ($mic) {
569                 local $self->{eml_each} = [ $eml_cb, @args ];
570                 $err = _imap_fetch_all($self, $mic, $uri);
571         } else {
572                 $err = "E: <$uri> not connected: $!";
573         }
574         die $err if $err && $self->{-can_die};
575         warn $err if $err;
576         $mic;
577 }
578
579 # may used cached auth info prepared by nn_for once
580 sub nn_get {
581         my ($self, $uri) = @_;
582         my $sec = uri_section($uri);
583         # see if caller saved result of nntp_common_init
584         my $cached = $self->{nn_cached} // {};
585         my $nn;
586         $nn = delete($cached->{$sec}) and return $nn;
587         my $nn_arg = $self->{nn_arg}->{$sec} or
588                         die "BUG: no Net::NNTP->new arg for $sec";
589         my $nntp_opt = $self->{nntp_opt}->{$sec};
590         $nn = nn_new($nn_arg, $nntp_opt, $uri) or return;
591         if (my $postconn = $nntp_opt->{-postconn}) {
592                 for my $m_arg (@$postconn) {
593                         my ($method, @args) = @$m_arg;
594                         $nn->$method(@args) and next;
595                         die "E: <$uri> $method failed\n";
596                         return;
597                 }
598         }
599         $nn;
600 }
601
602 sub _nntp_fetch_all ($$$) {
603         my ($self, $nn, $uri) = @_;
604         my ($group, $num_a, $num_b) = $uri->group;
605         my $sec = uri_section($uri);
606         my ($nr, $beg, $end) = $nn->group($group);
607         unless (defined($nr)) {
608                 my $msg = ndump($nn->message);
609                 return "E: GROUP $group <$sec> $msg";
610         }
611
612         # IMAPTracker is also used for tracking NNTP, UID == article number
613         # LIST.ACTIVE can get the equivalent of UIDVALIDITY, but that's
614         # expensive.  So we assume newsgroups don't change:
615         my ($itrk, $l_art) = _itrk_last($self, $uri);
616
617         # allow users to specify articles to refetch
618         # cf. https://tools.ietf.org/id/draft-gilman-news-url-01.txt
619         # nntp://example.com/inbox.foo/$num_a-$num_b
620         $beg = $num_a if defined($num_a) && $num_a < $beg;
621         $end = $num_b if defined($num_b) && $num_b < $end;
622         if (defined $l_art) {
623                 return if $l_art >= $end; # nothing to do
624                 $beg = $l_art + 1;
625         }
626         my ($err, $art, $last_art, $kw); # kw stays undef, no keywords in NNTP
627         unless ($self->{quiet}) {
628                 warn "# $uri fetching ARTICLE $beg..$end\n";
629         }
630         my $n = $self->{max_batch};
631         for ($beg..$end) {
632                 last if $self->{quit};
633                 $art = $_;
634                 if (--$n < 0) {
635                         run_commit_cb($self);
636                         $itrk->update_last(0, $last_art) if $itrk;
637                         $n = $self->{max_batch};
638                 }
639                 my $raw = $nn->article($art);
640                 unless (defined($raw)) {
641                         my $msg = ndump($nn->message);
642                         if ($nn->code == 421) { # pseudo response from Net::Cmd
643                                 $err = "E: $msg";
644                                 last;
645                         } else { # probably just a deleted message (spam)
646                                 warn "W: $msg";
647                                 next;
648                         }
649                 }
650                 $raw = join('', @$raw);
651                 $raw =~ s/\r\n/\n/sg;
652                 my ($eml_cb, @args) = @{$self->{eml_each}};
653                 $eml_cb->($uri, $art, $kw, PublicInbox::Eml->new(\$raw), @args);
654                 $last_art = $art;
655         }
656         run_commit_cb($self);
657         $itrk->update_last(0, $last_art) if $itrk;
658         $err;
659 }
660
661 sub nntp_each {
662         my ($self, $url, $eml_cb, @args) = @_;
663         my $uri = ref($url) ? $url : PublicInbox::URInntps->new($url);
664         my $sec = uri_section($uri);
665         local $0 = $uri->group ." $sec";
666         my $nn = nn_get($self, $uri);
667         return if $self->{quit};
668         my $err;
669         if ($nn) {
670                 local $self->{eml_each} = [ $eml_cb, @args ];
671                 $err = _nntp_fetch_all($self, $nn, $uri);
672         } else {
673                 $err = "E: <$uri> not connected: $!";
674         }
675         die $err if $err && $self->{-can_die};
676         warn $err if $err;
677         $nn;
678 }
679
680 sub new { bless {}, shift };
681
682 1;