Die Nutzung von Machine Learning bei der Klassifizierung von E-Mail-Bounces

Einleitung

TLDR

Die Klassifizierung von Bounce-Nachrichten (Fehler im Emailversand) ist mühselig, da die Nachrichten oft schlecht und uneinheitlich formatiert sind. Jeder E-Mail-Provider kann eigene Fehlermeldungen generieren. Händisch ermittelte Regeln sind daher fehleranfällig oder treffen möglicherweise nicht alle Fehler.

In einem Proof of Concept haben wir einen Supervised-Machine-Learning-Ansatz zur Bounce-Klassifizierung implementiert und evaluiert. Mit Gradient Boosting (XGBoost) erreichen wir akzeptable und mit Neuronale Netzen sehr gute Performanz. Gemessen an Klassifizierungmetriken sind Neuronale Netze besser als manuell erstellte Regeln.

Abzuwägen ist der höhere Entwicklungsaufwand.

Prolog

Heute, im Januar 2024, ist Machine Learning (und Natural Language Processing, NLP) im Mainstream angekommen. Mit ChatGPT lassen sich mehr als 180 Millionen Nutzer im Alltag helfen, sei es Städtetrips zu planen oder nur Emails vorformulieren zu lassen. Für die aufwendige Entwicklung von ChatGPT brauchte es die klügsten Köpf der Welt und ein Budget im Milliardenbereich.

Allerdings gibt es auch viele kleinere Probleme, bei denen man Machine Learning bzw. NLP Methoden anwenden kann. Wie das aussehen kann, zeigen wir hier.

Das Problem

Ein führendes deutsches e-Commerce Unternehmen bündelt nahezu die gesamte E-Mail-Kommunikation in einem System. Von den täglich Millionen versendeten E-Mails kommen nicht alle an. Dafür gibt es verschiedene Gründe: Etwa könnte ein Nutzer bei der Registrierung eine Email angegeben haben, die nicht mehr existiert, oder der Empfänger hat die Versandadresse auf eine Spamliste gesetzt. Oft ist der Grund aber auch technisch, beispielsweise könnte das Postfach temporär voll sein oder der Email-Server ist kurzfristig nicht erreichbar. Näher unterteilt man Bounces, die auf ein permanentes Problem deuten, als Hardbounces und temporäre Probleme als Softbounces. Eine gut formatierte Bouncenachricht sieht beispielsweise so aus:

Im Gesamtsystem muss das Unternehmen auf solche Fehler reagieren. Dabei muss zuerst der Grund für die Ablehnung herausgefunden werden. Dies ist oft nicht einfach, da die verschiedenen Provider individuelle Fehlertexte benutzen. Desweiteren sind viele Meldungen falsch oder schlecht konfiguriert und nutzen standardisierte Fehlercodes nicht bzw. falsch.

Ist der Grund bekannt, muss entschieden werden ob es sich in Zukunft lohnt, die Email-Adresse weiterhin anzuschreiben. Dabei muss das Unternehmen ein Kosten-Nutzen-Verhältnis abwägen. Es sollen so viele Emails wie möglich zugestellt werden. Wiederholt nicht zugestellte Emails können allerdings dazu führen, dass das Unternehmen Reputation verliert und schlimmstenfalls als Spammer klassifiziert wird. Es wird dann vom Emailanbieter des Kunden temporär auf eine Blockierliste gesetzt. Dies könnte Auswirkungen auf die Zustellung von Emails auf Email-Konten ohne Fehler haben, und kostet dem Unternehmen schlussendlich Geld.

Oft stellt der Email-Versanddienstleister dafür bereits eine eigene Funktionen bereit. In diesem Fall sind die Anforderungen unseres Unternehmens aber hoch, sodass es sich lohnt, ein eigenes System für das Bouncemanangement zu implementieren. 

Lösungsansätze

Die klassiche Lösung

Unsere initiale Lösung ist eine simple Textsuche, die in den Fehlermeldungen nach bestimmten Keywords sucht. Dieser Ansatz funktioniert sehr gut, jedoch müssen die Keywords genau ausgesucht werden, um nicht zu falschen Klassifizierungen zu führen. Auch braucht es viele Keywords, um möglichst viele Fehler korrekt zu klassifizieren. 

Weiterhin kann es dann sein, dass bei einer Fehlermeldung mehrere Keywords anschlagen. Dann braucht es einen Algorithmus oder eine Priorisierung der Keywords um die eigentliche Fehlerkategorie festzulegen.

Dadurch ist die Entwicklung einer guten Keyword-Liste leicht fehleranfällig.

Machine Learning

Als Alternative zu diesem Lösungsansatz mit klassischer Softwareentwicklung haben wir ein Machine-Learning-Modell entwickelt, das auf neuronalen Netzen (NN) und Natural Language Processing (NLP) basiert.

Genauer haben wir in unserer Lösung den Ansatz des Supervised Learning verfolgt. Dabei werden Input-Output-Paare genutzt, um einem neuronalen Netz die in den Daten enthaltenen Regeln beizubringen.

Das Modell besteht aus einem Embedding der Bounce-Nachrichten. In einem Embedding werden jedem Wort bzw. jeder Zeichenfolge (Token) der Bounce-Nachricht ein n-dimensionaler Vektor zugeordnet.

Im Trainigsprozess gruppieren sich die Vektoren nach ihrer semantischen Bedeutung. Beispielsweise würde das Embedding für den Fehlercode “5.5.0” ähnlich dem für das Wort “unavailable” sein, da das Wort “unavailable” in unseren Daten im Kontext des Fehlers mit dem Code “5.5.0” vorkommt. Das Wort “email” hingegen taucht in verschiedenen Fehlern auf und wäre daher im Raum der Embeddings eher zentral gelegen und dadurch verwand mit sehr vielen anderen Tokens.

Aufbauend auf den Emeddings hat das Modell einen Klassifizierungsteil, welcher basierend auf den Vektoren des Embeddings Wahrscheinlichkeiten für die passende Bounce-Kategorie ermittelt.

Hinweis: Wir haben auch andere Ansätze ausprobiert. Unter anderem Supervised Learning mit SVM, Decision Trees, Naive Bayes, und Gradient Boosting (XGBoost). Viele der Ansätze funktionieren fast so gut wie unser NN-Modell. Zusätzlich haben wir auch noch Zeit in Data Exploration investiert, die Ergebnisse daraus werden aber aus Platzgründen hier weggelassen.

Die Daten

Für den ML-Ansatz (Training und Evaluierung des Modells) haben wir über einem Zeitraum von drei Monaten Daten gesammelt. In dem Zeitraum gab es insgesamt etwa 3,5 Millionen Bounces. Dabei wiederholten sich allerdings Fehlermeldungen großer Provider. Beispielsweise macht allein die Nachricht “Messages queued (connection quotas met).” von Gmail gut 45% aller Fehler aus. Zwei weitere Gmail-Fehler summieren sich auf knapp 20%. Insgesamt deckt also nur ein Provider mit drei Fehlern 65% aller Fehlermeldungen ab – schlicht durch die große Anzahl an dorthin addressierte E-Mails.
Die Häufigkeit der Fehler ist für das Training zwar nicht direkt relevant, sollte aber für die Interpretation der Ergebnisse berücksichtigt werden. Durch Eliminieren der Duplikate ergeben sich ca. 21.000 einzigartige Fehler.

Für unseren Supervised-Learning-Ansatz brauchen wir allerdings gelabelte Daten, d.h. Input und Output Paare. Das manuelle Zuweisen der Kategorie pro Fehlernachricht würde bei 5 Sekunden pro Fehlernachricht ca. 29 Stunden dauern. Daher haben wir die Fehlertexte noch weiter vereinfacht. Beispielsweise beinhalten viele Fehler Informationen über den Server oder Zeichen, welche keine semantische Bedeutung haben. Wir haben daher die Fehlertexte nachbearbeitet und vereinfacht.

Beispiele:

				
					errorTextFull      = 554 5.7.1 Rejected for policy reasons (812656049C9230CD80)
errorTextFormatted = 554 5.7.1 Rejected for policy reasons #traceid#

errorTextFull      = DNS query failed for '#DOMAIN#'.
errorTextFormatted = dns query failed for #domain#

errorTextFull      = 550 Message was not accepted -- invalid mailbox. Local mailbox * is unavailable: user not found
errorTextFormatted = 550 message was not accepted invalid mailbox local mailbox #id# is unavailable user not found
				
			

Durch die Nachbearbeitung können wir viele Fehlertexte bereinigen, sodass schließlich 1.590 einzigartige Fehler verbleiben:

3482897 Bounces
  21636 einzigartige errorTextFull
   1590 einzigartige errorTextFormatted

Um den eigentlichen Datensatz für den ML-Ansatz zu definieren, wurden dann die formatierten Fehler manuell kategorisiert. Der Prozess ist in dem folgenden Schaubild visualisiert und ähnelt dem, wie wir es bei dem klassischen Ansatz über eine Keyword-List machen würden. Dabei wurde das für uns Menschen wichtigste Keyword für jede Fehlermeldung notiert. Die Keywords wurden dann den Fehlerkategorien zugeordnet. Das dabei entstandene Mapping kann dann genutzt werden, um die formatierten Fehlertexte mit Kategorieren zu labeln.

Im Unterschied zum klassischen Ansatz waren wir hier ein wenig freier bezüglich der Anzahl an Keywords und Interpretation von Fehlermeldungen, bei der mehrere Keywords auftreten.

Ablauf der Datenvorbereitung

Implementierung

Mit den gelabelten Fehlermeldungen können wir nun unsere Implementierung anschauen. Der Code ist hier einsehbar, die Daten sind natürlich nicht unser Eigentum und daher nicht öffentlich.

Die folgende Beschreibung ist relativ detailiert und setzt Python-Kenntnise voraus. Wer nicht an technischen Details interessiert ist, kann diese Kapitel gerne überspringen und sich direkt die Ergebnisse und das Fazit anschauen.

Unsere Implementierung nutzt Python mit PyTorch, pandas, scikit-learn, und weiteren kleineren Libraries.

Laden der Daten

Schauen wir uns zuerst an, wie wir mit den Daten umgehen. Dafür laden wir zunächst ein pandas DataFrame, welches die im unteren Schaubild dargestellte Spaltenstruktur hat. Aus dem DataFrame werden dann die formatierten Fehlertexte als Inputs für das Modell abgerufen.

				
					data_path = Path() / 'data' / 'labeled_data_25_3.pickle'
unique_df = pd.read_pickle(data_path)
				
			

DataFrame Struktur:

Die Daten können wir dann in Datensätze für das Training und für die Validierung unterteilen. Dafür bietet sich die Library scikit-learn an. X symbolisiert hier den Input (independent variables) und Y den Output (dependent variable), welchen wir ermitteln möchten. Wir benutzen 3/4 der Daten für das Trainieren des Modells und 1/4 für das Evaluieren. Die Grenze ist fast willkürlich, oft funktioniert 80/20 als gute Aufteilung. Wir haben mehr Daten für die Validierung genutzt in der Hoffnung, so eine bedeutsamere Evaluation zu erreichen. Durch stratify=Y haben wir eine etwa gleiche Verteilung der Kategorien der Fehlermeldungen im train und validation Datensatz. Beispiel: 0,1% von train ist Kategorie A => ~0,1% von val ist ebenfalls A.

				
					X = np.array(unique_df['errorTextFormatted'].values)
Y = unique_df['label'].values
Y = np.array(list(map(encoding.encode_combined_label, Y)))

x_val = np.array([])
y_val = np.array([])
x_train, x_val, y_train, y_val = train_test_split(X, Y,\
    random_state=30,\
    stratify=Y,\
    test_size=0.25)
				
			

Neben dem Unterteilen der Daten machen wir hier auch die später notwendige Codierung des Labels. Hier werden die verschiedenen Kategorienamen durch die Funktion encode_combined_label zu statischen Indizes übersetzt, welche man später für den Trainingsprozess braucht.

Das Embedding und der Input

Als nächstes wollen wir definieren, wie wir mit dem Input umgehen. Dafür schauen wir uns noch einmal an, was Embeddings sind. Ein Embedding ist prinzipiell nur eine Lookup-Map, die Indizes auf Vektoren (=> einzelne embeddings) mappt. Indizes repräsentieren Wörter bzw. Tokens. Vektoren repräsentieren dann die semantische Bedeutung der Indizes (Tokens) in einem multidimensionalen Raum, welcher durch die Embedding-Tiefe (Dimensionalität der Vektoren) definiert ist.

In PyToch braucht es auch nur zwei Parameter:

  1. Die Anzahl der Embeddings bzw. Indizes
  2. Die Dimensionalität der Vektoren

In folgenden Beispiel erstellen wir ein Embedding für fünf Indizes, welche jeweils auf einen dreidimensionalen Vektor verweisen. In diesem fiktiven Beispiel würde man den Input mit 5 Indizes in einem 3-dimensionalen Raum embedden.
Mit dem Zugriff über einen Tensor werden die Vektoren für den ersten und letzten Index geloggt:

				
					import torch.nn as nn
from torch import tensor

embedding = nn.Embedding(5, 3)

print(embedding(tensor([0, 4])))
# => tensor([[-0.4112,  0.2312,  0.3912],
#    [ 0.0129, -0.3718,  0.9632]])
				
			

Bei uns repräsentieren die Indizes Wörter bzw. Symbole (Token) der Fehlermeldungen. Um die Token Indizes zuzuordnen, können wir die vocab Funktion aus der torchtext Library benutzen. Diese nimmt ein Dictionary von Tokens und deren Häufigkeit entgegen und erstellt daraus Mappings von Indizes auf Tokens und umgekehrt.

Interessant ist hier, dass wir zwei besondere Tokens dem Vokabular hinzufügen.
Das erste – |unk| – ist ein Token, welches unbekannte Tokens repräsentiert. Das Vokabular wird nur mit Informationen aus den Trainingsdaten gebildet. Man geht davon aus, dass man in der Realität nach dem Training noch neuen, noch unbekannten Tokens begegnet. Durch den default_index wird sichergestellt, dass unbekannte Tokens nicht mit bekannten verwechselt werden.

Das zweite ist ein Platzhalter. Nicht alle Fehlermeldungen sind gleich lang. Würde man die Fehlermeldungen mit einer variablen Länge mit dem Embedding übersetzen, kämen auch unterschiedlich viele Vektoren heraus. Das ist unpraktisch, da darauf folgende Ebenen eines NN gewöhnlich eine feste Input-Form erwarten.

Daher definieren wir eine fixe Input Größe: Zu lange Fehlertext werden abgeschnitten, zu kurze werden mit dem Index des |emp| Tokens gefüllt.

				
					counter = Counter()
error_texts = list(x_train)
for err in error_texts:
    counter.update(err.split(' '))
    
sorted_by_freq_tuples = sorted(counter.items(), key=lambda x: x[1], reverse=True)
ordered_dict = OrderedDict(sorted_by_freq_tuples)

bounce_vocab = vocab(ordered_dict,\
    specials=['|unk|', '|emp|'])
bounce_vocab.set_default_index(0)

print(bounce_vocab["email"])
# => index=21;
print(bounce_vocab["das_token_gibt_es_nicht"])
# => index=0; token=''
				
			

Zusammenbau

Mit den Vorbereitungen können wir nun den eigentlichen Trainingsprozess definieren. Zunächst definieren wir einen Dataset, welcher für einen Index aus den Trainingsdaten einen Input für das neuronale Netz, und einen Output für die Loss-Function / Evaluierung bereitstellt. In dem DataSet wird das erläuterte Zurechtschneiden des Inputs und Übersetzen der Tokens zu Indizes angewendet:

				
					import torch
from torch.utils.data import Dataset

class BounceTextDataset(Dataset):
def __init__(self, X, Y, vocab, text_len=50,\
				tokenizer=lambda x: x.split(' '), \
				empty_token_index=1):
    self.x = X
    self.y = Y
	self.vocab = vocab
	self.text_len = text_len
	self.tokenizer = tokenizer
	self.empty_token_index = empty_token_index
	
	def encode_text(self, text):
		encoded = [self.vocab[x]\
		    for x in self.tokenizer(text)]
		return encoded

	def padd_txt(self, txt):
		l = len(txt)
		if l > self.text_len:
			return txt[:self.text_len]
		elif l < self.text_len:
			return txt + [self.empty_token_index for i in range(self.text_len - l)]
		else:
			return txt
		
	def __len__(self):
		return len(self.x)

	def __getitem__(self, idx):
		txt = self.x[idx]
		txt = self.encode_text(self.x[idx])
		
		txt = self.padd_txt(txt)
		txt = torch.tensor(txt, dtype=torch.int64)
		label = self.y[idx]
		label = torch.tensor(label, dtype=torch.int64)
		
		return txt, label
				
			

Als nächstes definieren wir das neuronale Netz, also das Modell. Es beginnt mit dem erklärten Embedding, welches durch die vocab_size und embedding_dim definiert ist.
Danach haben wir noch einen mehrteiligen Klassifizierungs-Teil, welcher aufbauend auf den Vektoren der Tokens Wahrscheinlichkeiten für die verschiedenen Fehlerkategorien errechnet.

Die Wahrscheinlichkeiten werden dann als Liste zurückgegeben. Jedes Element der Liste gibt das Vertrauen des Modells wieder, dass die dazugehörige Fehlerkategorie der richtige Fehler ist.

Beispiel:
3 Fehlerkategorien: ('A', 'B', 'C')
=> Model Output mit 3 Werten: [6, 8, 70]

Der Output ist nicht normalisiert. Jedoch ist klar erkennbar, dass das Model die Fehlerkategorie ‘C’ vermutet.

				
					import torch.nn as nn
  
class EmbeddingModel(nn.Module):
	def __init__(self, vocab_size, embed_dim, num_classes, input_len=40):
		super(EmbeddingModel, self).__init__()
		self.embedding = nn.Embedding(vocab_size, embed_dim)
		self.fc = nn.Linear(embed_dim*input_len, 128)
		self.relu1 = nn.ReLU()
		self.fc2 = nn.Linear(128, num_classes)
		self.init_weights()
		
	def forward(self, txt):
		embedded = self.embedding(txt)
		embedded = embedded.view((txt.shape[0],-1))
		fc = self.fc(embedded)
		relu1 = self.relu1(fc)
		fc2 = self.fc2(relu1)
		return fc2
				
			

Trainieren des Modells

Für das Trainieren des Modells benutzen wir die CrossEntroy Loss-Function. Sie ist hier dokumentiert. Die Loss-Function wird oft für Klassifizierungsprobleme genutzt und sorgt dafür, dass sich das Modell durch einen hohen “Loss” bei falschen Vorhersagen  gut verbessert, während korrekte Vorhersagen das Modell nicht verändern. Mit dem SGD Optimizer haben wir auch hier auf etablierte Verfahren gesetzt.

Die anderen Parameter und Hyperparameter wurden in verschiedenen Experimenten ermittelt.

Der Rest ist “gewöhnliche” PyTorch Entwicklung. Über einen DataLoader können Batches von Daten aus einem Dataset geladen werden. Die Funktionalität, welche das Training des Modell durchführt, ist abstrahiert durch eine fit Funktion. Diese wird hier aus Platzgründen nicht erläutert, kann aber hier nachgeschlagen werden. Gleiches gilt für den Epoch Evaluator, welcher während des Trainings die Performanz des Modells anhand von binären Klassifizierungsmetriken misst.

Das Modell ist nicht besonders groß und viele oder große Datenmenen verarbeiten wir auch nicht. Auf einem 2020er MacBook mit einem i5 Prozessor dauert der Trainingsprozess nur etwa zwei Minuten.

				
					###
### Parameters & Model
###
num_classes = encoding.num_combined_classes()
vocab_size = len(bounce_vocab)
embedding_dim = 16
input_len = 40

evaluator = evaluation.EpochEvaluator()
model = models.EmbeddingModel(vocab_size, embedding_dim, num_classes, \
								input_len=input_len)
print(model)


###
### Hyperparameters
###
learning_rate = 0.01
epochs = 200
batch_size = 8

optimizer = torch.optim.SGD(model.parameters(), lr = learning_rate)
criterion = torch.nn.CrossEntropyLoss()

###
### Data
###
train_ds = BounceTextDataset(x_train, y_train, bounce_vocab, text_len=padded_len)
val_ds = BounceTextDataset(x_val, y_val, bounce_vocab, text_len=padded_len)

train_loader = DataLoader(train_ds, shuffle=True, batch_size=batch_size)
val_loader = DataLoader(val_ds, batch_size=1)

dataloaders = {'train': train_loader, 'val': val_loader}

model = fit(model, dataloaders, epochs, optimizer, criterion, evaluator)
				
			

Ergebnisse und Fazit

Ergebnisse

In der untenstehenden Tabelle sind die Ergebnisse für die Validierungsdaten dargestellt. Precision, recall, und f1-score sind die betrachteten Metriken, aufgeschlüsselt nach den Kategorien von Fehlern. Die Spalte support gibt an, wie viele Fehlernachrichten mit der jeweiligen Kategorie es im Datensatz gibt.

Mit wenigen Experimenten schaffen wir es mit unserem Modell und einem einfachen Train/Validation-Split, eine nahezu perfekte Klassifizierung von während des Trainings nicht gesehenen Fehlermeldungen zu erreichen.

Lediglich eine Fehlermeldung aus dem Validation-Datensatz wird falsch klassifiziert.

				
					Validation Data:
                      precision    recall  f1-score   support

        User unknown       0.99      1.00      1.00       104
TemporaryUserProblem       1.00      1.00      1.00         8
        Mailbox full       1.00      1.00      1.00        26
                Spam       1.00      1.00      1.00        75
              Policy       1.00      1.00      1.00        24
    TransportProblem       1.00      1.00      1.00        81
        Unclassified       1.00      0.98      0.99        58
          Greylisted       1.00      1.00      1.00        22

            accuracy                           1.00       398
				
			

Diagramme von Loss und Accuracy beim Training und der Validierung zeigen ergänzend das gute Ergebnis. Beide Metriken verbessern sich stetig.

Bei solch guten Ergebnissen kann man fast misstrauisch werden. So kann es beispielsweise sein, dass Data leakage auftritt, d.h. einige der Fehlermeldungen aus dem Evaluierungsdatensatz existieren auch im Trainingsdatensatz. Dann würde man erwarten, dass das Modell keine allgemeingültige Lösung ermittelt hat und im echten Einsatz schlechter arbeiten würde.

Eine andere Interpretation ist, dass das Problem schlicht nicht einfach ist. Durch die Vorbereitung der Daten können wir die Komplexität von ~21.000 Fehlermeldungen auf 1.590 reduzieren. Gleichzeitig enthalten die Daten danach nur ca. 1.900 Tokens. Es kann gut sein, dass unser Modell so komplex ist, dass es die Trainingsdaten perfekt versteht. Validierungsdaten und auch spätere reale Daten sind schlicht nicht unterschiedlich genug, um zu einer eigentlich erwartbaren höheren Fehlerquote bei ungesehenen Daten zu führen.

Fazit

Am Ende des Projekts sind wir zwiegespalten über den Nutzen von Machine Learning im Kontext der Bounce-Klassifizierung.

Für den Einsatz eines neuronalen Netzes sprechen die besseren (nahezu perfekten) Klassifizierungsmetriken. Dadurch, dass die Fehlermeldungen so simpel sind, lernt das Modell quasi alle Fehlermeldungen auswendig. Das hat den Vorteil, dass auch selten vorkommende Fehlermeldungen korrekt klassifiziert werden. Ein regelbasierendes Verfahren müsste für den gleichen Effekt sehr viele Regeln für die vielen Fehler aufnehmen, was deutlich aufwendiger ist, als einmal die Fehlermeldung für den Trainingsprozess zu labeln. Zusätzlich kann es sein, dass mehrere Regeln für eine Fehlermeldung zutreffen. Dann braucht es eine Hierachie, welche fehleranfällig sein würde. Das neuronale Netz hingegen agiert wie eine Blackbox. Es lernt jedoch die semantische Beziehung zwischen den Tokens / Keywords, sodass es für Fehler mit mehreren populären Keywords dennoch ein Ergebnis mit hoher Zuversicht liefert.

Jedoch ist es prinzipiell ein Problem, dass wenige Bouncenachrichten von populären Providern um ein vielfaches häufiger sind und garantiert korrekt klassifiziert werden müssen – der Schaden wäre sonst verhältnismäßig groß. Bei einem regelbasierten Ansatz ist das einfach möglich – mit unserem Modell allerdings nicht.

Eine Kombination aus beiden Ansätzen könnte dieses Problem allerdings einfach umgehen.

Zusammengefasst hat dieses Projekt gezeigt, dass Machine Learning das vorliegende Problem grundsätzlich lösen kann. Im Vergleich zur klassischen Programmierung mit selbst ausgedachten Regeln bietet der ML-Ansatz hier zwar eine sehr gute Alternative oder Ergänzung, ist aber aufgrund des initial recht hohen Aufwands nicht klar besser.

Table of Contents

Weitere spannende Beiträge