DELIGHT Cybersecurity Workbook Series

Spam Detection System

This workbook is protected. Enter the access code provided by your instructor to continue.

DELIGHT Cybersecurity Workbook Series

Email Spam Detection System

A complete, step-by-step guide to building an AI-powered spam classifier — from raw email data to scanning your real Gmail inbox.

PythonGoogle Colabscikit-learn KNNGroq / Llama 3NLPGmail API
Prerequisites — Google Cloud OAuth & Gmail API setup
Complete first

Before running any code, you must set up access to the Gmail API through Google Cloud Console. This is a one-time browser process — no Python coding required. Once complete, your Python script can securely read emails from your inbox to classify them.

Why is this necessary? Google does not allow any app to access Gmail by default. You must register your app, declare what data it needs (read-only inbox access), and obtain permission. This process is called OAuth 2.0 — think of it as getting a visitor badge before entering a secure building. Without it, the Gmail code in Section 8 will fail with a 403 access_denied error.

Do not skip this section. Errors caused by missing setup cannot be fixed in Python — they must be resolved here in Google Cloud Console before writing a single line of code.

Part A — Create a Google Cloud project & enable Gmail API

A Google Cloud project is a container for all your API settings and credentials. Every app that uses Google APIs requires one.

  • 1
    Go to console.cloud.google.com and sign in with your Google account.
  • 2
    Click the project dropdown at the top → click New Project. Name it something like spam-detector → click Create.
  • 3
    Ensure your new project is selected in the dropdown, then search for "Gmail API" in the top search bar.
  • 4
    Click the Gmail API result → click Enable. Wait for the confirmation screen before continuing.

Part B — Configure the OAuth consent screen

The consent screen is the permission pop-up users see when authorising your app. Even if you are the only user, Google requires this to be configured before it will issue credentials.

  • 1
    In the left sidebar go to APIs & Services → OAuth consent screen.
  • 2
    Select External as the user type → click Create. (External + Test Users is the right choice for personal and student projects.)
  • 3
    Fill in App name (e.g. Spam Detector), User support email, and Developer contact email. Click Save and Continue.
  • 4
    On the Scopes page, click Add or Remove Scopes. Search for gmail.readonly and tick https://www.googleapis.com/auth/gmail.readonly. Click UpdateSave and Continue. (Read-only scope means your app can read emails only — it cannot send, delete, or modify anything.)
  • 5
    Click Save and Continue on the Test Users page — you will add yourself in Part C.

Part C — Add yourself as a Test User

While your app is in Testing mode (the default for new projects), only explicitly approved accounts can complete the sign-in flow. You must add your own Gmail address here — otherwise the OAuth step in Section 8 will be blocked with a 403 error.

  • 1
    From the left sidebar go to APIs & Services → OAuth consent screen.
  • 2
    Scroll to the Test users section → click + Add Users.
  • 3
    Enter your full Gmail address → click Add → click Save.
  • 4
    Confirm your address appears in the Test users list. You can add up to 100 test users — useful for group projects.
Most common mistake: Forgetting to add yourself as a Test User. This is the single biggest cause of the 403: access_denied error students encounter in Section 8. Always complete Part C before running any Python code.

Part D — Create OAuth 2.0 credentials & download credentials.json

Now we create the credential file that Python will use to identify your app when making Gmail API requests. Think of it as a digital ID card for your application.

  • 1
    In the left sidebar go to APIs & Services → Credentials.
  • 2
    Click + Create Credentials → choose OAuth client ID.
  • 3
    Set Application type to Desktop app. Give it a name (e.g. Colab Client) → click Create. (Choosing "Web Application" will cause a redirect_uri_mismatch error.)
  • 4
    In the popup that appears, click Download JSON.
  • 5
    Rename the downloaded file to exactly credentials.json. Keep this file secure — treat it like a password. Never share it or commit it to GitHub.
You are ready! You now have:  ✓ Gmail API enabled  ·  ✓ OAuth consent screen configured  ·  ✓ Yourself added as a Test User  ·  ✓ credentials.json saved on your computer. Proceed to Section 1 to start coding.
1
Problem explanation — what is spam detection?
Intro

Spam emails are unsolicited messages sent in bulk — advertisements, scams, phishing attempts, and malware delivery. They clog inboxes, waste time, and cause real harm when users click malicious links or open dangerous attachments. The scale is enormous: roughly 45% of all email sent globally is spam.

Traditional anti-spam methods relied on blocklists of known bad senders and hardcoded keyword filters. The problem? Spammers constantly rotate domains and rephrase messages to evade those rules. Machine learning changes this: instead of memorising bad patterns, we teach a model to recognise the statistical fingerprints of spam — word distributions, email lengths, language style — and apply those learned patterns to emails it has never seen before.

What this workbook builds — end to end

By the end of this workbook, you will have:

  • Loaded and explored a real email dataset with 5,171 labelled messages
  • Cleaned and preprocessed raw email text for machine learning
  • Trained a K-Nearest Neighbours (KNN) classifier on word frequency features
  • Used Groq / Llama 3 (a free AI API) to classify emails with zero training data
  • Connected the classifier to your real Gmail inbox to run a live spam scan
  • Exported the flagged spam results to a CSV for review

Two approaches — and why both matter

🏭 Traditional ML — KNN

A K-Nearest Neighbours classifier trained on thousands of labelled emails. It learns which word patterns appear in spam vs ham and votes on new emails by similarity. ~83% accuracy.

🤖 AI — Groq / Llama 3

A large language model (LLM) that classifies emails using deep language understanding. Zero training required — the model already understands language and uses a single prompt to decide spam vs ham.

💌 Live Gmail Integration

We connect to your real Gmail inbox via the Gmail API (OAuth 2.0). Your 10 most recent emails are fetched and classified automatically — real results, not sample data.

Binary classification — the core concept

A spam detector is a binary classifier. It reads an email and outputs one of two labels:

  • Spam (label = 1) — unwanted, unsolicited email that should be filtered out
  • Ham (label = 0) — legitimate email that should reach the inbox. "Ham" is the traditional NLP term for non-spam — it has no other special meaning.
Two approaches in one workbook:
❶ A KNN model trained on thousands of labelled email examples — ~83% accuracy.
Groq / Llama 3 — a free LLM that classifies with a zero-shot prompt and no training step at all.

The core idea — pseudocode first

Before diving into real code, here is the conceptual logic at the heart of any spam classifier:

Pseudocode — concept only, not executable
# Simplified logic — the real classifier learns this automatically from
# thousands of examples rather than relying on hardcoded rules like these

if message_contains_spam_keywords(message):   # e.g. "free", "winner", "click here"
    classify_as_spam()
else:
    classify_as_ham()

# The ML model learns which words, patterns and lengths appear in spam
# vs ham emails — then applies those patterns to new, unseen messages.
# It does NOT need explicit rules — it discovers them from the data.
Environment: All code in this workbook runs inside Google Colab — a free, browser-based Python notebook. No installation required. Open colab.research.google.com, create a new notebook, and paste each section's code into a separate cell. Run cells with Shift+Enter or the ▶ button.
2
Importing libraries — your toolbox
Setup

The first cell of any Python project imports the libraries (pre-built packages) you will use. Think of this as laying out your tools before starting a job. In Google Colab, all of these come pre-installed — no extra setup needed.

Run this cell every time you open the notebook. Python imports are session-based — if Colab restarts due to inactivity or a runtime error, all imports are erased and must be re-run from the top.

What each library does in this project

LibraryPurpose in this workbook
numpyEfficient numerical arrays. Used behind the scenes by scikit-learn for matrix operations during model training.
pandasLoads the CSV dataset into a DataFrame (think: Excel in Python). Used to filter, group, and display email data throughout.
seabornBuilds statistical charts. We use it to visualise the spam vs ham class distribution and email length boxplots.
matplotlibThe base plotting library that seaborn builds on. We call plt.show() to render charts in the notebook output cell.

Additional libraries (sklearn, groq, google-auth) are imported in the sections where they are first needed, keeping each section self-contained and easier to debug.

Python — Cell 1
# ─── Core data science libraries ──────────────────────────────────────────
import numpy as np           # numerical arrays and math operations
import pandas as pd          # DataFrame (table) operations
import seaborn as sns         # statistical visualisations (bar charts, boxplots)
import matplotlib.pyplot as plt  # display charts in notebook output

print('✓ Libraries imported successfully')
Missing package? If you see a ModuleNotFoundError, run !pip install seaborn in a new cell above. The ! prefix tells Colab to run a shell command rather than Python code. This is rarely needed since most packages are pre-installed on Colab's default runtime.
3
Dataset exploration — understanding the data
EDA

Before training any model, a data scientist always starts with Exploratory Data Analysis (EDA) — getting to know the dataset's structure and quirks before touching the model. Skipping EDA leads to wasted training time and confusing results. Think of it as reading the manual before operating new equipment.

Getting the dataset

We use the spam_ham_dataset.csv from Kaggle — a collection of 5,171 real emails labelled as spam or ham. Download it from: kaggle.com → venky73/spam-mails-dataset

Once downloaded, upload it to your Colab session via the Files panel (folder icon in the left sidebar → Upload) or by running from google.colab import files; files.upload() in a cell.

Understanding the dataset columns

ColumnTypeWhat it containsRole in the model
Unnamed: 0intRow index — a sequential number assigned when the CSV was savedNot used — ignored during training
labelstr"spam" or "ham" — the human-readable class nameUsed for display and analysis only
textstrFull email body text, exactly as receivedMain input feature — what the model reads
label_numint0 = ham, 1 = spam — the numeric version of the labelTarget variable y — what the model predicts
Key insight: Our entire feature set is the text column — raw strings of words. ML models cannot read text directly. Section 5 explains how we convert it into the numbers a model can process.
Python — Cell 2
# Load the dataset into a pandas DataFrame
data = pd.read_csv('spam_ham_dataset.csv')

# ── Quick overview ─────────────────────────────────────────────────────────
print(f'Dataset shape: {data.shape}')         # (rows, columns) — expect ~(5172, 4)
print(f'\nColumn names:  {data.columns.tolist()}')
print(f'\nData types:\n{data.dtypes}')
print(f'\nClass distribution:\n{data["label"].value_counts()}')

# Preview the first 5 rows to confirm the data loaded correctly
data.head()
What to expect: Around 5,171 rows, 4 columns. The class split is approximately 60% ham and 40% spam — slightly imbalanced, which we explore in Section 4.
Python — Cell 3
# Print one raw email from each class to build intuition before preprocessing
print("=== Example HAM (legitimate) email (first 300 chars) ===")
print(data[data['label'] == 'ham']['text'].iloc[0][:300])

print("\n=== Example SPAM email (first 300 chars) ===")
print(data[data['label'] == 'spam']['text'].iloc[0][:300])
Observation: Even reading a few raw emails reveals obvious patterns — spam emails often contain capitalised words like "FREE", "WIN", "OFFER", exclamation marks, urgency phrases, and suspicious links. Ham emails look like normal human correspondence. The classifier will learn to detect these statistical differences automatically, without any hardcoded rules.
4
Data analysis & cleaning — preparing text for ML
NLP Preprocessing

Raw email text is messy — it contains punctuation, mixed capitalisation, and common "filler" words like "the" and "is" that carry no spam signal. Machine learning works best on clean, consistent input. This section walks through four steps: class balance check, word frequency analysis, email length analysis, and text cleaning.

Step 1 — Visualise the class balance

Before any cleaning, we check how many emails are spam vs ham. This matters because an imbalanced dataset can mislead a model. If 90% of emails are ham and 10% are spam, a model that always guesses "ham" scores 90% accuracy while being completely useless at detecting spam. Understanding the balance helps us interpret results honestly.

Python — Cell 4
# ── Class distribution bar chart ─────────────────────────────────────────
class_counts = data['label'].value_counts()

plt.figure(figsize=(8, 5))
sns.barplot(x=class_counts.index, y=class_counts.values,
            palette=['#00e676', '#ff4d6d'])
plt.title('Class Distribution: Ham vs Spam', fontweight='bold')
plt.ylabel('Number of Emails')
plt.xlabel('Class Label')
plt.show()

print(f'Ham  (legitimate): {class_counts["ham"]} emails ({class_counts["ham"]/len(data)*100:.1f}%)')
print(f'Spam:              {class_counts["spam"]} emails ({class_counts["spam"]/len(data)*100:.1f}%)')

Step 2 — Word frequency analysis

Before cleaning, we inspect which words appear most often in spam vs ham emails. This builds intuition about which words are strong spam signals. Words like "free", "winner", "prize", and "click" are far more common in spam. This is exactly the kind of pattern the KNN classifier will learn to detect. We write a reusable helper function so we can run the same analysis before and after cleaning to compare.

Python — Cell 5
from collections import Counter

def get_most_common_words(label, num_words, column_name, data):
    """
    Returns the N most common words in all emails with a given label.
    Parameters:
        label       : 'spam' or 'ham'
        num_words   : how many top words to return
        column_name : which column to read text from ('text' or 'cleaned_txt')
        data        : the DataFrame
    """
    # Join all emails of this class into one long string, split into words
    text  = " ".join(msg for msg in data[data['label'] == label][column_name])
    words = text.lower().split()
    return Counter(words).most_common(num_words)

# Raw text (before cleaning) — includes stopwords and punctuation
ham_words  = get_most_common_words("ham",  20, "text", data)
spam_words = get_most_common_words("spam", 20, "text", data)

print("Top 20 words in HAM emails (before cleaning):")
print(ham_words)
print("\nTop 20 words in SPAM emails (before cleaning):")
print(spam_words)
What you will notice: Before cleaning, most top words are stopwords like "the", "to", "and", "a" — identical in both ham and spam. These are useless for classification. After cleaning (Step 4 below), distinctive spam words like "free", "offer", "click" emerge clearly. This contrast demonstrates exactly why cleaning is worth doing.

Step 3 — Email length analysis

Email length is a surprisingly useful signal. Spam emails tend to be either very short (one-liners promoting a product) or very long (walls of text hiding a malicious link). A boxplot of character length by class shows whether length alone carries predictive value and helps us understand the range of data our model will work with.

Python — Cell 6
# Create a new column with the character length of each email
data['text_length'] = data['text'].apply(len)

# Summary statistics: mean, median, min, max per class
print('Email length statistics by class:')
print(data.groupby('label')['text_length'].describe())

# Boxplot: box = middle 50% of data, centre line = median, dots = outliers
plt.figure(figsize=(10, 5))
sns.boxplot(x='label', y='text_length', data=data,
            palette={'ham': '#00e676', 'spam': '#ff4d6d'})
plt.title('Email Character Length by Class', fontweight='bold')
plt.ylabel('Character Count')
plt.xlabel('Label')
plt.ylim(0, 5000)   # cap at 5000 to stop extreme outliers dominating the chart
plt.show()

Step 4 — Text cleaning: lowercasing, removing punctuation & stopwords

This is the most important preprocessing step. We transform raw email text into clean text in three stages. Each stage reduces noise and helps the model focus on what matters:

1
Lowercasing — "FREE" and "free" are the same word. Without lowercasing, the model treats them as two completely separate features, doubling vocabulary noise.
2
Removing punctuation — Commas, exclamation marks, and brackets carry no semantic meaning for spam classification. Removing them reduces vocabulary size and eliminates noise characters.
3
Removing stopwords — Words like "the", "is", "a", "to", "and" appear equally in every email and add nothing to classification. scikit-learn provides a built-in English stopword list of ~318 words.
Common naming bug — read carefully: Some versions of this workbook use data['cleaned.txt'] (with a dot). Pandas interprets the dot as an attribute accessor, causing a KeyError. The correct column name throughout this workbook is data['cleaned_txt'] with an underscore. Check your code uses the underscore version consistently.
Python — Cell 7
import string
from sklearn.feature_extraction.text import ENGLISH_STOP_WORDS

def preprocess_text(text):
    """
    Clean a single email string:
      1. Lowercase all characters  →  "FREE" becomes "free"
      2. Remove punctuation        →  "Click!" becomes "Click"
      3. Remove English stopwords  →  "the", "is", "a" etc. stripped out
    Returns a cleaned string ready for CountVectorizer.
    """
    # Step 1: lowercase — unifies different capitalisations of the same word
    text = text.lower()
    # Step 2: remove all punctuation using Python's built-in string.punctuation
    text = text.translate(str.maketrans("", "", string.punctuation))
    # Step 3: split into words, filter out stopwords, rejoin into a clean string
    words = text.split()
    words = [w for w in words if w not in ENGLISH_STOP_WORDS]
    return ' '.join(words)

# Apply cleaning to every email — use 'cleaned_txt' (underscore, not dot)
data['cleaned_txt'] = data['text'].apply(preprocess_text)

print('✓ Text cleaning applied to all emails')
print('\nOriginal email (first 200 chars):')
print(data['text'].iloc[0][:200])
print('\nCleaned version:')
print(data['cleaned_txt'].iloc[0][:200])
data.head()
Python — Cell 8
# Compare top words AFTER cleaning — stopwords are now gone
# Distinctive spam words should now be clearly visible in the spam list
ham_words_clean  = get_most_common_words("ham",  20, "cleaned_txt", data)
spam_words_clean = get_most_common_words("spam", 20, "cleaned_txt", data)

print("Top 20 words in cleaned HAM emails:")
print(ham_words_clean)
print("\nTop 20 words in cleaned SPAM emails:")
print(spam_words_clean)
Expected improvement: After cleaning, the spam top-words shift to revealing ones: "free", "offer", "click", "unsubscribe", "money", "win". These are the words the KNN model will use as spam signals. Ham emails show conversational words like "write", "thanks", "know", "good". The contrast is now much sharper.
5
Feature engineering — turning words into numbers (CountVectorizer)
NLP → Features

Machine learning models work exclusively with numbers — they cannot read or understand words directly. We need to convert each email's cleaned text into a numerical representation that captures which words appear and how often. This process is called feature engineering.

The Bag of Words (BoW) model

We use the Bag of Words approach. The process works in three stages:

1
Build a vocabulary — scan all 5,171 emails and collect every unique word. With this dataset, this creates a vocabulary of roughly ~50,000 unique words.
2
Create a feature matrix — for each email, create a row of numbers where each number represents how many times that word appears in that email. Most values are 0 (the email doesn't contain most vocabulary words).
3
Result — a matrix of shape (5,171 emails × ~50,000 words). Each email is now a numerical vector the model can read and process.

Why "Bag"? — and its limitation

It's called a "bag" because the model throws all words into a bag and counts them — it ignores word order and sentence structure. "The cat sat on the mat" and "The mat sat on the cat" produce identical feature vectors. This is a known limitation, but for spam detection it works well because spam keywords ("free", "click", "winner") matter more than their sentence position.

Sparse matrices — saving memory efficiently

The resulting matrix is sparse — mostly zeros, because any single email only uses a tiny fraction of all ~50,000 possible words. A dense matrix storing every zero would be enormous. scikit-learn stores only the non-zero values using a compressed format, which is far more efficient. You will see scipy.sparse matrix printed when you check the type.

CountVectorizer handles everything automatically: it scans all emails to build the vocabulary, converts each email into its word-count vector, and returns a sparse matrix ready for model training. We just pass it our cleaned text and it does the rest.
Python — Cell 9
from sklearn.feature_extraction.text import CountVectorizer

# ── Initialise CountVectorizer ─────────────────────────────────────────────
# fit_transform() does two things in one step:
#   .fit()       — scans all emails and builds the vocabulary
#   .transform() — converts each email into its word-count vector
vectorizer = CountVectorizer()

# X = feature matrix (inputs to the model)
# y = target vector (labels: 0 = ham, 1 = spam)
X = vectorizer.fit_transform(data['cleaned_txt'])
y = data['label_num']

print(f'Feature matrix shape: {X.shape}')
# Expected output: (5172, ~50179) — 5172 emails, ~50179 unique vocabulary words
print(f'Matrix type:          {type(X)}')
# Shows 'scipy.sparse matrix' — only non-zero values are stored in memory
print(f'Vocabulary size:      {len(vectorizer.vocabulary_)}')
print(f'\nTarget class distribution:\n{y.value_counts()}')
Critical rule — fit_transform vs transform: We call fit_transform() only once on the training data to build the vocabulary. When classifying new emails (like your real Gmail inbox), we use only .transform()never fit_transform(). Calling fit_transform() again would rebuild the vocabulary from new data, changing the feature dimensions and breaking the trained model completely.
6
Training & evaluating the KNN model
Model Training

Now we train our first classifier. We use K-Nearest Neighbours (KNN) — a simple but intuitive algorithm that classifies new emails by comparing them to labelled examples it has already memorised.

How KNN works — step by step

1
Memory-based learning. Unlike most models, KNN does not build a mathematical formula during training. It simply memorises all training examples — their word-count vectors and their spam/ham labels.
2
At prediction time, a new email arrives. The model converts it into a word-count vector and computes its distance to every training email stored in memory.
3
Find the K nearest neighbours. With k=5, the model identifies the 5 most similar emails in the training set, measured by Euclidean distance across 50,000 word dimensions.
4
Majority vote. If 4 of the 5 nearest neighbours are spam, the new email is classified as spam. If 4 are ham, it is classified as ham. Simple, interpretable, effective.

Why an 80/20 train-test split?

We hold back 20% of the data for testing — the model never sees these emails during training. Imagine studying for an exam using the exact questions that will appear: you'd do perfectly without having actually learned anything. Holding back a test set gives an honest measure of how well the model generalises to new, unseen emails — which is what matters in a real inbox.

Known limitation of KNN for text: With ~50,000 features, computing distances between every pair of emails is computationally expensive and can be noisy in high-dimensional space. KNN accuracy of ~83% is decent but well below the >97% achievable with Naive Bayes or Random Forest on this dataset. Section 7 shows how a modern LLM can match or exceed this with zero training.
Python — Cell 10
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score, classification_report

# ── 80/20 train-test split ────────────────────────────────────────────────
# random_state=42 makes the split reproducible — every student gets
# the same train/test division so results can be directly compared.
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

print(f'Training set: {X_train.shape[0]} emails ({X_train.shape[0]/len(y)*100:.0f}% of data)')
print(f'Test set:     {X_test.shape[0]} emails ({X_test.shape[0]/len(y)*100:.0f}% of data)')

# ── Initialise and train KNN ──────────────────────────────────────────────
# n_neighbors=5: find 5 most similar training emails, then majority-vote their labels
knn = KNeighborsClassifier(n_neighbors=5)

print('\n⏳ Training KNN (may take 30–60 seconds on Colab)...')
knn.fit(X_train, y_train)
print('✓ Training complete')

# ── Generate predictions on the held-out test set ─────────────────────────
y_pred = knn.predict(X_test)

# ── Overall accuracy ──────────────────────────────────────────────────────
knn_accuracy = accuracy_score(y_test, y_pred)
print(f'\n✅ KNN Accuracy: {knn_accuracy:.4f} ({knn_accuracy*100:.2f}%)')
print('\n📊 Full Classification Report:')
print(classification_report(y_test, y_pred, target_names=['ham', 'spam']))
Expected result: KNN accuracy ≈ 83.3%. The model performs well on ham (high recall — correctly identifies most legitimate emails) but is less precise on spam, producing some false alarms. The classification report breaks this down per class.

Reading the classification report — four metrics explained

Accuracy alone is not enough to evaluate a spam filter. The report gives four numbers per class. Understanding each one is essential for any security application:

MetricWhat it measuresWhy it matters for spam
PrecisionOf all emails flagged as spam, how many actually were spam? (True Spam / All Flagged)Low precision = legitimate emails end up in the spam folder — can cause missed important messages
RecallOf all actual spam emails, how many were caught? (True Spam / All Real Spam)Low recall = spam slips through to the inbox — annoying and potentially dangerous
F1-ScoreHarmonic mean of precision and recall — a balanced single scoreUseful when both error types matter and you want one number to optimise
SupportNumber of actual emails of this class in the test setTells you if the results are based on enough examples to be statistically trustworthy
Security trade-off: Missing a spam email (low recall) is annoying but usually harmless. Flagging a legitimate email as spam (low precision) can cause a user to miss an important message — sometimes more serious. The right balance depends on the use case: corporate systems often favour high precision (protect real emails) while personal filters favour high recall (catch all spam).
7
Groq / Llama 3 — AI classifier with zero training
LLM Classification

The KNN model required training on thousands of labelled emails to learn spam patterns. A Large Language Model (LLM) takes a completely different approach — it already understands language deeply from pre-training on vast text corpora, and can classify emails using just a clear natural-language instruction (a "prompt"). No labelled dataset, no training step, no CountVectorizer.

We use Groq — a free API that provides access to Meta's open-source Llama 3 model. Groq's specialised hardware makes inference extremely fast, and the free tier is generous enough for student projects.

How zero-shot prompt-based classification works

1
We write a clear system instruction: "You are an email spam classifier. Output only 0 (ham) or 1 (spam)." This sets the model's role and constrains the output to a single character.
2
We include the first 500 characters of the email as user input. We truncate to 500 chars to keep API calls fast and stay within Groq's free-tier rate limits.
3
The model responds with a single character: 0 or 1. We set max_tokens=5 and temperature=0 — no randomness means consistent, deterministic classifications.
4
We parse the response and fall back to 0 (ham) if the model returns anything unexpected. Error handling prevents one bad API response from crashing the entire classification loop.

Getting your free Groq API key

  • Go to console.groq.com
  • Sign up with your Google account — no credit card required
  • Click API KeysCreate API Key → copy the key
  • In Colab, click the 🔑 Secrets icon in the left sidebar → add a new secret named GROQ_API_KEY and paste your key as the value
Why Colab Secrets? Pasting API keys directly in code is dangerous — if you share your notebook, everyone sees your key and can use it. Colab Secrets store keys in an encrypted vault only your session can read, accessed via userdata.get('KEY_NAME'). The key is never visible in your notebook output or version history.
Python — Cell 11
# Install the Groq Python client (run once per Colab session)
!pip install -q groq
Python — Cell 12
from groq import Groq
from google.colab import userdata
import time

# Load the API key from Colab Secrets — never paste keys directly into code
client = Groq(api_key=userdata.get('GROQ_API_KEY'))
print('✅ Groq client ready — connected to Llama 3')
Python — Cell 13
def classify_with_groq(email_text):
    """
    Classify a single email as spam (1) or ham (0) using Llama 3 via Groq.
    Sends the first 500 characters. temperature=0 = deterministic output.
    Returns: int — 0 (ham) or 1 (spam)
    """
    prompt = f"""You are an email spam classifier.
Classify the following email as either spam or ham (not spam).
Reply with ONLY the number 1 if it is spam, or 0 if it is ham.
Do not explain your reasoning. Output ONLY 0 or 1.

Email:
{email_text[:500]}
"""
    try:
        response = client.chat.completions.create(
            model="llama-3.3-70b-versatile",
            messages=[{"role": "user", "content": prompt}],
            max_tokens=5,    # we only need 1 character (0 or 1)
            temperature=0     # no randomness — consistent output
        )
        result = response.choices[0].message.content.strip()
        return int(result) if result in ['0', '1'] else 0
    except Exception as e:
        print(f'  ⏳ Rate limit hit — waiting 10s... ({e})')
        time.sleep(10)
        return classify_with_groq(email_text)   # recursive retry

print('✅ classify_with_groq() defined and ready')
Python — Cell 14
from sklearn.metrics import accuracy_score, classification_report

sample_size = 10
test_sample = data.sample(n=sample_size, random_state=42)
groq_preds  = []

print(f'Classifying {sample_size} emails with Groq (Llama 3)...\n')
for i, email in enumerate(test_sample['text']):
    pred = classify_with_groq(email)
    groq_preds.append(pred)
    print(f'  Email {i+1:>2}/{sample_size} → {"SPAM" if pred==1 else "HAM"}')
    time.sleep(1)   # pause between calls to respect rate limits

groq_accuracy = accuracy_score(test_sample['label_num'], groq_preds)
print(f'\n✅ Groq/Llama 3 accuracy on {sample_size} samples: {groq_accuracy:.4f}')
print('\n📊 Classification Report:')
print(classification_report(
    test_sample['label_num'], groq_preds, target_names=['ham', 'spam']
))

Interpreting the results — what the numbers tell us

Below is a typical result. Your exact numbers may vary since the sample is only 10 emails. The key insight is the trade-off between precision and recall when an LLM is over-cautious:

ClassMetricTypical valueWhat this means in plain English
HAMPrecision1.00Every email the model called HAM was genuinely legitimate — zero false alarms on the ham side
HAMRecall0.78It found 7 of 9 real HAM emails — 2 legitimate emails were misclassified as spam (over-cautious)
HAMF1-Score0.88Strong overall HAM performance despite the over-caution
SPAMPrecision0.33Only 1 of 3 emails it labelled SPAM was actual spam — 2 HAM emails were falsely flagged
SPAMRecall1.00It caught the 1 real SPAM email — it never missed actual spam in this sample
SPAMF1-Score0.50Weak SPAM performance on this small sample due to false positives
Key takeaway: Llama 3 is cautious — it would rather flag a legitimate email as spam than miss a real one. On a 10-email sample, one wrong classification = 10% accuracy drop, so these numbers aren't statistically reliable on their own. The important lesson: LLMs can classify emails without any training data and still compete with a KNN trained on thousands of labelled examples. This is the power of zero-shot learning.
Connect Gmail — scan your real inbox
Gmail API + OAuth

This is where the project becomes real. Instead of classifying sample emails from a dataset, we connect to your actual Gmail inbox using the Gmail API and run a live spam scan. You completed the Google Cloud setup in Section 0 — this section runs the Python code that uses those credentials.

Reminder: If you have not completed the OAuth setup in Section 0 — creating the Cloud project, enabling the Gmail API, adding yourself as a Test User, and downloading credentials.json — do that first. The code below will fail without it.

How the OAuth 2.0 flow works in code

OAuth 2.0 is the security protocol that lets your Python code access Gmail without needing your password directly. Here is the four-step flow:

1
You upload credentials.json to Colab. This file is your app's identity document — it tells Google which app is requesting permission.
2
The code generates a Google sign-in URL and prints it. You open it in a new browser tab, sign in with your Gmail, and click Allow.
3
Google gives you a one-time authorisation code. You copy it and paste it into the Colab input box, then press Enter.
4
The code exchanges this code for an access token, saves it to token.json, and builds a Gmail service object. On future runs, if token.json is still valid, steps 1–3 are skipped automatically.
Python — Cell 15
# Install Google API client libraries (pre-installed on Colab, but just in case)
!pip install -q google-auth google-auth-oauthlib google-api-python-client
Python — Cell 16
import os
from google.colab import files
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build

print('📁 A file picker will appear — select your credentials.json file')
uploaded = files.upload()

# Read-only scope: can read emails only — cannot send, delete, or modify
SCOPES           = ['https://www.googleapis.com/auth/gmail.readonly']
CREDENTIALS_PATH = 'credentials.json'
TOKEN_PATH       = 'token.json'

# Try to load a saved token from a previous run
creds = None
if os.path.exists(TOKEN_PATH):
    creds = Credentials.from_authorized_user_file(TOKEN_PATH, SCOPES)

# Run the OAuth flow if no valid token exists
if not creds or not creds.valid:
    if creds and creds.expired and creds.refresh_token:
        creds.refresh(Request())          # silently refresh expired token
    else:
        flow = Flow.from_client_secrets_file(
            CREDENTIALS_PATH, scopes=SCOPES,
            redirect_uri='urn:ietf:wg:oauth:2.0:oob'  # out-of-band flow for Colab
        )
        auth_url, _ = flow.authorization_url(prompt='consent')
        print('='*60)
        print('👉 STEP 1: Open this URL in a new browser tab:')
        print('='*60)
        print(auth_url)
        print('='*60)
        print('👉 STEP 2: Sign in with your Gmail and click Allow')
        print('👉 STEP 3: Copy the authorisation code shown by Google')
        print('='*60)
        code = input('📋 STEP 4: Paste the code here and press Enter: ')
        flow.fetch_token(code=code)
        creds = flow.credentials

    with open(TOKEN_PATH, 'w') as token:
        token.write(creds.to_json())
    print('✅ Token saved to token.json')

gmail_service = build('gmail', 'v1', credentials=creds)
print('✅ Gmail connected successfully!')
Expected flow: A URL prints → open it in a new tab → sign in → click Allow → Google shows you a code → copy it → paste into the Colab input box → press Enter → you see ✓ Gmail connected successfully!

Email fetching functions

Gmail's API returns emails as nested JSON with base64-encoded bodies. Two helper functions handle this complexity. get_email_body() recursively navigates Gmail's nested payload structure to extract plain text. fetch_inbox_emails() calls the API and returns a clean list of email dictionaries. You do not need to understand the Gmail API internals to use these.

Python — Cell 17
import base64

def get_email_body(payload):
    """
    Recursively extract plain text body from a Gmail message payload.
    Gmail stores bodies in a nested 'parts' structure. Some emails have
    a single body; others have multiple parts (plain text + HTML).
    We extract plain text and ignore HTML parts.
    """
    if 'parts' in payload:
        for part in payload['parts']:
            result = get_email_body(part)   # recursive call for nested parts
            if result: return result
    if payload.get('mimeType') == 'text/plain':
        data = payload.get('body', {}).get('data', '')
        if data:
            # Gmail encodes body bytes as URL-safe base64 — decode to readable text
            return base64.urlsafe_b64decode(data).decode('utf-8', errors='ignore')
    return ''

def fetch_inbox_emails(service, max_results=10):
    """Fetch N most recent emails. Returns list of {id, subject, sender, body}."""
    results  = service.users().messages().list(
        userId='me', labelIds=['INBOX'], maxResults=max_results
    ).execute()
    messages = results.get('messages', [])
    emails   = []
    for msg in messages:
        full_msg = service.users().messages().get(
            userId='me', id=msg['id'], format='full'
        ).execute()
        headers = full_msg['payload']['headers']
        subject = next((h['value'] for h in headers if h['name']=='Subject'), 'No Subject')
        sender  = next((h['value'] for h in headers if h['name']=='From'),    'Unknown')
        body    = get_email_body(full_msg['payload'])
        emails.append({'id':msg['id'], 'subject':subject, 'sender':sender, 'body':body})
    print(f'✅ Fetched {len(emails)} emails from inbox')
    return emails

inbox_emails = fetch_inbox_emails(gmail_service, max_results=10)
Python — Cell 18 — Run live spam detection
import time

print('🔍 Running spam detection on your inbox...\n')
print(f"{'#':<4} {'VERDICT':<10} {'SENDER':<35} {'SUBJECT'}")
print('─' * 95)

results = []
for i, email in enumerate(inbox_emails, 1):
    # Subject + body combined — subject often has the most direct spam signals
    text_to_classify = email['subject'] + ' ' + email['body']
    pred    = classify_with_groq(text_to_classify)
    verdict = '🚨 SPAM' if pred == 1 else '✅ HAM'
    subject_short = email['subject'][:40] + ('...' if len(email['subject']) > 40 else '')
    sender_short  = email['sender'][:33]
    results.append({'subject':email['subject'],'sender':email['sender'],
                     'verdict':'spam' if pred==1 else 'ham','pred':pred})
    print(f"{i:<4} {verdict:<10} {sender_short:<35} {subject_short}")
    time.sleep(2)   # 2-second pause — respects Groq free-tier rate limits

spam_count = sum(1 for r in results if r['pred'] == 1)
ham_count  = len(results) - spam_count
print(f"\n{'='*60}")
print(f"📊 Summary: {ham_count} HAM  |  {spam_count} SPAM  |  {len(results)} total scanned")
print(f"{'='*60}")
Python — Cell 19 — Export results
import pandas as pd

results_df = pd.DataFrame(results)
print(results_df[['verdict', 'sender', 'subject']].to_string(index=False))

# Save flagged spam to CSV — download from Colab Files panel (left sidebar)
spam_df = results_df[results_df['verdict'] == 'spam']
spam_df.to_csv('flagged_spam.csv', index=False)
print(f'\n✅ {len(spam_df)} spam email(s) saved to flagged_spam.csv')
results_df.to_csv('all_scan_results.csv', index=False)
print(f'✅ All {len(results_df)} results saved to all_scan_results.csv')
print('\nDownload both files from the Files panel in the left sidebar.')

Troubleshooting common Gmail API errors

Error messageCauseFix
403: access_deniedYour Gmail address is not on the Test Users listGo to Section 0 Part C and add your email as a test user
redirect_uri_mismatchWrong application type selected for credentialsCredentials → edit OAuth client → change type to Desktop app
Access blocked — app not verifiedApp is in Testing mode (expected, normal)Click AdvancedGo to [app] (unsafe). Normal for student test apps.
Empty body on some emailsEmail is HTML-only with no plain text partClassifier will use the subject line only. Slightly lower accuracy on these.
429 Rate limit from GroqAPI calls made too rapidlyIncrease the time.sleep() value in Cell 18 to 3 or 5 seconds
Privacy note: This workbook reads your real inbox and sends email text to Groq's API for classification. Groq's privacy policy applies to any data sent. For classroom demos, consider using a dedicated test Gmail account rather than your personal inbox. Never run this on a corporate or institutional email account without authorisation from your IT department.
Live email spam tester — try it right here in your browser
Interactive Demo

This interactive panel lets you test spam detection heuristics instantly — no Python, no Colab, no API key needed. Enter a subject line and email body below and click Analyse. The browser scores the email against patterns derived from the same signals the KNN model learns during training.

This is a browser-side heuristic demo, not the trained model. It uses keyword matching and pattern scoring to illustrate concepts. Run the Python KNN or Groq model in Colab for authoritative results. Use this panel to build intuition about what makes an email look like spam.

What the analyser checks

The scorer looks for a combination of signals that are statistically more common in spam than in legitimate email:

  • Spam keyword density — words and phrases like "free", "winner", "urgent", "click here", "act now", "limited time", "guaranteed"
  • Excessive capitalisation — ALL CAPS words signal urgency and shouting common in spam
  • Exclamation mark count — multiple exclamation marks are a classic spam indicator
  • Suspicious link patterns — raw IP addresses used as domains, or hyphens in domain names
  • Short high-pressure text — very short emails that contain multiple spam keywords
  • Price/percentage references — multiple monetary references common in promotional spam
📩 Interactive Spam Analyser — Try It Here

Enter a subject and body from any email you want to test. Click Analyse to see a spam probability score and a signal breakdown.

Going further — project extension ideas

  • Improve KNN accuracy: Try TfidfVectorizer instead of CountVectorizer — it downweights common words and emphasises rare, informative ones, often giving a 5–10% accuracy boost.
  • Try Naive Bayes: Replace KNN with MultinomialNB — the classic spam algorithm that typically achieves 95%+ accuracy on this dataset and trains in milliseconds.
  • Compare models side by side: Run KNN, Naive Bayes, and Groq/Llama 3 on the same test emails. Which misses spam? Which produces false alarms? Build a comparison table.
  • Auto-label in Gmail: Extend the code to use Gmail's labels endpoint to automatically add a "Suspected Spam" label to flagged emails in your inbox.
  • Scan more emails: Increase max_results in fetch_inbox_emails() and add longer sleep times to process your full inbox history.
  • Build a Streamlit app: Wrap the classifier in a streamlit web interface so anyone can paste an email and get an instant verdict — no Colab needed.