Spamhandling mit dem Request Tracker

Posted by jonas on Monday, September 22, 2025

Wann immer ihr uns eine Mail an hallo@uberspace.de schreibt, landet sie bei uns im - selbst gehosteten - Request Tracker. Und wann immer uns ein Spammer eine Mail schreibt, die es irgendwie durch den Spamfilter schafft, landet sie ebenfalls dort. Es wäre dann also schön, sie entsprechend in unseren Spamfilter einzulernen - aber wie?

Wie man Spamtraining mit einem konventionellen IMAP-Server und irgendeinem Mailprogramm macht, ist tausendfach für alle möglichen Mailserver im Netz dokumentiert: Mail in einen Ordner schieben, serverseitig reagiert dann z.B. Dovecots imapsieve und lernt sie dann in rspamds rspamc learn_spam oder SpamAssassins sa-learn. Aber wir bewegen uns hier nicht mehr in der Welt von IMAP - die Mail ist bereits ein Ticket.

Was tun?

Da greifen nun letztlich verschiedene Komponenten ineinander. Es gibt einen Wiki-Eintrag zum Spamhandling im RT. Der dreht sich aber weitestgehend darum, wie man Mails durch einen Spamfilter schickt, aber das tun wir natürlich auch schon lange. Und das Wegsortieren in eine andere Queue auch. Das geht im Request Tracker mit Scrips (nicht “Scripts!”), in diesem Fall:

Benutzerdefinierte Bedingung:

# Check if the current transaction is an email creation
return 0 unless $self->TransactionObj->Type eq 'Create';

# Get the X-Spam-Status
my $x_spam_status = $self->TransactionObj->Attachments->First->GetHeader('X-Spam-Status');

# Return true if there is an X-Spam-Status header and it contains "Yes"
return $x_spam_status && $x_spam_status =~ /Yes/;

Benutzerdefinierte Aktion Code festlegen:

$self->TicketObj->SetQueue('uberspace-spam');

# this shall block RT from executing other scrips including the autoresponder
return 0;

So weit, so unspannend.

Jetzt geht’s ans Lernen

Im Prinzip ließe sich genauso ein weiteres Scrip schreiben, das auf eine Transaktion reagiert, bei der ein Ticket aus der Spam-Queue in die normale Support-Queue verschoben wird. Das könnte dann irgendwelche Dinge tun, die die Mail zum Lernen in den Spamfilter schieben. Das Problem ist hier vor allem die Usability: Man muss dann erst im Feld “Grundlagen” in den Bearbeitungsmodus wechseln, dann für den Queue-Wechsel aus einem Dropdown mit diversen Einträgen zielgenau den richtigen heraussuchen, dann zur Änderung des Status aus einem weiteren Dropdown heraussuchen, dass das Ticket nun “deleted” sein soll, und dann speichern. Kann man machen, ist aber einigermaßen viel Herumgeklicke, mit viel Potential zum versehentlichen Danebenklicken.

Es geht zum Glück auch etwas schicker: Mit der RT::Extension::ReportSpam. Die verschafft einem in der Ticket-Anzeige ein “S”-Symbol, das kann man anklicken und dann ist das Ticket als Spam markiert. Es ist gleichzeitig auch gelöscht - an sich praktisch, aber eine Stolperfalle; dazu gleich.

ein Screenshot aus dem Request Tracker, der das “S”-Symbol zeigt

Das funktioniert soweit auch prima, lässt aber eine Frage offen: Was passiert denn nun mit dem Ticket? Die schlichte Antwort: Gar nichts. Es ist nur eben als Spam markiert.

Es ist auch irgendwie naheliegend, dass die Extension ja überhaupt nichts darüber weiß, wie in unserem Szenario Mails irgendwo hineingelernt werden können. Nett wäre vielleicht gewesen, wenn die Dokumentation der ReportSpam-Extension zumindest einen Hinweis darauf geben könnte, wie es denn jetzt weitergeht. Und weil mir dieser Hinweis sehr gefehlt hat, gibt es diesen Blogpost.

Was klar ist: Wir benutzen rspamd, müssen also irgendwie rspamd learn_spam und auch rspamd fuzzy_add benutzen. Es muss also irgendein Job her, der sich die Originalmail aus dem RT zieht (wie geht das überhaupt? Dazu kommen wir noch!) und sie dann - irgendwie - in den rspamd füttert. Und es muss auch irgendwie klar sein, dass eine Mail bereits gelernt wurde, damit wir das nicht wiederholt tun. Und es sollte auch irgendwo transparent stehen, nicht nur in irgendeinem Log, sondern direkt im Ticket.

Offene Fragen und Stolperfallen

Was ich bereits aus der Dokumentation der Extension wusste: Man kann bzw. muss nach HasAttribute = 'SpamReports' suchen. Es handelt sich nämlich nicht um ein “CustomField” - das müsste man nämlich explizit anlegen. Attribute hingegen kann man sich einfach jederzeit frei ausdenken und sie landen als arbiträre Daten in der Datenbank.

Mein erster Anlauf in Perl - da der RT selbst in Perl geschrieben ist, bietet sich das hier absolut an - hat zwar die Query ausgeführt, aber einfach keine Spamtickets gefunden. Das hat mich fast verrückt gemacht. Ich habe natürlich im Ticket gesehen, dass die Extension beim Spamreporting ein Ticket auch gleich auf “gelöscht” setzt. Aber ich hatte extra ein schönes ... AND Status = 'deleted' in meine Query eingebaut, und trotzdem wurde aber nichts gefunden.

Einen wichtigen Hinweis habe ich dann im Sourcecode des Moduls RT::Shredder gefunden, das wir bei uns schon lange einsetzen, um erledigte Tickets nach einiger Zeit auch wirklich aus der Datenbank zu löschen (wir haben extra ein “CustomField” eingebaut, um Tickets einen unterschiedlichen Charakter geben zu können, weil z.B. “Supportanfragen” einen anderen Charakter als das haben, was man juristisch einen “Geschäftsbrief” nennen würde, und somit gibt es je nach Ticket unterschiedliche Aufbewahrungsfristen). Und wie es dann oft so ist: Wenn man weiß, wonach genau man suchen muss, findet man es plötzlich auch als ganz kleine Randnotiz in der Dokumentation zu RT::Tickets, als dezente Erwähnung, dekoriert mit

BUG: There should be an API for this

Nun ja. Ich wusste nun also: Ein $tickets->{'allow_deleted_search'} = 1; braucht es noch, weil ansonsten sämtliche Queries gelöschte (genaugenommen: mit dem Status “deleted” versehene!) Tickets einfach grundsätzlich ausfiltern.

Nächste Frage: Wie komme ich denn nun an die Originalmail? Denn der RT zerschnipselt eingehende Mails in ihre MIME-Parts. Bei text/plain ist das simpel, weil’s dann nur einen Part gibt, aber die meisten Mails bestehen aus mehreren MIME-Parts - die rekursiv verschachtelt sein können und in separaten “Attachments” in der Datenbank liegen. Ich habe dann erstmal angefangen, mir diese MIME-Parts eben rekursiv selbst wieder zu einer vollständigen Mail zusammenzusetzen, bis ich endlich - und viel zu spät - darauf gekommen bin, dass die Methode ContentAsMIME, die einen MIME-Part zurückliefert, auch eine Children-Option bietet, mit der dann der RT selbst die Mail wieder vollständig zusammensetzen kann. Dabei kommt dann eine MIME::Entity raus, auf der ich ein stringify machen kann. Yay!

Damit die Mails nicht mehrfach gelernt werden, greife ich auf die Technik zurück, die die ReportSpam-Extension auch benutzt: Ich setze ein von mir frei ausgedachtes Attribut, in diesem Fall namens TrainedSpamFilter, damit ich die so markierten Mails später mit ... AND HasNoAttribute = 'TrainedSpamFilter' einfach ausfiltern kann.

Und damit man schließlich ohne Klimmzüge im RT auch sehen kann, ob ein Ticket wirklich gelernt wurde, nutzen wir die “Comment”-Funktionalität. Das ist nun wirklich simpel und hat schon tausend Codebeispiele im Netz.

Die andere Seite

Klar ist: Der RT läuft auf rt.uberspace.is, unser rspamd hingegen auf mx1.uberspace.is. Unverschlüsselt über’s Netz wollen wir nichts laufen lassen; extra ein VPN dafür wäre aber auch Quatsch. Wir nehmen also einfach SSH: Seitens des RT bauen wir eine SSH-Verbindung zum Mailserver auf und schieben die Mail via Pipe einfach rüber. Auf der Mailserver-Seite wartet ein triviales Shellscript:

#!/bin/sh

set -e

tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT

cat >"$tmp"

rspamc learn_spam <"$tmp"
rspamc fuzzy_add -f 11 -w 10 <"$tmp"

Der SSH-Public-Key des RT-Hosts, der in der authorized_keys auf dem Mailserver hinterlegt ist (natürlich unter einem extra angelegten User spamlearn, nicht als root), bekommt dann noch ein command="/home/spamlearn/bin/learn_spam",... dazu, damit er wirklich auch nur das tun kann.

Endlich! Perl!

Und damit wären wir soweit, alles zusammenzusetzen. Und da alles Wesentliche schon gesagt ist, kommt das jetzt ohne weiteren Kommentar:

#!/usr/bin/env perl

use strict;
use warnings;

# Make sure RT libraries are found
use lib '/opt/rt5/local/lib';
use lib '/opt/rt5/lib';

use RT -init;

# Search for tickets in the Spam queue
my $tickets = RT::Tickets->new( RT->SystemUser );

# Borrowed from RT::Shredder, otherwise deleted tickets cannot be found
$tickets->{'allow_deleted_search'} = 1;

# This attribute is set by RT::Extension::ReportSpam
$tickets->FromSQL("HasAttribute = 'SpamReports' AND HasNoAttribute = 'TrainedSpamFilter'");

while ( my $ticket = $tickets->Next ) {
    print "Processing ticket #" . $ticket->Id . "\n";

    # Open SSH connection to the spam learner
    open my $ssh_fh, '|-', 'ssh -T spamlearn@mx1.uberspace.is'
      or die "Failed to open SSH pipe: $!";

    # Feed the mail into the spam learner through SSH
    print $ssh_fh $ticket->Transactions->First->Attachments->First->ContentAsMIME( Children => 1 )->stringify;

    close $ssh_fh;

    if ( $? == 0 ) {

        # Mark ticket as trained so we don't train again on it
        $ticket->SetAttribute(
            Name    => 'TrainedSpamFilter',
            Content => 'yes',
        );

        # Leave a helpful comment in the ticket
        $ticket->Comment( Content => "This ticket has been used to train the spam filter", );

    }
    else {

        warn "Training failed for ticket #" . $ticket->Id . ", exit code: " . ( $? >> 8 ) . "\n";

    }
}

Da stehen jetzt natürlich ein paar Dinge, die in einer schöneren Welt gewiss als Variable realisiert wären, einfach hart kodiert drin, z.B. der Pfad zu unserer RT-Installation, oder das konkrete SSH-Kommando. Das jetzt aber als “Stück Software” zu veröffentlichen, mit Repo, Config, Doku, Tests… wäre für diesen Fall nun doch ein wenig hochgegriffen - es ist halt ein kurzes Script, das nun als Cronjob sein Dasein fristet. Mit besten Grüßen aus dem Alltag der Systemadministration!

Foto von Kenny Eliason auf Unsplash