]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/WatchMaildir.pm
4468a4484fd63dfdbca81aa3bf193fd48a3f4c89
[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         $path =~ /:2,[A-R]*S[T-Z]*\z/ or return;
75         my $mime = _path_to_mime($path) or return;
76         _force_mid($mime);
77         foreach my $inbox (values %{$self->{mdmap}}) {
78                 next unless ref $inbox;
79                 my $im = _importer_for($self, $inbox);
80                 $im->remove($mime);
81                 if (my $scrub = _scrubber_for($inbox)) {
82                         my $scrubbed = $scrub->scrub($mime) or next;
83                         $im->remove($scrubbed);
84                 }
85         }
86 }
87
88 # used to hash the relevant portions of a message when there are conflicts
89 sub _hash_mime2 {
90         my ($mime) = @_;
91         require Digest::SHA;
92         my $dig = Digest::SHA->new('SHA-1');
93         $dig->add($mime->header_obj->header_raw('Subject'));
94         $dig->add($mime->body_raw);
95         $dig->hexdigest;
96 }
97
98 sub _force_mid {
99         my ($mime) = @_;
100         # probably a bad idea, but we inject a Message-Id if
101         # one is missing, here..
102         my $mid = $mime->header_obj->header_raw('Message-Id');
103         if (!defined $mid || $mid =~ /\A\s*\z/) {
104                 $mid = '<' . _hash_mime2($mime) . '@generated>';
105                 $mime->header_set('Message-Id', $mid);
106         }
107 }
108
109 sub _try_path {
110         my ($self, $path) = @_;
111         my @p = split(m!/+!, $path);
112         return unless $p[-1] =~ /\A[a-zA-Z0-9][\w:,=\.]+\z/;
113         return unless -f $path;
114         if ($path !~ $self->{mdre}) {
115                 warn "unrecognized path: $path\n";
116                 return;
117         }
118         my $inbox = $self->{mdmap}->{$1};
119         unless ($inbox) {
120                 warn "unmappable dir: $1\n";
121                 return;
122         }
123         if (!ref($inbox) && $inbox eq 'watchspam') {
124                 return _check_spam($self, $path);
125         }
126         my $im = _importer_for($self, $inbox);
127         my $mime = _path_to_mime($path) or return;
128         $mime->header_set($_) foreach @PublicInbox::MDA::BAD_HEADERS;
129         my $wm = $inbox->{-watchheader};
130         if ($wm) {
131                 my $v = $mime->header_obj->header_raw($wm->[0]);
132                 return unless ($v && $v =~ $wm->[1]);
133         }
134         if (my $scrub = _scrubber_for($inbox)) {
135                 $mime = $scrub->scrub($mime) or return;
136         }
137
138         _force_mid($mime);
139         $im->add($mime);
140 }
141
142 sub watch {
143         my ($self) = @_;
144         my $cb = sub { _try_fsn_paths($self, \@_) };
145         my $mdir = $self->{mdir};
146
147         require Filesys::Notify::Simple;
148         my $watcher = Filesys::Notify::Simple->new($mdir);
149         $watcher->wait($cb) while (1);
150 }
151
152 sub scan {
153         my ($self) = @_;
154         my $mdir = $self->{mdir};
155         foreach my $dir (@$mdir) {
156                 my $ok = opendir(my $dh, $dir);
157                 unless ($ok) {
158                         warn "failed to open $dir: $!\n";
159                         next;
160                 }
161                 while (my $fn = readdir($dh)) {
162                         _try_path($self, "$dir/$fn");
163                 }
164                 closedir $dh;
165         }
166         _done_for_now($self);
167 }
168
169 sub _path_to_mime {
170         my ($path) = @_;
171         if (open my $fh, '<', $path) {
172                 local $/;
173                 my $str = <$fh>;
174                 $str or return;
175                 return Email::MIME->new(\$str);
176         } elsif ($!{ENOENT}) {
177                 return;
178         } else {
179                 warn "failed to open $path: $!\n";
180                 return;
181         }
182 }
183
184 sub _importer_for {
185         my ($self, $inbox) = @_;
186         my $im = $inbox->{-import} ||= eval {
187                 my $git = $inbox->git;
188                 my $name = $inbox->{name};
189                 my $addr = $inbox->{-primary_address};
190                 PublicInbox::Import->new($git, $name, $addr);
191         };
192         $self->{importers}->{"$im"} = $im;
193 }
194
195 sub _scrubber_for {
196         my ($inbox) = @_;
197         my $f = $inbox->{filter};
198         if ($f && $f =~ /::/) {
199                 eval "require $f";
200                 if ($@) {
201                         warn $@;
202                 } else {
203                         return $f->new;
204                 }
205         }
206         undef;
207 }
208
209 1;