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