Make notmuch-mutt script more portable
[notmuch] / contrib / notmuch-mutt / notmuch-mutt
1 #!/usr/bin/env perl
2 #
3 # notmuch-mutt - notmuch (of a) helper for Mutt
4 #
5 # Copyright: © 2011-2015 Stefano Zacchiroli <zack@upsilon.cc>
6 # License: GNU General Public License (GPL), version 3 or above
7 #
8 # See the bottom of this file for more documentation.
9 # A manpage can be obtained by running "pod2man notmuch-mutt > notmuch-mutt.1"
10
11 use strict;
12 use warnings;
13
14 use File::Path;
15 use Getopt::Long qw(:config no_getopt_compat);
16 use Mail::Header;
17 use Mail::Box::Maildir;
18 use Pod::Usage;
19 use String::ShellQuote;
20 use Term::ReadLine;
21 use Digest::SHA;
22
23
24 my $xdg_cache_dir = "$ENV{HOME}/.cache";
25 $xdg_cache_dir = $ENV{XDG_CACHE_HOME} if $ENV{XDG_CACHE_HOME};
26 my $cache_dir = "$xdg_cache_dir/notmuch/mutt";
27
28
29 # create an empty maildir (if missing) or empty an existing maildir"
30 sub empty_maildir($) {
31     my ($maildir) = (@_);
32     rmtree($maildir) if (-d $maildir);
33     my $folder = new Mail::Box::Maildir(folder => $maildir,
34                                         create => 1);
35     $folder->close();
36 }
37
38 # search($maildir, $remove_dups, $query)
39 # search mails according to $query with notmuch; store results in $maildir
40 sub search($$$) {
41     my ($maildir, $remove_dups, $query) = @_;
42     my $dup_option = "";
43
44     $query = shell_quote($query);
45
46     if ($remove_dups) {
47       $dup_option = "--duplicate=1";
48     }
49
50     empty_maildir($maildir);
51     system("notmuch search --output=files $dup_option $query"
52            . " | sed -e 's: :\\\\ :g'"
53            . " | while IFS= read -r searchoutput; do ln -s \$searchoutput $maildir/cur/; done");
54 }
55
56 sub prompt($$) {
57     my ($text, $default) = @_;
58     my $query = "";
59     my $term = Term::ReadLine->new( "notmuch-mutt" );
60     my $histfile = "$cache_dir/history";
61
62     $term->ornaments( 0 );
63     $term->unbind_key( ord( "\t" ) );
64     $term->MinLine( 3 );
65     $histfile = $ENV{MUTT_NOTMUCH_HISTFILE} if $ENV{MUTT_NOTMUCH_HISTFILE};
66     $term->ReadHistory($histfile) if (-r $histfile);
67     while (1) {
68         chomp($query = $term->readline($text, $default));
69         if ($query eq "?") {
70             system("man", "notmuch-search-terms");
71         } else {
72             $term->WriteHistory($histfile);
73             return $query;
74         }
75     }
76 }
77
78 sub get_message_id() {
79     my $mid = undef;
80     my @headers = ();
81
82     while (<STDIN>) {  # collect header lines in @headers
83         push(@headers, $_);
84         last if $_ =~ /^$/;
85     }
86     my $head = Mail::Header->new(\@headers);
87     $mid = $head->get("message-id") or undef;
88
89     if ($mid) {  # Message-ID header found
90         $mid =~ /^<(.*)>$/;  # extract message id
91         $mid = $1;
92     } else {  # Message-ID header not found, synthesize a message id
93               # based on SHA1, as notmuch would do.  See:
94               # https://git.notmuchmail.org/git/notmuch/blob/HEAD:/lib/sha1.c
95         my $sha = Digest::SHA->new(1);
96         $sha->add($_) foreach(@headers);
97         $sha->addfile(\*STDIN);
98         $mid = 'notmuch-sha1-' . $sha->hexdigest;
99     }
100
101     return $mid;
102 }
103
104 sub search_action($$$@) {
105     my ($interactive, $results_dir, $remove_dups, @params) = @_;
106
107     if (! $interactive) {
108         search($results_dir, $remove_dups, join(' ', @params));
109     } else {
110         my $query = prompt("search ('?' for man): ", join(' ', @params));
111         if ($query ne "") {
112             search($results_dir, $remove_dups, $query);
113         }
114     }
115 }
116
117 sub thread_action($$@) {
118     my ($results_dir, $remove_dups, @params) = @_;
119
120     my $mid = get_message_id();
121     if (! defined $mid) {
122         empty_maildir($results_dir);
123         die "notmuch-mutt: cannot find Message-Id, abort.\n";
124     }
125     my $search_cmd = 'notmuch search --output=threads ' . shell_quote("id:$mid");
126     my $tid = `$search_cmd`;    # get thread id
127     chomp($tid);
128
129     search($results_dir, $remove_dups, $tid);
130 }
131
132 sub tag_action(@) {
133     my $mid = get_message_id();
134     defined $mid or die "notmuch-mutt: cannot find Message-Id, abort.\n";
135
136     system("notmuch", "tag", @_, "--", "id:$mid");
137 }
138
139 sub die_usage() {
140     my %podflags = ( "verbose" => 1,
141                     "exitval" => 2 );
142     pod2usage(%podflags);
143 }
144
145 sub main() {
146     mkpath($cache_dir) unless (-d $cache_dir);
147
148     my $results_dir = "$cache_dir/results";
149     my $interactive = 0;
150     my $help_needed = 0;
151     my $remove_dups = 0;
152
153     my $getopt = GetOptions(
154         "h|help" => \$help_needed,
155         "o|output-dir=s" => \$results_dir,
156         "p|prompt" => \$interactive,
157         "r|remove-dups" => \$remove_dups);
158     if (! $getopt || $#ARGV < 0) { die_usage() };
159     my ($action, @params) = ($ARGV[0], @ARGV[1..$#ARGV]);
160
161     foreach my $param (@params) {
162       $param =~ s/folder:=/folder:/g;
163     }
164
165     if ($help_needed) {
166         die_usage();
167     } elsif ($action eq "search" && $#ARGV == 0 && ! $interactive) {
168         print STDERR "Error: no search term provided\n\n";
169         die_usage();
170     } elsif ($action eq "search") {
171         search_action($interactive, $results_dir, $remove_dups, @params);
172     } elsif ($action eq "thread") {
173         thread_action($results_dir, $remove_dups, @params);
174     } elsif ($action eq "tag") {
175         tag_action(@params);
176     } else {
177         die_usage();
178     }
179 }
180
181 main();
182
183 __END__
184
185 =head1 NAME
186
187 notmuch-mutt - notmuch (of a) helper for Mutt
188
189 =head1 SYNOPSIS
190
191 =over
192
193 =item B<notmuch-mutt> [I<OPTION>]... search [I<SEARCH-TERM>]...
194
195 =item B<notmuch-mutt> [I<OPTION>]... thread < I<MAIL>
196
197 =item B<notmuch-mutt> [I<OPTION>]... tag [I<TAGS>]... < I<MAIL>
198
199 =back
200
201 =head1 DESCRIPTION
202
203 notmuch-mutt is a frontend to the notmuch mail indexer capable of populating
204 a maildir with search results.
205
206 =head1 OPTIONS
207
208 =over 4
209
210 =item -o DIR
211
212 =item --output-dir DIR
213
214 Store search results as (symlink) messages under maildir DIR. Beware: DIR will
215 be overwritten. (Default: F<~/.cache/notmuch/mutt/results/>)
216
217 =item -p
218
219 =item --prompt
220
221 Instead of using command line search terms, prompt the user for them (only for
222 "search").
223
224 =item -r
225
226 =item --remove-dups
227
228 Remove emails with duplicate message-ids from search results.  (Passes
229 --duplicate=1 to notmuch search command.)  Note this can hide search
230 results if an email accidentally or maliciously uses the same message-id
231 as a different email.
232
233 =item -h
234
235 =item --help
236
237 Show usage information and exit.
238
239 =back
240
241 =head1 INTEGRATION WITH MUTT
242
243 notmuch-mutt can be used to integrate notmuch with the Mutt mail user agent
244 (unsurprisingly, given the name). To that end, you should define macros like
245 the following in your Mutt configuration (usually one of: F<~/.muttrc>,
246 F</etc/Muttrc>, or a configuration snippet under F</etc/Muttrc.d/>):
247
248     macro index <F8> \
249     "<enter-command>set my_old_pipe_decode=\$pipe_decode my_old_wait_key=\$wait_key nopipe_decode nowait_key<enter>\
250     <shell-escape>notmuch-mutt -r --prompt search<enter>\
251     <change-folder-readonly>`echo ${XDG_CACHE_HOME:-$HOME/.cache}/notmuch/mutt/results`<enter>\
252     <enter-command>set pipe_decode=\$my_old_pipe_decode wait_key=\$my_old_wait_key<enter>" \
253           "notmuch: search mail"
254
255     macro index <F9> \
256     "<enter-command>set my_old_pipe_decode=\$pipe_decode my_old_wait_key=\$wait_key nopipe_decode nowait_key<enter>\
257     <pipe-message>notmuch-mutt -r thread<enter>\
258     <change-folder-readonly>`echo ${XDG_CACHE_HOME:-$HOME/.cache}/notmuch/mutt/results`<enter>\
259     <enter-command>set pipe_decode=\$my_old_pipe_decode wait_key=\$my_old_wait_key<enter>" \
260           "notmuch: reconstruct thread"
261
262     macro index <F6> \
263     "<enter-command>set my_old_pipe_decode=\$pipe_decode my_old_wait_key=\$wait_key nopipe_decode nowait_key<enter>\
264     <pipe-message>notmuch-mutt tag -- -inbox<enter>\
265     <enter-command>set pipe_decode=\$my_old_pipe_decode wait_key=\$my_old_wait_key<enter>" \
266           "notmuch: remove message from inbox"
267
268 The first macro (activated by <F8>) prompts the user for notmuch search terms
269 and then jump to a temporary maildir showing search results. The second macro
270 (activated by <F9>) reconstructs the thread corresponding to the current mail
271 and show it as search results. The third macro (activated by <F6>) removes the
272 tag C<inbox> from the current message; by changing C<-inbox> this macro may be
273 customised to add or remove tags appropriate to the users notmuch work-flow.
274
275 To keep notmuch index current you should then periodically run C<notmuch
276 new>. Depending on your local mail setup, you might want to do that via cron,
277 as a hook triggered by mail retrieval, etc.
278
279 =head1 SEE ALSO
280
281 mutt(1), notmuch(1)
282
283 =head1 AUTHOR
284
285 Copyright: (C) 2011-2012 Stefano Zacchiroli <zack@upsilon.cc>
286
287 License: GNU General Public License (GPL), version 3 or higher
288
289 =cut