summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--document.rest.txt209
-rw-r--r--maildir-indexer.pl388
2 files changed, 597 insertions, 0 deletions
diff --git a/document.rest.txt b/document.rest.txt
new file mode 100644
index 0000000..2fabe97
--- /dev/null
+++ b/document.rest.txt
@@ -0,0 +1,209 @@
+==================================================================
+ Un sistema automatico di incartellamento della posta elettronica
+==================================================================
+
+ovvero, come io gestisco la mia posta elettronica
+=================================================
+
+:Author: dakkar@thenautilus.net
+:Date: 2009-01-10
+
+Il mio archivio di posta elettronica conta, al momento, 74711
+messaggi. Sono iscritto a circa 40 mailing list (per la maggior parte
+di esse non tengo copia di tutti i messaggi). Come faccio a
+raccapezzarmici?
+
+Innanzi tutto, ogni mailing list ha una sua cartella, e |procmail|_ è
+sufficiente per gestire questa parte; ad esempio, per mandare nella
+cartella apposita i messaggi della lista `mongers`::
+
+ # mongers
+ :0:
+ * (^From|^TO|List-Id:).*mongers[@.](lists\.)?perl\.it
+ .mail.Perl.mongers/
+
+Come potreste notare, se aveste perso un eccesso di tempo a studiare
+|procmail|, la mia posta è archiviata in formato |maildir|_.
+
+Altre regole |procmail| mettono in cartelle apposite i messaggi
+provenienti da mittenti particolari (gente che conosco, negozi, etc).
+
+Accedo alla posta tramite |dovecot|_ (server) e |claws|_ (client).
+
+*Non* uso una cartella "messaggi inviati": |claws| è configurato in
+modo da salvare i messaggi che scrivo nella cartella che "sto
+guardando" [#savehere]_. In pratica, il risultato è che le mie
+risposte stanno nella stessa cartella dei messaggi a cui
+rispondono. In questo modo ho sempre i thread *interamente* visibili.
+
+.. [#savehere] Tasto destro sulla radice delle cartelle (nella lista
+ cartelle), "Properties…", sezione "Compose", marcare "Save copy of
+ outgoing messages to this folder instead of Sent", marcare anche il
+ chekcbox a destra ("Apply to subfolders"), pulsante "Apply".
+
+A questo punto, se le mie cartelle fossero tutte e sole quelle in cui
+|procmail| mette i messaggi, sarei a posto. "Peccato" che mi servano
+anche cartelle di altro genere: ad esempio, per argomento. I messaggi
+finiscono in queste cartelle solo perché ce li sposto io, manualmente,
+dal client. Per completare l'automatizzazione del tutto, manca un
+componente: un sistema per cui un messaggio di risposta che arriva,
+finisca nella stessa cartella in cui sta il messaggio a cui risponde,
+anche se quest'ultimo è stato spostato a mano.
+
+Incartellamento delle risposte
+------------------------------
+
+Come dice la |rfc2822|_, se un messaggio è una risposta, dovrà avere
+un campo (nell'header) ``In-Reply-To:`` il cui valore sia il "message id" del
+messaggio a cui sta rispondendo. Inoltre, il campo ``References:``
+contiene i "message id" di tutta una sequenza di messaggi del thread
+[#thread]_.
+
+.. [#thread] Sì, lo so che è un po' più complicato di così, ma questa
+ è un'approssimazione sufficiente per i nostri fini. Chi vuole i
+ dettagli può leggersi la |rfc2822|_ e |jwz|_.
+
+Di conseguenza, per sapere in quale cartella mettere un messaggio,
+basta trovare in quale cartella stia il messaggio il cui campo
+``Message-ID:`` abbia lo stesso valore di uno dei "message id"
+riferiti nel messaggio in arrivo. Ovviamente, se il messaggio in
+arrivo riferisce più di un messaggio, potremmo avere una situazione
+ambigua; per fortuna, per come sono usati di solito i campi dei
+riferimenti, l'ambiguità si risolve esaminando prima il valore in
+``In-Reply-To:``, poi quelli in ``References:`` dall'ultimo al primo
+(al solito, i dettagli sono nella |rfc2822|). Che esista un solo
+messaggio con un dato "message id" è vero a meno che io non abbia due
+copie di uno stesso messaggio; possiamo ignorare il problema.
+
+Un'implementazione banale consisterebbe in poco più che un ``grep``
+ricorsivo. Funziona, ma è un po' lenta: rischia di impiegare svariati
+secondi, e andrebbe eseguita per ciascun messaggio che arriva.
+
+Un metodo più furbo sta nel tenersi un indice che mappi i "message id"
+alle cartelle. Per tenerlo aggiornato, però, dobbiamo tenere conto che
+nuovi messaggi vengono aggiunti (o perché arrivano, o perché sono
+copie dei messaggi che io invio) e quelli esistenti spostati. Ci serve
+un modo per intercettare questi cambiamenti, e aggiornare l'indice.
+
+Il problema di notare modifiche in una cartella è tanto comune (serve,
+ad esempio, ai file manager) che Linux fornisce una soluzione a
+livello di kernel: |inotify|_. Siccome usarlo non è esattamente
+banale, mi sono appoggiato a |inotify-tools|_.
+
+A questo punto ho tutti i pezzi che servono per scrivere il server che
+gestisca l'indice dei messaggi:
+
+- all'inizio, il server si scandisce l'archivio di posta e si segna in
+ quale cartella sta ciascun "message id"
+- nel frattempo, usa ``inotifywait`` (parte di |inotify-tools|) per
+ tenere traccia delle modifiche
+- il server ascolta su una socket, e quando gli viene inviato l'header
+ di un messaggio, ne estrae i campi dei riferimenti, e risponde con
+ il nome della cartella in cui deve andare questo messaggio (o nulla,
+ se il messaggio non è una risposta, o non abbiamo il messaggio cui
+ risponde)
+
+Avendo questo, si scrive un semplice client::
+
+ #!/bin/bash
+ netcat localhost 9000 | tr -d '\015\012'
+
+e due regole |procmail|::
+
+ :0 hi
+ AUTOFOLDER=| ~/bin/find_folder
+
+ :0
+ * AUTOFOLDER ?? ^\.mail\.
+ $AUTOFOLDER/
+
+e i messaggi finiscono automaticamente dove devono.
+
+.. warning:: |procmail| e ``Out of memory``
+
+ Alcune versioni di |procmail| (di sicuro la 3.22-r7 distribuita in
+ Gentoo) hanno un bug che causa un'allocazione esagerata di memoria
+ quando si tenta di catturare l'output di un programma in una
+ variabile (come nelle regole precedenti). Se vi capita, controllate
+ se la vostra distribuzione ha una versione corretta; altrimenti,
+ potete sempre applicare la patch_.
+
+Come è fatto il server
+----------------------
+
+Dalla descrizione del server si può notare come esso debba occuparsi
+di svariate funzioni contemporaneamente: scandire l'archivio, ricevere
+le notifiche di cambiamenti, rispondere alle richieste del client. In
+Perl ci sono sostanzialmente due strategie per affrontare il problema:
+
+* gestione asincrona degli eventi (ad esempio con |poe|_)
+* multi-threading
+
+A me i thread, pure con i piccoli problemi che hanno in Perl,
+risultano più "naturali". Ho perciò strutturato il server in questo
+modo:
+
+- un thread riceve le notifiche da ``inotifywait``, e aggiorna l'indice
+- un thread scandisce l'archivio, e aggiorna l'indice (questo thread
+ termina una volta scandito tutto l'indice)
+- il thread principale si sospende sulla socket
+- quando arriva una richiesta sulla socket, un nuovo thread viene
+ creato per servirla
+
+Il programma_ non è molto complesso. Il grosso del lavoro è fatto da
+|email-simple|_ (per estrarre i campi dai messaggi) e |file-next|_
+(per scandire l'archivio).
+
+Un paio di note sulle strutture dati: per gestire correttamente
+aggiunte, rimozioni e spostamenti, e non confondersi con le manovre
+che fa |dovecot| [#manovre]_, uso due hash: ``%files2id`` mappa dalla
+coppia mailbox-nomefile al "message id" (ovvero,
+``$files2id{$mailbox}->{$file}=$id``); ``%id2mailbox`` mappa da un
+"message id" a una *lista* di mailbox. Sì, una lista: questo sia per
+non avere errori in caso di messaggi duplicati, sia per le menzionate
+manovre di |dovecot|. Il server restituisce sempre l'ultima mailbox
+nella lista, che è tenuta in ordine cronologico.
+
+Inoltre, siccome queste strutture sono lette e scritte da thread
+diversi, è necessario assicurare la mutua esclusione degli accessi
+tramite lock.
+
+.. [#manovre] Ad esempio, per spostare un messaggio, prima lo duplica
+ nella cartella di destinazione, poi lo cancella da quella di
+ origine.
+
+Altro materiale
+---------------
+
+* il programma_, colorato da |ppi|
+* il sorgente_ nudo e crudo
+* il pod_
+
+.. |procmail| replace:: ``procmail``
+.. _procmail: http://www.procmail.org/
+.. |maildir| replace:: ``MailDir++``
+.. _maildir: http://www.inter7.com/courierimap/README.maildirquota.html
+.. |dovecot| replace:: ``dovecot``
+.. _dovecot: http://www.dovecot.org/
+.. |claws| replace:: `Claws Mail`
+.. _claws: http://www.claws-mail.org/
+.. |rfc2822| replace:: `RFC` 2822
+.. _rfc2822: http://www.faqs.org/rfcs/rfc2822.html
+.. |jwz| replace:: l'algoritmo di threading di Jamie Zawinski
+.. _jwz: http://www.jwz.org/doc/threading.html
+.. |inotify| replace:: ``inotify``
+.. _inotify: http://en.wikipedia.org/wiki/Inotify
+.. |inotify-tools| replace:: ``inotify-tools``
+.. _`inotify-tools`: http://inotify-tools.sourceforge.net/
+.. |poe| replace:: ``POE``
+.. _poe: http://search.cpan.org/dist/POE/
+.. |email-simple| replace:: ``Email::Simple``
+.. _`email-simple`: http://search.cpan.org/dist/Email-Simple/
+.. |file-next| replace:: ``File::Next``
+.. _`file-next`: http://search.cpan.org/dist/File-Next/
+.. |ppi| replace:: ``PPI::HTML``
+.. _ppi: http://search.cpan.org/dist/PPI-HTML/
+.. _programma: maildir-indexer.pl.html
+.. _sorgente: maildir-indexer.pl
+.. _pod: maildir-indexer.pod.html
+.. _patch: http://bugs.gentoo.org/200006
diff --git a/maildir-indexer.pl b/maildir-indexer.pl
new file mode 100644
index 0000000..1a7a4fc
--- /dev/null
+++ b/maildir-indexer.pl
@@ -0,0 +1,388 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+use threads;
+use threads::shared;
+use IO::Socket::INET;
+use File::Next;
+use Email::Simple;
+
+=head1 NAME
+
+maildir-indexer.pl
+
+=head1 AUTHOR
+
+Gianni Ceccarelli <dakkar@thenautilus.net>
+
+=head1 COPYRIGHT & LICENSE
+
+Copyright 2009 Gianni Ceccarelli
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or (at
+your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+For a copy of the GNU General Public License, see
+L<http://www.gnu.org/licenses/>.
+
+=cut
+
+{
+
+=head1 Index handling
+
+C<%files2id> maps a mailbox-file pair to a message id
+(C<$files2id{$mailbox}->{$file}=$id>).
+
+C<%id2mailbox> maps a message id to a list of mailboxes.
+
+These hashes are private to the index handling section: the following
+function must be used to manipulate the index.
+
+=cut
+
+my %files2id : shared;
+my %id2mailbox : shared;
+
+=head2 C<add_file>
+
+ add_file($mailbox,$file,$id);
+
+Adds an entry to the index. Takes care of locking, and of sharing
+newly-created hashes and arrays.
+
+=cut
+
+sub add_file {
+ my ($mailbox,$file,$id)=@_;
+ lock(%files2id);
+
+ # Auto-vivified references are not shared, we must share them explicitly.
+ # See threads::shared for an explanation of the '&share' call style.
+
+ $files2id{$mailbox}||=&share({});
+ $files2id{$mailbox}->{$file}=$id;
+
+ $id2mailbox{$id}||=&share([]);
+ push @{$id2mailbox{$id}},$mailbox;
+
+ return;
+}
+
+=head2 C<del_file>
+
+ del_file($mailbox,$file);
+
+Removes an entry from the index. Takes care of locking, and of
+removing empty hashes and arrays.
+
+=cut
+
+sub del_file {
+ my ($mailbox,$file)=@_;
+ lock(%files2id);
+
+ return unless exists $files2id{$mailbox};
+ return unless exists $files2id{$mailbox}->{$file};
+ my $id=delete $files2id{$mailbox}->{$file};
+
+ return unless exists $id2mailbox{$id};
+ # remove the mailbox, keeping the order
+ @{$id2mailbox{$id}}=grep {$_ ne $mailbox} @{$id2mailbox{$id}};
+ delete $id2mailbox{$id} unless @{$id2mailbox{$id}};
+
+ return;
+}
+
+=head2 C<get_mailboxes>
+
+ @mailboxes = get_mailboxes($id);
+
+Returns all the mailboxes that contain a message with the given
+message id, in roughly chronological order. Can return an empty list.
+
+=cut
+
+sub get_mailboxes {
+ my ($id)=@_;
+ lock(%files2id);
+
+ return unless exists $id2mailbox{$id};
+ return @{$id2mailbox{$id}};
+}
+
+}
+
+=head1 Message & mailbox parsing
+
+=head2 C<id_from_file>
+
+ $id = id_from_file($dir,$file);
+
+Returns the message id of the C<$file> in the C<$dir>. Returns
+C<undef> if the file can't be read, or it does not contain a message
+id.
+
+=cut #'
+
+sub id_from_file {
+ my ($dir,$file)=@_;
+
+ # we might use Email::Simple here, but it's useless to load
+ # and parse the entire message
+
+ open my $fh,'<',"$dir/$file" or return;
+ while (my $line=<$fh>) {
+ $line=~s{[\x0d\x0a]+\z}{};
+ return unless length($line); # end of headers, no messageid found
+ $line =~ m{^Message-ID: \s* <(\S+?)>}xi and return $1;
+ }
+ return;
+}
+
+=head2 C<split_refs>
+
+ @ids = split_refs($header_field_value);
+
+Extracts the message ids from the value of a header file such as
+C<In-Reply-To:> or C<References:>.
+
+=cut
+
+sub split_refs {
+ my ($str)=@_;
+ return unless $str;
+
+ $str=~s{^.*<}{};$str=~s{>.*$}{};
+ return split />.*?</,$str;
+}
+
+=head2 C<mailbox_from_path>
+
+ $mailbox = mailbox_from_path($dir);
+
+Extracts the mailbox name from a directory name. Assumes that all
+mailboxes are under a C<.maildir/> directory. Returns C<undef> if the
+directory name can't be parsed.
+
+=cut #'
+
+sub mailbox_from_path {
+ my ($dir)=@_;
+
+ # /home/dakkar/.maildir/.mail.Personal.Dakkar/cur
+ # ->
+ # .mail.Personal.Dakkar
+ $dir=~m{/.maildir/(.*?)/(?:cur|new|tmp)} and return $1;
+ return;
+}
+
+=head2 C<ignorable>
+
+ if (ignorable($mailbox)) { ... }
+
+Returns whether a mailbox can be safely ignored by the indexer. There
+are two ignorable mailboxes: C<.Trash> and C<.mail.SPAM>.
+
+=cut
+
+sub ignorable {
+ return $_[0] =~ m{(?:^|/)(?:\.mail\.SPAM|\.Trash)(?:/|$)};
+}
+
+=head1 Scanning the mail archive
+
+=head2 C<scan_directory>
+
+Using L<File::Next>, scans C<$ENV{HOME}/.maildir> for message files,
+and adds them to the index.
+
+=cut
+
+sub scan_directory {
+ my $files=File::Next::files({
+ file_filter => sub {
+ # /cur/ and /new/ are the only interesting directories in a maildir
+ ($File::Next::dir =~ m{/(?:cur|new)(?:/|$)})
+ and
+ # we ignore dovecot's files
+ ($_ !~ m{^dovecot});
+ },
+ descend_filter => sub {
+ # ignorable mailboxes are ignored
+ !ignorable($_);
+ },
+ follow_symlinks=>0,
+ }, "$ENV{HOME}/.maildir");
+
+ my ($dir,$file,$fullpath,$mailbox,$id);
+ while (($dir,$file,$fullpath)=$files->()) {
+ $mailbox=mailbox_from_path($dir);
+ next unless defined $mailbox; # might not be a real mailbox
+ next if ignorable($mailbox); # should not happen
+ $id=id_from_file($dir,$file);
+ next unless defined $id; # might not be a message, or have disappeared
+ add_file($mailbox,$file,$id);
+ }
+
+ return;
+}
+
+{
+
+=head1 Watching the mail archive for changes
+
+=cut
+
+my $child_pid : shared;
+# cleanup action
+END {
+ kill 2,$child_pid if $child_pid;
+}
+
+=head2 C<watch_directory>
+
+Parses the output from C<inotifywait>. For the events C<CREATED> and
+C<MOVED_TO>, adds entries to the index. For the events C<DELETED> and
+C<MOVED_FROM>, removes entries from the index.
+
+=cut
+
+sub watch_directory {
+ # see inotifywait(1)
+ $child_pid=
+ open my $inotify,'-|',
+ qw(inotifywait -q -m -r
+ --exclude (^|/)(dovecot|tmp/).*
+ -c
+ -e move -e delete -e create),
+ "$ENV{HOME}/.maildir"
+ or die "Can't start inotifywait: $!";
+
+ while (my $line=<$inotify>) {
+ chomp($line);
+
+ # these lines are a naif parser for the CSV output of inotifywait
+ my ($dir,$event,$file)=
+ ($line =~ m{^ ( (?:".*?") | [^,]* ) ,
+ ( (?:".*?") | [^,]* ) ,
+ ( (?:".*?") | [^,]* ) $ }smx );
+ s{(?:\A")|(?:"\z)}{}g for $dir,$event,$file;
+
+ my $mailbox=mailbox_from_path($dir);
+ next unless defined $mailbox; # might not be a mailbox event
+ next if ignorable($mailbox); # ignore ignorable mailboxes
+
+ if ($event =~ /CREATE|MOVED_TO/) {
+ # a file has appeared: get the message id
+ my $id=id_from_file($dir,$file);
+ if (defined $id) {
+ # it is a proper message, add it to the index
+ add_file($mailbox,$file,$id);
+ }
+ }
+ elsif ($event =~ /DELETE|MOVED_FROM/) {
+ # a file has disappeared: remove it from the index
+ del_file($mailbox,$file);
+ }
+ }
+}
+
+}
+
+=head1 Serving requests
+
+=head2 C<server>
+
+Opens a listening socket on C<localhost:9000>. Whenever a connection
+is received, executes C<handle_client> in a separate thread.
+
+=cut
+
+sub server {
+ my $serv_sock=IO::Socket::INET->new(
+ Listen=>10,
+ LocalAddr=>'127.0.0.1',
+ LocalPort=>9000,
+ Proto=>'tcp',
+ ReuseAddr=>1,
+ );
+ while (my $other_sock=$serv_sock->accept) {
+ threads->create(\&handle_client,$other_sock)->detach;
+ }
+}
+
+=head2 C<handle_client>
+
+This funtction expects a mail message from the socket. The message is
+parsesd, and possible references extracted. If the are references, and
+one of them is known to the index, the mailbox name of such a
+reference is printed to the socket.
+
+=cut
+
+sub handle_client {
+ my ($client)=@_;
+
+ # we get a message header, followed by a blank line
+ my $header;
+ while (my $line=<$client>) {
+ $line=~s{[\x0d\x0a]+\z}{\x0d\x0a};
+ $header.=$line;
+ last if $line eq "\x0d\x0a";
+ }
+
+ my $header_obj=Email::Simple->new(\$header)->header_obj;
+ # In-Reply-To is the most trustworthy
+ # References are listed in chronological order
+ # @refs ends up with the ids in decreasing order of importance
+ my @refs=(split_refs($header_obj->header('In-Reply-To')),
+ reverse(split_refs($header_obj->header('References'))));
+
+ my @mailboxes;
+ for my $id (@refs) {
+ @mailboxes=get_mailboxes($id);
+ if (@mailboxes) {
+ # we know where a message is! tell the client
+ # (we only print the most recent mailbox)
+ # the client may have disconnected: printing to a
+ # closed socket makes the server die
+ print {$client} "$mailboxes[-1]\x0d\x0a"
+ if $client->connected;
+
+ return;
+ }
+ }
+ return;
+}
+
+=head1 Main program
+
+=over 4
+
+=item *
+
+start C<watch_directory> in a thread
+
+=item *
+
+start C<scan_directory> in another thread
+
+=item *
+
+start C<server> in the main thread
+
+=back
+
+=cut
+
+threads->create(\&watch_directory)->detach;
+threads->create(\&scan_directory)->detach;
+server();