]> git.notmuchmail.org Git - notmuch/blob - contrib/notmuch-mutt/notmuch-mutt
b38258f5772a7808889e5f1c1c9cd0ea61c76187
[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
128     $mid =~ s/ //g; # notmuch strips spaces before storing Message-Id
129     $mid =~ s/"/""/g; # escape all double quote characters
130
131     my $search_cmd = 'notmuch search --output=threads ' . shell_quote(qq{id:"$mid"});
132     my $tid = `$search_cmd`;    # get thread id
133     chomp($tid);
134
135     search($results_dir, $remove_dups, $tid);
136 }
137
138 sub tag_action(@) {
139     my $mid = get_message_id();
140     defined $mid or die "notmuch-mutt: cannot find Message-Id, abort.\n";
141
142     $mid =~ s/ //g; # notmuch strips spaces before storing Message-Id
143     $mid =~ s/"/""/g; # escape all double quote characters
144
145     system("notmuch", "tag", @_, "--", qq{id:"$mid"});
146 }
147
148 sub die_usage() {
149     my %podflags = ( "verbose" => 1,
150                     "exitval" => 2 );
151     pod2usage(%podflags);
152 }
153
154 sub main() {
155     mkpath($cache_dir) unless (-d $cache_dir);
156
157     my $results_dir = "$cache_dir/results";
158     my $interactive = 0;
159     my $help_needed = 0;
160     my $remove_dups = 0;
161
162     my $getopt = GetOptions(
163         "h|help" => \$help_needed,
164         "o|output-dir=s" => \$results_dir,
165         "p|prompt" => \$interactive,
166         "r|remove-dups" => \$remove_dups);
167     if (! $getopt || $#ARGV < 0) { die_usage() };
168     my ($action, @params) = ($ARGV[0], @ARGV[1..$#ARGV]);
169
170     foreach my $param (@params) {
171       $param =~ s/folder:=/folder:/g;
172     }
173
174     if ($help_needed) {
175         die_usage();
176     } elsif ($action eq "search" && $#ARGV == 0 && ! $interactive) {
177         print STDERR "Error: no search term provided\n\n";
178         die_usage();
179     } elsif ($action eq "search") {
180         search_action($interactive, $results_dir, $remove_dups, @params);
181     } elsif ($action eq "thread") {
182         thread_action($results_dir, $remove_dups, @params);
183     } elsif ($action eq "tag") {
184         tag_action(@params);
185     } else {
186         die_usage();
187     }
188 }
189
190 main();
191
192 __END__
193
194 =head1 NAME
195
196 notmuch-mutt - notmuch (of a) helper for Mutt
197
198 =head1 SYNOPSIS
199
200 =over
201
202 =item B<notmuch-mutt> [I<OPTION>]... search [I<SEARCH-TERM>]...
203
204 =item B<notmuch-mutt> [I<OPTION>]... thread < I<MAIL>
205
206 =item B<notmuch-mutt> [I<OPTION>]... tag [I<TAGS>]... < I<MAIL>
207
208 =back
209
210 =head1 DESCRIPTION
211
212 notmuch-mutt is a frontend to the notmuch mail indexer capable of populating
213 a maildir with search results.
214
215 =head1 OPTIONS
216
217 =over 4
218
219 =item -o DIR
220
221 =item --output-dir DIR
222
223 Store search results as (symlink) messages under maildir DIR. Beware: DIR will
224 be overwritten. (Default: F<~/.cache/notmuch/mutt/results/>)
225
226 =item -p
227
228 =item --prompt
229
230 Instead of using command line search terms, prompt the user for them (only for
231 "search").
232
233 =item -r
234
235 =item --remove-dups
236
237 Remove emails with duplicate message-ids from search results.  (Passes
238 --duplicate=1 to notmuch search command.)  Note this can hide search
239 results if an email accidentally or maliciously uses the same message-id
240 as a different email.
241
242 =item -h
243
244 =item --help
245
246 Show usage information and exit.
247
248 =back
249
250 =head1 INTEGRATION WITH MUTT
251
252 notmuch-mutt can be used to integrate notmuch with the Mutt mail user agent
253 (unsurprisingly, given the name). To that end, you should define macros like
254 the following in your Mutt configuration (usually one of: F<~/.muttrc>,
255 F</etc/Muttrc>, or a configuration snippet under F</etc/Muttrc.d/>):
256
257     macro index <F8> \
258     "<enter-command>set my_old_pipe_decode=\$pipe_decode my_old_wait_key=\$wait_key nopipe_decode nowait_key<enter>\
259     <shell-escape>notmuch-mutt -r --prompt search<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: search mail"
263
264     macro index <F9> \
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 -r thread<enter>\
267     <change-folder-readonly>`echo ${XDG_CACHE_HOME:-$HOME/.cache}/notmuch/mutt/results`<enter>\
268     <enter-command>set pipe_decode=\$my_old_pipe_decode wait_key=\$my_old_wait_key<enter>" \
269           "notmuch: reconstruct thread"
270
271     macro index <F6> \
272     "<enter-command>set my_old_pipe_decode=\$pipe_decode my_old_wait_key=\$wait_key nopipe_decode nowait_key<enter>\
273     <pipe-message>notmuch-mutt tag -- -inbox<enter>\
274     <enter-command>set pipe_decode=\$my_old_pipe_decode wait_key=\$my_old_wait_key<enter>" \
275           "notmuch: remove message from inbox"
276
277 The first macro (activated by <F8>) prompts the user for notmuch search terms
278 and then jump to a temporary maildir showing search results. The second macro
279 (activated by <F9>) reconstructs the thread corresponding to the current mail
280 and show it as search results. The third macro (activated by <F6>) removes the
281 tag C<inbox> from the current message; by changing C<-inbox> this macro may be
282 customised to add or remove tags appropriate to the users notmuch work-flow.
283
284 To keep notmuch index current you should then periodically run C<notmuch
285 new>. Depending on your local mail setup, you might want to do that via cron,
286 as a hook triggered by mail retrieval, etc.
287
288 =head1 SEE ALSO
289
290 mutt(1), notmuch(1)
291
292 =head1 AUTHOR
293
294 Copyright: (C) 2011-2012 Stefano Zacchiroli <zack@upsilon.cc>
295
296 License: GNU General Public License (GPL), version 3 or higher
297
298 =cut