]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/View.pm
view: permalink (per-message) view shows multiple messages
[public-inbox.git] / lib / PublicInbox / View.pm
1 # Copyright (C) 2014-2018 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3 #
4 # Used for displaying the HTML web interface.
5 # See Documentation/design_www.txt for this.
6 package PublicInbox::View;
7 use strict;
8 use warnings;
9 use PublicInbox::MsgTime qw(msg_datestamp);
10 use PublicInbox::Hval qw/ascii_html obfuscate_addrs/;
11 use PublicInbox::Linkify;
12 use PublicInbox::MID qw/mid_clean id_compress mid_mime mid_escape mids/;
13 use PublicInbox::MsgIter;
14 use PublicInbox::Address;
15 use PublicInbox::WwwStream;
16 use PublicInbox::Reply;
17 require POSIX;
18
19 use constant INDENT => '  ';
20 use constant TCHILD => '` ';
21 sub th_pfx ($) { $_[0] == 0 ? '' : TCHILD };
22
23 # public functions: (unstable)
24
25 sub msg_html {
26         my ($ctx, $mime, $more) = @_;
27         my $hdr = $mime->header_obj;
28         my $ibx = $ctx->{-inbox};
29         my $obfs_ibx = $ctx->{-obfs_ibx} = $ibx->{obfuscate} ? $ibx : undef;
30         my $tip = _msg_html_prepare($hdr, $ctx, $more, 0);
31         my $end = 2;
32         PublicInbox::WwwStream->response($ctx, 200, sub {
33                 my ($nr, undef) = @_;
34                 if ($nr == 1) {
35                         $tip . multipart_text_as_html($mime, '', $obfs_ibx) .
36                                 '</pre><hr>'
37                 } elsif ($more && @$more) {
38                         ++$end;
39                         msg_html_more($ctx, $more, $nr);
40                 } elsif ($nr == $end) {
41                         # fake an EOF if generating the footer fails;
42                         # we want to at least show the message if something
43                         # here crashes:
44                         eval {
45                                 '<pre>' . html_footer($hdr, 1, $ctx) .
46                                 '</pre>' . msg_reply($ctx, $hdr)
47                         };
48                 } else {
49                         undef
50                 }
51         });
52 }
53
54 sub msg_page {
55         my ($ctx) = @_;
56         my $mid = $ctx->{mid};
57         my $ibx = $ctx->{-inbox};
58         my ($first, $more, $head, $tail, $db);
59         if (my $srch = $ibx->search) {
60                 $srch->retry_reopen(sub {
61                         ($head, $tail, $db) = $srch->each_smsg_by_mid($mid);
62                         for (; !defined($first) && $head != $tail; $head++) {
63                                 my @args = ($head, $db, $mid);
64                                 my $smsg = PublicInbox::SearchMsg->get(@args);
65                                 next if $smsg->type ne 'mail';
66                                 $first = $ibx->msg_by_smsg($smsg);
67                         }
68                         if ($head != $tail) {
69                                 $more = [ $head, $tail, $db ];
70                         }
71                 });
72         } else {
73                 $first = $ibx->msg_by_mid($mid) or return;
74         }
75         $first ? msg_html($ctx, PublicInbox::MIME->new($first), $more) : undef;
76 }
77
78 sub msg_html_more {
79         my ($ctx, $more, $nr) = @_;
80         my $str = eval {
81                 my $mref;
82                 my ($head, $tail, $db) = @$more;
83                 for (; !defined($mref) && $head != $tail; $head++) {
84                         my $smsg = PublicInbox::SearchMsg->get($head, $db,
85                                                                 $ctx->{mid});
86                         next if $smsg->type ne 'mail';
87                         $mref = $ctx->{-inbox}->msg_by_smsg($smsg);
88                 }
89                 if ($head == $tail) { # done
90                         @$more = ();
91                 } else {
92                         $more->[0] = $head;
93                 }
94                 if ($mref) {
95                         my $mime = PublicInbox::MIME->new($mref);
96                         _msg_html_prepare($mime->header_obj, $ctx, $more, $nr) .
97                                 multipart_text_as_html($mime, '',
98                                                         $ctx->{-obfs_ibx}) .
99                                 '</pre><hr>'
100                 } else {
101                         '';
102                 }
103         };
104         if ($@) {
105                 warn "Error lookup up additional messages: $@\n";
106                 $str = '<pre>Error looking up additional messages</pre>';
107         }
108         $str;
109 }
110
111 # /$INBOX/$MESSAGE_ID/#R
112 sub msg_reply {
113         my ($ctx, $hdr) = @_;
114         my $se_url =
115          'https://kernel.org/pub/software/scm/git/docs/git-send-email.html';
116         my $p_url =
117          'https://en.wikipedia.org/wiki/Posting_style#Interleaved_style';
118
119         my $info = '';
120         my $ibx = $ctx->{-inbox};
121         if (my $url = $ibx->{infourl}) {
122                 $url = PublicInbox::Hval::prurl($ctx->{env}, $url);
123                 $info = qq(\n  List information: <a\nhref="$url">$url</a>\n);
124         }
125
126         my ($arg, $link, $reply_to_all) =
127                         PublicInbox::Reply::mailto_arg_link($ibx, $hdr);
128
129         # mailto: link only works if address obfuscation is disabled
130         if ($link) {
131                 $link = <<EOF;
132
133 * If your mail client supports setting the <b>In-Reply-To</b> header
134   via mailto: links, try the <a
135 href="$link">mailto: link</a>
136 EOF
137         }
138
139         push @$arg, '/path/to/YOUR_REPLY';
140         $arg = ascii_html(join(" \\\n    ", '', @$arg));
141         <<EOF
142 <hr><pre
143 id=R><b>Reply instructions:</b>
144
145 You may reply publically to <a
146 href=#t>this message</a> via plain-text email
147 using any one of the following methods:
148
149 * Save the following mbox file, import it into your mail client,
150   and $reply_to_all from there: <a
151 href=raw>mbox</a>
152
153   Avoid top-posting and favor interleaved quoting:
154   <a
155 href="$p_url">$p_url</a>
156 $info
157 * Reply using the <b>--to</b>, <b>--cc</b>, and <b>--in-reply-to</b>
158   switches of git-send-email(1):
159
160   git send-email$arg
161
162   <a
163 href="$se_url">$se_url</a>
164 $link</pre>
165 EOF
166 }
167
168 sub in_reply_to {
169         my ($hdr) = @_;
170         my %mid = map { $_ => 1 } $hdr->header_raw('Message-ID');
171         my @refs = (($hdr->header_raw('References') || '') =~ /<([^>]+)>/g);
172         push(@refs, (($hdr->header_raw('In-Reply-To') || '') =~ /<([^>]+)>/g));
173         while (defined(my $irt = pop @refs)) {
174                 next if $mid{"<$irt>"};
175                 return $irt;
176         }
177         undef;
178 }
179
180 sub _hdr_names_html ($$) {
181         my ($hdr, $field) = @_;
182         my $val = $hdr->header($field) or return '';
183         ascii_html(join(', ', PublicInbox::Address::names($val)));
184 }
185
186 sub nr_to_s ($$$) {
187         my ($nr, $singular, $plural) = @_;
188         return "0 $plural" if $nr == 0;
189         $nr == 1 ? "$nr $singular" : "$nr $plural";
190 }
191
192 # this is already inside a <pre>
193 sub index_entry {
194         my ($mime, $ctx, $more) = @_;
195         my $srch = $ctx->{srch};
196         my $hdr = $mime->header_obj;
197         my $subj = $hdr->header('Subject');
198
199         my $mid_raw = mid_clean(mid_mime($mime));
200         my $id = id_compress($mid_raw, 1);
201         my $id_m = 'm'.$id;
202
203         my $root_anchor = $ctx->{root_anchor} || '';
204         my $irt;
205         my $obfs_ibx = $ctx->{-obfs_ibx};
206
207         my $rv = "<a\nhref=#e$id\nid=m$id>*</a> ";
208         $subj = '<b>'.ascii_html($subj).'</b>';
209         obfuscate_addrs($obfs_ibx, $subj) if $obfs_ibx;
210         $subj = "<u\nid=u>$subj</u>" if $root_anchor eq $id_m;
211         $rv .= $subj . "\n";
212         $rv .= _th_index_lite($mid_raw, \$irt, $id, $ctx);
213         my @tocc;
214         foreach my $f (qw(To Cc)) {
215                 my $dst = _hdr_names_html($hdr, $f);
216                 if ($dst ne '') {
217                         obfuscate_addrs($obfs_ibx, $dst) if $obfs_ibx;
218                         push @tocc, "$f: $dst";
219                 }
220         }
221         my $from = _hdr_names_html($hdr, 'From');
222         obfuscate_addrs($obfs_ibx, $from) if $obfs_ibx;
223         $rv .= "From: $from @ "._msg_date($hdr)." UTC";
224         my $upfx = $ctx->{-upfx};
225         my $mhref = $upfx . mid_escape($mid_raw) . '/';
226         $rv .= qq{ (<a\nhref="$mhref">permalink</a> / };
227         $rv .= qq{<a\nhref="${mhref}raw">raw</a>)\n};
228         $rv .= '  '.join('; +', @tocc) . "\n" if @tocc;
229
230         my $mapping = $ctx->{mapping};
231         if (!$mapping && (defined($irt) || defined($irt = in_reply_to($hdr)))) {
232                 my $mirt = PublicInbox::Hval->new_msgid($irt);
233                 my $href = $upfx . $mirt->{href}. '/';
234                 my $html = $mirt->as_html;
235                 $rv .= qq(In-Reply-To: &lt;<a\nhref="$href">$html</a>&gt;\n)
236         }
237         $rv .= "\n";
238
239         # scan through all parts, looking for displayable text
240         msg_iter($mime, sub { $rv .= add_text_body($mhref, $obfs_ibx, $_[0]) });
241
242         # add the footer
243         $rv .= "\n<a\nhref=#$id_m\nid=e$id>^</a> ".
244                 "<a\nhref=\"$mhref\">permalink</a>" .
245                 " <a\nhref=\"${mhref}raw\">raw</a>" .
246                 " <a\nhref=\"${mhref}#R\">reply</a>";
247
248         my $hr;
249         if (my $pct = $ctx->{pct}) { # used by SearchView.pm
250                 $rv .= "\t[relevance $pct->{$mid_raw}%]";
251                 $hr = 1;
252         } elsif ($mapping) {
253                 my $nested = 'nested';
254                 my $flat = 'flat';
255                 my $end = '';
256                 if ($ctx->{flat}) {
257                         $hr = 1;
258                         $flat = "<b>$flat</b>";
259                 } else {
260                         $nested = "<b>$nested</b>";
261                 }
262                 $rv .= "\t[<a\nhref=\"${mhref}T/#u\">$flat</a>";
263                 $rv .= "|<a\nhref=\"${mhref}t/#u\">$nested</a>]";
264                 $rv .= " <a\nhref=#r$id>$ctx->{s_nr}</a>";
265         } else {
266                 $hr = $ctx->{-hr};
267         }
268
269         $rv .= $more ? '</pre><hr><pre>' : '</pre>' if $hr;
270         $rv;
271 }
272
273 sub pad_link ($$;$) {
274         my ($mid, $level, $s) = @_;
275         $s ||= '...';
276         my $id = id_compress($mid, 1);
277         (' 'x19).indent_for($level).th_pfx($level)."<a\nhref=#r$id>($s)</a>\n";
278 }
279
280 sub _th_index_lite {
281         my ($mid_raw, $irt, $id, $ctx) = @_;
282         my $rv = '';
283         my $mapping = $ctx->{mapping} or return $rv;
284         my $pad = '  ';
285         my $mid_map = $mapping->{$mid_raw};
286         defined $mid_map or
287                 return 'public-inbox BUG: '.ascii_html($mid_raw).' not mapped';
288         my ($attr, $node, $idx, $level) = @$mid_map;
289         my $children = $node->{children};
290         my $nr_c = scalar @$children;
291         my $nr_s = 0;
292         my $siblings;
293         if (my $smsg = $node->{smsg}) {
294                 ($$irt) = (($smsg->{references} || '') =~ m/<([^>]+)>\z/);
295         }
296         my $irt_map = $mapping->{$$irt} if defined $$irt;
297         if (defined $irt_map) {
298                 $siblings = $irt_map->[1]->{children};
299                 $nr_s = scalar(@$siblings) - 1;
300                 $rv .= $pad . $irt_map->[0];
301                 if ($idx > 0) {
302                         my $prev = $siblings->[$idx - 1];
303                         my $pmid = $prev->{id};
304                         if ($idx > 2) {
305                                 my $s = ($idx - 1). ' preceding siblings ...';
306                                 $rv .= pad_link($pmid, $level, $s);
307                         } elsif ($idx == 2) {
308                                 my $ppmid = $siblings->[0]->{id};
309                                 $rv .= $pad . $mapping->{$ppmid}->[0];
310                         }
311                         $rv .= $pad . $mapping->{$pmid}->[0];
312                 }
313         }
314         my $s_s = nr_to_s($nr_s, 'sibling', 'siblings');
315         my $s_c = nr_to_s($nr_c, 'reply', 'replies');
316         $attr =~ s!\n\z!</b>\n!s;
317         $attr =~ s!<a\nhref.*</a> !!s; # no point in duplicating subject
318         $attr =~ s!<a\nhref=[^>]+>([^<]+)</a>!$1!s; # no point linking to self
319         $rv .= "<b>@ $attr";
320         if ($nr_c) {
321                 my $cmid = $children->[0]->{id};
322                 $rv .= $pad . $mapping->{$cmid}->[0];
323                 if ($nr_c > 2) {
324                         my $s = ($nr_c - 1). ' more replies';
325                         $rv .= pad_link($cmid, $level + 1, $s);
326                 } elsif (my $cn = $children->[1]) {
327                         $rv .= $pad . $mapping->{$cn->{id}}->[0];
328                 }
329         }
330
331         my $next = $siblings->[$idx+1] if $siblings && $idx >= 0;
332         if ($next) {
333                 my $nmid = $next->{id};
334                 $rv .= $pad . $mapping->{$nmid}->[0];
335                 my $nnext = $nr_s - $idx;
336                 if ($nnext > 2) {
337                         my $s = ($nnext - 1).' subsequent siblings';
338                         $rv .= pad_link($nmid, $level, $s);
339                 } elsif (my $nn = $siblings->[$idx + 2]) {
340                         $rv .= $pad . $mapping->{$nn->{id}}->[0];
341                 }
342         }
343         $rv .= $pad ."<a\nhref=#r$id>$s_s, $s_c; $ctx->{s_nr}</a>\n";
344 }
345
346 sub walk_thread {
347         my ($rootset, $ctx, $cb) = @_;
348         my @q = map { (0, $_, -1) } @$rootset;
349         while (@q) {
350                 my ($level, $node, $i) = splice(@q, 0, 3);
351                 defined $node or next;
352                 $cb->($ctx, $level, $node, $i);
353                 ++$level;
354                 $i = 0;
355                 unshift @q, map { ($level, $_, $i++) } @{$node->{children}};
356         }
357 }
358
359 sub pre_thread  {
360         my ($ctx, $level, $node, $idx) = @_;
361         $ctx->{mapping}->{$node->{id}} = [ '', $node, $idx, $level ];
362         skel_dump($ctx, $level, $node);
363 }
364
365 sub thread_index_entry {
366         my ($ctx, $level, $mime) = @_;
367         my ($beg, $end) = thread_adj_level($ctx, $level);
368         $beg . '<pre>' . index_entry($mime, $ctx, 0) . '</pre>' . $end;
369 }
370
371 sub stream_thread ($$) {
372         my ($rootset, $ctx) = @_;
373         my $inbox = $ctx->{-inbox};
374         my $mime;
375         my @q = map { (0, $_) } @$rootset;
376         my $level;
377         while (@q) {
378                 $level = shift @q;
379                 my $node = shift @q or next;
380                 my $cl = $level + 1;
381                 unshift @q, map { ($cl, $_) } @{$node->{children}};
382                 $mime = $inbox->msg_by_smsg($node->{smsg}) and last;
383         }
384         return missing_thread($ctx) unless $mime;
385
386         $ctx->{-obfs_ibx} = $inbox->{obfuscate} ? $inbox : undef;
387         $mime = PublicInbox::MIME->new($mime);
388         $ctx->{-title_html} = ascii_html($mime->header('Subject'));
389         $ctx->{-html_tip} = thread_index_entry($ctx, $level, $mime);
390         PublicInbox::WwwStream->response($ctx, 200, sub {
391                 return unless $ctx;
392                 while (@q) {
393                         $level = shift @q;
394                         my $node = shift @q or next;
395                         my $cl = $level + 1;
396                         unshift @q, map { ($cl, $_) } @{$node->{children}};
397                         my $mid = $node->{id};
398                         if ($mime = $inbox->msg_by_smsg($node->{smsg})) {
399                                 $mime = PublicInbox::MIME->new($mime);
400                                 return thread_index_entry($ctx, $level, $mime);
401                         } else {
402                                 return ghost_index_entry($ctx, $level, $node);
403                         }
404                 }
405                 my $ret = join('', thread_adj_level($ctx, 0));
406                 $ret .= ${$ctx->{dst}}; # skel
407                 $ctx = undef;
408                 $ret;
409         });
410 }
411
412 sub thread_html {
413         my ($ctx) = @_;
414         my $mid = $ctx->{mid};
415         my $srch = $ctx->{srch};
416         my $sres = $srch->get_thread($mid);
417         my $msgs = load_results($srch, $sres);
418         my $nr = $sres->{total};
419         return missing_thread($ctx) if $nr == 0;
420         my $skel = '<hr><pre>';
421         $skel .= $nr == 1 ? 'only message in thread' : 'end of thread';
422         $skel .= ", back to <a\nhref=\"../../\">index</a>\n\n";
423         $skel .= "<b\nid=t>Thread overview:</b> ";
424         $skel .= $nr == 1 ? '(only message)' : "$nr+ messages";
425         $skel .= " (download: <a\nhref=\"../t.mbox.gz\">mbox.gz</a>";
426         $skel .= " / follow: <a\nhref=\"../t.atom\">Atom feed</a>)\n";
427         $skel .= "-- links below jump to the message on this page --\n";
428         $ctx->{-upfx} = '../../';
429         $ctx->{cur_level} = 0;
430         $ctx->{dst} = \$skel;
431         $ctx->{prev_attr} = '';
432         $ctx->{prev_level} = 0;
433         $ctx->{root_anchor} = anchor_for($mid);
434         $ctx->{mapping} = {};
435         $ctx->{s_nr} = "$nr+ messages in thread";
436
437         my $rootset = thread_results($msgs, $srch);
438
439         # reduce hash lookups in pre_thread->skel_dump
440         my $inbox = $ctx->{-inbox};
441         $ctx->{-obfs_ibx} = $inbox->{obfuscate} ? $inbox : undef;
442         walk_thread($rootset, $ctx, *pre_thread);
443
444         $skel .= '</pre>';
445         return stream_thread($rootset, $ctx) unless $ctx->{flat};
446
447         # flat display: lazy load the full message from smsg
448         my $mime;
449         while ($mime = shift @$msgs) {
450                 $mime = $inbox->msg_by_smsg($mime) and last;
451         }
452         return missing_thread($ctx) unless $mime;
453         $mime = PublicInbox::MIME->new($mime);
454         $ctx->{-title_html} = ascii_html($mime->header('Subject'));
455         $ctx->{-html_tip} = '<pre>'.index_entry($mime, $ctx, scalar @$msgs);
456         $mime = undef;
457         PublicInbox::WwwStream->response($ctx, 200, sub {
458                 return unless $msgs;
459                 while ($mime = shift @$msgs) {
460                         $mime = $inbox->msg_by_smsg($mime) and last;
461                 }
462                 if ($mime) {
463                         $mime = PublicInbox::MIME->new($mime);
464                         return index_entry($mime, $ctx, scalar @$msgs);
465                 }
466                 $msgs = undef;
467                 $skel;
468         });
469 }
470
471 sub multipart_text_as_html {
472         my ($mime, $upfx, $obfs_ibx) = @_;
473         my $rv = "";
474
475         # scan through all parts, looking for displayable text
476         msg_iter($mime, sub { $rv .= add_text_body($upfx, $obfs_ibx, $_[0]) });
477         $rv;
478 }
479
480 sub flush_quote {
481         my ($s, $l, $quot) = @_;
482
483         # show everything in the full version with anchor from
484         # short version (see above)
485         my $rv = $l->linkify_1(join('', @$quot));
486         @$quot = ();
487
488         # we use a <span> here to allow users to specify their own
489         # color for quoted text
490         $rv = $l->linkify_2(ascii_html($rv));
491         $$s .= qq(<span\nclass="q">) . $rv . '</span>'
492 }
493
494 sub attach_link ($$$$;$) {
495         my ($upfx, $ct, $p, $fn, $err) = @_;
496         my ($part, $depth, @idx) = @$p;
497         my $nl = $idx[-1] > 1 ? "\n" : '';
498         my $idx = join('.', @idx);
499         my $size = bytes::length($part->body);
500
501         # hide attributes normally, unless we want to aid users in
502         # spotting MUA problems:
503         $ct =~ s/;.*// unless $err;
504         $ct = ascii_html($ct);
505         my $desc = $part->header('Content-Description');
506         $desc = $fn unless defined $desc;
507         $desc = '' unless defined $desc;
508         my $sfn;
509         if (defined $fn && $fn =~ /\A[[:alnum:]][\w\.-]+[[:alnum:]]\z/) {
510                 $sfn = $fn;
511         } elsif ($ct eq 'text/plain') {
512                 $sfn = 'a.txt';
513         } else {
514                 $sfn = 'a.bin';
515         }
516         my $ret = qq($nl<a\nhref="$upfx$idx-$sfn">);
517         if ($err) {
518                 $ret .=
519 "[-- Warning: decoded text below may be mangled --]\n";
520         }
521         $ret .= "[-- Attachment #$idx: ";
522         my $ts = "Type: $ct, Size: $size bytes";
523         $desc = ascii_html($desc);
524         $ret .= ($desc eq '') ? "$ts --]" : "$desc --]\n[-- $ts --]";
525         $ret .= "</a>\n";
526 }
527
528 sub add_text_body {
529         my ($upfx, $obfs_ibx, $p) = @_;
530         # $p - from msg_iter: [ Email::MIME, depth, @idx ]
531         my ($part, $depth) = @$p; # attachment @idx is unused
532         my $ct = $part->content_type || 'text/plain';
533         my $fn = $part->filename;
534
535         if ($ct =~ m!\btext/x?html\b!i) {
536                 return attach_link($upfx, $ct, $p, $fn);
537         }
538
539         my $s = eval { $part->body_str };
540
541         # badly-encoded message? tell the world about it!
542         my $err = $@;
543         if ($err) {
544                 if ($ct =~ m!\btext/plain\b!i) {
545                         # Try to assume UTF-8 because Alpine seems to
546                         # do wacky things and set charset=X-UNKNOWN
547                         $part->charset_set('UTF-8');
548                         $s = eval { $part->body_str };
549
550                         # If forcing charset=UTF-8 failed,
551                         # attach_link will warn further down...
552                         $s = $part->body if $@;
553                 } else {
554                         return attach_link($upfx, $ct, $p, $fn);
555                 }
556         }
557
558         my @lines = split(/^/m, $s);
559         $s = '';
560         if (defined($fn) || $depth > 0 || $err) {
561                 $s .= attach_link($upfx, $ct, $p, $fn, $err);
562                 $s .= "\n";
563         }
564         my @quot;
565         my $l = PublicInbox::Linkify->new;
566         foreach my $cur (@lines) {
567                 if ($cur !~ /^>/) {
568                         # show the previously buffered quote inline
569                         flush_quote(\$s, $l, \@quot) if @quot;
570
571                         # regular line, OK
572                         $l->linkify_1($cur);
573                         $s .= $l->linkify_2(ascii_html($cur));
574                 } else {
575                         push @quot, $cur;
576                 }
577         }
578
579         if (@quot) { # ugh, top posted
580                 flush_quote(\$s, $l, \@quot);
581                 obfuscate_addrs($obfs_ibx, $s) if $obfs_ibx;
582                 $s;
583         } else {
584                 obfuscate_addrs($obfs_ibx, $s) if $obfs_ibx;
585                 if ($s =~ /\n\z/s) { # common, last line ends with a newline
586                         $s;
587                 } else { # some editors don't do newlines...
588                         $s .= "\n";
589                 }
590         }
591 }
592
593 sub _msg_html_prepare {
594         my ($hdr, $ctx, $more, $nr) = @_;
595         my $srch = $ctx->{srch} if $ctx;
596         my $atom = '';
597         my $obfs_ibx = $ctx->{-obfs_ibx};
598         my $rv = '';
599         my $mids = mids($hdr);
600         my $multiple = scalar(@$mids) > 1; # zero, one, infinity
601         if ($nr == 0) {
602                 if ($more) {
603                         $rv .=
604 "<pre>WARNING: multiple messages refer to this Message-ID\n</pre>";
605                 }
606                 $rv .= "<pre\nid=b>"; # anchor for body start
607         } else {
608                 $rv .= '<pre>';
609         }
610         if ($srch) {
611                 $ctx->{-upfx} = '../';
612         }
613         my @title;
614         foreach my $h (qw(From To Cc Subject Date)) {
615                 my $v = $hdr->header($h);
616                 defined($v) && ($v ne '') or next;
617                 $v = PublicInbox::Hval->new($v);
618
619                 if ($h eq 'From') {
620                         my @n = PublicInbox::Address::names($v->raw);
621                         $title[1] = ascii_html(join(', ', @n));
622                         obfuscate_addrs($obfs_ibx, $title[1]) if $obfs_ibx;
623                 } elsif ($h eq 'Subject') {
624                         $title[0] = $v->as_html;
625                         if ($srch) {
626                                 $rv .= qq($h: <a\nhref="#r"\nid=t>);
627                                 $rv .= $v->as_html . "</a>\n";
628                                 next;
629                         }
630                 }
631                 $v = $v->as_html;
632                 obfuscate_addrs($obfs_ibx, $v) if $obfs_ibx;
633                 $rv .= "$h: $v\n";
634
635         }
636         $title[0] ||= '(no subject)';
637         $ctx->{-title_html} = join(' - ', @title);
638         foreach (@$mids) {
639                 my $mid = PublicInbox::Hval->new_msgid($_) ;
640                 my $mhtml = $mid->as_html;
641                 if ($multiple) {
642                         my $href = $mid->{href};
643                         $rv .= "Message-ID: ";
644                         $rv .= "<a\nhref=\"../$href/\">";
645                         $rv .= "&lt;$mhtml&gt;</a> ";
646                         $rv .= "(<a\nhref=\"../$href/raw\">raw</a>)\n";
647                 } else {
648                         $rv .= "Message-ID: &lt;$mhtml&gt; ";
649                         $rv .= "(<a\nhref=\"raw\">raw</a>)\n";
650                 }
651         }
652         $rv .= _parent_headers($hdr, $srch);
653         $rv .= "\n";
654 }
655
656 sub thread_skel {
657         my ($dst, $ctx, $hdr, $tpfx) = @_;
658         my $srch = $ctx->{srch};
659         my $mid = mid_clean($hdr->header_raw('Message-ID'));
660         my $sres = $srch->get_thread($mid);
661         my $nr = $sres->{total};
662         my $expand = qq(expand[<a\nhref="${tpfx}T/#u">flat</a>) .
663                         qq(|<a\nhref="${tpfx}t/#u">nested</a>]  ) .
664                         qq(<a\nhref="${tpfx}t.mbox.gz">mbox.gz</a>  ) .
665                         qq(<a\nhref="${tpfx}t.atom">Atom feed</a>);
666
667         my $parent = in_reply_to($hdr);
668         $$dst .= "\n<b>Thread overview: </b>";
669         if ($nr <= 1) {
670                 if (defined $parent) {
671                         $$dst .= "$expand\n ";
672                         $$dst .= ghost_parent("$tpfx../", $parent) . "\n";
673                 } else {
674                         $$dst .= "[no followups] $expand\n";
675                 }
676                 $ctx->{next_msg} = undef;
677                 $ctx->{parent_msg} = $parent;
678                 return;
679         }
680
681         $$dst .= "$nr+ messages / $expand";
682         $$dst .= qq!  <a\nhref="#b">top</a>\n!;
683
684         my $subj = $hdr->header('Subject');
685         defined $subj or $subj = '';
686         $ctx->{prev_subj} = [ split(/ /, $srch->subject_normalized($subj)) ];
687         $ctx->{cur} = $mid;
688         $ctx->{prev_attr} = '';
689         $ctx->{prev_level} = 0;
690         $ctx->{dst} = $dst;
691         $sres = load_results($srch, $sres);
692
693         # reduce hash lookups in skel_dump
694         my $ibx = $ctx->{-inbox};
695         $ctx->{-obfs_ibx} = $ibx->{obfuscate} ? $ibx : undef;
696         walk_thread(thread_results($sres, $srch), $ctx, *skel_dump);
697
698         $ctx->{parent_msg} = $parent;
699 }
700
701 sub _parent_headers {
702         my ($hdr, $srch) = @_;
703         my $rv = '';
704
705         my $irt = in_reply_to($hdr);
706         if (defined $irt) {
707                 my $v = PublicInbox::Hval->new_msgid($irt);
708                 my $html = $v->as_html;
709                 my $href = $v->{href};
710                 $rv .= "In-Reply-To: &lt;";
711                 $rv .= "<a\nhref=\"../$href/\">$html</a>&gt;\n";
712         }
713
714         # do not display References: if search is present,
715         # we show the thread skeleton at the bottom, instead.
716         return $rv if $srch;
717
718         my $refs = $hdr->header_raw('References');
719         if ($refs) {
720                 # avoid redundant URLs wasting bandwidth
721                 my %seen;
722                 $seen{$irt} = 1 if defined $irt;
723                 my @refs;
724                 my @raw_refs = ($refs =~ /<([^>]+)>/g);
725                 foreach my $ref (@raw_refs) {
726                         next if $seen{$ref};
727                         $seen{$ref} = 1;
728                         push @refs, linkify_ref_nosrch($ref);
729                 }
730
731                 if (@refs) {
732                         $rv .= 'References: '. join("\n\t", @refs) . "\n";
733                 }
734         }
735         $rv;
736 }
737
738 sub html_footer {
739         my ($hdr, $standalone, $ctx, $rhref) = @_;
740
741         my $srch = $ctx->{srch} if $ctx;
742         my $upfx = '../';
743         my $tpfx = '';
744         my $idx = $standalone ? " <a\nhref=\"$upfx\">index</a>" : '';
745         my $irt = '';
746         if ($idx && $srch) {
747                 $idx .= "\n";
748                 thread_skel(\$idx, $ctx, $hdr, $tpfx);
749                 my ($next, $prev);
750                 my $parent = '       ';
751                 $next = $prev = '    ';
752
753                 if (my $n = $ctx->{next_msg}) {
754                         $n = PublicInbox::Hval->new_msgid($n)->{href};
755                         $next = "<a\nhref=\"$upfx$n/\"\nrel=next>next</a>";
756                 }
757                 my $u;
758                 my $par = $ctx->{parent_msg};
759                 if ($par) {
760                         $u = PublicInbox::Hval->new_msgid($par)->{href};
761                         $u = "$upfx$u/";
762                 }
763                 if (my $p = $ctx->{prev_msg}) {
764                         $prev = PublicInbox::Hval->new_msgid($p)->{href};
765                         if ($p && $par && $p eq $par) {
766                                 $prev = "<a\nhref=\"$upfx$prev/\"\n" .
767                                         'rel=prev>prev parent</a>';
768                                 $parent = '';
769                         } else {
770                                 $prev = "<a\nhref=\"$upfx$prev/\"\n" .
771                                         'rel=prev>prev</a>';
772                                 $parent = " <a\nhref=\"$u\">parent</a>" if $u;
773                         }
774                 } elsif ($u) { # unlikely
775                         $parent = " <a\nhref=\"$u\"\nrel=prev>parent</a>";
776                 }
777                 $irt = "$next $prev$parent ";
778         } else {
779                 $irt = '';
780         }
781         $rhref ||= '#R';
782         $irt .= qq(<a\nhref="$rhref">reply</a>);
783         $irt .= $idx;
784 }
785
786 sub linkify_ref_nosrch {
787         my $v = PublicInbox::Hval->new_msgid($_[0]);
788         my $html = $v->as_html;
789         my $href = $v->{href};
790         "&lt;<a\nhref=\"../$href/\">$html</a>&gt;";
791 }
792
793 sub anchor_for {
794         my ($msgid) = @_;
795         'm' . id_compress($msgid, 1);
796 }
797
798 sub ghost_parent {
799         my ($upfx, $mid) = @_;
800
801         $mid = PublicInbox::Hval->new_msgid($mid);
802         my $href = $mid->{href};
803         my $html = $mid->as_html;
804         qq{[parent not found: &lt;<a\nhref="$upfx$href/">$html</a>&gt;]};
805 }
806
807 sub indent_for {
808         my ($level) = @_;
809         $level ? INDENT x ($level - 1) : '';
810 }
811
812 sub load_results {
813         my ($srch, $sres) = @_;
814         my $msgs = delete $sres->{msgs};
815         $srch->retry_reopen(sub { [ map { $_->mid; $_ } @$msgs ] });
816 }
817
818 sub thread_results {
819         my ($msgs, $srch) = @_;
820         require PublicInbox::SearchThread;
821         PublicInbox::SearchThread::thread($msgs, *sort_ds, $srch);
822 }
823
824 sub missing_thread {
825         my ($ctx) = @_;
826         require PublicInbox::ExtMsg;
827         PublicInbox::ExtMsg::ext_msg($ctx);
828 }
829
830 sub _msg_date {
831         my ($hdr) = @_;
832         fmt_ts(msg_datestamp($hdr));
833 }
834
835 sub fmt_ts { POSIX::strftime('%Y-%m-%d %k:%M', gmtime($_[0])) }
836
837 sub dedupe_subject {
838         my ($prev_subj, $subj, $val) = @_;
839
840         my $omit = ''; # '"' denotes identical text omitted
841         my (@prev_pop, @curr_pop);
842         while (@$prev_subj && @$subj && $subj->[-1] eq $prev_subj->[-1]) {
843                 push(@prev_pop, pop(@$prev_subj));
844                 push(@curr_pop, pop(@$subj));
845                 $omit ||= $val;
846         }
847         pop @$subj if @$subj && $subj->[-1] =~ /^re:\s*/i;
848         if (scalar(@curr_pop) == 1) {
849                 $omit = '';
850                 push @$prev_subj, @prev_pop;
851                 push @$subj, @curr_pop;
852         }
853         $omit;
854 }
855
856 sub skel_dump {
857         my ($ctx, $level, $node) = @_;
858         my $smsg = $node->{smsg} or return _skel_ghost($ctx, $level, $node);
859
860         my $dst = $ctx->{dst};
861         my $cur = $ctx->{cur};
862         my $mid = $smsg->{mid};
863
864         my $f = ascii_html($smsg->from_name);
865         my $obfs_ibx = $ctx->{-obfs_ibx};
866         obfuscate_addrs($obfs_ibx, $f) if $obfs_ibx;
867
868         my $d = fmt_ts($smsg->{ds}) . ' ' . indent_for($level) . th_pfx($level);
869         my $attr = $f;
870         $ctx->{first_level} ||= $level;
871
872         if ($attr ne $ctx->{prev_attr} || $ctx->{prev_level} > $level) {
873                 $ctx->{prev_attr} = $attr;
874         }
875         $ctx->{prev_level} = $level;
876
877         if ($cur) {
878                 if ($cur eq $mid) {
879                         delete $ctx->{cur};
880                         $$dst .= "<b>$d<a\nid=r\nhref=\"#t\">".
881                                  "$attr [this message]</a></b>\n";
882                         return;
883                 } else {
884                         $ctx->{prev_msg} = $mid;
885                 }
886         } else {
887                 $ctx->{next_msg} ||= $mid;
888         }
889
890         # Subject is never undef, this mail was loaded from
891         # our Xapian which would've resulted in '' if it were
892         # really missing (and Filter rejects empty subjects)
893         my @subj = split(/ /, $ctx->{srch}->subject_normalized($smsg->subject));
894
895         # remove common suffixes from the subject if it matches the previous,
896         # so we do not show redundant text at the end.
897         my $prev_subj = $ctx->{prev_subj} || [];
898         $ctx->{prev_subj} = [ @subj ];
899         my $omit = dedupe_subject($prev_subj, \@subj, '&#34; ');
900         my $end;
901         if (@subj) {
902                 my $subj = join(' ', @subj);
903                 $subj = ascii_html($subj);
904                 obfuscate_addrs($obfs_ibx, $subj) if $obfs_ibx;
905                 $end = "$subj</a> $omit$f\n"
906         } else {
907                 $end = "$f</a>\n";
908         }
909         my $m;
910         my $id = '';
911         my $mapping = $ctx->{mapping};
912         if ($mapping) {
913                 my $map = $mapping->{$mid};
914                 $id = id_compress($mid, 1);
915                 $m = '#m'.$id;
916                 $map->[0] = "$d<a\nhref=\"$m\">$end";
917                 $id = "\nid=r".$id;
918         } else {
919                 $m = $ctx->{-upfx}.mid_escape($mid).'/';
920         }
921         $$dst .=  $d . "<a\nhref=\"$m\"$id>" . $end;
922 }
923
924 sub _skel_ghost {
925         my ($ctx, $level, $node) = @_;
926
927         my $mid = $node->{id};
928         my $d = $ctx->{pct} ? '    [irrelevant] ' # search result
929                             : '     [not found] ';
930         $d .= indent_for($level) . th_pfx($level);
931         my $upfx = $ctx->{-upfx};
932         my $m = PublicInbox::Hval->new_msgid($mid);
933         my $href = $upfx . $m->{href} . '/';
934         my $html = $m->as_html;
935
936         my $mapping = $ctx->{mapping};
937         my $map = $mapping->{$mid} if $mapping;
938         if ($map) {
939                 my $id = id_compress($mid, 1);
940                 $map->[0] = $d . qq{&lt;<a\nhref=#r$id>$html</a>&gt;\n};
941                 $d .= qq{&lt;<a\nhref="$href"\nid=r$id>$html</a>&gt;\n};
942         } else {
943                 $d .= qq{&lt;<a\nhref="$href">$html</a>&gt;\n};
944         }
945         my $dst = $ctx->{dst};
946         $$dst .= $d;
947 }
948
949 sub sort_ds {
950         [ sort {
951                 (eval { $a->topmost->{smsg}->ds } || 0) <=>
952                 (eval { $b->topmost->{smsg}->ds } || 0)
953         } @{$_[0]} ];
954 }
955
956 # accumulate recent topics if search is supported
957 # returns 200 if done, 404 if not
958 sub acc_topic {
959         my ($ctx, $level, $node) = @_;
960         my $srch = $ctx->{srch};
961         my $mid = $node->{id};
962         my $x = $node->{smsg} || $srch->lookup_mail($mid);
963         my ($subj, $ds);
964         my $topic;
965         if ($x) {
966                 $subj = $x->subject;
967                 $subj = $srch->subject_normalized($subj);
968                 $ds = $x->ds;
969                 if ($level == 0) {
970                         $topic = [ $ds, 1, { $subj => $mid }, $subj ];
971                         $ctx->{-cur_topic} = $topic;
972                         push @{$ctx->{order}}, $topic;
973                         return;
974                 }
975
976                 $topic = $ctx->{-cur_topic}; # should never be undef
977                 $topic->[0] = $ds if $ds > $topic->[0];
978                 $topic->[1]++;
979                 my $seen = $topic->[2];
980                 if (scalar(@$topic) == 3) { # parent was a ghost
981                         push @$topic, $subj;
982                 } elsif (!$seen->{$subj}) {
983                         push @$topic, $level, $subj;
984                 }
985                 $seen->{$subj} = $mid; # latest for subject
986         } else { # ghost message
987                 return if $level != 0; # ignore child ghosts
988                 $topic = [ -666, 0, {} ];
989                 $ctx->{-cur_topic} = $topic;
990                 push @{$ctx->{order}}, $topic;
991         }
992 }
993
994 sub dump_topics {
995         my ($ctx) = @_;
996         my $order = delete $ctx->{order}; # [ ds, subj1, subj2, subj3, ... ]
997         if (!@$order) {
998                 $ctx->{-html_tip} = '<pre>[No topics in range]</pre>';
999                 return 404;
1000         }
1001
1002         my @out;
1003         my $ibx = $ctx->{-inbox};
1004         my $obfs_ibx = $ibx->{obfuscate} ? $ibx : undef;
1005         my $srch = $ctx->{srch};
1006
1007         # sort by recency, this allows new posts to "bump" old topics...
1008         foreach my $topic (sort { $b->[0] <=> $a->[0] } @$order) {
1009                 my ($ds, $n, $seen, $top, @ex) = @$topic;
1010                 @$topic = ();
1011                 next unless defined $top;  # ghost topic
1012                 my $mid = delete $seen->{$top};
1013                 my $href = mid_escape($mid);
1014                 my $prev_subj = [ split(/ /, $top) ];
1015                 $top = PublicInbox::Hval->new($top)->as_html;
1016                 $ds = fmt_ts($ds);
1017
1018                 # $n isn't the total number of posts on the topic,
1019                 # just the number of posts in the current results window
1020                 my $anchor;
1021                 if ($n == 1) {
1022                         $n = '';
1023                         $anchor = '#u'; # top of only message
1024                 } else {
1025                         $n = " ($n+ messages)";
1026                         $anchor = '#t'; # thread skeleton
1027                 }
1028
1029                 my $mbox = qq(<a\nhref="$href/t.mbox.gz">mbox.gz</a>);
1030                 my $atom = qq(<a\nhref="$href/t.atom">Atom</a>);
1031                 my $s = "<a\nhref=\"$href/T/$anchor\"><b>$top</b></a>\n" .
1032                         " $ds UTC $n - $mbox / $atom\n";
1033                 for (my $i = 0; $i < scalar(@ex); $i += 2) {
1034                         my $level = $ex[$i];
1035                         my $subj = $ex[$i + 1];
1036                         $mid = delete $seen->{$subj};
1037                         my @subj = split(/ /, $srch->subject_normalized($subj));
1038                         my @next_prev = @subj; # full copy
1039                         my $omit = dedupe_subject($prev_subj, \@subj, ' &#34;');
1040                         $prev_subj = \@next_prev;
1041                         $subj = ascii_html(join(' ', @subj));
1042                         obfuscate_addrs($obfs_ibx, $subj) if $obfs_ibx;
1043                         $href = mid_escape($mid);
1044                         $s .= indent_for($level) . TCHILD;
1045                         $s .= "<a\nhref=\"$href/T/#u\">$subj</a>$omit\n";
1046                 }
1047                 push @out, $s;
1048         }
1049         $ctx->{-html_tip} = '<pre>' . join("\n", @out) . '</pre>';
1050         200;
1051 }
1052
1053 sub index_nav { # callback for WwwStream
1054         my (undef, $ctx) = @_;
1055         delete $ctx->{qp} or return;
1056         my ($next, $prev);
1057         $next = $prev = '    ';
1058         my $latest = '';
1059
1060         my $next_o = $ctx->{-next_o};
1061         if ($next_o) {
1062                 $next = qq!<a\nhref="?o=$next_o"\nrel=next>next</a>!;
1063         }
1064         if (my $cur_o = $ctx->{-cur_o}) {
1065                 $latest = qq! <a\nhref=.>latest</a>!;
1066
1067                 my $o = $cur_o - ($next_o - $cur_o);
1068                 if ($o > 0) {
1069                         $prev = qq!<a\nhref="?o=$o"\nrel=prev>prev</a>!;
1070                 } elsif ($o == 0) {
1071                         $prev = qq!<a\nhref=.\nrel=prev>prev</a>!;
1072                 }
1073         }
1074         "<hr><pre>page: $next $prev$latest</pre>";
1075 }
1076
1077 sub index_topics {
1078         my ($ctx) = @_;
1079         my ($off) = (($ctx->{qp}->{o} || '0') =~ /(\d+)/);
1080         my $opts = { offset => $off, limit => 200 };
1081
1082         $ctx->{order} = [];
1083         my $srch = $ctx->{srch};
1084         my $sres = $srch->query('', $opts);
1085         my $nr = scalar @{$sres->{msgs}};
1086         if ($nr) {
1087                 $sres = load_results($srch, $sres);
1088                 walk_thread(thread_results($sres, $srch), $ctx, *acc_topic);
1089         }
1090         $ctx->{-next_o} = $off+ $nr;
1091         $ctx->{-cur_o} = $off;
1092         PublicInbox::WwwStream->response($ctx, dump_topics($ctx), *index_nav);
1093 }
1094
1095 sub thread_adj_level {
1096         my ($ctx, $level) = @_;
1097
1098         my $max = $ctx->{cur_level};
1099         if ($level <= 0) {
1100                 return ('', '') if $max == 0; # flat output
1101
1102                 # reset existing lists
1103                 my $beg = $max > 1 ? ('</ul></li>' x ($max - 1)) : '';
1104                 $ctx->{cur_level} = 0;
1105                 ("$beg</ul>", '');
1106         } elsif ($level == $max) { # continue existing list
1107                 qw(<li> </li>);
1108         } elsif ($level < $max) {
1109                 my $beg = $max > 1 ? ('</ul></li>' x ($max - $level)) : '';
1110                 $ctx->{cur_level} = $level;
1111                 ("$beg<li>", '</li>');
1112         } else { # ($level > $max) # start a new level
1113                 $ctx->{cur_level} = $level;
1114                 my $beg = ($max ? '<li>' : '') . '<ul><li>';
1115                 ($beg, '</li>');
1116         }
1117 }
1118
1119 sub ghost_index_entry {
1120         my ($ctx, $level, $node) = @_;
1121         my ($beg, $end) = thread_adj_level($ctx,  $level);
1122         $beg . '<pre>'. ghost_parent($ctx->{-upfx}, $node->{id})
1123                 . '</pre>' . $end;
1124 }
1125
1126 1;