| #!/usr/bin/perl -w |
| # Copyright (C) all contributors <meta@public-inbox.org> |
| # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt> |
| # |
| # Used for editing messages in a public-inbox. |
| # Supports v2 inboxes only, for now. |
| use strict; |
| use warnings; |
| use Getopt::Long qw(:config gnu_getopt no_ignore_case auto_abbrev); |
| use PublicInbox::AdminEdit; |
| use File::Temp 0.19 (); # 0.19 for TMPDIR |
| use PublicInbox::ContentHash qw(content_hash); |
| use PublicInbox::MID qw(mid_clean mids); |
| PublicInbox::Admin::check_require('-index'); |
| use PublicInbox::Eml; |
| use PublicInbox::InboxWritable qw(eml_from_path); |
| use PublicInbox::Import; |
| |
| my $help = <<'EOF'; |
| usage: public-inbox-edit -m MESSAGE-ID [--all] [INBOX_DIRS] |
| |
| destructively edit messages in a public inbox |
| |
| options: |
| |
| --all edit all configured inboxes |
| -m MESSAGE-ID edit the message with a given Message-ID |
| -F FILE edit the message matching the contents of FILE |
| --force forcibly edit even if Message-ID is ambiguous |
| --raw do not perform "From " line escaping |
| |
| See public-inbox-edit(1) man page for full documentation. |
| EOF |
| |
| my $opt = { verbose => 1, all => 0, -min_inbox_version => 2, raw => 0 }; |
| my @opt = qw(mid|m=s file|F=s raw C=s@); |
| GetOptions($opt, @PublicInbox::AdminEdit::OPT, @opt) or die $help; |
| if ($opt->{help}) { print $help; exit 0 }; |
| PublicInbox::Admin::do_chdir(delete $opt->{C}); |
| |
| my $cfg = PublicInbox::Config->new; |
| my $editor = $ENV{MAIL_EDITOR}; # e.g. "mutt -f" |
| unless (defined $editor) { |
| my $k = 'publicinbox.mailEditor'; |
| $editor = $cfg->{lc($k)} if $cfg; |
| unless (defined $editor) { |
| warn "\`$k' not configured, trying \`git var GIT_EDITOR'\n"; |
| chomp($editor = `git var GIT_EDITOR`); |
| warn "Will use $editor to edit mail\n"; |
| } |
| } |
| |
| my $mid = $opt->{mid}; |
| my $file = $opt->{file}; |
| if (defined $mid && defined $file) { |
| die "the --mid and --file options are mutually exclusive\n"; |
| } |
| |
| my @ibxs = PublicInbox::Admin::resolve_inboxes(\@ARGV, $opt, $cfg); |
| PublicInbox::AdminEdit::check_editable(\@ibxs); |
| |
| my $found = {}; # chash => [ [ibx, smsg] [, [ibx, smsg] ] ] |
| |
| sub find_mid ($$$) { |
| my ($found, $mid, $ibxs) = @_; |
| foreach my $ibx (@$ibxs) { |
| my $over = $ibx->over; |
| my ($id, $prev); |
| while (my $smsg = $over->next_by_mid($mid, \$id, \$prev)) { |
| my $ref = $ibx->msg_by_smsg($smsg); |
| my $mime = PublicInbox::Eml->new($ref); |
| my $chash = content_hash($mime); |
| my $tuple = [ $ibx, $smsg ]; |
| push @{$found->{$chash} ||= []}, $tuple |
| } |
| PublicInbox::InboxWritable::cleanup($ibx); |
| } |
| $found; |
| } |
| |
| sub show_cmd ($$) { |
| my ($ibx, $smsg) = @_; |
| " GIT_DIR=$ibx->{inboxdir}/all.git \\\n git show $smsg->{blob}\n"; |
| } |
| |
| sub show_found ($) { |
| my ($found) = @_; |
| foreach my $to_edit (values %$found) { |
| foreach my $tuple (@$to_edit) { |
| my ($ibx, $smsg) = @$tuple; |
| warn show_cmd($ibx, $smsg); |
| } |
| } |
| } |
| |
| if (defined($mid)) { |
| $mid = mid_clean($mid); |
| find_mid($found, $mid, \@ibxs); |
| my $nr = scalar(keys %$found); |
| die "No message found for <$mid>\n" unless $nr; |
| if ($nr > 1) { |
| warn <<""; |
| Multiple messages with different content found matching |
| <$mid>: |
| |
| show_found($found); |
| die "Use --force to edit all of them\n" if !$opt->{force}; |
| warn "Will edit all of them\n"; |
| } |
| } else { |
| my $eml = eml_from_path($file) or die "open($file) failed: $!"; |
| my $mids = mids($eml); |
| find_mid($found, $_, \@ibxs) for (@$mids); # populates $found |
| my $chash = content_hash($eml); |
| my $to_edit = $found->{$chash}; |
| unless ($to_edit) { |
| my $nr = scalar(keys %$found); |
| if ($nr > 0) { |
| warn <<""; |
| $nr matches to Message-ID(s) in $file, but none matched content |
| Partial matches below: |
| |
| show_found($found); |
| } elsif ($nr == 0) { |
| $mids = join('', map { " <$_>\n" } @$mids); |
| warn <<""; |
| No matching messages found matching Message-ID(s) in $file |
| $mids |
| |
| } |
| exit 1; |
| } |
| $found = { $chash => $to_edit }; |
| } |
| |
| my %tmpopt = ( |
| TEMPLATE => 'public-inbox-edit-XXXX', |
| TMPDIR => 1, |
| SUFFIX => $opt->{raw} ? '.eml' : '.mbox', |
| ); |
| |
| foreach my $to_edit (values %$found) { |
| my $edit_fh = File::Temp->new(%tmpopt); |
| $edit_fh->autoflush(1); |
| my $edit_fn = $edit_fh->filename; |
| my ($ibx, $smsg) = @{$to_edit->[0]}; |
| my $old_raw = $ibx->msg_by_smsg($smsg); |
| PublicInbox::InboxWritable::cleanup($ibx); |
| |
| my $tmp = $$old_raw; |
| if (!$opt->{raw}) { |
| my $oid = $smsg->{blob}; |
| print $edit_fh "From mboxrd\@$oid Thu Jan 1 00:00:00 1970\n" |
| or die "failed to write From_ line: $!"; |
| $tmp =~ s/^(>*From )/>$1/gm; |
| } |
| print $edit_fh $tmp or |
| die "failed to write tempfile for editing: $!"; |
| |
| # run the editor, respecting spaces/quote |
| retry_edit: |
| if (system(qw(sh -c), $editor.' "$@"', $editor, $edit_fn)) { |
| if (!(-t STDIN) && !$opt->{force}) { |
| die "E: $editor failed: $?\n"; |
| } |
| print STDERR "$editor failed, "; |
| print STDERR "continuing as forced\n" if $opt->{force}; |
| while (!$opt->{force}) { |
| print STDERR "(r)etry, (c)ontinue, (q)uit?\n"; |
| chomp(my $op = <STDIN> || ''); |
| $op = lc($op); |
| goto retry_edit if $op eq 'r'; |
| if ($op eq 'q') { |
| # n.b. we'll lose the exit signal, here, |
| # oh well; "q" is user-specified anyways. |
| exit($? >> 8); |
| } |
| last if $op eq 'c'; # continuing |
| print STDERR "\`$op' not recognized\n"; |
| } |
| } |
| |
| # reread the edited file, not using $edit_fh since $EDITOR may |
| # rename/relink $edit_fn |
| open my $new_fh, '<', $edit_fn or |
| die "can't read edited file ($edit_fn): $!\n"; |
| my $new_raw = PublicInbox::IO::read_all $new_fh; |
| |
| if (!$opt->{raw}) { |
| PublicInbox::Eml::strip_from($new_raw); |
| |
| # check if user forgot to purge (in mutt) after editing |
| if ($new_raw =~ /^From /sm) { |
| if (-t STDIN) { |
| print STDERR <<''; |
| Extra "From " lines detected in new mbox. |
| Did you forget to purge the original message from the mbox after editing? |
| |
| while (1) { |
| print STDERR <<""; |
| (y)es to re-edit, (n)o to continue |
| |
| chomp(my $op = <STDIN> || ''); |
| $op = lc($op); |
| goto retry_edit if $op eq 'y'; |
| last if $op eq 'n'; # continuing |
| print STDERR "\`$op' not recognized\n"; |
| } |
| } else { # non-interactive path |
| # unlikely to happen, as extra From lines are |
| # only a common mistake (for me) with |
| # interactive use |
| warn <<""; |
| W: possible message boundary splitting error |
| |
| } |
| } |
| # unescape what we escaped: |
| $new_raw =~ s/^>(>*From )/$1/gm; |
| } |
| |
| my $new_mime = PublicInbox::Eml->new(\$new_raw); |
| my $old_mime = PublicInbox::Eml->new($old_raw); |
| |
| # make sure we don't compare unwanted headers, since mutt adds |
| # Content-Length, Status, and Lines headers: |
| PublicInbox::Import::drop_unwanted_headers($new_mime); |
| PublicInbox::Import::drop_unwanted_headers($old_mime); |
| |
| # allow changing Received: and maybe other headers which can |
| # contain sensitive info. |
| my $nhdr = $new_mime->header_obj->as_string; |
| my $ohdr = $old_mime->header_obj->as_string; |
| if (($nhdr eq $ohdr) && |
| (content_hash($new_mime) eq content_hash($old_mime))) { |
| warn "No change detected to:\n", show_cmd($ibx, $smsg); |
| |
| next unless $opt->{verbose}; |
| # should we consider this machine-parseable? |
| PublicInbox::AdminEdit::show_rewrites(\*STDOUT, $ibx, []); |
| next; |
| } |
| |
| foreach my $tuple (@$to_edit) { |
| $ibx = PublicInbox::InboxWritable->new($tuple->[0]); |
| $smsg = $tuple->[1]; |
| my $im = $ibx->importer(0); |
| my $commits = $im->replace($old_mime, $new_mime); |
| $im->done; |
| unless ($commits) { |
| warn "Failed to replace:\n", show_cmd($ibx, $smsg); |
| next; |
| } |
| next unless $opt->{verbose}; |
| # should we consider this machine-parseable? |
| PublicInbox::AdminEdit::show_rewrites(\*STDOUT, $ibx, $commits); |
| } |
| } |