From 4d35d00f7766272c3d8da1532003fc9caa160717 Mon Sep 17 00:00:00 2001 From: dakkar Date: Thu, 16 Apr 2009 20:42:26 +0200 Subject: first version --- document.rest.txt | 209 +++++++++++++++++++++++++++++ maildir-indexer.pl | 388 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 597 insertions(+) create mode 100644 document.rest.txt create mode 100644 maildir-indexer.pl 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 + +=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. + +=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($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($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 + + @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 = id_from_file($dir,$file); + +Returns the message id of the C<$file> in the C<$dir>. Returns +C 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 + + @ids = split_refs($header_field_value); + +Extracts the message ids from the value of a header file such as +C or C. + +=cut + +sub split_refs { + my ($str)=@_; + return unless $str; + + $str=~s{^.*<}{};$str=~s{>.*$}{}; + return split />.*? + + $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 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 + + 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 + +Using L, 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 + +Parses the output from C. For the events C and +C, adds entries to the index. For the events C and +C, 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 + +Opens a listening socket on C. Whenever a connection +is received, executes C 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 + +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 in a thread + +=item * + +start C in another thread + +=item * + +start C in the main thread + +=back + +=cut + +threads->create(\&watch_directory)->detach; +threads->create(\&scan_directory)->detach; +server(); -- cgit v1.2.3