]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/View.pm
view: account for missing In-Reply-To header
[public-inbox.git] / lib / PublicInbox / View.pm
1 # Copyright (C) 2014, Eric Wong <normalperson@yhbt.net> and all contributors
2 # License: AGPLv3 or later (https://www.gnu.org/licenses/agpl-3.0.txt)
3 package PublicInbox::View;
4 use strict;
5 use warnings;
6 use URI::Escape qw/uri_escape_utf8/;
7 use Date::Parse qw/str2time/;
8 use Encode qw/find_encoding/;
9 use Encode::MIME::Header;
10 use Email::MIME::ContentType qw/parse_content_type/;
11 use PublicInbox::Hval;
12 use PublicInbox::MID qw/mid_clean mid_compress mid2path/;
13 use Digest::SHA qw/sha1_hex/;
14 my $SALT = rand;
15 require POSIX;
16
17 # TODO: make these constants tunable
18 use constant MAX_INLINE_QUOTED => 12; # half an 80x24 terminal
19 use constant MAX_TRUNC_LEN => 72;
20 use constant PRE_WRAP => "<pre\nstyle=\"white-space:pre-wrap\">";
21 use constant T_ANCHOR => '#u';
22
23 *ascii_html = *PublicInbox::Hval::ascii_html;
24
25 my $enc_utf8 = find_encoding('UTF-8');
26
27 # public functions:
28 sub msg_html {
29         my ($ctx, $mime, $full_pfx, $footer) = @_;
30         if (defined $footer) {
31                 $footer = "\n" . $footer;
32         } else {
33                 $footer = '';
34         }
35         headers_to_html_header($mime, $full_pfx, $ctx) .
36                 multipart_text_as_html($mime, $full_pfx) .
37                 '</pre><hr />' . PRE_WRAP .
38                 html_footer($mime, 1, $full_pfx, $ctx) .
39                 $footer .
40                 '</pre></body></html>';
41 }
42
43 sub feed_entry {
44         my ($class, $mime, $full_pfx) = @_;
45
46         PRE_WRAP . multipart_text_as_html($mime, $full_pfx) . '</pre>';
47 }
48
49 sub in_reply_to {
50         my ($header_obj) = @_;
51         my $irt = $header_obj->header('In-Reply-To');
52
53         return mid_clean($irt) if (defined $irt);
54
55         my $refs = $header_obj->header('References');
56         if ($refs && $refs =~ /<([^>]+)>\s*\z/s) {
57                 return $1;
58         }
59         undef;
60 }
61
62 # this is already inside a <pre>
63 sub index_entry {
64         my ($fh, $mime, $level, $state) = @_;
65         my $midx = $state->{anchor_idx}++;
66         my $ctx = $state->{ctx};
67         my $srch = $ctx->{srch};
68         my ($prev, $next) = ($midx - 1, $midx + 1);
69         my $part_nr = 0;
70         my $enc = enc_for($mime->header("Content-Type"));
71         my $subj = $mime->header('Subject');
72         my $header_obj = $mime->header_obj;
73
74         my $mid_raw = $header_obj->header('Message-ID');
75         my $id = anchor_for($mid_raw);
76         my $seen = $state->{seen};
77         $seen->{$id} = "#$id"; # save the anchor for later
78
79         my $mid = PublicInbox::Hval->new_msgid($mid_raw);
80         my $from = PublicInbox::Hval->new_oneline($mime->header('From'))->raw;
81         my @from = Email::Address->parse($from);
82         $from = $from[0]->name;
83
84         $from = PublicInbox::Hval->new_oneline($from)->as_html;
85         $subj = PublicInbox::Hval->new_oneline($subj)->as_html;
86         my $more = 'permalink';
87         my $root_anchor = $state->{root_anchor};
88         my $path = $root_anchor ? '../../' : '';
89         my $href = $mid->as_href;
90         my $irt = in_reply_to($header_obj);
91
92         my ($anchor_idx, $anchor);
93         if (defined $irt) {
94                 $anchor_idx = anchor_for($irt);
95                 $anchor = $seen->{$anchor_idx};
96         }
97         if ($srch) {
98                 my $t = $ctx->{flat} ? 'T' : 't';
99                 $subj = "<a\nhref=\"${path}$href/$t/#u\">$subj</a>";
100         }
101         if ($root_anchor && $root_anchor eq $id) {
102                 $subj = "<u\nid=\"u\">$subj</u>";
103         }
104
105         my $ts = _msg_date($mime);
106         my $rv = "<table\nsummary=l$level><tr>";
107         if ($level) {
108                 $rv .= '<td><pre>' . ('  ' x $level) . '</pre></td>';
109         }
110         $rv .= "<td\nid=s$midx>" . PRE_WRAP;
111         $rv .= "<b\nid=\"$id\">$subj</b>\n";
112         $rv .= "- by $from @ $ts UTC - ";
113         $rv .= "<a\nhref=\"#s$next\">next</a>";
114         if ($prev >= 0) {
115                 $rv .= "/<a\nhref=\"#s$prev\">prev</a>";
116         }
117         $fh->write($rv .= "\n\n");
118
119         my ($fhref, $more_ref);
120         my $mhref = "${path}$href/";
121
122         # show full messages at level == 0 in threaded view
123         if ($level > 0 || ($ctx->{flat} && $root_anchor ne $id)) {
124                 $fhref = "${path}$href/f/";
125                 $more_ref = \$more;
126         }
127         # scan through all parts, looking for displayable text
128         $mime->walk_parts(sub {
129                 index_walk($fh, $_[0], $enc, \$part_nr, $fhref, $more_ref);
130         });
131         $mime->body_set('');
132
133         my $txt = "${path}$href/raw";
134         $rv = "\n<a\nhref=\"$mhref\">$more</a> <a\nhref=\"$txt\">raw</a> ";
135         $rv .= html_footer($mime, 0, undef, $ctx);
136
137         if (defined $irt) {
138                 unless (defined $anchor) {
139                         my $v = PublicInbox::Hval->new_msgid($irt);
140                         $v = $v->as_href;
141                         $anchor = "${path}$v/";
142                         $seen->{$anchor_idx} = $anchor;
143                 }
144                 $rv .= " <a\nhref=\"$anchor\">parent</a>";
145         }
146         if ($srch) {
147                 if ($ctx->{flat}) {
148                         $rv .= " [<a\nhref=\"${path}$href/t/#u\">threaded</a>" .
149                                 "|<b>flat</b>]";
150                 } else {
151                         $rv .= " [<b>threaded</b>|" .
152                                 "<a\nhref=\"${path}$href/T/#u\">flat</a>]";
153                 }
154         }
155
156         $fh->write($rv .= '</pre></td></tr></table>');
157 }
158
159 sub thread_html {
160         my ($ctx, $foot, $srch) = @_;
161         sub { emit_thread_html($_[0], $ctx, $foot, $srch) }
162 }
163
164 # only private functions below.
165
166 sub emit_thread_html {
167         my ($cb, $ctx, $foot, $srch) = @_;
168         my $mid = mid_compress($ctx->{mid});
169         my $res = $srch->get_thread($mid);
170         my $msgs = load_results($res);
171         my $nr = scalar @$msgs;
172         return missing_thread($cb) if $nr == 0;
173         my $flat = $ctx->{flat};
174         my $orig_cb = $cb;
175         my $state = {
176                 ctx => $ctx,
177                 seen => {},
178                 root_anchor => anchor_for($mid),
179                 anchor_idx => 0,
180         };
181
182         require PublicInbox::GitCatFile;
183         my $git = PublicInbox::GitCatFile->new($ctx->{git_dir});
184         if ($flat) {
185                 __thread_entry(\$cb, $git, $state, $_, 0) for (@$msgs);
186         } else {
187                 my $th = thread_results($msgs);
188                 thread_entry(\$cb, $git, $state, $_, 0) for $th->rootset;
189         }
190         $git = undef;
191         Email::Address->purge_cache;
192
193         # there could be a race due to a message being deleted in git
194         # but still being in the Xapian index:
195         return missing_thread($cb) if ($orig_cb eq $cb);
196
197         my $final_anchor = $state->{anchor_idx};
198         my $next = "<a\nid=\"s$final_anchor\">";
199         $next .= $final_anchor == 1 ? 'only message in' : 'end of';
200         $next .= " thread</a>, back to <a\nhref=\"../../\">index</a>";
201         if ($flat) {
202                 $next .= " [<a\nhref=\"../t/#u\">threaded</a>|<b>flat</b>]";
203         } else {
204                 $next .= " [<b>threaded</b>|<a\nhref=\"../T/#u\">flat</a>]";
205         }
206         $next .= "\ndownload thread: <a\nhref=\"../t.mbox.gz\">mbox.gz</a>";
207         $next .= " / follow: <a\nhref=\"../t.atom\">Atom feed</a>";
208         $cb->write("<hr />" . PRE_WRAP . $next . "\n\n". $foot .
209                    "</pre></body></html>");
210         $cb->close;
211 }
212
213 sub index_walk {
214         my ($fh, $part, $enc, $part_nr, $fhref, $more) = @_;
215         my $s = add_text_body($enc, $part, $part_nr, $fhref);
216
217         if ($more) {
218                 # drop the remainder of git patches, they're usually better
219                 # to review when the full message is viewed
220                 $s =~ s!^---+\n.*\z!!ms and $$more = 'more...';
221
222                 # Drop signatures
223                 $s =~ s/^-- \n.*\z//ms and $$more = 'more...';
224         }
225
226         # kill any leading or trailing whitespace lines
227         $s =~ s/^\s*$//sgm;
228         $s =~ s/\s+\z//s;
229
230         if ($s ne '') {
231                 # kill per-line trailing whitespace
232                 $s =~ s/[ \t]+$//sgm;
233                 $s .= "\n" unless $s =~ /\n\z/s;
234         }
235         $fh->write($s);
236 }
237
238 sub enc_for {
239         my ($ct, $default) = @_;
240         $default ||= $enc_utf8;
241         defined $ct or return $default;
242         my $ct_parsed = parse_content_type($ct);
243         if ($ct_parsed) {
244                 if (my $charset = $ct_parsed->{attributes}->{charset}) {
245                         my $enc = find_encoding($charset);
246                         return $enc if $enc;
247                 }
248         }
249         $default;
250 }
251
252 sub multipart_text_as_html {
253         my ($mime, $full_pfx, $srch) = @_;
254         my $rv = "";
255         my $part_nr = 0;
256         my $enc = enc_for($mime->header("Content-Type"));
257
258         # scan through all parts, looking for displayable text
259         $mime->walk_parts(sub {
260                 my ($part) = @_;
261                 $rv .= add_text_body($enc, $part, \$part_nr, $full_pfx);
262         });
263         $mime->body_set('');
264         $rv;
265 }
266
267 sub add_filename_line {
268         my ($enc, $fn) = @_;
269         my $len = 72;
270         my $pad = "-";
271         $fn = $enc->decode($fn);
272         $len -= length($fn);
273         $pad x= ($len/2) if ($len > 0);
274         "$pad " . ascii_html($fn) . " $pad\n";
275 }
276
277 my $LINK_RE = qr!\b((?:ftp|https?|nntp)://
278                  [\@:\w\.-]+/
279                  ?[\@\w\+\&\?\.\%\;/#=-]*)!x;
280
281 sub linkify_1 {
282         my ($link_map, $s) = @_;
283         $s =~ s!$LINK_RE!
284                 my $url = $1;
285                 # salt this, as this could be exploited to show
286                 # links in the HTML which don't show up in the raw mail.
287                 my $key = sha1_hex($url . $SALT);
288                 $link_map->{$key} = $url;
289                 'PI-LINK-'. $key;
290         !ge;
291         $s;
292 }
293
294 sub linkify_2 {
295         my ($link_map, $s) = @_;
296
297         # Added "PI-LINK-" prefix to avoid false-positives on git commits
298         $s =~ s!\bPI-LINK-([a-f0-9]{40})\b!
299                 my $key = $1;
300                 my $url = $link_map->{$key};
301                 if (defined $url) {
302                         $url = ascii_html($url);
303                         "<a\nhref=\"$url\">$url</a>";
304                 } else {
305                         # false positive or somebody tried to mess with us
306                         $key;
307                 }
308         !ge;
309         $s;
310 }
311
312 sub flush_quote {
313         my ($quot, $n, $part_nr, $full_pfx, $final) = @_;
314
315         if ($full_pfx) {
316                 if (!$final && scalar(@$quot) <= MAX_INLINE_QUOTED) {
317                         # show quote inline
318                         my %l;
319                         my $rv = join('', map { linkify_1(\%l, $_) } @$quot);
320                         @$quot = ();
321                         $rv = ascii_html($rv);
322                         return linkify_2(\%l, $rv);
323                 }
324
325                 # show a short snippet of quoted text and link to full version:
326                 @$quot = map { s/^(?:>\s*)+//gm; $_ } @$quot;
327                 my $cur = join(' ', @$quot);
328                 @$quot = split(/\s+/, $cur);
329                 $cur = '';
330                 do {
331                         my $tmp = shift(@$quot);
332                         my $len = length($tmp) + length($cur);
333                         if ($len > MAX_TRUNC_LEN) {
334                                 @$quot = ();
335                         } else {
336                                 $cur .= $tmp . ' ';
337                         }
338                 } while (@$quot && length($cur) < MAX_TRUNC_LEN);
339                 @$quot = ();
340                 $cur =~ s/ \z/ .../s;
341                 $cur = ascii_html($cur);
342                 my $nr = ++$$n;
343                 "&gt; [<a\nhref=\"$full_pfx#q${part_nr}_$nr\">$cur</a>]\n";
344         } else {
345                 # show everything in the full version with anchor from
346                 # short version (see above)
347                 my $nr = ++$$n;
348                 my $rv = "";
349                 my %l;
350                 $rv .= join('', map { linkify_1(\%l, $_) } @$quot);
351                 @$quot = ();
352                 $rv = ascii_html($rv);
353                 "<a\nid=q${part_nr}_$nr></a>" . linkify_2(\%l, $rv);
354         }
355 }
356
357 sub add_text_body {
358         my ($enc_msg, $part, $part_nr, $full_pfx) = @_;
359         return '' if $part->subparts;
360
361         my $ct = $part->content_type;
362         # account for filter bugs...
363         if (defined $ct && $ct =~ m!\btext/[xh]+tml\b!i) {
364                 $part->body_set('');
365                 return '';
366         }
367         my $enc = enc_for($ct, $enc_msg);
368         my $n = 0;
369         my $nr = 0;
370         my $s = $part->body;
371         $part->body_set('');
372         $s = $enc->decode($s);
373         my @lines = split(/^/m, $s);
374         $s = '';
375
376         if ($$part_nr > 0) {
377                 my $fn = $part->filename;
378                 defined($fn) or $fn = "part #" . ($$part_nr + 1);
379                 $s .= add_filename_line($enc, $fn);
380         }
381
382         my @quot;
383         while (defined(my $cur = shift @lines)) {
384                 if ($cur !~ /^>/) {
385                         # show the previously buffered quote inline
386                         if (scalar @quot) {
387                                 $s .= flush_quote(\@quot, \$n, $$part_nr,
388                                                   $full_pfx, 0);
389                         }
390
391                         # regular line, OK
392                         my %l;
393                         $cur = linkify_1(\%l, $cur);
394                         $cur = ascii_html($cur);
395                         $s .= linkify_2(\%l, $cur);
396                 } else {
397                         push @quot, $cur;
398                 }
399         }
400         $s .= flush_quote(\@quot, \$n, $$part_nr, $full_pfx, 1) if scalar @quot;
401         $s .= "\n" unless $s =~ /\n\z/s;
402         ++$$part_nr;
403         $s;
404 }
405
406 sub headers_to_html_header {
407         my ($mime, $full_pfx, $ctx) = @_;
408         my $srch = $ctx->{srch} if $ctx;
409         my $rv = "";
410         my @title;
411         my $header_obj = $mime->header_obj;
412         my $mid = $header_obj->header('Message-ID');
413         $mid = PublicInbox::Hval->new_msgid($mid);
414         my $mid_href = $mid->as_href;
415         foreach my $h (qw(From To Cc Subject Date)) {
416                 my $v = $mime->header($h);
417                 defined($v) && ($v ne '') or next;
418                 $v = PublicInbox::Hval->new_oneline($v);
419
420                 if ($h eq 'From') {
421                         my @from = Email::Address->parse($v->raw);
422                         $title[1] = ascii_html($from[0]->name);
423                 } elsif ($h eq 'Subject') {
424                         $title[0] = $v->as_html;
425                         if ($srch) {
426                                 my $p = $full_pfx ? '' : '../';
427                                 $rv .= "$h: <a\nid=\"t\"\nhref=\"${p}t/#u\">";
428                                 $rv .= $v->as_html . "</a>\n";
429                                 next;
430                         }
431                 }
432                 $rv .= "$h: " . $v->as_html . "\n";
433
434         }
435         $rv .= 'Message-ID: &lt;' . $mid->as_html . '&gt; ';
436         my $raw_ref = $full_pfx ? 'raw' : '../raw';
437         $rv .= "(<a\nhref=\"$raw_ref\">raw</a>)\n";
438         if ($srch) {
439                 $rv .= "<a\nhref=\"#r\">References: [see below]</a>\n";
440         } else {
441                 $rv .= _parent_headers_nosrch($header_obj);
442         }
443         $rv .= "\n";
444
445         ("<html><head><title>".  join(' - ', @title) .
446          '</title></head><body>' . PRE_WRAP . $rv);
447 }
448
449 sub thread_inline {
450         my ($dst, $ctx, $cur, $full_pfx) = @_;
451         my $srch = $ctx->{srch};
452         my $mid = mid_compress(mid_clean($cur->header('Message-ID')));
453         my $res = $srch->get_thread($mid);
454         my $nr = $res->{total};
455
456         if ($nr <= 1) {
457                 $$dst .= "\n[no followups, yet]\n";
458                 return;
459         }
460         my $upfx = $full_pfx ? '' : '../';
461
462         $$dst .= "\n\n~$nr messages in thread: ".
463                  "(<a\nhref=\"${upfx}t/#u\">expand</a>)\n";
464         my $subj = $srch->subject_path($cur->header('Subject'));
465         my $state = {
466                 seen => { $subj => 1 },
467                 srch => $srch,
468                 cur => $mid,
469         };
470         for (thread_results(load_results($res))->rootset) {
471                 inline_dump($dst, $state, $upfx, $_, 0);
472         }
473         $state->{next_msg};
474 }
475
476 sub _parent_headers_nosrch {
477         my ($header_obj) = @_;
478         my $rv = '';
479
480         my $irt = in_reply_to($header_obj);
481         if (defined $irt) {
482                 my $v = PublicInbox::Hval->new_msgid($irt);
483                 my $html = $v->as_html;
484                 my $href = $v->as_href;
485                 $rv .= "In-Reply-To: &lt;";
486                 $rv .= "<a\nhref=\"../$href/\">$html</a>&gt;\n";
487         }
488
489         my $refs = $header_obj->header('References');
490         if ($refs) {
491                 # avoid redundant URLs wasting bandwidth
492                 my %seen;
493                 $seen{$irt} = 1 if defined $irt;
494                 my @refs;
495                 my @raw_refs = ($refs =~ /<([^>]+)>/g);
496                 foreach my $ref (@raw_refs) {
497                         next if $seen{$ref};
498                         $seen{$ref} = 1;
499                         push @refs, linkify_ref($ref);
500                 }
501
502                 if (@refs) {
503                         $rv .= 'References: '. join(' ', @refs) . "\n";
504                 }
505         }
506         $rv;
507 }
508
509 sub html_footer {
510         my ($mime, $standalone, $full_pfx, $ctx) = @_;
511         my %cc; # everyone else
512         my $to; # this is the From address
513
514         foreach my $h (qw(From To Cc)) {
515                 my $v = $mime->header($h);
516                 defined($v) && ($v ne '') or next;
517                 my @addrs = Email::Address->parse($v);
518                 foreach my $recip (@addrs) {
519                         my $address = $recip->address;
520                         my $dst = lc($address);
521                         $cc{$dst} ||= $address;
522                         $to ||= $dst;
523                 }
524         }
525         Email::Address->purge_cache if $standalone;
526
527         my $subj = $mime->header('Subject') || '';
528         $subj = "Re: $subj" unless $subj =~ /\bRe:/i;
529         my $mid = $mime->header('Message-ID');
530         my $irt = uri_escape_utf8($mid);
531         delete $cc{$to};
532         $to = uri_escape_utf8($to);
533         $subj = uri_escape_utf8($subj);
534
535         my $cc = uri_escape_utf8(join(',', sort values %cc));
536         my $href = "mailto:$to?In-Reply-To=$irt&Cc=${cc}&Subject=$subj";
537
538         my $srch = $ctx->{srch} if $ctx;
539         my $upfx = $full_pfx ? '../' : '../../';
540         my $idx = $standalone ? " <a\nhref=\"$upfx\">index</a>" : '';
541         if ($idx && $srch) {
542                 my $next = thread_inline(\$idx, $ctx, $mime, $full_pfx);
543                 $irt = in_reply_to($mime->header_obj);
544                 if (defined $irt) {
545                         $irt = PublicInbox::Hval->new_msgid($irt);
546                         $irt = $irt->as_href;
547                         $irt = "<a\nhref=\"$upfx$irt/\">parent</a> ";
548                 } else {
549                         $irt = ' ' x length('parent ');
550                 }
551                 if ($next) {
552                         $irt .= "<a\nhref=\"$upfx$next/\">next</a> ";
553                 } else {
554                         $irt .= '     ';
555                 }
556         } else {
557                 $irt = '';
558         }
559
560         "$irt<a\nhref=\"" . ascii_html($href) . '">reply</a>' . $idx;
561 }
562
563 sub linkify_ref {
564         my $v = PublicInbox::Hval->new_msgid($_[0]);
565         my $html = $v->as_html;
566         my $href = $v->as_href;
567         "&lt;<a\nhref=\"../$href/\">$html</a>&gt;";
568 }
569
570 sub anchor_for {
571         my ($msgid) = @_;
572         my $id = $msgid;
573         if ($id !~ /\A[a-f0-9]{40}\z/) {
574                 $id = mid_compress(mid_clean($id), 1);
575         }
576         'm' . $id;
577 }
578
579 sub thread_html_head {
580         my ($cb, $mime) = @_;
581         $$cb = $$cb->([200, ['Content-Type'=> 'text/html; charset=UTF-8']]);
582
583         my $s = PublicInbox::Hval->new_oneline($mime->header('Subject'));
584         $s = $s->as_html;
585         $$cb->write("<html><head><title>$s</title></head><body>");
586 }
587
588 sub __thread_entry {
589         my ($cb, $git, $state, $mime, $level) = @_;
590
591         # lazy load the full message from mini_mime:
592         my $path = mid2path(mid_clean($mime->header('Message-ID')));
593         $mime = eval { Email::MIME->new($git->cat_file("HEAD:$path")) };
594         if ($mime) {
595                 if ($state->{anchor_idx} == 0) {
596                         thread_html_head($cb, $mime);
597                 }
598                 index_entry($$cb, $mime, $level, $state);
599         }
600 }
601
602 sub thread_entry {
603         my ($cb, $git, $state, $node, $level) = @_;
604         return unless $node;
605         if (my $mime = $node->message) {
606                 __thread_entry($cb, $git, $state, $mime, $level);
607         }
608         thread_entry($cb, $git, $state, $node->child, $level + 1);
609         thread_entry($cb, $git, $state, $node->next, $level);
610 }
611
612 sub load_results {
613         my ($res) = @_;
614
615         [ map { $_->mini_mime } @{delete $res->{msgs}} ];
616 }
617
618 sub msg_timestamp {
619         my ($mime) = @_;
620         my $ts = eval { str2time($mime->header('Date')) };
621         defined($ts) ? $ts : 0;
622 }
623
624 sub thread_results {
625         my ($msgs) = @_;
626         require PublicInbox::Thread;
627         my $th = PublicInbox::Thread->new(@$msgs);
628         $th->thread;
629         no warnings 'once';
630         $th->order(*PublicInbox::Thread::sort_ts);
631         $th
632 }
633
634 sub missing_thread {
635         my ($cb) = @_;
636         my $title = 'Thread does not exist';
637         $cb->([404, ['Content-Type' => 'text/html']])->write(<<EOF);
638 <html><head><title>$title</title></head><body><pre>$title
639 <a href="../../">Return to index</a></pre></body></html>
640 EOF
641 }
642
643 sub _msg_date {
644         my ($mime) = @_;
645         my $ts = $mime->header('X-PI-TS') || msg_timestamp($mime);
646         POSIX::strftime('%Y-%m-%d %H:%M', gmtime($ts));
647 }
648
649 sub _inline_header {
650         my ($dst, $state, $upfx, $mime, $level) = @_;
651         my $pfx = '  ' x $level;
652
653         my $cur = $state->{cur};
654         my $mid = $mime->header('Message-ID');
655         my $f = $mime->header('X-PI-From');
656         my $d = _msg_date($mime);
657         $f = PublicInbox::Hval->new($f);
658         $d = PublicInbox::Hval->new($d);
659         $f = $f->as_html;
660         $d = $d->as_html . ' UTC';
661         my $midc = mid_compress(mid_clean($mid));
662         if ($cur) {
663                 if ($cur eq $midc) {
664                         delete $state->{cur};
665                         $$dst .= "$pfx` <b><a\nid=\"r\"\nhref=\"#t\">".
666                                  "[this message]</a></b> by $f @ $d\n";
667
668                         return;
669                 }
670         } else {
671                 $state->{next_msg} ||= $midc;
672         }
673
674         # Subject is never undef, this mail was loaded from
675         # our Xapian which would've resulted in '' if it were
676         # really missing (and Filter rejects empty subjects)
677         my $s = $mime->header('Subject');
678         my $h = $state->{srch}->subject_path($s);
679         if ($state->{seen}->{$h}) {
680                 $s = undef;
681         } else {
682                 $state->{seen}->{$h} = 1;
683                 $s = PublicInbox::Hval->new($s);
684                 $s = $s->as_html;
685         }
686         my $m = PublicInbox::Hval->new_msgid($mid);
687         $m = $upfx . '../' . $m->as_href . '/';
688         if (defined $s) {
689                 $$dst .= "$pfx` <a\nhref=\"$m\">$s</a>\n" .
690                          "$pfx  $f @ $d\n";
691         } else {
692                 $$dst .= "$pfx` <a\nhref=\"$m\">$f @ $d</a>\n";
693         }
694 }
695
696 sub inline_dump {
697         my ($dst, $state, $upfx, $node, $level) = @_;
698         return unless $node;
699         return if $state->{stopped};
700         if (my $mime = $node->message) {
701                 _inline_header($dst, $state, $upfx, $mime, $level);
702         }
703         inline_dump($dst, $state, $upfx, $node->child, $level+1);
704         inline_dump($dst, $state, $upfx, $node->next, $level);
705 }
706
707 1;