X-Git-Url: http://www.git.stargrave.org/?a=blobdiff_plain;f=lib%2FPublicInbox%2FView.pm;h=e7e387d3069f0f5f04daf1873b6bd09ec40fa7c0;hb=4af9fd9c5e46eed341a535f37d54cf228303326c;hp=98d0219dbc57c353163872c9269e63da16d67641;hpb=bc067a7562a586bed92401fe1084bbe423b9451a;p=public-inbox.git diff --git a/lib/PublicInbox/View.pm b/lib/PublicInbox/View.pm index 98d0219d..e7e387d3 100644 --- a/lib/PublicInbox/View.pm +++ b/lib/PublicInbox/View.pm @@ -8,56 +8,71 @@ use strict; use warnings; use URI::Escape qw/uri_escape_utf8/; use Date::Parse qw/str2time/; -use Encode qw/find_encoding/; use Encode::MIME::Header; -use Email::MIME::ContentType qw/parse_content_type/; -use PublicInbox::Hval; -use PublicInbox::MID qw/mid_clean id_compress mid2path/; -use Digest::SHA qw/sha1_hex/; -my $SALT = rand; -my $MBOX_TITLE = 'title="download thread as gzipped mbox"'; +use Plack::Util; +use PublicInbox::Hval qw/ascii_html/; +use PublicInbox::Linkify; +use PublicInbox::MID qw/mid_clean id_compress mid2path mid_mime/; +use PublicInbox::MsgIter; +use PublicInbox::Address; +use PublicInbox::WwwStream; require POSIX; -# TODO: make these constants tunable -use constant MAX_INLINE_QUOTED => 12; # half an 80x24 terminal -use constant MAX_TRUNC_LEN => 72; -use constant T_ANCHOR => '#u'; use constant INDENT => ' '; +use constant TCHILD => '` '; +sub th_pfx ($) { $_[0] == 0 ? '' : TCHILD }; -*ascii_html = *PublicInbox::Hval::ascii_html; - -my $enc_utf8 = find_encoding('UTF-8'); - -# public functions: +# public functions: (unstable) sub msg_html { - my ($ctx, $mime, $full_pfx, $footer) = @_; - if (defined $footer) { - $footer = "\n" . $footer; - } else { - $footer = ''; - } - headers_to_html_header($mime, $full_pfx, $ctx) . - multipart_text_as_html($mime, $full_pfx) . - '
' . PublicInbox::Hval::PRE . - html_footer($mime, 1, $full_pfx, $ctx) . - $footer . - ''; + my ($ctx, $mime, $footer) = @_; + my $hdr = $mime->header_obj; + my $tip = _msg_html_prepare($hdr, $ctx); + PublicInbox::WwwStream->new($ctx, sub { + my ($nr, undef) = @_; + if ($nr == 1) { + $tip . multipart_text_as_html($mime, '') . + '
' + } elsif ($nr == 2) { + '
' . html_footer($hdr, 1, $ctx) .
+			'
' . msg_reply($ctx, $hdr) . '
' + } else { + undef + } + }); } -sub feed_entry { - my ($class, $mime, $full_pfx) = @_; +# /$INBOX/$MESSAGE_ID/#R +sub msg_reply { + my ($ctx, $hdr) = @_; + my $se_url = + 'https://kernel.org/pub/software/scm/git/docs/git-send-email.html'; - PublicInbox::Hval::PRE . - multipart_text_as_html($mime, $full_pfx) . ''; + my ($arg, $link) = mailto_arg_link($hdr); + push @$arg, '/path/to/YOUR_REPLY'; + + "
". + "You may reply publically to this message via\n". + "plain-text email using any one of the following methods:\n\n" . + "* Save the following mbox file, import it into your mail client,\n" . + " and reply-to-all from there: mbox\n\n" . + "* Reply to all the recipients using the --to, --cc,\n" . + " and --in-reply-to switches of git-send-email(1):\n\n" . + "\tgit send-email \\\n\t\t" . + join(" \\\n\t\t", @$arg ). "\n\n" . + qq( $se_url\n\n) . + "* If your mail client supports setting the In-Reply-To" . + " header\n via mailto: links, try the " . + qq(mailto: link\n) . + ''; } sub in_reply_to { - my ($header_obj) = @_; - my $irt = $header_obj->header('In-Reply-To'); + my ($hdr) = @_; + my $irt = $hdr->header_raw('In-Reply-To'); return mid_clean($irt) if (defined $irt); - my $refs = $header_obj->header('References'); + my $refs = $hdr->header_raw('References'); if ($refs && $refs =~ /<([^>]+)>\s*\z/s) { return $1; } @@ -66,75 +81,44 @@ sub in_reply_to { # this is already inside a
 sub index_entry {
-	my ($fh, $mime, $level, $state) = @_;
+	my ($mime, $level, $state) = @_;
 	my $midx = $state->{anchor_idx}++;
 	my $ctx = $state->{ctx};
 	my $srch = $ctx->{srch};
-	my ($prev, $next) = ($midx - 1, $midx + 1);
-	my $part_nr = 0;
-	my $enc = enc_for($mime->header("Content-Type"));
-	my $subj = $mime->header('Subject');
-	my $header_obj = $mime->header_obj;
+	my $hdr = $mime->header_obj;
+	my $subj = $hdr->header('Subject');
 
-	my $mid_raw = mid_clean($header_obj->header('Message-ID'));
+	my $mid_raw = mid_clean(mid_mime($mime));
 	my $id = anchor_for($mid_raw);
 	my $seen = $state->{seen};
 	$seen->{$id} = "#$id"; # save the anchor for children, later
 
 	my $mid = PublicInbox::Hval->new_msgid($mid_raw);
-	my $from = PublicInbox::Hval->new_oneline($mime->header('From'))->raw;
-	my @from = Email::Address->parse($from);
-	$from = $from[0]->name;
+	my $from = PublicInbox::Address::from_name($hdr->header('From'));
 
-	$from = PublicInbox::Hval->new_oneline($from)->as_html;
-	$subj = PublicInbox::Hval->new_oneline($subj)->as_html;
 	my $root_anchor = $state->{root_anchor} || '';
 	my $path = $root_anchor ? '../../' : '';
 	my $href = $mid->as_href;
-	my $irt = in_reply_to($header_obj);
+	my $irt = in_reply_to($hdr);
 	my $parent_anchor = $seen->{anchor_for($irt)} if defined $irt;
 
-	if ($srch) {
-		my $t = $ctx->{flat} ? 'T' : 't';
-		$subj = "$subj";
-	}
-	if ($root_anchor eq $id) {
-		$subj = "$subj";
-	}
+	$from = ascii_html($from);
+	$subj = ascii_html($subj);
+	$subj = "$subj";
+	$subj = "$subj" if $root_anchor eq $id;
 
-	my $ts = _msg_date($mime);
-	my $rv = "";
-	if ($level) {
-		$rv .= '
' . (INDENT x $level) . '
'; - } - $rv .= "" . PublicInbox::Hval::PRE; + my $ts = _msg_date($hdr); + my $rv = ""; $rv .= "$subj\n"; - $rv .= "- $from @ $ts UTC - "; - $rv .= "next"; - if ($prev >= 0) { - $rv .= "/prev"; - } - $fh->write($rv .= "\n\n"); + my $txt = "${path}$href/raw"; + my $fh = $state->{fh}; + $fh->write($rv .= "- $from @ $ts UTC (raw)\n\n"); - my ($fhref, $more_ref); my $mhref = "${path}$href/"; - my $more = 'permalink'; - # show full message if it's our root message - my $neq = $root_anchor ne $id; - if ($neq || ($neq && $level != 0 && !$ctx->{flat})) { - $fhref = "${path}$href/f/"; - $more_ref = \$more; - } # scan through all parts, looking for displayable text - $mime->walk_parts(sub { - index_walk($fh, $_[0], $enc, \$part_nr, $fhref, $more_ref); - }); - $mime->body_set(''); - - my $txt = "${path}$href/raw"; - $rv = "\n$more raw "; - $rv .= html_footer($mime, 0, undef, $ctx); + msg_iter($mime, sub { index_walk($fh, $mhref, $_[0]) }); + $rv = "\n" . html_footer($hdr, 0, $ctx, "$path$href/#R"); if (defined $irt) { unless (defined $parent_anchor) { @@ -144,361 +128,272 @@ sub index_entry { } $rv .= " parent"; } - if (my $pct = $state->{pct}) { - $rv .= " [$pct->{$mid_raw}%]"; + if (my $pct = $state->{pct}) { # used by SearchView.pm + $rv .= " [relevance $pct->{$mid_raw}%]"; } elsif ($srch) { + my $threaded = 'threaded'; + my $flat = 'flat'; + my $end = ''; if ($ctx->{flat}) { - $rv .= " [threaded" . - "|flat]"; + $flat = "$flat"; + $end = "\n"; # for lynx } else { - $rv .= " [threaded|" . - "flat]"; + $threaded = "$threaded"; } + $rv .= " [$threaded"; + $rv .= "|$flat]$end"; } - - $fh->write($rv .= '
'); + $fh->write($rv .= ''); } sub thread_html { my ($ctx, $foot, $srch) = @_; + # $_[0] in sub is the Plack callback sub { emit_thread_html($_[0], $ctx, $foot, $srch) } } # only private functions below. sub emit_thread_html { - my ($cb, $ctx, $foot, $srch) = @_; + my ($res, $ctx, $foot, $srch) = @_; my $mid = $ctx->{mid}; - my $res = $srch->get_thread($mid); - my $msgs = load_results($res); - my $nr = scalar @$msgs; - return missing_thread($cb, $ctx) if $nr == 0; my $flat = $ctx->{flat}; - my $orig_cb = $cb; + my $msgs = load_results($srch->get_thread($mid, { asc => $flat })); + my $nr = scalar @$msgs; + return missing_thread($res, $ctx) if $nr == 0; my $seen = {}; my $state = { + res => $res, ctx => $ctx, seen => $seen, root_anchor => anchor_for($mid), anchor_idx => 0, + cur_level => 0, }; require PublicInbox::Git; - my $git = $ctx->{git} ||= PublicInbox::Git->new($ctx->{git_dir}); + $ctx->{git} ||= PublicInbox::Git->new($ctx->{git_dir}); if ($flat) { pre_anchor_entry($seen, $_) for (@$msgs); - __thread_entry(\$cb, $git, $state, $_, 0) for (@$msgs); + __thread_entry($state, $_, 0) for (@$msgs); } else { my $th = thread_results($msgs); - thread_entry(\$cb, $git, $state, $_, 0) for $th->rootset; + thread_entry($state, $_, 0) for $th->rootset; + if (my $max = $state->{cur_level}) { + $state->{fh}->write( + ('' x ($max - 1)) . ''); + } } - $git = undef; - Email::Address->purge_cache; # there could be a race due to a message being deleted in git # but still being in the Xapian index: - return missing_thread($cb, $ctx) if ($orig_cb eq $cb); + my $fh = delete $state->{fh} or return missing_thread($res, $ctx); my $final_anchor = $state->{anchor_idx}; my $next = ""; $next .= $final_anchor == 1 ? 'only message in' : 'end of'; $next .= " thread, back to index"; $next .= "\ndownload thread: "; - $next .= "mbox.gz"; + $next .= "mbox.gz"; $next .= " / follow: Atom feed"; - $cb->write("
" . PublicInbox::Hval::PRE . $next . "\n\n". - $foot . ""); - $cb->close; + $fh->write('
' . $next . "\n\n".
+			$foot .  '
'); + $fh->close; } sub index_walk { - my ($fh, $part, $enc, $part_nr, $fhref, $more) = @_; - my $s = add_text_body($enc, $part, $part_nr, $fhref); - - if ($more) { - my $m = 0; - # drop the remainder of git patches, they're usually better - # to review when the full message is viewed - $s =~ s!^---+\n.*\z!!ms and $m = 1; - - # Drop signatures - $s =~ s/^-- \n.*\z//ms and $m = 1; - $$more = "More...\n\n$$more" if $m; - } + my ($fh, $upfx, $p) = @_; + my $s = add_text_body($upfx, $p); - # kill any leading or trailing whitespace lines - $s =~ s/^\s*$//sgm; - $s =~ s/\s+\z//s; + return if $s eq ''; - if ($s ne '') { - # kill per-line trailing whitespace - $s =~ s/[ \t]+$//sgm; - $s .= "\n" unless $s =~ /\n\z/s; - } - $fh->write($s); -} + $s .= "\n"; # ensure there's a trailing newline -sub enc_for { - my ($ct, $default) = @_; - $default ||= $enc_utf8; - defined $ct or return $default; - my $ct_parsed = parse_content_type($ct); - if ($ct_parsed) { - if (my $charset = $ct_parsed->{attributes}->{charset}) { - my $enc = find_encoding($charset); - return $enc if $enc; - } - } - $default; + $fh->write($s); } sub multipart_text_as_html { - my ($mime, $full_pfx, $srch) = @_; + my ($mime, $upfx) = @_; my $rv = ""; - my $part_nr = 0; - my $enc = enc_for($mime->header("Content-Type")); # scan through all parts, looking for displayable text - $mime->walk_parts(sub { - my ($part) = @_; - $rv .= add_text_body($enc, $part, \$part_nr, $full_pfx, 1); + msg_iter($mime, sub { + my ($p) = @_; + $p = add_text_body($upfx, $p); + $rv .= $p; + $rv .= "\n" if $p ne ''; }); - $mime->body_set(''); $rv; } -sub add_filename_line { - my ($enc, $fn) = @_; - my $len = 72; - my $pad = "-"; - $fn = $enc->decode($fn); - $len -= length($fn); - $pad x= ($len/2) if ($len > 0); - "$pad " . ascii_html($fn) . " $pad\n"; -} - -my $LINK_RE = qr!\b((?:ftp|https?|nntp):// - [\@:\w\.-]+/ - ?[\@\w\+\&\?\.\%\;/#=-]*)!x; - -sub linkify_1 { - my ($link_map, $s) = @_; - $s =~ s!$LINK_RE! - my $url = $1; - # salt this, as this could be exploited to show - # links in the HTML which don't show up in the raw mail. - my $key = sha1_hex($url . $SALT); - $link_map->{$key} = $url; - 'PI-LINK-'. $key; - !ge; - $s; -} - -sub linkify_2 { - my ($link_map, $s) = @_; - - # Added "PI-LINK-" prefix to avoid false-positives on git commits - $s =~ s!\bPI-LINK-([a-f0-9]{40})\b! - my $key = $1; - my $url = $link_map->{$key}; - if (defined $url) { - $url = ascii_html($url); - "$url"; - } else { - # false positive or somebody tried to mess with us - $key; - } - !ge; - $s; -} - sub flush_quote { - my ($quot, $n, $part_nr, $full_pfx, $final, $do_anchor) = @_; - - if ($full_pfx) { - if (!$final && scalar(@$quot) <= MAX_INLINE_QUOTED) { - # show quote inline - my %l; - my $rv = join('', map { linkify_1(\%l, $_) } @$quot); - @$quot = (); - $rv = ascii_html($rv); - return linkify_2(\%l, $rv); - } - - # show a short snippet of quoted text and link to full version: - @$quot = map { s/^(?:>\s*)+//gm; $_ } @$quot; - my $cur = join(' ', @$quot); - @$quot = split(/\s+/, $cur); - $cur = ''; - do { - my $tmp = shift(@$quot); - my $len = length($tmp) + length($cur); - if ($len > MAX_TRUNC_LEN) { - @$quot = (); - } else { - $cur .= $tmp . ' '; - } - } while (@$quot && length($cur) < MAX_TRUNC_LEN); - @$quot = (); - $cur =~ s/ \z/ .../s; - $cur = ascii_html($cur); - my $nr = ++$$n; - "> [$cur]\n"; + my ($s, $l, $quot) = @_; + + # show everything in the full version with anchor from + # short version (see above) + my $rv = $l->linkify_1(join('', @$quot)); + @$quot = (); + + # we use a
here to allow users to specify their own + # color for quoted text + $rv = $l->linkify_2(ascii_html($rv)); + $$s .= qq() . $rv . '' +} + +sub attach_link ($$$$) { + my ($upfx, $ct, $p, $fn) = @_; + my ($part, $depth, @idx) = @$p; + my $nl = $idx[-1] > 1 ? "\n" : ''; + my $idx = join('.', @idx); + my $size = bytes::length($part->body); + $ct ||= 'text/plain'; + $ct =~ s/;.*//; # no attributes + $ct = ascii_html($ct); + my $desc = $part->header('Content-Description'); + $desc = $fn unless defined $desc; + $desc = '' unless defined $desc; + my $sfn; + if (defined $fn && $fn =~ /\A[[:alnum:]][\w\.-]+[[:alnum:]]\z/) { + $sfn = $fn; + } elsif ($ct eq 'text/plain') { + $sfn = 'a.txt'; } else { - # show everything in the full version with anchor from - # short version (see above) - my %l; - my $rv .= join('', map { linkify_1(\%l, $_) } @$quot); - @$quot = (); - $rv = ascii_html($rv); - return linkify_2(\%l, $rv) unless $do_anchor; - my $nr = ++$$n; - "" . linkify_2(\%l, $rv); + $sfn = 'a.bin'; } + my @ret = qq($nl[-- Attachment #$idx: ); + my $ts = "Type: $ct, Size: $size bytes"; + push(@ret, ($desc eq '') ? "$ts --]" : "$desc --]\n[-- $ts --]"); + join('', @ret, ''); } sub add_text_body { - my ($enc_msg, $part, $part_nr, $full_pfx, $do_anchor) = @_; - return '' if $part->subparts; - + my ($upfx, $p) = @_; # from msg_iter: [ Email::MIME, depth, @idx ] + my ($part, $depth, @idx) = @$p; my $ct = $part->content_type; - # account for filter bugs... + my $fn = $part->filename; + if (defined $ct && $ct =~ m!\btext/x?html\b!i) { - $part->body_set(''); - return ''; + return attach_link($upfx, $ct, $p, $fn); } - my $enc = enc_for($ct, $enc_msg); - my $n = 0; - my $nr = 0; - my $s = $part->body; - $part->body_set(''); - $s = $enc->decode($s); + + my $s = eval { $part->body_str }; + + # badly-encoded message? tell the world about it! + return attach_link($upfx, $ct, $p, $fn) if $@; + my @lines = split(/^/m, $s); $s = ''; - - if ($$part_nr > 0) { - my $fn = $part->filename; - defined($fn) or $fn = "part #" . ($$part_nr + 1); - $s .= add_filename_line($enc, $fn); + if (defined($fn) || $depth > 0) { + $s .= attach_link($upfx, $ct, $p, $fn); + $s .= "\n\n"; } - my @quot; + my $l = PublicInbox::Linkify->new; while (defined(my $cur = shift @lines)) { if ($cur !~ /^>/) { # show the previously buffered quote inline - if (scalar @quot) { - $s .= flush_quote(\@quot, \$n, $$part_nr, - $full_pfx, 0, $do_anchor); - } + flush_quote(\$s, $l, \@quot) if @quot; # regular line, OK - my %l; - $cur = linkify_1(\%l, $cur); + $cur = $l->linkify_1($cur); $cur = ascii_html($cur); - $s .= linkify_2(\%l, $cur); + $s .= $l->linkify_2($cur); } else { push @quot, $cur; } } - if (scalar @quot) { - $s .= flush_quote(\@quot, \$n, $$part_nr, $full_pfx, 1, - $do_anchor); - } - $s .= "\n" unless $s =~ /\n\z/s; - ++$$part_nr; + + flush_quote(\$s, $l, \@quot) if @quot; + $s =~ s/[ \t]+$//sgm; # kill per-line trailing whitespace + $s =~ s/\A\n+//s; # kill leading blank lines + $s =~ s/\s+\z//s; # kill all trailing spaces (final "\n" added if ne '') $s; } -sub headers_to_html_header { - my ($mime, $full_pfx, $ctx) = @_; +sub _msg_html_prepare { + my ($hdr, $ctx) = @_; my $srch = $ctx->{srch} if $ctx; - my $rv = ""; + my $atom = ''; + my $rv = ""; # anchor for body start + + if ($srch) { + $ctx->{-upfx} = '../'; + } my @title; - my $header_obj = $mime->header_obj; - my $mid = $header_obj->header('Message-ID'); + my $mid = $hdr->header_raw('Message-ID'); $mid = PublicInbox::Hval->new_msgid($mid); foreach my $h (qw(From To Cc Subject Date)) { - my $v = $header_obj->header($h); + my $v = $hdr->header($h); defined($v) && ($v ne '') or next; - $v = PublicInbox::Hval->new_oneline($v); + $v = PublicInbox::Hval->new($v); if ($h eq 'From') { - my @from = Email::Address->parse($v->raw); - $title[1] = ascii_html($from[0]->name); + my $n = PublicInbox::Address::from_name($v->raw); + $title[1] = ascii_html($n); } elsif ($h eq 'Subject') { $title[0] = $v->as_html; if ($srch) { - $rv .= "$h: "; - $rv .= $v->as_html . "\n"; + $rv .= qq($h: ); + $rv .= $v->as_html . "\n"; next; } } $rv .= "$h: " . $v->as_html . "\n"; } + $ctx->{-title_html} = join(' - ', @title); $rv .= 'Message-ID: <' . $mid->as_html . '> '; - my $upfx = $full_pfx ? '' : '../'; - $rv .= "(raw)\n"; - my $atom; - if ($srch) { - thread_inline(\$rv, $ctx, $mime, $upfx); - - $atom = qq{!; - } else { - $rv .= _parent_headers_nosrch($header_obj); - $atom = ''; - } + $rv .= "(raw)\n"; + $rv .= _parent_headers($hdr, $srch); $rv .= "\n"; - - ("". join(' - ', @title) . - "$atom" . PublicInbox::Hval::PRE . $rv); } -sub thread_inline { - my ($dst, $ctx, $cur, $upfx) = @_; +sub thread_skel { + my ($dst, $ctx, $hdr, $tpfx) = @_; my $srch = $ctx->{srch}; - my $mid = mid_clean($cur->header('Message-ID')); - my $res = $srch->get_thread($mid); - my $nr = $res->{total}; - my $expand = "expand " . - "/ mbox.gz"; - - $$dst .= 'Thread: '; - my $parent = in_reply_to($cur); + my $mid = mid_clean($hdr->header_raw('Message-ID')); + my $sres = $srch->get_thread($mid); + my $nr = $sres->{total}; + my $expand = qq(expand ) . + qq(/ mbox.gz ) . + qq(/ Atom feed); + + my $parent = in_reply_to($hdr); if ($nr <= 1) { - $$dst .= "[no followups, yet] ($expand)\n"; + if (defined $parent) { + $$dst .= "($expand)\n "; + $$dst .= ghost_parent("$tpfx../", $parent) . "\n"; + } else { + $$dst .= "[no followups, yet] ($expand)\n"; + } $ctx->{next_msg} = undef; $ctx->{parent_msg} = $parent; return; } - $$dst .= "~$nr messages (skip / " . - $expand . ")\n"; + $$dst .= "$nr+ messages in thread ($expand"; + $$dst .= qq! / [top])\n!; - my $subj = $srch->subject_path($cur->header('Subject')); + my $subj = $srch->subject_path($hdr->header('Subject')); my $state = { seen => { $subj => 1 }, srch => $srch, cur => $mid, - parent_cmp => defined $parent ? $parent : '', - parent => $parent, prev_attr => '', prev_level => 0, }; - for (thread_results(load_results($res))->rootset) { - inline_dump($dst, $state, $upfx, $_, 0); + for (thread_results(load_results($sres))->rootset) { + skel_dump($dst, $state, $tpfx, $_, 0); } - $$dst .= ""; # anchor for body start $ctx->{next_msg} = $state->{next_msg}; - $ctx->{parent_msg} = $state->{parent}; + $ctx->{parent_msg} = $parent; } -sub _parent_headers_nosrch { - my ($header_obj) = @_; +sub _parent_headers { + my ($hdr, $srch) = @_; my $rv = ''; - my $irt = in_reply_to($header_obj); + my $irt = in_reply_to($hdr); if (defined $irt) { my $v = PublicInbox::Hval->new_msgid($irt, 1); my $html = $v->as_html; @@ -507,7 +402,11 @@ sub _parent_headers_nosrch { $rv .= "$html>\n"; } - my $refs = $header_obj->header('References'); + # do not display References: if search is present, + # we show the thread skeleton at the bottom, instead. + return $rv if $srch; + + my $refs = $hdr->header_raw('References'); if ($refs) { # avoid redundant URLs wasting bandwidth my %seen; @@ -527,68 +426,73 @@ sub _parent_headers_nosrch { $rv; } -sub html_footer { - my ($mime, $standalone, $full_pfx, $ctx) = @_; +sub mailto_arg_link { + my ($hdr) = @_; my %cc; # everyone else my $to; # this is the From address foreach my $h (qw(From To Cc)) { - my $v = $mime->header($h); + my $v = $hdr->header($h); defined($v) && ($v ne '') or next; - my @addrs = Email::Address->parse($v); - foreach my $recip (@addrs) { - my $address = $recip->address; + my @addrs = PublicInbox::Address::emails($v); + foreach my $address (@addrs) { my $dst = lc($address); $cc{$dst} ||= $address; $to ||= $dst; } } - Email::Address->purge_cache if $standalone; + my @arg; - my $subj = $mime->header('Subject') || ''; + my $subj = $hdr->header('Subject') || ''; $subj = "Re: $subj" unless $subj =~ /\bRe:/i; - my $mid = $mime->header('Message-ID'); + my $mid = $hdr->header_raw('Message-ID'); + push @arg, "--in-reply-to='" . ascii_html($mid) . "'"; my $irt = uri_escape_utf8($mid); delete $cc{$to}; + push @arg, '--to=' . ascii_html($to); $to = uri_escape_utf8($to); $subj = uri_escape_utf8($subj); - - my $cc = uri_escape_utf8(join(',', sort values %cc)); + my $cc = join(',', sort values %cc); + push @arg, '--cc=' . ascii_html($cc); + $cc = uri_escape_utf8($cc); my $href = "mailto:$to?In-Reply-To=$irt&Cc=${cc}&Subject=$subj"; $href =~ s/%20/+/g; + (\@arg, $href); +} + +sub html_footer { + my ($hdr, $standalone, $ctx, $rhref) = @_; + my $srch = $ctx->{srch} if $ctx; - my $upfx = $full_pfx ? '../' : '../../'; + my $upfx = '../'; + my $tpfx = ''; my $idx = $standalone ? " index" : ''; - - if ($srch && $standalone) { - $idx .= qq{ / follow: Atom feed\n}; - } + my $irt = ''; if ($idx && $srch) { + $idx .= "\n"; + thread_skel(\$idx, $ctx, $hdr, $tpfx); my $p = $ctx->{parent_msg}; my $next = $ctx->{next_msg}; if ($p) { - $p = PublicInbox::Hval->new_oneline($p); + $p = PublicInbox::Hval->new_msgid($p); $p = $p->as_href; - $irt = "parent "; + $irt = "parent "; } else { $irt = ' ' x length('parent '); } if ($next) { - $irt .= "next "; + my $n = PublicInbox::Hval->new_msgid($next)->as_href; + $irt .= "next "; } else { $irt .= ' ' x length('next '); } - if ($p || $next) { - $irt .= "thread "; - } else { - $irt .= ' ' x length('thread '); - } } else { $irt = ''; } - - "$irtreply' . $idx; + $rhref ||= '#R'; + $irt .= qq(reply); + $irt .= $idx; } sub linkify_ref_nosrch { @@ -608,20 +512,22 @@ sub anchor_for { } sub thread_html_head { - my ($cb, $header, $state) = @_; - $$cb = $$cb->([200, ['Content-Type'=> 'text/html; charset=UTF-8']]); + my ($hdr, $state) = @_; + my $res = delete $state->{res} or die "BUG: no Plack callback in {res}"; + my $fh = $res->([200, ['Content-Type'=> 'text/html; charset=UTF-8']]); + $state->{fh} = $fh; - my $s = PublicInbox::Hval->new_oneline($header->header('Subject')); - $s = $s->as_html; - $$cb->write("$s". + my $s = ascii_html($hdr->header('Subject')); + $fh->write("$s". qq{! . + PublicInbox::Hval::STYLE . ""); } sub pre_anchor_entry { my ($seen, $mime) = @_; - my $id = anchor_for($mime->header('Message-ID')); + my $id = anchor_for(mid_mime($mime)); $seen->{$id} = "#$id"; # save the anchor for children, later } @@ -636,107 +542,131 @@ sub ghost_parent { qq{[parent not found: <$html>]}; } -sub ghost_table { - my ($upfx, $mid, $level) = @_; - "" . - (INDENT x $level) . "" . - PublicInbox::Hval::PRE . ghost_parent($upfx, $mid) . - ''; +sub thread_adj_level { + my ($state, $level) = @_; + + my $max = $state->{cur_level}; + if ($level <= 0) { + return '' if $max == 0; # flat output + + # reset existing lists + my $x = $max > 1 ? ('' x ($max - 1)) : ''; + $state->{fh}->write($x . ''); + $state->{cur_level} = 0; + return ''; + } + if ($level == $max) { # continue existing list + $state->{fh}->write('
  • '); + } elsif ($level < $max) { + my $x = $max > 1 ? ('
  • ' x ($max - $level)) : ''; + $state->{fh}->write($x .= '
  • '); + $state->{cur_level} = $level; + } else { # ($level > $max) # start a new level + $state->{cur_level} = $level; + $state->{fh}->write(($max ? '
  • ' : '') . '
    • '); + } + '
    • '; +} + +sub ghost_flush { + my ($state, $upfx, $mid, $level) = @_; + my $end = '
      '. ghost_parent($upfx, $mid) . '
      '; + $state->{fh}->write($end .= thread_adj_level($state, $level)); } sub __thread_entry { - my ($cb, $git, $state, $mime, $level) = @_; + my ($state, $mime, $level) = @_; # lazy load the full message from mini_mime: $mime = eval { - my $path = mid2path(mid_clean($mime->header('Message-ID'))); - Email::MIME->new($git->cat_file('HEAD:'.$path)); + my $path = mid2path(mid_clean(mid_mime($mime))); + Email::MIME->new($state->{ctx}->{git}->cat_file('HEAD:'.$path)); } or return; - if ($state->{anchor_idx} == 0) { - thread_html_head($cb, $mime, $state); - } - + thread_html_head($mime, $state) if $state->{anchor_idx} == 0; if (my $ghost = delete $state->{ghost}) { # n.b. ghost messages may only be parents, not children foreach my $g (@$ghost) { - $$cb->write(ghost_table('../../', @$g)); + ghost_flush($state, '../../', @$g); } } - index_entry($$cb, $mime, $level, $state); + my $end = thread_adj_level($state, $level); + index_entry($mime, $level, $state); + $state->{fh}->write($end) if $end; + 1; } -sub __ghost_entry { +sub indent_for { + my ($level) = @_; + INDENT x ($level - 1); +} + +sub __ghost_prepare { my ($state, $node, $level) = @_; my $ghost = $state->{ghost} ||= []; push @$ghost, [ $node->messageid, $level ]; } sub thread_entry { - my ($cb, $git, $state, $node, $level) = @_; + my ($state, $node, $level) = @_; return unless $node; if (my $mime = $node->message) { - unless (__thread_entry($cb, $git, $state, $mime, $level)) { - __ghost_entry($state, $node, $level); + unless (__thread_entry($state, $mime, $level)) { + __ghost_prepare($state, $node, $level); } } else { - __ghost_entry($state, $node, $level); + __ghost_prepare($state, $node, $level); } - thread_entry($cb, $git, $state, $node->child, $level + 1); - thread_entry($cb, $git, $state, $node->next, $level); + thread_entry($state, $node->child, $level + 1); + thread_entry($state, $node->next, $level); } sub load_results { - my ($res) = @_; + my ($sres) = @_; - [ map { $_->mini_mime } @{delete $res->{msgs}} ]; + [ map { $_->mini_mime } @{delete $sres->{msgs}} ]; } sub msg_timestamp { - my ($mime) = @_; - my $ts = eval { str2time($mime->header('Date')) }; + my ($hdr) = @_; + my $ts = eval { str2time($hdr->header('Date')) }; defined($ts) ? $ts : 0; } sub thread_results { - my ($msgs, $nosubject) = @_; + my ($msgs) = @_; require PublicInbox::Thread; my $th = PublicInbox::Thread->new(@$msgs); - no warnings 'once'; - $Mail::Thread::nosubject = $nosubject; $th->thread; $th->order(*sort_ts); $th } sub missing_thread { - my ($cb, $ctx) = @_; + my ($res, $ctx) = @_; require PublicInbox::ExtMsg; - $cb->(PublicInbox::ExtMsg::ext_msg($ctx)) + $res->(PublicInbox::ExtMsg::ext_msg($ctx)) } sub _msg_date { - my ($mime) = @_; - my $ts = $mime->header('X-PI-TS') || msg_timestamp($mime); + my ($hdr) = @_; + my $ts = $hdr->header('X-PI-TS') || msg_timestamp($hdr); fmt_ts($ts); } sub fmt_ts { POSIX::strftime('%Y-%m-%d %k:%M', gmtime($_[0])) } -sub _inline_header { - my ($dst, $state, $upfx, $mime, $level) = @_; - my $dot = $level == 0 ? '' : '` '; +sub _skel_header { + my ($dst, $state, $upfx, $hdr, $level) = @_; my $cur = $state->{cur}; - my $mid = mid_clean($mime->header('Message-ID')); - my $f = $mime->header('X-PI-From'); - my $d = _msg_date($mime); - $f = PublicInbox::Hval->new($f)->as_html; - $d = PublicInbox::Hval->new($d)->as_html; - my $pfx = ' ' . $d . ' ' . (INDENT x $level); + my $mid = mid_clean($hdr->header_raw('Message-ID')); + my $f = ascii_html($hdr->header('X-PI-From')); + my $d = _msg_date($hdr); + my $pfx = "$d " . indent_for($level) . th_pfx($level); my $attr = $f; $state->{first_level} ||= $level; @@ -750,7 +680,7 @@ sub _inline_header { if ($cur) { if ($cur eq $mid) { delete $state->{cur}; - $$dst .= "$pfx$dot". + $$dst .= "$pfx". "$attr [this message]\n"; return; @@ -762,7 +692,7 @@ sub _inline_header { # Subject is never undef, this mail was loaded from # our Xapian which would've resulted in '' if it were # really missing (and Filter rejects empty subjects) - my $s = $mime->header('Subject'); + my $s = $hdr->header('Subject'); my $h = $state->{srch}->subject_path($s); if ($state->{seen}->{$h}) { $s = undef; @@ -773,31 +703,32 @@ sub _inline_header { } my $m = PublicInbox::Hval->new_msgid($mid); $m = $upfx . '../' . $m->as_href . '/'; - if (defined $s) { - $$dst .= "$pfx$dot$s$attr\n"; - } else { - $$dst .= "$pfx$dot$f\n"; - } + $$dst .= "$pfx"; + $$dst .= defined($s) ? "$s $f\n" : "$f\n"; } -sub inline_dump { +sub skel_dump { my ($dst, $state, $upfx, $node, $level) = @_; return unless $node; if (my $mime = $node->message) { - my $mid = mid_clean($mime->header('Message-ID')); - if ($mid eq $state->{parent_cmp}) { - $state->{parent} = $mid; - } - _inline_header($dst, $state, $upfx, $mime, $level); + my $hdr = $mime->header_obj; + my $mid = mid_clean($hdr->header_raw('Message-ID')); + _skel_header($dst, $state, $upfx, $hdr, $level); } else { - my $dot = $level == 0 ? '' : '` '; - my $pfx = length(' 1970-01-01 13:37 ') . - (INDENT x $level) . $dot; - $$dst .= $pfx; - $$dst .= ghost_parent("$upfx../", $node->messageid) . "\n"; + my $mid = $node->messageid; + if ($mid eq 'subject dummy') { + $$dst .= "\t[no common parent]\n"; + } else { + $$dst .= ' [not found] '; + $$dst .= indent_for($level) . th_pfx($level); + $mid = PublicInbox::Hval->new_msgid($mid); + my $href = "$upfx../" . $mid->as_href . '/'; + my $html = $mid->as_html; + $$dst .= qq{<$html>\n}; + } } - inline_dump($dst, $state, $upfx, $node->child, $level+1); - inline_dump($dst, $state, $upfx, $node->next, $level); + skel_dump($dst, $state, $upfx, $node->child, $level+1); + skel_dump($dst, $state, $upfx, $node->next, $level); } sub sort_ts { @@ -807,13 +738,6 @@ sub sort_ts { } @_; } -sub rsort_ts { - sort { - (eval { $b->topmost->message->header('X-PI-TS') } || 0) <=> - (eval { $a->topmost->message->header('X-PI-TS') } || 0) - } @_; -} - # accumulate recent topics if search is supported # returns 1 if done, undef if not sub add_topic { @@ -823,27 +747,21 @@ sub add_topic { if (my $x = $node->message) { $x = $x->header_obj; - my ($topic, $subj); + my $subj; $subj = $x->header('Subject'); $subj = $state->{srch}->subject_normalized($subj); - $topic = $subj; - - # kill "[PATCH v2]" etc. for summarization - unless ($level == 0) { - $topic =~ s/\A\s*\[[^\]]+\]\s*//g; - } - if (++$state->{subjs}->{$topic} == 1) { - push @{$state->{order}}, [ $level, $subj, $topic ]; + if (++$state->{subjs}->{$subj} == 1) { + push @{$state->{order}}, [ $level, $subj ]; } - my $mid = mid_clean($x->header('Message-ID')); + my $mid = mid_clean($x->header_raw('Message-ID')); my $ts = $x->header('X-PI-TS'); - my $exist = $state->{latest}->{$topic}; + my $exist = $state->{latest}->{$subj}; if (!$exist || $exist->[1] < $ts) { - $state->{latest}->{$topic} = [ $mid, $ts ]; + $state->{latest}->{$subj} = [ $mid, $ts ]; } } else { # ghost message, do not bump level @@ -854,71 +772,75 @@ sub add_topic { add_topic($state, $node->next, $level); } -sub dump_topics { +sub emit_topics { my ($state) = @_; my $order = $state->{order}; my $subjs = $state->{subjs}; my $latest = $state->{latest}; - return "\n[No topics in range]" unless (scalar @$order); - my $dst = ''; + my $fh = $state->{fh}; + return $fh->write("\n[No topics in range]") unless scalar @$order; my $pfx; my $prev = 0; my $prev_attr = ''; + my $cur; + my @recent; while (defined(my $info = shift @$order)) { - my ($level, $subj, $topic) = @$info; - my $n = delete $subjs->{$topic}; - my ($mid, $ts) = @{delete $latest->{$topic}}; - $mid = PublicInbox::Hval->new($mid)->as_href; + my ($level, $subj) = @$info; + my $n = delete $subjs->{$subj}; + my ($mid, $ts) = @{delete $latest->{$subj}}; + $mid = PublicInbox::Hval->new_msgid($mid)->as_href; $subj = PublicInbox::Hval->new($subj)->as_html; - $pfx = INDENT x ($level - 1); + $pfx = indent_for($level); my $nl = $level == $prev ? "\n" : ''; - my $dot = $level == 0 ? '' : '` '; - $dst .= "$nl$pfx$dot$subj\n"; + if ($nl && $cur) { + push @recent, $cur; + $cur = undef; + } + $cur ||= [ $ts, '' ]; + $cur->[0] = $ts if $ts > $cur->[0]; + $cur->[1] .= $nl . $pfx . th_pfx($level) . + "" . + $subj . "\n"; - my $attr; $ts = fmt_ts($ts); - if ($n == 1) { - $attr = "@ $ts UTC"; - $n = ""; - } else { - # $n isn't the total number of posts on the topic, - # just the number of posts in the current results - # window, so leave it unlabeled - $attr = "@ $ts UTC"; - $n = " ($n)"; - } + my $attr = " $ts UTC"; + + # $n isn't the total number of posts on the topic, + # just the number of posts in the current results window + $n = $n == 1 ? '' : " ($n+ messages)"; + if ($level == 0 || $attr ne $prev_attr) { - my $mbox = qq(mbox.gz); + my $mbox = qq(mbox.gz); my $atom = qq(Atom); $pfx .= INDENT if $level > 0; - $dst .= $pfx . $attr . $n . " - $mbox / $atom\n"; + $cur->[1] .= $pfx . $attr . $n . " - $mbox / $atom\n"; $prev_attr = $attr; } } - $dst .= ''; + push @recent, $cur if $cur; + @recent = map { $_->[1] } sort { $b->[0] <=> $a->[0] } @recent; + $fh->write(join('', @recent) . ''); } sub emit_index_topics { - my ($state, $fh) = @_; - my $off = $state->{ctx}->{cgi}->param('o'); - $off = 0 unless defined $off; + my ($state) = @_; + my ($off) = (($state->{ctx}->{cgi}->param('o') || '0') =~ /(\d+)/); $state->{order} = []; $state->{subjs} = {}; $state->{latest} = {}; my $max = 25; - my %opts = ( offset => int $off, limit => $max * 4 ); + my %opts = ( offset => $off, limit => $max * 4 ); while (scalar @{$state->{order}} < $max) { - my $res = $state->{srch}->query('', \%opts); - my $nr = scalar @{$res->{msgs}} or last; + my $sres = $state->{srch}->query('', \%opts); + my $nr = scalar @{$sres->{msgs}} or last; - for (rsort_ts(thread_results(load_results($res), 1)->rootset)) { + for (thread_results(load_results($sres))->rootset) { add_topic($state, $_, 0); } $opts{offset} += $nr; } - $fh->write(dump_topics($state)); + emit_topics($state); $opts{offset}; }