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