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