]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/Mbox.pm
6d902e6ce25b722d6a79420a8378dc9bd2964129
[public-inbox.git] / lib / PublicInbox / Mbox.pm
1 # Copyright (C) 2015-2019 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3
4 # Streaming (via getline) interface for formatting messages as an mboxrd.
5 # Used by the PSGI web interface.
6 #
7 # public-inbox-httpd favors "getline" response bodies to take a
8 # "pull"-based approach to feeding slow clients (as opposed to a
9 # more common "push" model)
10 package PublicInbox::Mbox;
11 use strict;
12 use warnings;
13 use PublicInbox::MID qw/mid_clean mid_escape/;
14 use PublicInbox::Hval qw/to_filename/;
15 use Email::Simple;
16 use Email::MIME::Encode;
17
18 sub subject_fn ($) {
19         my ($hdr) = @_;
20         my $fn = $hdr->header('Subject');
21         return 'no-subject' unless defined($fn);
22
23         # no need for full Email::MIME, here
24         if ($fn =~ /=\?/) {
25                 eval { $fn = Encode::decode('MIME-Header', $fn) };
26                 $fn = 'no-subject' if $@;
27         }
28         $fn =~ s/^re:\s+//i;
29         $fn = to_filename($fn);
30         $fn eq '' ? 'no-subject' : $fn;
31 }
32
33 sub mb_stream {
34         my ($more) = @_;
35         bless $more, 'PublicInbox::Mbox';
36 }
37
38 # called by PSGI server as body response
39 # this gets called twice for every message, once to return the header,
40 # once to retrieve the body
41 sub getline {
42         my ($more) = @_; # self
43         my ($ctx, $id, $prev, $next, $mref, $hdr) = @$more;
44         if ($hdr) { # first message hits this, only
45                 pop @$more; # $hdr
46                 return msg_hdr($ctx, $hdr);
47         }
48         if ($mref) { # all messages hit this
49                 pop @$more; # $mref
50                 return msg_body($$mref);
51         }
52         my $cur = $next or return;
53         my $ibx = $ctx->{-inbox};
54         $next = $ibx->over->next_by_mid($ctx->{mid}, \$id, \$prev);
55         $mref = $ibx->msg_by_smsg($cur) or return;
56         $hdr = Email::Simple->new($mref)->header_obj;
57         @$more = ($ctx, $id, $prev, $next, $mref); # $next may be undef, here
58         msg_hdr($ctx, $hdr); # all but first message hits this
59 }
60
61 sub close {} # noop
62
63 sub emit_raw {
64         my ($ctx) = @_;
65         my $mid = $ctx->{mid};
66         my $ibx = $ctx->{-inbox};
67         my ($mref, $more, $id, $prev, $next);
68         if (my $over = $ibx->over) {
69                 my $smsg = $over->next_by_mid($mid, \$id, \$prev) or return;
70                 $mref = $ibx->msg_by_smsg($smsg) or return;
71                 $next = $over->next_by_mid($mid, \$id, \$prev);
72         } else {
73                 $mref = $ibx->msg_by_mid($mid) or return;
74         }
75         my $hdr = Email::Simple->new($mref)->header_obj;
76         $more = [ $ctx, $id, $prev, $next, $mref, $hdr ]; # for ->getline
77         my $fn = subject_fn($hdr);
78         my @hdr = ('Content-Type');
79         if ($ibx->{obfuscate}) {
80                 # obfuscation is stupid, but maybe scrapers are, too...
81                 push @hdr, 'application/mbox';
82                 $fn .= '.mbox';
83         } else {
84                 push @hdr, 'text/plain';
85                 $fn .= '.txt';
86         }
87         push @hdr, 'Content-Disposition', "inline; filename=$fn";
88         [ 200, \@hdr, mb_stream($more) ];
89 }
90
91 sub msg_hdr ($$;$) {
92         my ($ctx, $header_obj, $mid) = @_;
93
94         # drop potentially confusing headers, ssoma already should've dropped
95         # Lines and Content-Length
96         foreach my $d (qw(Lines Bytes Content-Length Status)) {
97                 $header_obj->header_set($d);
98         }
99         my $ibx = $ctx->{-inbox};
100         my $base = $ibx->base_url($ctx->{env});
101         $mid = $ctx->{mid} unless defined $mid;
102         $mid = mid_escape($mid);
103         my @append = (
104                 'Archived-At', "<$base$mid/>",
105                 'List-Archive', "<$base>",
106                 'List-Post', "<mailto:$ibx->{-primary_address}>",
107         );
108         my $crlf = $header_obj->crlf;
109         my $buf = "From mboxrd\@z Thu Jan  1 00:00:00 1970\n" .
110                         $header_obj->as_string;
111         for (my $i = 0; $i < @append; $i += 2) {
112                 my $k = $append[$i];
113                 my $v = $append[$i + 1];
114                 my @v = $header_obj->header($k);
115                 foreach (@v) {
116                         if ($v eq $_) {
117                                 $v = undef;
118                                 last;
119                         }
120                 }
121                 $buf .= "$k: $v$crlf" if defined $v;
122         }
123         $buf .= $crlf;
124 }
125
126 sub msg_body ($) {
127         # mboxrd quoting style
128         # https://en.wikipedia.org/wiki/Mbox#Modified_mbox
129         # https://www.loc.gov/preservation/digital/formats/fdd/fdd000385.shtml
130         # https://web.archive.org/http://www.qmail.org/man/man5/mbox.html
131         $_[0] =~ s/^(>*From )/>$1/gm;
132         $_[0] .= "\n";
133 }
134
135 sub thread_mbox {
136         my ($ctx, $over, $sfx) = @_;
137         eval { require IO::Compress::Gzip };
138         return sub { need_gzip(@_) } if $@;
139         my $mid = $ctx->{mid};
140         my $msgs = $over->get_thread($mid, {});
141         return [404, [qw(Content-Type text/plain)], []] if !@$msgs;
142         my $prev = $msgs->[-1];
143         my $i = 0;
144         my $cb = sub {
145                 while (1) {
146                         if (my $smsg = $msgs->[$i++]) {
147                                 return $smsg;
148                         }
149                         # refill result set
150                         $msgs = $over->get_thread($mid, $prev);
151                         return unless @$msgs;
152                         $prev = $msgs->[-1];
153                         $i = 0;
154                 }
155         };
156         PublicInbox::MboxGz->response($ctx, $cb, $msgs->[0]->subject);
157 }
158
159 sub emit_range {
160         my ($ctx, $range) = @_;
161
162         my $query;
163         if ($range eq 'all') { # TODO: YYYY[-MM]
164                 $query = '';
165         } else {
166                 return [404, [qw(Content-Type text/plain)], []];
167         }
168         mbox_all($ctx, $query);
169 }
170
171 sub mbox_all_ids {
172         my ($ctx) = @_;
173         my $prev = 0;
174         my $ibx = $ctx->{-inbox};
175         my $ids = $ibx->mm->ids_after(\$prev) or return
176                 [404, [qw(Content-Type text/plain)], ["No results found\n"]];
177         my $i = 0;
178         my $over = $ibx->over or
179                 return PublicInbox::WWW::need($ctx, 'Overview');
180         my $cb = sub {
181                 do {
182                         while ((my $num = $ids->[$i++])) {
183                                 my $smsg = $over->get_art($num) or next;
184                                 return $smsg;
185                         }
186                         $ids = $ibx->mm->ids_after(\$prev);
187                         $i = 0;
188                 } while (@$ids);
189                 undef;
190         };
191         return PublicInbox::MboxGz->response($ctx, $cb, 'all');
192 }
193
194 sub mbox_all {
195         my ($ctx, $query) = @_;
196
197         eval { require IO::Compress::Gzip };
198         return sub { need_gzip(@_) } if $@;
199         return mbox_all_ids($ctx) if $query eq '';
200         my $opts = { mset => 2 };
201         my $srch = $ctx->{-inbox}->search or
202                 return PublicInbox::WWW::need($ctx, 'Search');;
203         my $mset = $srch->query($query, $opts);
204         $opts->{offset} = $mset->size or
205                         return [404, [qw(Content-Type text/plain)],
206                                 ["No results found\n"]];
207         my $i = 0;
208         my $cb = sub { # called by MboxGz->getline
209                 while (1) {
210                         while (my $mi = (($mset->items)[$i++])) {
211                                 my $doc = $mi->get_document;
212                                 my $smsg = $srch->retry_reopen(sub {
213                                         PublicInbox::SearchMsg->load_doc($doc);
214                                 }) or next;
215                                 return $smsg;
216                         }
217                         # refill result set
218                         $mset = $srch->query($query, $opts);
219                         my $size = $mset->size or return;
220                         $opts->{offset} += $size;
221                         $i = 0;
222                 }
223         };
224         PublicInbox::MboxGz->response($ctx, $cb, 'results-'.$query);
225 }
226
227 sub need_gzip {
228         my $fh = $_[0]->([501, ['Content-Type' => 'text/html']]);
229         my $title = 'gzipped mbox not available';
230         $fh->write(<<EOF);
231 <html><head><title>$title</title><body><pre>$title
232 The administrator needs to install the IO::Compress::Gzip Perl module
233 to support gzipped mboxes.
234 <a href="../">Return to index</a></pre></body></html>
235 EOF
236         $fh->close;
237 }
238
239 1;
240
241 package PublicInbox::MboxGz;
242 use strict;
243 use warnings;
244 use PublicInbox::Hval qw/to_filename/;
245
246 sub new {
247         my ($class, $ctx, $cb) = @_;
248         my $buf = '';
249         bless {
250                 buf => \$buf,
251                 gz => IO::Compress::Gzip->new(\$buf, Time => 0),
252                 cb => $cb,
253                 ctx => $ctx,
254         }, $class;
255 }
256
257 sub response {
258         my ($class, $ctx, $cb, $fn) = @_;
259         my $body = $class->new($ctx, $cb);
260         # http://www.iana.org/assignments/media-types/application/gzip
261         my @h = qw(Content-Type application/gzip);
262         if ($fn) {
263                 $fn = to_filename($fn);
264                 push @h, 'Content-Disposition', "inline; filename=$fn.mbox.gz";
265         }
266         [ 200, \@h, $body ];
267 }
268
269 # called by Plack::Util::foreach or similar
270 sub getline {
271         my ($self) = @_;
272         my $ctx = $self->{ctx} or return;
273         my $gz = $self->{gz};
274         while (my $smsg = $self->{cb}->()) {
275                 my $mref = $ctx->{-inbox}->msg_by_smsg($smsg) or next;
276                 my $h = Email::Simple->new($mref)->header_obj;
277                 $gz->write(PublicInbox::Mbox::msg_hdr($ctx, $h, $smsg->{mid}));
278                 $gz->write(PublicInbox::Mbox::msg_body($$mref));
279
280                 my $bref = $self->{buf};
281                 if (length($$bref) >= 8192) {
282                         my $ret = $$bref; # copy :<
283                         ${$self->{buf}} = '';
284                         return $ret;
285                 }
286
287                 # be fair to other clients on public-inbox-httpd:
288                 return '';
289         }
290         delete($self->{gz})->close;
291         # signal that we're done and can return undef next call:
292         delete $self->{ctx};
293         ${delete $self->{buf}};
294 }
295
296 sub close {} # noop
297
298 1;