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