/ maildrop

Maildrop Revisited

Es ist gerade mal eine Woche her, dass ich über mein maildrop-Setup auf Uberspace geschrieben habe. In der Zwischenzeit habe ich allerdings meine Skripte ein wenig aufgeräumt und erweitert.

Insbesondere habe ich auch DSPAM so mit eingebunden, dass Spam und Ham nach dem Neulernen nochmal richtig zugestellt werden. Anders als im Uberspace-Wiki muss man also nicht daran denken, Ham in den Trainings-Ordner zu kopieren anstatt ihn zu verschieben.

Aber der Reihe nach.


Filterfile 2.0

Nach der man-page zur maildrop-Filtersprache ist das Verzeichnis ~/.mailfilters/ eigentlich für den sogenannten embedded mode vorgesehen, in dem maildrop die Mails nicht selber zustellt sondern nur für andere Programme das Filtern übernimmt.

Niemand hindert einen jedoch daran, hier Skripte unterzubringen, die dann per include in den zentralen .mailfilter eingebunden werden können. Auf diese Weise habe ich mein Setup modularisiert:

checkmaildir

Dieses Skript ermittelt wie gewohnt das Mailverzeichnis des Empfängers.

# set default Maildir
MAILDIR="$HOME/Maildir"

# check if we're called from a .qmail-EXT instead of .qmail
import EXT
if ( $EXT )
{
  # does a vmailmgr user named $EXT exist?
  # if yes, deliver mail to his Maildir instead
  CHECKMAILDIR = `dumpvuser $EXT | grep '^Directory' | awk '{ print $2 }'`
  if ( $CHECKMAILDIR )
  {
    MAILDIR="$HOME/$CHECKMAILDIR"
  }
}
writelog

Wenn gewünscht, sorgt das folgende Skript dafür, dass alle ausgelieferten Mails in ~/var/log/mailfilter{-<mailboxname>} protokolliert werden.

# set up logging if desired
if ( $WRITELOG )
{
  LOGFILENAME="$HOME/var/log/mailfilter.log"

  # change logfile name if we've been called from .qmail-EXT
  import EXT
  if ( $EXT )
  {
    LOGFILENAME="$HOME/var/log/mailfilter-$EXT.log"
  }
  
  logfile "$LOGFILENAME"
}
handlespam

Das Spam-Handling erfolgt zunächst einmal zentral, um die Mails nach dem Training neu ausliefern zu können.

Wenn die Umgebungsvariable REDELIVER_SPAM gesetzt ist, wird die Mail direkt in den Spamordner verschoben. Wenn hingegen REDELIVER_HAM gesetzt ist, wird die Mail an den Spamfiltern vorbei direkt an die Filterregeln weitergereicht. Auf diese Weise landen auch false positives zu guter Letzt im richtigen Ordner.

# Handle Spam. Take care of re-trained messages from dspam-learn.
import REDELIVER_SPAM
if ( $REDELIVER_SPAM )
{
  DESTDIR="$MAILDIR/.Spam"
  SILENT_DESTDIR=$SILENT_SPAM
  include "$HOME/.mailfilters/deliver"
}

import REDELIVER_HAM
if ( ! $REDELIVER_HAM )
{
  if ( $SPAMASSASSIN_ENABLE )
  {
    # Show mail to SpamAssassin
    include "$HOME/.mailfilters/spamassassin"
  }

  if ( $DSPAM_ENABLE )
  {
    # Show mail to DSPAM
    include "$HOME/.mailfilters/dspam"
  }
}
spamassassin

Dieses Modul lässt die Mail vom SpamAssassin begutachten und verschiebt die Mail bei Bedarf in das Spam-Verzeichnis.

# show the mail to SpamAssassin
xfilter "/usr/bin/spamc"

# process SPAM
if ( /^X-Spam-Level: \*{$SPAMASSASSIN_MINSPAMSCORE,}$/ )
{
  DESTDIR="$MAILDIR/.Spam"
  SILENT_DESTDIR=$SILENT_SPAM
  include "$HOME/.mailfilters/deliver"
}
dspam

Das DSPAM-Modul funktioniert genau so, nur dass hier noch zusätzlich bei Bedarf die Trainingsverzeichnisse angelegt werden. Im Gegensatz zum Uberspace-Wiki habe ich mich hier für englische Ordnernamen entschieden. Außerdem landet verdächtige Mail nicht in einem weiteren Unterordner, sondern direkt im Spamfilter-Ordner.

# check folder structure
`test -d "$MAILDIR/.Spam"`
if( $RETURNCODE == 1 )
{
  `maildirmake "$MAILDIR/.Spam"`
}
`test -d "$MAILDIR/.Spam.Learn as Ham"`
if( $RETURNCODE == 1 )
{
  `maildirmake "$MAILDIR/.Spam.Learn as Ham"`
}
`test -d "$MAILDIR/.Spam.Learn as Spam"`
if( $RETURNCODE == 1 )
{
  `maildirmake "$MAILDIR/.Spam.Learn as Spam"`
}

# show the mail to DSPAM
xfilter "/package/host/localhost/dspam/bin/dspam --mode=teft --deliver=innocent,spam --stdout"

# process SPAM
if ( /^X-DSPAM-Result: Spam/ )
{
  DESTDIR="$MAILDIR/.Spam"
  SILENT_DESTDIR=$SILENT_SPAM
  include "$HOME/.mailfilters/deliver"
}
deliver

Fehlt noch die eigentliche Zustellung. Hier wird je nach Bedarf das Zielverzeichnis angelegt, und die Zustellung kann auch still erfolgen:

# Hierarchically create DESTDIR if it does not already exist.
HIER="$DESTDIR"
CONTINUE=1
while ( $CONTINUE && $HIER =~ /^(.*\/(\.[^.]+)*)(\.[^.]+)$/ )
{
  if ( $MATCH3 ne ".INBOX" )
  {
    `test -d "$MATCH"`
    if ( $RETURNCODE == 1 )
    {
      `maildirmake "$MATCH"`
    }
    else
    {
      CONTINUE=0
    }
  }
  HIER=$MATCH1
}

# Deliver mail. If SILENT_DESTDIR is set, mark all mail in
# DESTDIR as read.
if ( $SILENT_DESTDIR )
{
  # mark as read
  cc "$DESTDIR"
  `find "$DESTDIR/new/" -mindepth 1 -maxdepth 1 -type f -printf '%f\0' | xargs -0 -I {} mv "$DESTDIR/new/{}" "$DESTDIR/cur/{}:2,S"`
  exit
}
else
{
  to "$DESTDIR"
}

Dieses Skript beendet auf jeden Fall den aktuellen Skriptdurchlauf, entweder per exit, nachdem die Mail mit cc zugestellt und als gelesen markiert wurde, oder per regulärer Zustellung mit to. Aus der maildropfilter man-page:

The to statement is the final delivery statement. maildrop delivers message, then immediately terminates, [...]

Danke an LDer für den Hinweis, dass dieses Verhalten in der ursprünglichen Version des Artikels zu kurz gekommen war!

~/.mailfilter-<mailboxname>

Während alle vorangegangenen Helfer-Skripte in ~/.mailfilters/ landen, kommt das eigentliche Filterfile direkt ins Hauptverzeichnis. Durch den modularen Aufbau wird das Grundgerüst relativ kurz:

# Configuration

WRITELOG="0"
SPAMASSASSIN_ENABLE="1"
SPAMASSASSIN_MINSPAMSCORE="5"
DSPAM_ENABLE="1"
SILENT_SPAM="0"

# ------------------------------------------------------

# Write log file, if desrired. Set Maildir. Handle Spam.
include "$HOME/.mailfilters/writelog"
include "$HOME/.mailfilters/checkmaildir"
include "$HOME/.mailfilters/handlespam"


# Set default destination Maildir
DESTDIR="$MAILDIR"
SILENT_DESTDIR="0"

# ------------------------------------------------------

# Here go the filter rules...

# ------------------------------------------------------

# Finally, deliver mail
include "$HOME/.mailfilters/deliver"

Ganz oben lässt sich das Verhalten der Helferskripte konfigurieren. Für die Schalter gilt dabei: Werte ungleich Null sind true, Null oder nicht gesetzt false. Im gekennzeichneten Mittelteil lassen sich dann unter anderem die im letzten Artikel vorgeschlagenen Filterregeln unterbringen.

Ein kleiner Nachteil der Modularisierung sei nicht verschwiegen: Normalerweise macht maildrop beim Start eine Syntaxprüfung des Filterfiles und steigt gegebenenfalls. sofort mit einer Fehlermeldung aus. Dies trifft aber nicht auf mit include eingebundene Skripte zu. Die werden erst geladen und überprüft, wenn sie tatsächlich gebraucht werden. Das kann das Debugging erschweren und zu seltsamen Effekten führen.


dspam-learn anpassen

Jetzt müssen wir nur noch dafür sorgen, dass dspam-learn die Mails nach dem Bearbeiten nicht einfach löscht sondern eine neue Zustellung startet.

Dazu übergeben wir den Nachrichtentext erneut an maildrop und setzen dabei REDELIVER_HAM und REDELIVER_SPAM entsprechend. Außerdem muss natürlich der Benutzer und sein zugehöriges Filterfile ermittelt werden. Damit das funktioniert, muss das Filterfile unbedingt unter ~/.mailfilter-<mailboxname> für VMailMgr-Postfächer, bzw. ~/.mailfilter für die Default-Mailbox abgelegt werden.

Das Skript geht übrigens stillschweigend davon aus, dass das Filterfile für jede betroffene Mailbox tatsächlich existiert. Alles andere ergibt ja auch keinen Sinn - irgendwie müssen die Spammails ja zunächst wegsortiert werden.

#!/bin/bash
###############################################################
# 2013-07-16 Christopher Hirschmann
#            c.hirschmann@jonaspasche.com
# 2014-05-10 Modifications for re-delivery by Thorsten Koester
###############################################################
#
# 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.
#
# You should have received a copy of the GNU General Public
# License along with this program.  If not, see
# <http://www.gnu.org/licenses/>.
#
###############################################################
#
# This script will look for a system user's primary maildir
# $HOME/Maildir and possible vMailMgr maildir under
# $HOME/users/ and search them for folders indicating a
# certain DSPAM setup:
#
# \
#  \___ Inbox
#   \
#    \___ Spam
#          \
#           \___ Learn as Ham
#            \
#             \___ Learn as Spam
#
# If it finds this folder structure in any maildir, it will
# show files from the folders 'Learn as Ham' and 'Learn as
# Spam' to DSPAM (so it can learn) and after that it will
# re-deliver them via maildrop.
#
# You can make this script more verbose with '-v'.
#
###############################################################

while getopts ":hv" Option
do
	case $Option in
		h       )       echo -e "Usage:\n-v\tbe verbose\n-h\tthis help message"; exit 0;;
		v       )       VERBOSE=1; ;;
		*       )       echo -e "ERROR: Unimplemented option chosen: -${OPTARG}"; exit 1;;
	esac
done

if [ ! `grep -q "CentOS release 5" /etc/redhat-release` ]; then
# ionice -c3 is not supported on CentOS 5 for users != root
# (needs Linux => 2.6.25)
# so let's fall back to ionice -c2 -n7 which is better than
# nothing
    BE_NICE="nice -n 19 ionice -c2 -n7"
else
    BE_NICE="nice -n 19 ionice -c3"
fi

for DIR in $HOME/Maildir `find ${HOME}/users/ -mindepth 1 -maxdepth 1 -type d`;
do
	if [ "${VERBOSE}" == "1" ];
	then
		echo "Looking for mails in ${DIR}."
	fi
	if [ -d "$DIR/.Spam.Learn as Spam" ];
	then
		for SUBDIR in new cur ;
		do
# In order to be able to process filenames regardless of any
# kind of weird characters we need to list these files
# separated by null bytes and process them accordingly.
# To achieve this we use bash process substitution (look it
# up, if this looks weird for you).
# The relevant steps are these:
#
#   while IFS= read -d $'\0' -r file;
#   # do stuff
#   done < <(find "$DIR/.Spam.Learn as Spam/$SUBDIR" -type f -print0)

			IFSSAVE=$IFS;
			while IFS= read -d $'\0' -r file;
			do
				if [ "${VERBOSE}" == "1" ];
				then
					echo "Eating \"$file\". Yuk!";
				fi
				$BE_NICE /package/host/localhost/dspam/bin/dspam --class=spam --source=error --stdout < "${file}";
				if [ "${DIR}" == "$HOME/Maildir" ];
				then
					if [ "${VERBOSE}" == "1" ];
					then
						echo "Re-Delivering Spam to default mailbox.";
					fi
					REDELIVER_SPAM=1 /usr/bin/maildrop $HOME/.mailfilter < "${file}";
				else
					if [ "${VERBOSE}" == "1" ];
					then
						echo "Re-Delivering Spam to user \"${DIR##*/}\".";
					fi
					EXT=${DIR##*/} REDELIVER_SPAM=1 /usr/bin/maildrop $HOME/.mailfilter-${DIR##*/} < "${file}";
				fi
				rm -f "${file}";
			done < <(find "$DIR/.Spam.Learn as Spam/$SUBDIR" -type f -print0)
			IFS=$IFSSAVE;
		done
	fi
	if [ -d "$DIR/.Spam.Learn as Ham" ];
	then
		for SUBDIR in new cur ;
		do
			IFSSAVE=$IFS;
			while IFS= read -d $'\0' -r file;
			do
				if [ "${VERBOSE}" == "1" ];
				then
					echo "Eating \"$file\". Yum!";
				fi
				$BE_NICE /package/host/localhost/dspam/bin/dspam --class=innocent --source=error --stdout < "${file}";
				if [ "${DIR}" == "$HOME/Maildir" ];
				then
					if [ "${VERBOSE}" == "1" ];
					then
						echo "Re-Delivering Ham to default mailbox.";
					fi
					REDELIVER_HAM=1 /usr/bin/maildrop $HOME/.mailfilter < "${file}";
				else
					if [ "${VERBOSE}" == "1" ];
					then
						echo "Re-Delivering Ham to user \"${DIR##*/}\".";
					fi
					EXT=${DIR##*/} REDELIVER_HAM=1 /usr/bin/maildrop $HOME/.mailfilter-${DIR##*/} < "${file}";
				fi
				rm -f "${file}";
			done < <(find "$DIR/.Spam.Learn as Ham/$SUBDIR" -type f -print0)
			IFS=$IFSSAVE;
		done
	fi
done

if [ "${VERBOSE}" == "1" ];
then
	echo "Done looking for mails."
fi

Inbetriebnahme

Wie aus dem Uberspace-Wiki bekannt, muss dspam-learn regelmäßig als Service aufgerufen werden. Selbstverständlich verwenden wir hier die eben modifizierte Version:

test -d ~/service || uberspace-setup-svscan
runwhen-conf ~/etc/run-dspam-learn "$HOME/bin/dspam-learn"
sed -i -e "s/^RUNWHEN=.*/RUNWHEN=\",M=`awk 'BEGIN { srand(); printf("%d\n",rand()*60) }'`\"/" ~/etc/run-dspam-learn/run
ln -s ~/etc/run-dspam-learn ~/service/dspam-learn

Auch sollte man seine DSPAM-Datenbank regelmäßig bereinigen lassen:

test -d ~/service || uberspace-setup-svscan
runwhen-conf ~/etc/run-dspam_clean_hashdb "/usr/local/bin/dspam_clean_hashdb"
sed -i -e "s/^RUNWHEN=.*/RUNWHEN=\",H=`awk 'BEGIN { srand(); printf("%d\n",rand()*24) }'`\"/" ~/etc/run-dspam_clean_hashdb/run
ln -s ~/etc/run-dspam_clean_hashdb ~/service/dspam_clean_hashdb

Damit müsste DSPAM funktionieren. Allerdings dürfte es einige Spammails lang dauern, bis der Filter soweit angelernt ist, dass er korrekt arbeitet.

Im laufenden Betrieb ist es wichtig zu wissen, dass bereits von SpamAssassin ausgefilterte Mails nicht DSPAM zum Umlernen vorgelegt werden können. DSPAM hat diese Mail ja nie gesehen und kann auch gar nicht mehr in die Filterung eingreifen.

Im Uberspace-Standard-Setup werden Mails, die SpamAssassin für verdächtig hält, ab einer Spam-Punktzahl von fünf mit [SPAM] im Betreff gekennzeichnet. Normalerweise ist SpamAssassin damit, zumindest bei mir, eher auf der sicheren Seite. Es bietet sich daher an, $SPAMASSASSIN_MINSPAMSCORE="5" zu setzen, SpamAssassin als Werkzeug fürs Grobe zu nutzen, und die Feinarbeit dann DSPAM zu überlassen. Wenn man sein eigenes SpamAssassin-Setup eingerichtet hat, sollte man die Einstellungen natürlich entsprechend anpassen.