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