]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/WatchMaildir.pm
watchmaildir: get rid of unused spamdir field
[public-inbox.git] / lib / PublicInbox / WatchMaildir.pm
1 # Copyright (C) 2016-2018 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3 #
4 # ref: https://cr.yp.to/proto/maildir.html
5 #       http://wiki2.dovecot.org/MailboxFormat/Maildir
6 package PublicInbox::WatchMaildir;
7 use strict;
8 use warnings;
9 use PublicInbox::MIME;
10 use PublicInbox::Git;
11 use PublicInbox::Import;
12 use PublicInbox::MDA;
13 use PublicInbox::Spawn qw(spawn);
14 use PublicInbox::InboxWritable;
15 use File::Temp qw//;
16 use PublicInbox::Filter::Base;
17 use PublicInbox::Spamcheck;
18 *REJECT = *PublicInbox::Filter::Base::REJECT;
19
20 sub new {
21         my ($class, $config) = @_;
22         my (%mdmap, @mdir, $spamc);
23         my %uniq;
24
25         # "publicinboxwatch" is the documented namespace
26         # "publicinboxlearn" is legacy but may be supported
27         # indefinitely...
28         foreach my $pfx (qw(publicinboxwatch publicinboxlearn)) {
29                 my $k = "$pfx.watchspam";
30                 if (my $dir = $config->{$k}) {
31                         if ($dir =~ s/\Amaildir://) {
32                                 $dir =~ s!/+\z!!;
33                                 # skip "new", no MUA has seen it, yet.
34                                 my $cur = "$dir/cur";
35                                 my $old = $mdmap{$cur};
36                                 if (ref($old)) {
37                                         foreach my $ibx (@$old) {
38                                                 warn <<"";
39 "$cur already watched for `$ibx->{name}'
40
41                                         }
42                                         die;
43                                 }
44                                 push @mdir, $cur;
45                                 $uniq{$cur}++;
46                                 $mdmap{$cur} = 'watchspam';
47                         } else {
48                                 warn "unsupported $k=$dir\n";
49                         }
50                 }
51         }
52
53         my $k = 'publicinboxwatch.spamcheck';
54         my $default = undef;
55         my $spamcheck = PublicInbox::Spamcheck::get($config, $k, $default);
56         $spamcheck = _spamcheck_cb($spamcheck) if $spamcheck;
57
58         $config->each_inbox(sub {
59                 # need to make all inboxes writable for spam removal:
60                 my $ibx = $_[0] = PublicInbox::InboxWritable->new($_[0]);
61
62                 my $watch = $ibx->{watch} or return;
63                 if ($watch =~ s/\Amaildir://) {
64                         $watch =~ s!/+\z!!;
65                         if (my $wm = $ibx->{watchheader}) {
66                                 my ($k, $v) = split(/:/, $wm, 2);
67                                 $ibx->{-watchheader} = [ $k, qr/\Q$v\E/ ];
68                         }
69                         my $new = "$watch/new";
70                         my $cur = "$watch/cur";
71                         push @mdir, $new unless $uniq{$new}++;
72                         push @mdir, $cur unless $uniq{$cur}++;
73
74                         push @{$mdmap{$new} ||= []}, $ibx;
75                         push @{$mdmap{$cur} ||= []}, $ibx;
76                 } else {
77                         warn "watch unsupported: $k=$watch\n";
78                 }
79         });
80         return unless @mdir;
81
82         my $mdre = join('|', map { quotemeta($_) } @mdir);
83         $mdre = qr!\A($mdre)/!;
84         bless {
85                 spamcheck => $spamcheck,
86                 mdmap => \%mdmap,
87                 mdir => \@mdir,
88                 mdre => $mdre,
89                 config => $config,
90                 importers => {},
91                 opendirs => {}, # dirname => dirhandle (in progress scans)
92         }, $class;
93 }
94
95 sub _done_for_now {
96         my ($self) = @_;
97         my $importers = $self->{importers};
98         foreach my $im (values %$importers) {
99                 $im->done;
100         }
101 }
102
103 sub _try_fsn_paths {
104         my ($self, $scan_re, $paths) = @_;
105         foreach (@$paths) {
106                 my $path = $_->{path};
107                 if ($path =~ $scan_re) {
108                         scan($self, $path);
109                 } else {
110                         _try_path($self, $path);
111                 }
112         }
113         _done_for_now($self);
114 }
115
116 sub _remove_spam {
117         my ($self, $path) = @_;
118         # path must be marked as (S)een
119         $path =~ /:2,[A-R]*S[T-Za-z]*\z/ or return;
120         my $mime = _path_to_mime($path) or return;
121         $self->{config}->each_inbox(sub {
122                 my ($ibx) = @_;
123                 eval {
124                         my $im = _importer_for($self, $ibx);
125                         $im->remove($mime, 'spam');
126                         if (my $scrub = $ibx->filter) {
127                                 my $scrubbed = $scrub->scrub($mime, 1);
128                                 $scrubbed or return;
129                                 $scrubbed == REJECT() and return;
130                                 $im->remove($scrubbed, 'spam');
131                         }
132                 };
133                 if ($@) {
134                         warn "error removing spam at: ", $path,
135                                 " from ", $ibx->{name}, ': ', $@, "\n";
136                 }
137         })
138 }
139
140 sub _try_path {
141         my ($self, $path) = @_;
142         return unless PublicInbox::InboxWritable::is_maildir_path($path);
143         if ($path !~ $self->{mdre}) {
144                 warn "unrecognized path: $path\n";
145                 return;
146         }
147         my $inboxes = $self->{mdmap}->{$1};
148         unless ($inboxes) {
149                 warn "unmappable dir: $1\n";
150                 return;
151         }
152         if (!ref($inboxes) && $inboxes eq 'watchspam') {
153                 return _remove_spam($self, $path);
154         }
155         foreach my $ibx (@$inboxes) {
156                 my $mime = _path_to_mime($path) or next;
157                 my $im = _importer_for($self, $ibx);
158
159                 my $wm = $ibx->{-watchheader};
160                 if ($wm) {
161                         my $v = $mime->header_obj->header_raw($wm->[0]);
162                         next unless ($v && $v =~ $wm->[1]);
163                 }
164                 if (my $scrub = $ibx->filter) {
165                         my $ret = $scrub->scrub($mime) or next;
166                         $ret == REJECT() and next;
167                         $mime = $ret;
168                 }
169                 $im->add($mime, $self->{spamcheck});
170         }
171 }
172
173 sub quit { trigger_scan($_[0], 'quit') }
174
175 sub watch {
176         my ($self) = @_;
177         my $scan = File::Temp->newdir("public-inbox-watch.$$.scan.XXXXXX",
178                                         TMPDIR => 1);
179         my $scandir = $self->{scandir} = $scan->dirname;
180         my $re = qr!\A$scandir/!;
181         my $cb = sub { _try_fsn_paths($self, $re, \@_) };
182
183         # lazy load here, we may support watching via IMAP IDLE
184         # in the future...
185         require Filesys::Notify::Simple;
186         my $fsn = Filesys::Notify::Simple->new([@{$self->{mdir}}, $scandir]);
187         $fsn->wait($cb) until $self->{quit};
188 }
189
190 sub trigger_scan {
191         my ($self, $base) = @_;
192         my $dir = $self->{scandir} or return;
193         open my $fh, '>', "$dir/$base" or die "open $dir/$base failed: $!\n";
194         close $fh or die "close $dir/$base failed: $!\n";
195 }
196
197 sub scan {
198         my ($self, $path) = @_;
199         if ($path =~ /quit\z/) {
200                 %{$self->{opendirs}} = ();
201                 _done_for_now($self);
202                 delete $self->{scandir};
203                 $self->{quit} = 1;
204                 return;
205         }
206         # else: $path =~ /(cont|full)\z/
207         return if $self->{quit};
208         my $max = 10;
209         my $opendirs = $self->{opendirs};
210         my @dirnames = keys %$opendirs;
211         foreach my $dir (@dirnames) {
212                 my $dh = delete $opendirs->{$dir};
213                 my $n = $max;
214                 while (my $fn = readdir($dh)) {
215                         _try_path($self, "$dir/$fn");
216                         last if --$n < 0;
217                 }
218                 $opendirs->{$dir} = $dh if $n < 0;
219         }
220         if ($path =~ /full\z/) {
221                 foreach my $dir (@{$self->{mdir}}) {
222                         next if $opendirs->{$dir}; # already in progress
223                         my $ok = opendir(my $dh, $dir);
224                         unless ($ok) {
225                                 warn "failed to open $dir: $!\n";
226                                 next;
227                         }
228                         my $n = $max;
229                         while (my $fn = readdir($dh)) {
230                                 _try_path($self, "$dir/$fn");
231                                 last if --$n < 0;
232                         }
233                         $opendirs->{$dir} = $dh if $n < 0;
234                 }
235         }
236         _done_for_now($self);
237         # do we have more work to do?
238         trigger_scan($self, 'cont') if keys %$opendirs;
239 }
240
241 sub _path_to_mime {
242         my ($path) = @_;
243         if (open my $fh, '<', $path) {
244                 local $/;
245                 my $str = <$fh>;
246                 $str or return;
247                 return PublicInbox::MIME->new(\$str);
248         } elsif ($!{ENOENT}) {
249                 return;
250         } else {
251                 warn "failed to open $path: $!\n";
252                 return;
253         }
254 }
255
256 sub _importer_for {
257         my ($self, $ibx) = @_;
258         my $importers = $self->{importers};
259         my $im = $importers->{"$ibx"} ||= $ibx->importer(0);
260         if (scalar(keys(%$importers)) > 2) {
261                 delete $importers->{"$ibx"};
262                 _done_for_now($self);
263         }
264
265         $importers->{"$ibx"} = $im;
266 }
267
268 sub _spamcheck_cb {
269         my ($sc) = @_;
270         sub {
271                 my ($mime) = @_;
272                 my $tmp = '';
273                 if ($sc->spamcheck($mime, \$tmp)) {
274                         return PublicInbox::MIME->new(\$tmp);
275                 }
276                 warn $mime->header('Message-ID')." failed spam check\n";
277                 undef;
278         }
279 }
280
281 1;