1 # Copyright (C) 2016-2020 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
4 # ref: https://cr.yp.to/proto/maildir.html
5 # http://wiki2.dovecot.org/MailboxFormat/Maildir
6 package PublicInbox::WatchMaildir;
10 use PublicInbox::InboxWritable;
11 use PublicInbox::Filter::Base qw(REJECT);
12 use PublicInbox::Spamcheck;
13 use PublicInbox::Sigfd;
14 use PublicInbox::DS qw(now);
16 *mime_from_path = \&PublicInbox::InboxWritable::mime_from_path;
18 sub compile_watchheaders ($) {
21 if (my $whs = $ibx->{watchheader}) {
23 my ($k, $v) = split(/:/, $_, 2);
24 # XXX should this be case-insensitive?
25 # Or, mutt-style, case-sensitive iff
26 # a capital letter exists?
27 push @$watch_hdrs, [ $k, qr/\Q$v\E/ ];
30 if (my $list_ids = $ibx->{listid}) {
32 # RFC2919 section 6 stipulates
33 # "case insensitive equality"
34 my $re = qr/<[ \t]*\Q$_\E[ \t]*>/i;
35 push @$watch_hdrs, ['List-Id', $re ];
38 $ibx->{-watchheaders} = $watch_hdrs if scalar @$watch_hdrs;
42 my ($class, $config) = @_;
44 my %imap; # url => [inbox objects] or 'watchspam'
46 # "publicinboxwatch" is the documented namespace
47 # "publicinboxlearn" is legacy but may be supported
49 foreach my $pfx (qw(publicinboxwatch publicinboxlearn)) {
50 my $k = "$pfx.watchspam";
51 defined(my $dirs = $config->{$k}) or next;
52 $dirs = PublicInbox::Config::_array($dirs);
53 for my $dir (@$dirs) {
54 if (is_maildir($dir)) {
55 # skip "new", no MUA has seen it, yet.
56 $mdmap{"$dir/cur"} = 'watchspam';
57 } elsif (my $url = imap_url($dir)) {
58 $imap{$url} = 'watchspam';
60 warn "unsupported $k=$dir\n";
65 my $k = 'publicinboxwatch.spamcheck';
67 my $spamcheck = PublicInbox::Spamcheck::get($config, $k, $default);
68 $spamcheck = _spamcheck_cb($spamcheck) if $spamcheck;
70 $config->each_inbox(sub {
71 # need to make all inboxes writable for spam removal:
72 my $ibx = $_[0] = PublicInbox::InboxWritable->new($_[0]);
74 my $watches = $ibx->{watch} or return;
75 $watches = PublicInbox::Config::_array($watches);
76 for my $watch (@$watches) {
77 if (is_maildir($watch)) {
78 compile_watchheaders($ibx);
79 my ($new, $cur) = ("$watch/new", "$watch/cur");
80 my $cur_dst = $mdmap{$cur} //= [];
81 return if is_watchspam($cur, $cur_dst, $ibx);
82 push @{$mdmap{$new} //= []}, $ibx;
84 } elsif (my $url = imap_url($watch)) {
85 return if is_watchspam($url, $imap{$url}, $ibx);
86 compile_watchheaders($ibx);
87 push @{$imap{$url} ||= []}, $ibx;
89 warn "watch unsupported: $k=$watch\n";
95 if (scalar keys %mdmap) {
96 $mdre = join('|', map { quotemeta($_) } keys %mdmap);
97 $mdre = qr!\A($mdre)/!;
99 return unless $mdre || scalar(keys %imap);
101 spamcheck => $spamcheck,
105 imap => scalar keys %imap ? \%imap : undef,
107 opendirs => {}, # dirname => dirhandle (in progress scans)
108 ops => [], # 'quit', 'full'
114 my $importers = $self->{importers};
115 foreach my $im (values %$importers) {
120 sub remove_eml_i { # each_inbox callback
121 my ($ibx, $arg) = @_;
122 my ($self, $eml, $loc) = @$arg;
124 my $im = _importer_for($self, $ibx);
125 $im->remove($eml, 'spam');
126 if (my $scrub = $ibx->filter($im)) {
127 my $scrubbed = $scrub->scrub($eml, 1);
129 $scrubbed == REJECT() and return;
130 $im->remove($scrubbed, 'spam');
133 warn "error removing spam at: $loc from $ibx->{name}: $@\n" if $@;
137 my ($self, $path) = @_;
138 # path must be marked as (S)een
139 $path =~ /:2,[A-R]*S[T-Za-z]*\z/ or return;
140 my $eml = mime_from_path($path) or return;
141 $self->{config}->each_inbox(\&remove_eml_i, [ $self, $eml, $path ]);
144 sub import_eml ($$$) {
145 my ($self, $ibx, $eml) = @_;
146 my $im = _importer_for($self, $ibx);
148 # any header match means it's eligible for the inbox:
149 if (my $watch_hdrs = $ibx->{-watchheaders}) {
151 my $hdr = $eml->header_obj;
152 for my $wh (@$watch_hdrs) {
153 my @v = $hdr->header_raw($wh->[0]);
154 $ok = grep(/$wh->[1]/, @v) and last;
159 if (my $scrub = $ibx->filter($im)) {
160 my $ret = $scrub->scrub($eml) or return;
161 $ret == REJECT() and return;
164 $im->add($eml, $self->{spamcheck});
168 my ($self, $path) = @_;
169 return unless PublicInbox::InboxWritable::is_maildir_path($path);
170 if ($path !~ $self->{mdre}) {
171 warn "unrecognized path: $path\n";
174 my $inboxes = $self->{mdmap}->{$1};
176 warn "unmappable dir: $1\n";
179 if (!ref($inboxes) && $inboxes eq 'watchspam') {
180 return _remove_spam($self, $path);
183 my $warn_cb = $SIG{__WARN__} || sub { print STDERR @_ };
184 local $SIG{__WARN__} = sub {
185 $warn_cb->("path: $path\n");
188 foreach my $ibx (@$inboxes) {
189 my $eml = mime_from_path($path) or next;
190 import_eml($self, $ibx, $eml);
197 %{$self->{opendirs}} = ();
198 _done_for_now($self);
199 if (my $imap_pid = $self->{-imap_pid}) {
200 kill('QUIT', $imap_pid);
202 for (qw(idle_pids poll_pids)) {
203 my $pids = $self->{$_} or next;
204 kill('QUIT', $_) for (keys %$pids);
206 if (my $idle_mic = $self->{idle_mic}) {
207 eval { $idle_mic->done };
209 warn "IDLE DONE error: $@\n";
210 eval { $idle_mic->disconnect };
211 warn "IDLE LOGOUT error: $@\n" if $@;
216 sub watch_fs_init ($) {
219 delete $self->{done_timer};
220 _done_for_now($self);
223 _try_path($self, $_[0]->fullname);
224 $self->{done_timer} //= PublicInbox::DS::requeue($done);
226 require PublicInbox::DirIdle;
227 # inotify_create + EPOLL_CTL_ADD
228 PublicInbox::DirIdle->new([keys %{$self->{mdmap}}], $cb);
231 # returns the git config section name, e.g [imap "imaps://user@example.com"]
232 # without the mailbox, so we can share connections between different inboxes
233 sub imap_section ($) {
235 $uri->scheme . '://' . $uri->authority;
238 sub cfg_intvl ($$$) {
239 my ($cfg, $key, $url) = @_;
240 my $v = $cfg->urlmatch($key, $url) // return;
241 $v =~ /\A[0-9]+(?:\.[0-9]+)?\z/s and return $v + 0;
242 if (ref($v) eq 'ARRAY') {
243 $v = join(', ', @$v);
244 warn "W: $key has multiple values: $v\nW: $key ignored\n";
246 warn "W: $key=$v is not a numeric value in seconds\n";
250 # flesh out common IMAP-specific data structures
251 sub imap_common_init ($) {
253 my $cfg = $self->{config};
254 my $mic_args = {}; # scheme://authority => Mail:IMAPClient arg
255 for my $url (sort keys %{$self->{imap}}) {
256 my $uri = PublicInbox::URIimap->new($url);
257 my $sec = imap_section($uri);
258 for my $f (qw(Starttls Debug Compress)) {
260 my $orig = $cfg->urlmatch($k, $url) // next;
261 my $v = PublicInbox::Config::_git_config_bool($orig);
263 $mic_args->{$sec}->{$f} = $v;
265 warn "W: $k=$orig for $url is not boolean\n";
268 my $to = cfg_intvl($cfg, 'imap.timeout', $url);
269 $mic_args->{$sec}->{Timeout} = $to if $to;
270 $to = cfg_intvl($cfg, 'imap.pollInterval', $url);
271 $self->{imap_opt}->{$sec}->{poll_intvl} = $to if $to;
272 $to = cfg_intvl($cfg, 'imap.IdleInterval', $url);
273 $self->{imap_opt}->{$sec}->{idle_intvl} = $to if $to;
275 my $k = 'imap.fetchBatchSize';
276 my $bs = $cfg->urlmatch($k, $url) // next;
277 if ($bs =~ /\A([0-9]+)\z/) {
278 $self->{imap_opt}->{$sec}->{batch_size} = $bs;
280 warn "$k=$bs is not an integer\n";
286 sub auth_anon_cb { '' }; # for Mail::IMAPClient::Authcallback
288 sub mic_for ($$$) { # mic = Mail::IMAPClient
289 my ($self, $uri, $mic_args) = @_;
290 my $url = $uri->as_string;
293 protocol => $uri->scheme,
295 username => $uri->user,
296 password => $uri->password,
298 my $common = $mic_args->{imap_section($uri)} // {};
299 my $host = $cred->{host};
302 # IMAPClient mishandles `0', so we pass `127.0.0.1'
303 Server => $host eq '0' ? '127.0.0.1' : $host,
304 Ssl => $uri->scheme eq 'imaps',
305 Keepalive => 1, # SO_KEEPALIVE
306 %$common, # may set Starttls, Compress, Debug ....
308 my $mic = PublicInbox::IMAPClient->new(%$mic_arg) or
309 die "E: <$url> new: $@\n";
311 # default to using STARTTLS if it's available, but allow
312 # it to be disabled since I usually connect to localhost
313 if (!$mic_arg->{Ssl} && !defined($mic_arg->{Starttls}) &&
314 $mic->has_capability('STARTTLS') &&
315 $mic->can('starttls')) {
316 $mic->starttls or die "E: <$url> STARTTLS: $@\n";
319 # do we even need credentials?
320 if (!defined($cred->{username}) &&
321 $mic->has_capability('AUTH=ANONYMOUS')) {
325 Git::credential($cred, 'fill'); # may prompt user here
326 $mic->User($mic_arg->{User} = $cred->{username});
327 $mic->Password($mic_arg->{Password} = $cred->{password});
328 } else { # AUTH=ANONYMOUS
329 $mic->Authmechanism($mic_arg->{Authmechanism} = 'ANONYMOUS');
330 $mic->Authcallback($mic_arg->{Authcallback} = \&auth_anon_cb);
332 if ($mic->login && $mic->IsAuthenticated) {
333 # success! keep IMAPClient->new arg in case we get disconnected
334 $self->{mic_arg}->{imap_section($uri)} = $mic_arg;
336 warn "E: <$url> LOGIN: $@\n";
339 Git::credential($cred, $mic ? 'approve' : 'reject') if $cred;
343 sub imap_import_msg ($$$$) {
344 my ($self, $url, $uid, $raw) = @_;
345 # our target audience expects LF-only, save storage
346 $$raw =~ s/\r\n/\n/sg;
348 my $inboxes = $self->{imap}->{$url};
350 for my $ibx (@$inboxes) {
351 my $eml = PublicInbox::Eml->new($$raw);
352 my $x = import_eml($self, $ibx, $eml);
354 } elsif ($inboxes eq 'watchspam') {
355 my $eml = PublicInbox::Eml->new($raw);
356 my $arg = [ $self, $eml, "$url UID:$uid" ];
357 $self->{config}->each_inbox(\&remove_eml_i, $arg);
359 die "BUG: destination unknown $inboxes";
363 sub imap_fetch_all ($$$) {
364 my ($self, $mic, $uri) = @_;
365 my $sec = imap_section($uri);
366 my $mbx = $uri->mailbox;
367 my $url = $uri->as_string;
368 $mic->Clear(1); # trim results history
369 $mic->examine($mbx) or return "E: EXAMINE $mbx ($sec) failed: $!";
370 my ($r_uidval, $r_uidnext);
371 for ($mic->Results) {
372 /^\* OK \[UIDVALIDITY ([0-9]+)\].*/ and $r_uidval = $1;
373 /^\* OK \[UIDNEXT ([0-9]+)\].*/ and $r_uidnext = $1;
374 last if $r_uidval && $r_uidnext;
376 $r_uidval //= $mic->uidvalidity($mbx) //
377 return "E: $url cannot get UIDVALIDITY";
378 $r_uidnext //= $mic->uidnext($mbx) //
379 return "E: $url cannot get UIDNEXT";
380 my $itrk = PublicInbox::IMAPTracker->new($url);
381 my ($l_uidval, $l_uid) = $itrk->get_last;
382 $l_uidval //= $r_uidval; # first time
384 if ($l_uidval != $r_uidval) {
385 return "E: $url UIDVALIDITY mismatch\n".
386 "E: local=$l_uidval != remote=$r_uidval";
388 my $r_uid = $r_uidnext - 1;
389 if ($l_uid != 1 && $l_uid > $r_uid) {
390 return "E: $url local UID exceeds remote ($l_uid > $r_uid)\n".
391 "E: $url strangely, UIDVALIDLITY matches ($l_uidval)\n";
393 return if $l_uid >= $r_uid; # nothing to do
395 warn "I: $url fetching UID $l_uid:$r_uid\n";
396 $mic->Uid(1); # the default, we hope
397 my $bs = $self->{imap_opt}->{$sec}->{batch_size} // 1;
398 my $req = $mic->imap4rev1 ? 'BODY.PEEK[]' : 'RFC822.PEEK';
400 # TODO: FLAGS may be useful for personal use
404 my $warn_cb = $SIG{__WARN__} || sub { print STDERR @_ };
405 local $SIG{__WARN__} = sub {
407 $warn_cb->("$url UID:$batch\n");
412 # I wish "UID FETCH $START:*" could work, but:
413 # 1) servers do not need to return results in any order
414 # 2) Mail::IMAPClient doesn't offer a streaming API
415 $uids = $mic->search("UID $l_uid:*") or
416 return "E: $url UID SEARCH $l_uid:* error: $!";
417 return if scalar(@$uids) == 0;
419 # RFC 3501 doesn't seem to indicate order of UID SEARCH
420 # responses, so sort it ourselves. Order matters so
421 # IMAPTracker can store the newest UID.
422 @$uids = sort { $a <=> $b } @$uids;
424 # Did we actually get new messages?
425 return if $uids->[0] < $l_uid;
427 $l_uid = $uids->[-1] + 1; # for next search
430 while (scalar @$uids) {
431 my @batch = splice(@$uids, 0, $bs);
432 $batch = join(',', @batch);
433 local $0 = "UID:$batch $mbx $sec";
434 my $r = $mic->fetch_hash($batch, $req);
435 unless ($r) { # network error?
436 $err = "E: $url UID FETCH $batch error: $!";
439 for my $uid (@batch) {
440 # messages get deleted, so holes appear
441 my $per_uid = delete $r->{$uid} // next;
442 my $raw = delete($per_uid->{$key}) // next;
443 imap_import_msg($self, $url, $uid, \$raw);
445 last if $self->{quit};
447 last if $self->{quit};
449 _done_for_now($self);
450 $itrk->update_last($r_uidval, $last_uid) if defined $last_uid;
451 } until ($err || $self->{quit});
455 sub imap_idle_once ($$$$) {
456 my ($self, $mic, $intvl, $url) = @_;
457 my $i = $intvl //= (29 * 60);
458 my $end = now() + $intvl;
459 warn "I: $url idling for ${intvl}s\n";
460 local $0 = "IDLE $0";
461 unless ($mic->idle) {
462 return if $self->{quit};
463 return "E: IDLE failed on $url: $!";
465 $self->{idle_mic} = $mic; # for ->quit
467 until ($self->{quit} || grep(/^\* [0-9]+ EXISTS/, @res) || $i <= 0) {
468 @res = $mic->idle_data($i);
471 delete $self->{idle_mic};
472 unless ($self->{quit}) {
473 $mic->IsConnected or return "E: IDLE disconnected on $url";
474 $mic->done or return "E: IDLE DONE failed on $url: $!";
479 # idles on a single URI
480 sub watch_imap_idle_1 ($$$) {
481 my ($self, $uri, $intvl) = @_;
482 my $sec = imap_section($uri);
483 my $mic_arg = $self->{mic_arg}->{$sec} or
484 die "BUG: no Mail::IMAPClient->new arg for $sec";
486 local $0 = $uri->mailbox." $sec";
487 until ($self->{quit}) {
488 $mic //= delete($self->{mics}->{$sec}) //
489 PublicInbox::IMAPClient->new(%$mic_arg);
490 my $err = imap_fetch_all($self, $mic, $uri);
491 $err //= imap_idle_once($self, $mic, $intvl, $uri->as_string);
492 if ($err && !$self->{quit}) {
495 sleep 60 unless $self->{quit};
500 sub watch_atfork_child ($) {
502 delete $self->{idle_pids};
503 delete $self->{poll_pids};
504 delete $self->{opendirs};
505 PublicInbox::DS->Reset;
506 PublicInbox::Sigfd::sig_setmask($self->{oldset});
507 %SIG = (%SIG, %{$self->{sig}});
510 sub watch_atfork_parent ($) {
512 _done_for_now($self);
513 $self->{mics} = {}; # going to be forking, so disconnect
516 sub imap_idle_reap { # PublicInbox::DS::dwaitpid callback
517 my ($self, $pid) = @_;
518 my $uri_intvl = delete $self->{idle_pids}->{$pid} or
519 die "BUG: PID=$pid (unknown) reaped: \$?=$?\n";
521 my ($uri, $intvl) = @$uri_intvl;
522 my $url = $uri->as_string;
523 return if $self->{quit};
524 warn "W: PID=$pid on $url died: \$?=$?\n" if $?;
525 push @{$self->{idle_todo}}, $uri_intvl;
526 PubicInbox::DS::requeue($self); # call ->event_step to respawn
529 sub imap_idle_fork ($$) {
530 my ($self, $uri_intvl) = @_;
531 my ($uri, $intvl) = @$uri_intvl;
532 defined(my $pid = fork) or die "fork: $!";
534 watch_atfork_child($self);
535 watch_imap_idle_1($self, $uri, $intvl);
538 $self->{idle_pids}->{$pid} = $uri_intvl;
539 PublicInbox::DS::dwaitpid($pid, \&imap_idle_reap, $self);
544 return if $self->{quit};
545 my $idle_todo = $self->{idle_todo};
546 if ($idle_todo && @$idle_todo) {
547 watch_atfork_parent($self);
548 while (my $uri_intvl = shift(@$idle_todo)) {
549 imap_idle_fork($self, $uri_intvl);
552 goto(&fs_scan_step) if $self->{mdre};
555 sub watch_imap_fetch_all ($$) {
556 my ($self, $uris) = @_;
557 for my $uri (@$uris) {
558 my $sec = imap_section($uri);
559 my $mic_arg = $self->{mic_arg}->{$sec} or
560 die "BUG: no Mail::IMAPClient->new arg for $sec";
561 my $mic = PublicInbox::IMAPClient->new(%$mic_arg) or next;
562 my $err = imap_fetch_all($self, $mic, $uri);
563 last if $self->{quit};
564 warn $err, "\n" if $err;
568 sub imap_fetch_fork ($) { # DS::add_timer callback
569 my ($self, $intvl, $uris) = @{$_[0]};
570 return if $self->{quit};
571 watch_atfork_parent($self);
572 defined(my $pid = fork) or die "fork: $!";
574 watch_atfork_child($self);
575 watch_imap_fetch_all($self, $uris);
578 $self->{poll_pids}->{$pid} = [ $intvl, $uris ];
579 PublicInbox::DS::dwaitpid($pid, \&imap_fetch_reap, $self);
582 sub imap_fetch_reap { # PublicInbox::DS::dwaitpid callback
583 my ($self, $pid) = @_;
584 my $intvl_uris = delete $self->{poll_pids}->{$pid} or
585 die "BUG: PID=$pid (unknown) reaped: \$?=$?\n";
586 return if $self->{quit};
587 my ($intvl, $uris) = @$intvl_uris;
589 warn "W: PID=$pid died: \$?=$?\n",
590 map { $_->as_string."\n" } @$uris;
592 warn('I: will check ', $_->as_string, " in ${intvl}s\n") for @$uris;
593 PublicInbox::DS::add_timer($intvl, \&imap_fetch_fork,
594 [$self, $intvl, $uris]);
597 sub watch_imap_init ($) {
599 eval { require PublicInbox::IMAPClient } or
600 die "Mail::IMAPClient is required for IMAP:\n$@\n";
601 eval { require Git } or
602 die "Git (Perl module) is required for IMAP:\n$@\n";
603 eval { require PublicInbox::IMAPTracker } or
604 die "DBD::SQLite is required for IMAP\n:$@\n";
606 my $mic_args = imap_common_init($self); # read args from config
608 # make sure we can connect and cache the credentials in memory
609 $self->{mic_arg} = {}; # schema://authority => IMAPClient->new args
610 my $mics = $self->{mics} = {}; # schema://authority => IMAPClient obj
611 for my $url (sort keys %{$self->{imap}}) {
612 my $uri = PublicInbox::URIimap->new($url);
613 $mics->{imap_section($uri)} //= mic_for($self, $uri, $mic_args);
616 my $idle = []; # [ [ uri1, intvl1 ], [uri2, intvl2] ]
617 my $poll = {}; # intvl_seconds => [ uri1, uri2 ]
618 for my $url (keys %{$self->{imap}}) {
619 my $uri = PublicInbox::URIimap->new($url);
620 my $sec = imap_section($uri);
621 my $mic = $mics->{$sec};
622 my $intvl = $self->{imap_opt}->{$sec}->{poll_intvl};
623 if ($mic->has_capability('IDLE') && !$intvl) {
624 $intvl = $self->{imap_opt}->{$sec}->{idle_intvl};
625 push @$idle, [ $uri, $intvl // () ];
627 push @{$poll->{$intvl || 120}}, $uri;
631 $self->{idle_pids} = {};
632 $self->{idle_todo} = $idle;
633 PublicInbox::DS::requeue($self); # ->event_step to fork
635 return unless scalar keys %$poll;
636 $self->{poll_pids} = {};
638 # poll all URIs for a given interval sequentially
639 while (my ($intvl, $uris) = each %$poll) {
640 PublicInbox::DS::add_timer(0, \&imap_fetch_fork,
641 [$self, $intvl, $uris]);
646 my ($self, $sig, $oldset) = @_;
647 $self->{oldset} = $oldset;
649 watch_imap_init($self) if $self->{imap};
650 watch_fs_init($self) if $self->{mdre};
651 PublicInbox::DS->SetPostLoopCallback(sub {});
652 PublicInbox::DS->EventLoop until $self->{quit};
653 _done_for_now($self);
657 my ($self, $op) = @_;
658 push @{$self->{ops}}, $op;
659 PublicInbox::DS::requeue($self);
664 return if $self->{quit};
665 my $op = shift @{$self->{ops}};
667 # continue existing scan
669 my $opendirs = $self->{opendirs};
670 my @dirnames = keys %$opendirs;
671 foreach my $dir (@dirnames) {
672 my $dh = delete $opendirs->{$dir};
674 while (my $fn = readdir($dh)) {
675 _try_path($self, "$dir/$fn");
678 $opendirs->{$dir} = $dh if $n < 0;
680 if ($op && $op eq 'full') {
681 foreach my $dir (keys %{$self->{mdmap}}) {
682 next if $opendirs->{$dir}; # already in progress
683 my $ok = opendir(my $dh, $dir);
685 warn "failed to open $dir: $!\n";
689 while (my $fn = readdir($dh)) {
690 _try_path($self, "$dir/$fn");
693 $opendirs->{$dir} = $dh if $n < 0;
696 _done_for_now($self);
697 # do we have more work to do?
698 PublicInbox::DS::requeue($self) if keys %$opendirs;
702 my ($self, $op) = @_;
703 push @{$self->{ops}}, $op;
708 my ($self, $ibx) = @_;
709 my $importers = $self->{importers};
710 my $im = $importers->{"$ibx"} ||= $ibx->importer(0);
711 if (scalar(keys(%$importers)) > 2) {
712 delete $importers->{"$ibx"};
713 _done_for_now($self);
716 $importers->{"$ibx"} = $im;
724 if ($sc->spamcheck($mime, \$tmp)) {
725 return PublicInbox::Eml->new(\$tmp);
727 warn $mime->header('Message-ID')." failed spam check\n";
733 $_[0] =~ s!\Amaildir:!! or return;
740 my ($cur, $ws, $ibx) = @_;
741 if ($ws && !ref($ws) && $ws eq 'watchspam') {
743 E: $cur is a spam folder and cannot be used for `$ibx->{name}' input
752 require PublicInbox::URIimap;
753 my $uri = PublicInbox::URIimap->new($url);
754 $uri ? $uri->canonical->as_string : undef;