DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

How to Build an AI Model That Classifies Phishing Emails

Phishing emails account for over 90% of confirmed breaches according to the Verizon DBIR. The core problem with rule-based and blacklist-based filters is that they are reactive — you have to have seen an attack before you can block it. An ML-based classifier trained on email content generalizes to new campaigns based on linguistic and structural patterns, not signatures.

This post walks through building a working phishing email classifier in Python: feature engineering, a TF-IDF baseline you can ship in an afternoon, and a fine-tuned language model for when precision really matters. All code is runnable.

What Makes an Email "Phishy"

Before writing a line of code, understand your feature space. Phishing emails cluster around a handful of detectable patterns:

  • Urgency and fear language: "Your account will be suspended in 24 hours", "Immediate action required"
  • Impersonation signals: sender display name doesn't match the actual domain, lookalike domains (paypa1.com, amazon-support.com)
  • Credential harvesting intent: requests for passwords, social security numbers, card details
  • Suspicious URLs: IP-based links, URL shorteners, domains with suspicious TLDs (.tk, .ml, .ga)
  • Generic salutations: "Dear Customer", "Dear User" rather than a real name

A model trained on enough labeled examples will learn these patterns without you explicitly coding each one — but knowing the signal space helps you evaluate whether the model has learned the right things.

Dataset Selection

For this project, combine publicly available datasets:

  • CEAS 2008 phishing corpus (~5k phishing emails)
  • SpamAssassin public corpus (~6k emails — keep the legitimate subset)
  • Enron email dataset (~30k legitimate corporate emails)

The class imbalance matters. Real inboxes have roughly 0.5–2% phishing, so train with class_weight="balanced" and measure recall on the phishing class specifically. A model that achieves 99% accuracy by predicting everything as legitimate is worthless in practice.

Baseline: TF-IDF + Logistic Regression

A TF-IDF pipeline is fast to train, interpretable, and often good enough for internal tooling. Start here before reaching for transformers.

import re
import email
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report


def extract_text(raw_email: str) -> str:
    # Extract subject and body text from a raw email string.
    try:
        msg = email.message_from_string(raw_email)
        subject = msg.get('Subject', '') or ''
        body = ''
        if msg.is_multipart():
            for part in msg.walk():
                if part.get_content_type() == 'text/plain':
                    payload = part.get_payload(decode=True)
                    body += payload.decode('utf-8', errors='ignore') if payload else ''
        else:
            payload = msg.get_payload(decode=True)
            body = payload.decode('utf-8', errors='ignore') if payload else ''
        return f'{subject} {body}'
    except Exception:
        return raw_email


def url_heuristics(text: str) -> str:
    # Append URL-derived pseudo-tokens that signal phishing patterns.
    import re
    urls = re.findall(r'https?://[^\s<>"\']]+', text)
    domains = [re.sub(r'^https?://', '', u).split('/')[0] for u in urls]
    tokens = []
    if any(re.match(r'\d+\.\d+\.\d+\.\d+', d) for d in domains):
        tokens.append('FEAT_IP_URL')
    if any(d.endswith(('.tk', '.ml', '.ga', '.cf')) for d in domains):
        tokens.append('FEAT_SUSPICIOUS_TLD')
    if any(d in {'bit.ly', 't.co', 'goo.gl', 'ow.ly', 'tinyurl.com'} for d in domains):
        tokens.append('FEAT_URL_SHORTENER')
    return text + ' ' + ' '.join(tokens)


# emails.csv has columns: raw (str), label (0=legit, 1=phishing)
df = pd.read_csv('emails.csv')
df['text'] = df['raw'].apply(extract_text).apply(url_heuristics)

X_train, X_test, y_train, y_test = train_test_split(
    df['text'], df['label'], test_size=0.2, stratify=df['label'], random_state=42
)

pipeline = Pipeline([
    ('tfidf', TfidfVectorizer(
        sublinear_tf=True,
        max_features=50_000,
        ngram_range=(1, 2),
        min_df=2,
    )),
    ('clf', LogisticRegression(C=1.0, max_iter=1000, class_weight='balanced')),
])

pipeline.fit(X_train, y_train)
print(classification_report(y_test, pipeline.predict(X_test), target_names=['legitimate', 'phishing']))
Enter fullscreen mode Exit fullscreen mode

On a balanced 10k dataset this typically yields F1 ~0.92–0.94 on the phishing class. The model is interpretable: inspect the top coefficients with pipeline.named_steps['tfidf'].get_feature_names_out() combined with pipeline.named_steps['clf'].coef_ to understand exactly what it learned.

Fine-Tuning a Language Model for Production

When false negatives carry real cost — a missed phishing email that leads to a breach — a fine-tuned small language model consistently outperforms TF-IDF by 3–5% F1. DistilBERT (66M parameters) runs comfortably on a CPU inference endpoint and trains in under 10 minutes on a GPU.

import numpy as np
from datasets import Dataset
from transformers import (
    AutoTokenizer,
    AutoModelForSequenceClassification,
    TrainingArguments,
    Trainer,
)
from sklearn.metrics import f1_score, accuracy_score

MODEL_NAME = 'distilbert-base-uncased'
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)


def tokenize_batch(batch):
    return tokenizer(batch['text'], truncation=True, max_length=512, padding='max_length')


train_ds = Dataset.from_dict({'text': X_train.tolist(), 'label': y_train.tolist()})
test_ds  = Dataset.from_dict({'text': X_test.tolist(),  'label': y_test.tolist()})

train_ds = train_ds.map(tokenize_batch, batched=True)
test_ds  = test_ds.map(tokenize_batch,  batched=True)

model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME, num_labels=2)


def compute_metrics(eval_pred):
    logits, labels = eval_pred
    preds = np.argmax(logits, axis=-1)
    return {
        'accuracy': accuracy_score(labels, preds),
        'f1': f1_score(labels, preds, average='binary'),
    }


args = TrainingArguments(
    output_dir='./phishing-clf',
    num_train_epochs=3,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    evaluation_strategy='epoch',
    save_strategy='epoch',
    load_best_model_at_end=True,
    metric_for_best_model='f1',
    learning_rate=2e-5,
    weight_decay=0.01,
    report_to='none',
)

trainer = Trainer(
    model=model,
    args=args,
    train_dataset=train_ds,
    eval_dataset=test_ds,
    compute_metrics=compute_metrics,
)

trainer.train()
trainer.save_model('./phishing-clf-final')
Enter fullscreen mode Exit fullscreen mode

Three epochs on a V100 takes around 8 minutes for 30k examples. On CPU inference, expect roughly 50ms per email — acceptable for a mail gateway integration.

Serving the Classifier

Wrap the trained model in a FastAPI endpoint. Keep the preprocessing pipeline identical to what you used at training time; skipping this step is the most common source of silent accuracy regression in production.

from fastapi import FastAPI
from pydantic import BaseModel
from transformers import pipeline as hf_pipeline

app = FastAPI()
_clf = hf_pipeline(
    'text-classification',
    model='./phishing-clf-final',
    tokenizer=MODEL_NAME,
    truncation=True,
    max_length=512,
)


class EmailRequest(BaseModel):
    subject: str
    body: str


@app.post('/classify')
def classify(req: EmailRequest):
    text = url_heuristics(f'{req.subject} {req.body}')
    result = _clf(text)[0]
    return {
        'phishing': result['label'] == 'LABEL_1',
        'confidence': round(result['score'], 4),
    }
Enter fullscreen mode Exit fullscreen mode

Wire this behind your mail gateway, an IMAP IDLE listener, or a milter integration and you have a classifier that updates with each monthly retrain cycle.

Production Considerations

A few things that are not obvious from the training code:

Retraining cadence. Phishing campaigns evolve continuously. Retrain monthly at minimum; weekly if analysts are feeding labeled examples from reported emails back into the pipeline.

Threshold tuning. The default 0.5 decision threshold is arbitrary. Plot the precision-recall curve for the phishing class and pick a threshold based on your organization's acceptable false-positive rate. A 0.3 threshold that catches 98% of phishing at the cost of 2% false positives may be better than a 0.5 threshold that catches 94% at 0.5%.

Header-based features. SPF/DKIM/DMARC pass/fail status, reply-to address mismatch, and routing anomalies are strong signals the body model cannot see. Add them as supplementary binary features fed into a stacked ensemble on top of the classifier's probability output.

Adversarial robustness. Attackers obfuscate text with Unicode lookalikes, zero-width characters, or image-only content. Normalize Unicode to NFKC before tokenization. For image-only attachments, run OCR before classification.

The classifier handles content analysis well, but it does not replace infrastructure controls. If you are building this as part of a broader email security program, the security hardening checklists at ayinedjimi-consultants.fr cover the SPF/DKIM/DMARC enforcement, attachment sandboxing, and link rewriting controls that the model alone cannot enforce.

The Takeaway

The TF-IDF baseline gets you to F1 ~0.93 in an afternoon and is worth shipping while you train the transformer. The fine-tuned language model pushes recall on phishing to ~0.97 and generalizes better to novel campaigns. In both cases: measure recall on the phishing class specifically, not overall accuracy; tune your decision threshold against a realistic false-positive budget; and plan for monthly retraining. A classifier trained six months ago without updates is losing ground to adversaries every day.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)