]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/WatchMaildir.pm
watch_maildir: spam removal support
[public-inbox.git] / lib / PublicInbox / WatchMaildir.pm
1 # Copyright (C) 2016 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3 package PublicInbox::WatchMaildir;
4 use strict;
5 use warnings;
6 use Email::MIME;
7 use Email::MIME::ContentType;
8 $Email::MIME::ContentType::STRICT_PARAMS = 0; # user input is imperfect
9 use PublicInbox::Git;
10 use PublicInbox::Import;
11 use PublicInbox::MDA;
12 use PublicInbox::Spawn qw(spawn);
13
14 sub new {
15         my ($class, $config) = @_;
16         my (%mdmap, @mdir);
17         my $k = 'publicinboxlearn.watchspam';
18         if (my $spamdir = $config->{$k}) {
19                 if ($spamdir =~ s/\Amaildir://) {
20                         $spamdir =~ s!/+\z!!;
21                         # skip "new", no MUA has seen it, yet.
22                         my $cur = "$spamdir/cur";
23                         push @mdir, $cur;
24                         $mdmap{$cur} = 'watchspam';
25                 } else {
26                         warn "unsupported $k=$spamdir\n";
27                 }
28         }
29         foreach $k (keys %$config) {
30                 $k =~ /\Apublicinbox\.([^\.]+)\.watch\z/ or next;
31                 my $name = $1;
32                 my $watch = $config->{$k};
33                 if ($watch =~ s/\Amaildir://) {
34                         $watch =~ s!/+\z!!;
35                         my $inbox = $config->lookup_name($name);
36                         if (my $wm = $inbox->{watchheader}) {
37                                 my ($k, $v) = split(/:/, $wm, 2);
38                                 $inbox->{-watchheader} = [ $k, qr/\Q$v\E/ ];
39                         }
40                         my $new = "$watch/new";
41                         my $cur = "$watch/cur";
42                         push @mdir, $new, $cur;
43                         die "$new already in use\n" if $mdmap{$new};
44                         die "$cur already in use\n" if $mdmap{$cur};
45                         $mdmap{$new} = $mdmap{$cur} = $inbox;
46                 } else {
47                         warn "watch unsupported: $k=$watch\n";
48                 }
49         }
50         return unless @mdir;
51
52         my $mdre = join('|', map { quotemeta($_) } @mdir);
53         $mdre = qr!\A($mdre)/!;
54         bless {
55                 mdmap => \%mdmap,
56                 mdir => \@mdir,
57                 mdre => $mdre,
58                 importers => {},
59         }, $class;
60 }
61
62 sub _done_for_now {
63         $_->done foreach values %{$_[0]->{importers}};
64 }
65
66 sub _try_fsn_paths {
67         my ($self, $paths) = @_;
68         _try_path($self, $_->{path}) foreach @$paths;
69         _done_for_now($self);
70 }
71
72 sub _check_spam {
73         my ($self, $path) = @_;
74         my $mime = _path_to_mime($path) or return;
75         _force_mid($mime);
76         foreach my $inbox (values %{$self->{mdmap}}) {
77                 next unless ref $inbox;
78                 my $im = _importer_for($self, $inbox);
79                 $im->remove($mime);
80                 if (my $scrub = _scrubber_for($inbox)) {
81                         my $scrubbed = $scrub->scrub($mime) or next;
82                         $im->remove($scrubbed);
83                 }
84         }
85 }
86
87 # used to hash the relevant portions of a message when there are conflicts
88 sub _hash_mime2 {
89         my ($mime) = @_;
90         require Digest::SHA;
91         my $dig = Digest::SHA->new('SHA-1');
92         $dig->add($mime->header_obj->header_raw('Subject'));
93         $dig->add($mime->body_raw);
94         $dig->hexdigest;
95 }
96
97 sub _force_mid {
98         my ($mime) = @_;
99         # probably a bad idea, but we inject a Message-Id if
100         # one is missing, here..
101         my $mid = $mime->header_obj->header_raw('Message-Id');
102         if (!defined $mid || $mid =~ /\A\s*\z/) {
103                 $mid = '<' . _hash_mime2($mime) . '@generated>';
104                 $mime->header_set('Message-Id', $mid);
105         }
106 }
107
108 sub _try_path {
109         my ($self, $path) = @_;
110         if ($path !~ $self->{mdre}) {
111                 warn "unrecognized path: $path\n";
112                 return;
113         }
114         my $inbox = $self->{mdmap}->{$1};
115         unless ($inbox) {
116                 warn "unmappable dir: $1\n";
117                 return;
118         }
119         if (!ref($inbox) && $inbox eq 'watchspam') {
120                 return _check_spam($self, $path);
121         }
122         my $im = _importer_for($self, $inbox);
123         my $mime = _path_to_mime($path) or return;
124         $mime->header_set($_) foreach @PublicInbox::MDA::BAD_HEADERS;
125         my $wm = $inbox->{-watchheader};
126         if ($wm) {
127                 my $v = $mime->header_obj->header_raw($wm->[0]);
128                 return unless ($v && $v =~ $wm->[1]);
129         }
130         if (my $scrub = _scrubber_for($inbox)) {
131                 $mime = $scrub->scrub($mime) or return;
132         }
133
134         _force_mid($mime);
135         $im->add($mime);
136 }
137
138 sub watch {
139         my ($self) = @_;
140         my $cb = sub { _try_fsn_paths($self, \@_) };
141         my $mdir = $self->{mdir};
142
143         require Filesys::Notify::Simple;
144         my $watcher = Filesys::Notify::Simple->new($mdir);
145         $watcher->wait($cb) while (1);
146 }
147
148 sub scan {
149         my ($self) = @_;
150         my $mdir = $self->{mdir};
151         foreach my $dir (@$mdir) {
152                 my $ok = opendir(my $dh, $dir);
153                 unless ($ok) {
154                         warn "failed to open $dir: $!\n";
155                         next;
156                 }
157                 while (my $fn = readdir($dh)) {
158                         next unless $fn =~ /\A[a-zA-Z0-9][\w:,=\.]+\z/;
159                         $fn = "$dir/$fn";
160                         if (-f $fn) {
161                                 _try_path($self, $fn);
162                         } else {
163                                 warn "not a file: $fn\n";
164                         }
165                 }
166                 closedir $dh;
167         }
168         _done_for_now($self);
169 }
170
171 sub _path_to_mime {
172         my ($path) = @_;
173         if (open my $fh, '<', $path) {
174                 local $/;
175                 my $str = <$fh>;
176                 $str or return;
177                 return Email::MIME->new(\$str);
178         } elsif ($!{ENOENT}) {
179                 return;
180         } else {
181                 warn "failed to open $path: $!\n";
182                 return;
183         }
184 }
185
186 sub _importer_for {
187         my ($self, $inbox) = @_;
188         my $im = $inbox->{-import} ||= eval {
189                 my $git = $inbox->git;
190                 my $name = $inbox->{name};
191                 my $addr = $inbox->{-primary_address};
192                 PublicInbox::Import->new($git, $name, $addr);
193         };
194         $self->{importers}->{"$im"} = $im;
195 }
196
197 sub _scrubber_for {
198         my ($inbox) = @_;
199         my $f = $inbox->{filter};
200         if ($f && $f =~ /::/) {
201                 eval "require $f";
202                 if ($@) {
203                         warn $@;
204                 } else {
205                         return $f->new;
206                 }
207         }
208         undef;
209 }
210
211 1;