]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/MboxReader.pm
imap+nntp: share COMPRESS implementation
[public-inbox.git] / lib / PublicInbox / MboxReader.pm
1 # Copyright (C) 2020-2021 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3
4 # reader for mbox variants we support
5 package PublicInbox::MboxReader;
6 use strict;
7 use v5.10.1;
8 use Data::Dumper;
9 $Data::Dumper::Useqq = 1; # should've been the default, for bad data
10
11 my $from_strict =
12         qr/^From \S+ +\S+ \S+ +\S+ [^\n:]+:[^\n:]+:[^\n:]+ [^\n:]+\n/sm;
13
14 # cf: https://doc.dovecot.org/configuration_manual/mail_location/mbox/
15 my %status2kw = (F => 'flagged', A => 'answered', R => 'seen', T => 'draft');
16 # O (old/non-recent), and D (deleted) aren't in JMAP,
17 # so probably won't be supported by us.
18 sub mbox_keywords {
19         my $eml = $_[-1];
20         my $s = "@{[$eml->header_raw('X-Status'),$eml->header_raw('Status')]}";
21         my %kw;
22         $s =~ s/([FART])/$kw{$status2kw{$1}} = 1/sge;
23         [ sort(keys %kw) ];
24 }
25
26 sub _mbox_from {
27         my ($mbfh, $from_re, $eml_cb, @arg) = @_;
28         my $buf = '';
29         my @raw;
30         while (defined(my $r = read($mbfh, $buf, 65536, length($buf)))) {
31                 if ($r == 0) { # close here to check for "curl --fail"
32                         close($mbfh) or die "error closing mbox: \$?=$? $!";
33                         @raw = ($buf);
34                 } else {
35                         @raw = split(/$from_strict/mos, $buf, -1);
36                         next if scalar(@raw) == 0;
37                         $buf = pop(@raw); # last bit may be incomplete
38                 }
39                 @raw = grep /[^ \t\r\n]/s, @raw; # skip empty messages
40                 while (defined(my $raw = shift @raw)) {
41                         $raw =~ s/^\r?\n\z//ms;
42                         $raw =~ s/$from_re/$1/gms;
43                         my $eml = PublicInbox::Eml->new(\$raw);
44                         $eml_cb->($eml, @arg) if $eml->raw_size;
45                 }
46                 return if $r == 0; # EOF
47         }
48         die "error reading mboxo/mboxrd handle: $!";
49 }
50
51 sub mboxrd {
52         my (undef, $mbfh, $eml_cb, @arg) = @_;
53         _mbox_from($mbfh, qr/^>(>*From )/ms, $eml_cb, @arg);
54 }
55
56 sub mboxo {
57         my (undef, $mbfh, $eml_cb, @arg) = @_;
58         _mbox_from($mbfh, qr/^>(From )/ms, $eml_cb, @arg);
59 }
60
61 sub _cl_body {
62         my ($mbfh, $bref, $cl) = @_;
63         my $body = substr($$bref, 0, $cl, '');
64         my $need = $cl - length($body);
65         if ($need > 0) {
66                 $mbfh or die "E: needed $need bytes after EOF";
67                 defined(my $r = read($mbfh, $body, $need, length($body))) or
68                         die "E: read error: $!\n";
69                 $r == $need or die "E: read $r of $need bytes\n";
70         }
71         \$body;
72 }
73
74 sub _extract_hdr {
75         my ($ref) = @_;
76         if (index($$ref, "\r\n") < 0 && (my $pos = index($$ref, "\n\n")) >= 0) {
77                 # likely on *nix
78                 \substr($$ref, 0, $pos + 2, ''); # sv_chop on $$ref
79         } elsif ($$ref =~ /\r?\n\r?\n/s) {
80                 \substr($$ref, 0, $+[0], ''); # sv_chop on $$ref
81         } else {
82                 undef
83         }
84 }
85
86 sub _mbox_cl ($$$;@) {
87         my ($mbfh, $uxs_from, $eml_cb, @arg) = @_;
88         my $buf = '';
89         while (defined(my $r = read($mbfh, $buf, 65536, length($buf)))) {
90                 if ($r == 0) { # detect "curl --fail"
91                         close($mbfh) or
92                                 die "error closing mboxcl/mboxcl2: \$?=$? $!";
93                         undef $mbfh;
94                 }
95                 while (my $hdr = _extract_hdr(\$buf)) {
96                         $$hdr =~ s/\A[\r\n]*From [^\n]*\n//s or
97                                 die "E: no 'From ' line in:\n", Dumper($hdr);
98                         my $eml = PublicInbox::Eml->new($hdr);
99                         next unless $eml->raw_size;
100                         my @cl = $eml->header_raw('Content-Length');
101                         my $n = scalar(@cl);
102                         $n == 0 and die "E: Content-Length missing in:\n",
103                                         Dumper($eml->as_string);
104                         $n == 1 or die "E: multiple ($n) Content-Length in:\n",
105                                         Dumper($eml->as_string);
106                         $cl[0] =~ /\A[0-9]+\z/ or die
107                                 "E: Content-Length `$cl[0]' invalid\n",
108                                         Dumper($eml->as_string);
109                         if (($eml->{bdy} = _cl_body($mbfh, \$buf, $cl[0]))) {
110                                 $uxs_from and
111                                         ${$eml->{bdy}} =~ s/^>From /From /sgm;
112                         }
113                         $eml_cb->($eml, @arg);
114                 }
115                 if ($r == 0) {
116                         $buf =~ /[^ \r\n\t]/ and
117                                 warn "W: leftover at end of mboxcl/mboxcl2:\n",
118                                         Dumper(\$buf);
119                         return;
120                 }
121         }
122         die "error reading mboxcl/mboxcl2 handle: $!";
123 }
124
125 sub mboxcl {
126         my (undef, $mbfh, $eml_cb, @arg) = @_;
127         _mbox_cl($mbfh, 1, $eml_cb, @arg);
128 }
129
130 sub mboxcl2 {
131         my (undef, $mbfh, $eml_cb, @arg) = @_;
132         _mbox_cl($mbfh, undef, $eml_cb, @arg);
133 }
134
135 sub new { bless \(my $x), __PACKAGE__ }
136
137 sub reads {
138         my $ifmt = $_[-1];
139         $ifmt =~ /\Ambox(?:rd|cl|cl2|o)\z/ ? __PACKAGE__->can($ifmt) : undef
140 }
141
142 # all of these support -c for stdout and -d for decompression,
143 # mutt is commonly distributed with hooks for gz, bz2 and xz, at least
144 # { foo => '' } means "--foo" is passed to the command-line,
145 # otherwise { foo => '--bar' } passes "--bar"
146 my %zsfx2cmd = (
147         gz => [ qw(GZIP pigz gzip) ],
148         bz2 => [ 'bzip2', {} ],
149         xz => [ 'xz', {} ],
150         # don't add new entries here unless MUA support is widely available
151 );
152
153 sub zsfx ($) {
154         my ($pathname) = @_;
155         my $allow = join('|', keys %zsfx2cmd);
156         $pathname =~ /\.($allow)\z/ ? $1 : undef;
157 }
158
159 sub zsfx2cmd ($$$) {
160         my ($zsfx, $decompress, $lei) = @_;
161         my $x = $zsfx2cmd{$zsfx} // die "BUG: no support for suffix=.$zsfx";
162         my @info = @$x;
163         my $cmd_opt = ref($info[-1]) ? pop(@info) : undef;
164         my @cmd = (undef, $decompress ? qw(-dc) : qw(-c));
165         require PublicInbox::Spawn;
166         for my $exe (@info) {
167                 # I think respecting client's ENV{GZIP} is OK, not sure
168                 # about ENV overrides for other, less-common compressors
169                 if ($exe eq uc($exe)) {
170                         $exe = $lei->{env}->{$exe} or next;
171                 }
172                 $cmd[0] = PublicInbox::Spawn::which($exe) and last;
173         }
174         $cmd[0] // die join(' or ', @info)." missing for .$zsfx";
175
176         # not all gzip support --rsyncable, FreeBSD gzip doesn't even exit
177         # with an error code
178         if (!$decompress && $cmd[0] =~ m!/gzip\z! && !defined($cmd_opt)) {
179                 pipe(my ($r, $w)) or die "pipe: $!";
180                 open my $null, '+>', '/dev/null' or die "open: $!";
181                 my $rdr = { 0 => $null, 1 => $null, 2 => $w };
182                 my $tst = [ $cmd[0], '--rsyncable' ];
183                 my $pid = PublicInbox::Spawn::spawn($tst, undef, $rdr);
184                 close $w;
185                 my $err = do { local $/; <$r> };
186                 waitpid($pid, 0) == $pid or die "BUG: waitpid: $!";
187                 $cmd_opt = $err ? {} : { rsyncable => '' };
188                 push(@$x, $cmd_opt);
189         }
190         for my $bool (keys %$cmd_opt) {
191                 my $switch = $cmd_opt->{$bool} // next;
192                 push @cmd, '--'.($switch || $bool);
193         }
194         for my $key (qw(rsyncable)) { # support compression level?
195                 my $switch = $cmd_opt->{$key} // next;
196                 my $val = $lei->{opt}->{$key} // next;
197                 push @cmd, $switch, $val;
198         }
199         \@cmd;
200 }
201
202 sub zsfxcat ($$$) {
203         my ($in, $zsfx, $lei) = @_;
204         my $cmd = zsfx2cmd($zsfx, 1, $lei);
205         PublicInbox::Spawn::popen_rd($cmd, undef, { 0 => $in, 2 => $lei->{2} });
206 }
207
208 1;