bc97908e00600ba40538b43fd6fbbe707c2958fa
[notmuch] / contrib / notmuch-mutt / notmuch-mutt
1 #!/usr/bin/perl -w
2 #
3 # notmuch-mutt - notmuch (of a) helper for Mutt
4 #
5 # Copyright: © 2011-2012 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 Getopt::Long qw(:config no_getopt_compat);
16 use Mail::Internet;
17 use Mail::Box::Maildir;
18 use Pod::Usage;
19 use String::ShellQuote;
20 use Term::ReadLine;
21 use Digest::SHA;
22 use File::Which;
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 # Match files by size and SHA-256; then delete duplicates
40 sub builtin_remove_dups($) {
41     my ($maildir) = @_;
42     my (%size_to_files, %sha_to_files);
43
44     # Group files by matching sizes
45     foreach my $file (glob("$maildir/cur/*")) {
46         my $size = -s $file;
47         push(@{$size_to_files{$size}}, $file) if $size;
48     }
49
50     foreach my $same_size_files (values %size_to_files) {
51         # Don't run sha unless there is another file of the same size
52         next if scalar(@$same_size_files) < 2;
53         %sha_to_files = ();
54
55         # Group files with matching sizes by SHA-256
56         foreach my $file (@$same_size_files) {
57             open(my $fh, '<', $file) or next;
58             binmode($fh);
59             my $sha256hash = Digest::SHA->new(256)->addfile($fh)->hexdigest;
60             close($fh);
61
62             push(@{$sha_to_files{$sha256hash}}, $file);
63         }
64
65         # Remove duplicates
66         foreach my $same_sha_files (values %sha_to_files) {
67             next if scalar(@$same_sha_files) < 2;
68             unlink(@{$same_sha_files}[1..$#$same_sha_files]);
69         }
70     }
71 }
72
73 # Use either fdupes or the built-in scanner to detect and remove duplicate
74 # search results in the maildir
75 sub remove_duplicates($) {
76     my ($maildir) = @_;
77
78     my $fdupes = which("fdupes");
79     if ($fdupes) {
80       system("$fdupes --hardlinks --symlinks --delete --noprompt"
81              . " --quiet $maildir/cur/ > /dev/null");
82     } else {
83         builtin_remove_dups($maildir);
84     }
85 }
86
87 # search($maildir, $remove_dups, $query)
88 # search mails according to $query with notmuch; store results in $maildir
89 sub search($$$) {
90     my ($maildir, $remove_dups, $query) = @_;
91     $query = shell_quote($query);
92
93     empty_maildir($maildir);
94     system("notmuch search --output=files $query"
95            . " | sed -e 's: :\\\\ :g'"
96            . " | xargs --no-run-if-empty ln -s -t $maildir/cur/");
97     remove_duplicates($maildir) if ($remove_dups);
98 }
99
100 sub prompt($$) {
101     my ($text, $default) = @_;
102     my $query = "";
103     my $term = Term::ReadLine->new( "notmuch-mutt" );
104     my $histfile = "$cache_dir/history";
105
106     $term->ornaments( 0 );
107     $term->unbind_key( ord( "\t" ) );
108     $term->MinLine( 3 );
109     $histfile = $ENV{MUTT_NOTMUCH_HISTFILE} if $ENV{MUTT_NOTMUCH_HISTFILE};
110     $term->ReadHistory($histfile) if (-r $histfile);
111     while (1) {
112         chomp($query = $term->readline($text, $default));
113         if ($query eq "?") {
114             system("man", "notmuch-search-terms");
115         } else {
116             $term->WriteHistory($histfile);
117             return $query;
118         }
119     }
120 }
121
122 sub get_message_id() {
123     my $mail = Mail::Internet->new(\*STDIN);
124     my $mid = $mail->head->get("message-id") or return undef;
125     $mid =~ /^<(.*)>$/; # get message-id value
126     return $1;
127 }
128
129 sub search_action($$$@) {
130     my ($interactive, $results_dir, $remove_dups, @params) = @_;
131
132     if (! $interactive) {
133         search($results_dir, $remove_dups, join(' ', @params));
134     } else {
135         my $query = prompt("search ('?' for man): ", join(' ', @params));
136         if ($query ne "") {
137             search($results_dir, $remove_dups, $query);
138         }
139     }
140 }
141
142 sub thread_action($$@) {
143     my ($results_dir, $remove_dups, @params) = @_;
144
145     my $mid = get_message_id();
146     if (! defined $mid) {
147         empty_maildir($results_dir);
148         die "notmuch-mutt: cannot find Message-Id, abort.\n";
149     }
150     my $search_cmd = 'notmuch search --output=threads ' . shell_quote("id:$mid");
151     my $tid = `$search_cmd`;    # get thread id
152     chomp($tid);
153
154     search($results_dir, $remove_dups, $tid);
155 }
156
157 sub tag_action(@) {
158     my $mid = get_message_id();
159     defined $mid or die "notmuch-mutt: cannot find Message-Id, abort.\n";
160
161     system("notmuch tag "
162            . shell_quote(join(' ', @_))
163            . " id:$mid");
164 }
165
166 sub die_usage() {
167     my %podflags = ( "verbose" => 1,
168                     "exitval" => 2 );
169     pod2usage(%podflags);
170 }
171
172 sub main() {
173     mkpath($cache_dir) unless (-d $cache_dir);
174
175     my $results_dir = "$cache_dir/results";
176     my $interactive = 0;
177     my $help_needed = 0;
178     my $remove_dups = 0;
179
180     my $getopt = GetOptions(
181         "h|help" => \$help_needed,
182         "o|output-dir=s" => \$results_dir,
183         "p|prompt" => \$interactive,
184         "r|remove-dups" => \$remove_dups);
185     if (! $getopt || $#ARGV < 0) { die_usage() };
186     my ($action, @params) = ($ARGV[0], @ARGV[1..$#ARGV]);
187
188     foreach my $param (@params) {
189       $param =~ s/folder:=/folder:/g;
190     }
191
192     if ($help_needed) {
193         die_usage();
194     } elsif ($action eq "search" && $#ARGV == 0 && ! $interactive) {
195         print STDERR "Error: no search term provided\n\n";
196         die_usage();
197     } elsif ($action eq "search") {
198         search_action($interactive, $results_dir, $remove_dups, @params);
199     } elsif ($action eq "thread") {
200         thread_action($results_dir, $remove_dups, @params);
201     } elsif ($action eq "tag") {
202         tag_action(@params);
203     } else {
204         die_usage();
205     }
206 }
207
208 main();
209
210 __END__
211
212 =head1 NAME
213
214 notmuch-mutt - notmuch (of a) helper for Mutt
215
216 =head1 SYNOPSIS
217
218 =over
219
220 =item B<notmuch-mutt> [I<OPTION>]... search [I<SEARCH-TERM>]...
221
222 =item B<notmuch-mutt> [I<OPTION>]... thread < I<MAIL>
223
224 =item B<notmuch-mutt> [I<OPTION>]... tag [I<TAGS>]... < I<MAIL>
225
226 =back
227
228 =head1 DESCRIPTION
229
230 notmuch-mutt is a frontend to the notmuch mail indexer capable of populating
231 a maildir with search results.
232
233 =head1 OPTIONS
234
235 =over 4
236
237 =item -o DIR
238
239 =item --output-dir DIR
240
241 Store search results as (symlink) messages under maildir DIR. Beware: DIR will
242 be overwritten. (Default: F<~/.cache/notmuch/mutt/results/>)
243
244 =item -p
245
246 =item --prompt
247
248 Instead of using command line search terms, prompt the user for them (only for
249 "search").
250
251 =item -r
252
253 =item --remove-dups
254
255 Remove duplicates from search results.
256
257 =item -h
258
259 =item --help
260
261 Show usage information and exit.
262
263 =back
264
265 =head1 INTEGRATION WITH MUTT
266
267 notmuch-mutt can be used to integrate notmuch with the Mutt mail user agent
268 (unsurprisingly, given the name). To that end, you should define macros like
269 the following in your Mutt configuration (usually one of: F<~/.muttrc>,
270 F</etc/Muttrc>, or a configuration snippet under F</etc/Muttrc.d/>):
271
272     macro index <F8> \
273           "<enter-command>unset wait_key<enter><shell-escape>notmuch-mutt -r --prompt search<enter><change-folder-readonly>~/.cache/notmuch/mutt/results<enter>" \
274           "notmuch: search mail"
275     macro index <F9> \
276           "<enter-command>unset wait_key<enter><pipe-message>notmuch-mutt -r thread<enter><change-folder-readonly>~/.cache/notmuch/mutt/results<enter><enter-command>set wait_key<enter>" \
277           "notmuch: reconstruct thread"
278     macro index <F6> \
279           "<enter-command>unset wait_key<enter><pipe-message>notmuch-mutt tag -- -inbox<enter>" \
280           "notmuch: remove message from inbox"
281
282 The first macro (activated by <F8>) prompts the user for notmuch search terms
283 and then jump to a temporary maildir showing search results. The second macro
284 (activated by <F9>) reconstructs the thread corresponding to the current mail
285 and show it as search results. The third macro (activated by <F6>) removes the
286 tag C<inbox> from the current message; by changing C<-inbox> this macro may be
287 customised to add or remove tags appropriate to the users notmuch work-flow.
288
289 To keep notmuch index current you should then periodically run C<notmuch
290 new>. Depending on your local mail setup, you might want to do that via cron,
291 as a hook triggered by mail retrieval, etc.
292
293 =head1 SEE ALSO
294
295 mutt(1), notmuch(1)
296
297 =head1 AUTHOR
298
299 Copyright: (C) 2011-2012 Stefano Zacchiroli <zack@upsilon.cc>
300
301 License: GNU General Public License (GPL), version 3 or higher
302
303 =cut