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