Phishing Sim

📨 First Internship — Ethical Phishing Simulation Platform (Practical Guide)

A complete, hands-on walkthrough for building a safe phishing simulation platform with Flask + SQLite, including your actual project code and detailed explanations.

Sep 15, 2025 Safety & Ethics

Overview

This guide shows how to build an ethical phishing simulation platform for training and awareness. The example stack is intentionally small (Flask + SQLite) so it's easy to run locally and understand every part. Everything below is designed for testing with explicit consent and dummy recipients.

  • Local-first architecture — run on your machine or staging environment.
  • No production sending to unknown recipients — always use test inboxes.
  • Mock feedback page — do not collect user credentials.

Objectives

  • Create reusable email templates (safe test content).
  • Send emails via SMTP in a controlled manner.
  • Track opens (pixel) and clicks (redirects) and record them in SQLite.
  • Provide post-click education and a feedback flow.
  • Generate simple CSV / SQL reports for analysis.

Tools & Technologies

• Python 3.10+, Flask — small backend for sending & tracking
• SQLite — single-file event storage (easy to inspect)
• SMTP (Gmail App Password for testing) — safe sending to test addresses
• Docker — containerization for consistent environments
• jq / sqlite3 — CLI utilities for reporting and inspection

Walkthrough — your actual code (app.py)

Below is your `app.py` (verbatim). After the code block you'll find a clear explanation of each important part so readers can run, understand, and adapt it.

.py
app.py (verbatim)
# app.py
import os
import sqlite3
import json
import uuid
from datetime import datetime
from flask import Flask, render_template, request, redirect, url_for, make_response
import smtplib, ssl
from email.mime.text import MIMEText

from config import DB_PATH, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, FROM_EMAIL, SECRET_KEY

import smtplib
from email.mime.text import MIMEText

app = Flask(__name__)
app.config["SECRET_KEY"] = SECRET_KEY

# DB helpers
def db_conn():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn

def now_iso():
    return datetime.utcnow().isoformat()
# robust send_email (replace existing one)
import smtplib, ssl
from email.mime.text import MIMEText
from config import SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, FROM_EMAIL

def send_email(to_email, subject, html_body):
    msg = MIMEText(html_body, "html")
    msg["Subject"] = subject
    msg["From"] = FROM_EMAIL
    msg["To"] = to_email

    context = ssl.create_default_context()
    try:
        with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as server:
            server.ehlo()
            caps = server.esmtp_features
            print(f"[SMTP] connected to {SMTP_HOST}:{SMTP_PORT}, capabilities: {list(caps.keys())}")

            if "starttls" in caps:
                server.starttls(context=context)
                server.ehlo()
                print("[SMTP] STARTTLS negotiated")
            else:
                print("[SMTP] STARTTLS not offered by server (skipping)")

            if SMTP_USER:
                try:
                    server.login(SMTP_USER, SMTP_PASS)
                    print("[SMTP] logged in as", SMTP_USER)
                except Exception as e:
                    print("[SMTP] login failed:", repr(e))
                    raise

            server.sendmail(FROM_EMAIL, [to_email], msg.as_string())
            print("[SMTP] message sent to", to_email)
    except Exception as exc:
        print("[SMTP] send_email exception:", repr(exc))
        raise
    
# Initialize DB if not exists (very small schema)
def init_db():
    sql = """
    PRAGMA foreign_keys = ON;
    CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        email TEXT NOT NULL UNIQUE,
        name TEXT
    );
    CREATE TABLE IF NOT EXISTS campaigns (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        template TEXT NOT NULL,
        created_at TEXT NOT NULL
    );
    CREATE TABLE IF NOT EXISTS campaign_targets (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        campaign_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        token TEXT NOT NULL UNIQUE,
        mail_sent_at TEXT,
        opened_at TEXT,
        clicked_at TEXT,
        submitted_at TEXT,
        input_data TEXT,
        FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
        FOREIGN KEY (user_id) REFERENCES users(id)
    );
    CREATE TABLE IF NOT EXISTS events (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        campaign_target_id INTEGER,
        event_type TEXT NOT NULL,
        event_time TEXT NOT NULL,
        meta TEXT
    );
    """
    conn = db_conn()
    conn.executescript(sql)
    conn.commit()
    conn.close()

init_db()

# Routes: simple HTML rendered inline if templates missing
def simple_layout(body_html):
    return f"""
    <!doctype html><html><head><meta charset="utf-8"><title>PhishSim</title>
    <style>body{{font-family:system-ui, -apple-system, sans-serif;margin:24px}}table{{border-collapse:collapse}}td,th{{padding:8px;border:1px solid #ddd}}</style>
    </head><body>
    <header><a href="/">PhishSim</a> | <a href="/create_campaign">Create Campaign</a></header>
    <main>{body_html}</main>
    <footer style="margin-top:24px;color:#666">Lab only — ethical use</footer>
    </body></html>
    """

@app.route("/")
def dashboard():
    conn = db_conn()
    c = conn.cursor()
    total_targets = c.execute("SELECT COUNT(*) FROM campaign_targets").fetchone()[0]
    total_sent = c.execute("SELECT COUNT(*) FROM campaign_targets WHERE mail_sent_at IS NOT NULL").fetchone()[0]
    total_opened = c.execute("SELECT COUNT(*) FROM campaign_targets WHERE opened_at IS NOT NULL").fetchone()[0]
    total_clicked = c.execute("SELECT COUNT(*) FROM campaign_targets WHERE clicked_at IS NOT NULL").fetchone()[0]
    total_submitted = c.execute("SELECT COUNT(*) FROM campaign_targets WHERE submitted_at IS NOT NULL").fetchone()[0]
    campaigns = c.execute("SELECT * FROM campaigns ORDER BY created_at DESC").fetchall()
    conn.close()

    rows = "".join(f"<tr><td>{r['name']}</td><td><a href='/campaign/{r['id']}'>View</a></td></tr>" for r in campaigns)
    body = f"""
      <h1>Dashboard</h1>
      <p>Targets: {total_targets} | Sent: {total_sent} | Opened: {total_opened} | Clicked: {total_clicked} | Submitted: {total_submitted}</p>
      <h3>Campaigns</h3>
      <table><tr><th>Name</th><th>Action</th></tr>{rows}</table>
    """
    return simple_layout(body)

@app.route("/create_campaign", methods=["GET","POST"])
def create_campaign():
    if request.method == "POST":
        name = request.form.get("name","Campaign")
        template = request.form.get("template","generic_phish")
        conn = db_conn()
        c = conn.cursor()
        c.execute("INSERT INTO campaigns (name, template, created_at) VALUES (?,?,?)", (name, template, now_iso()))
        conn.commit()
        camp_id = c.lastrowid
        conn.close()
        return redirect(url_for("campaign_detail", campaign_id=camp_id))
    body = """
      <h2>Create Campaign</h2>
      <form method="POST">
        <label>Name: <input name="name" required></label><br/><br/>
        <label>Template: <input name="template" value="generic_phish" required></label><br/><br/>
        <button type="submit">Create</button>
      </form>
    """
    return simple_layout(body)

@app.route("/campaign/<int:campaign_id>")
def campaign_detail(campaign_id):
    conn = db_conn()
    c = conn.cursor()
    campaign = c.execute("SELECT * FROM campaigns WHERE id=?", (campaign_id,)).fetchone()
    targets = c.execute("SELECT ct.*, u.email FROM campaign_targets ct JOIN users u ON u.id=ct.user_id WHERE campaign_id=?", (campaign_id,)).fetchall()
    conn.close()
    trows = "".join(f"<tr><td>{t['email']}</td><td>{t['mail_sent_at'] or '-'}</td><td>{t['opened_at'] or '-'}</td><td>{t['clicked_at'] or '-'}</td><td>{t['submitted_at'] or '-'}</td></tr>" for t in targets)
    body = f"""
      <h2>Campaign: {campaign['name']}</h2>
      <h3>Targets</h3>
      <table><tr><th>Email</th><th>Sent</th><th>Opened</th><th>Clicked</th><th>Submitted</th></tr>{trows}</table>
      <h3>Add Target</h3>
      <form method="POST" action="/campaign/{campaign_id}/add_target">
        <label>Email: <input name="email" required></label>
        <label>Name: <input name="name"></label>
        <button type="submit">Add</button>
      </form>
      <form method="POST" action="/campaign/{campaign_id}/send" style="margin-top:12px">
        <button type="submit">Send Campaign (send unsent targets)</button>
      </form>
    """
    return simple_layout(body)

@app.route("/campaign/<int:campaign_id>/add_target", methods=["POST"])
def add_target(campaign_id):
    email = request.form.get("email").strip()
    name = request.form.get("name","")
    conn = db_conn()
    c = conn.cursor()
    c.execute("INSERT OR IGNORE INTO users (email, name) VALUES (?,?)", (email, name))
    user = c.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()
    user_id = user["id"]
    token = uuid.uuid4().hex
    c.execute("INSERT INTO campaign_targets (campaign_id, user_id, token) VALUES (?,?,?)", (campaign_id, user_id, token))
    conn.commit()
    conn.close()
    return redirect(url_for("campaign_detail", campaign_id=campaign_id))

@app.route("/campaign/<int:campaign_id>/send", methods=["POST"])
def send_campaign(campaign_id):
    conn = db_conn()
    c = conn.cursor()
    campaign = c.execute("SELECT * FROM campaigns WHERE id=?", (campaign_id,)).fetchone()
    targets = c.execute("SELECT ct.id, ct.token, u.email FROM campaign_targets ct JOIN users u ON u.id=ct.user_id WHERE campaign_id=? AND ct.mail_sent_at IS NULL", (campaign_id,)).fetchall()
    for t in targets:
        token = t["token"]
        email = t["email"]
        # build links (localhost). If testing from other devices use LAN IP or ngrok externally.
        open_pixel = url_for("pixel", token=token, _external=True)
        click_url = url_for("redirect_click", token=token, _external=True)
        submit_url = url_for("submit_form", token=token, _external=True)
        # simple inline template
        html = f"""
          <p>Hi,</p>
          <p>We noticed unusual activity. Please <a href="{click_url}">verify your account</a>.</p>
          <p>If link broken: {click_url}</p>
          <img src="{open_pixel}" style="width:1px;height:1px;display:none" alt=""/>
          <p>Regards,<br/>IT Security</p>
        """
        subject = f"{campaign['name']} — Action Required"
        try:
            send_email(email, subject, html)
            c.execute("UPDATE campaign_targets SET mail_sent_at=? WHERE id=?", (now_iso(), t["id"]))
        except Exception as e:
            print("send failed", e)
    conn.commit()
    conn.close()
    return redirect(url_for("campaign_detail", campaign_id=campaign_id))

@app.route("/t/<token>/pixel.png")
def pixel(token):
    conn = db_conn()
    c = conn.cursor()
    ct = c.execute("SELECT id FROM campaign_targets WHERE token=?", (token,)).fetchone()
    if ct:
        ct_id = ct["id"]
        c.execute("UPDATE campaign_targets SET opened_at = ? WHERE id = ? AND opened_at IS NULL", (now_iso(), ct_id))
        c.execute("INSERT INTO events (campaign_target_id, event_type, event_time) VALUES (?,?,?)", (ct_id, "open", now_iso()))
        conn.commit()
    conn.close()
    # 1x1 gif
    gif = b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02L\x01\x00;'
    r = make_response(gif)
    r.headers.set("Content-Type","image/gif")
    r.headers.set("Cache-Control","no-cache, no-store, must-revalidate")
    return r

@app.route("/t/<token>/click")
def redirect_click(token):
    conn = db_conn()
    c = conn.cursor()
    ct = c.execute("SELECT id FROM campaign_targets WHERE token=?", (token,)).fetchone()
    if ct:
        ct_id = ct["id"]
        c.execute("UPDATE campaign_targets SET clicked_at = ? WHERE id = ? AND clicked_at IS NULL", (now_iso(), ct_id))
        c.execute("INSERT INTO events (campaign_target_id, event_type, event_time) VALUES (?,?,?)", (ct_id, "click", now_iso()))
        conn.commit()
    conn.close()
    # redirect to fake login page route
    return redirect(url_for("phish_page", token=token))

@app.route("/phish/<token>")
def phish_page(token):
    # small fake login form that POSTs to /t/<token>/submit
    html = f"""
      <h2>Account Verification</h2>
      <form action="{url_for('submit_form', token=token)}" method="post">
        <label>Email: <input name="email" required></label><br/><br/>
        <label>Password: <input name="password" type="password" required></label><br/><br/>
        <button type="submit">Verify</button>
      </form>
    """
    return simple_layout(html)

@app.route("/t/<token>/submit", methods=["POST"])
def submit_form(token):
    conn = db_conn()
    c = conn.cursor()
    ct = c.execute("SELECT id FROM campaign_targets WHERE token=?", (token,)).fetchone()
    if ct:
        ct_id = ct["id"]
        # DO NOT store real passwords in production; this is lab only
        data = json.dumps({k: request.form.get(k) for k in request.form.keys()})
        c.execute("UPDATE campaign_targets SET submitted_at=?, input_data=? WHERE id=?", (now_iso(), data, ct_id))
        c.execute("INSERT INTO events (campaign_target_id, event_type, event_time, meta) VALUES (?,?,?,?)", (ct_id, "submit", now_iso(), data))
        conn.commit()
    conn.close()
    # awareness page
    body = """
      <h2>Simulation Complete</h2>
      <p>This was an ethical phishing simulation. Never enter credentials from unexpected emails. Contact your admin.</p>
    """
    return simple_layout(body)

if __name__ == "__main__":
    # run on all interfaces so LAN devices can reach if you use your LAN IP in links
    app.run(host="0.0.0.0", port=9000, debug=True)

Explanation — app.py (section highlights)

Imports & config

The file imports Flask, sqlite3, SMTP libs and loads values from config.py. Ensure you have a config.py or environment fallback that provides DB_PATH, SMTP_*, FROM_EMAIL, and SECRET_KEY.

DB helpers & schema

init_db() creates four tables: users, campaigns, campaign_targets, and events. The campaign_targets table stores per-target token and timestamps (sent/opened/clicked/submitted).

send_email()

The robust SMTP helper negotiates STARTTLS if available and logs in with provided credentials. It prints SMTP capabilities for debugging. Keep SMTP credentials safe and revoke app passwords after tests.

Routes / flows
  • / shows a simple dashboard with counts and campaign list.
  • /create_campaign creates a new campaign via a small HTML form.
  • /campaign/<id> shows targets and exposes adding targets + sending action.
  • /campaign/<id>/send composes an HTML email containing a 1×1 pixel and a tracked click link, then calls send_email and marks mail_sent_at.
  • /t/<token>/pixel.png logs opens and returns a 1×1 GIF with cache-control disabled.
  • /t/<token>/click logs click and redirects to /phish/<token>.
  • /phish/<token> renders a tiny fake login form (lab only). The submit endpoint stores submitted data in DB and shows an awareness page.
Safety warnings

This code stores submitted form data (including potential passwords) in the DB — the file contains a comment: DO NOT store real passwords in production. For ethical testing: (1) never target real users without consent, (2) prefer not to collect any credentials at all, and (3) if you must accept test input, redact sensitive fields and secure the DB.


.py
test_mail.py
# test_mail.py
import smtplib, ssl
from email.mime.text import MIMEText

smtp_host = "smtp.gmail.com"
smtp_port = 587
user = "anon@oneriki.in"
passwd = "APP_PASSWORD"

msg = MIMEText("This is a test email from PhishSim setup on macOS.")
msg["Subject"] = "SMTP Test"
msg["From"] = user
msg["To"] = "blogs@surajv5.in"  

context = ssl.create_default_context()

with smtplib.SMTP(smtp_host, smtp_port) as server:
    server.ehlo()
    server.starttls(context=context)
    server.login(user, passwd)
    server.sendmail(user, [msg["To"]], msg.as_string())

print("Test email sent successfully!")
How to use:
  1. Fill in config.py with real values (or set env vars) for SMTP and DB path.
  2. Place a 1×1 pixel at assets/px.png (or the path your app uses) — the code currently returns an inline gif for the pixel route.
  3. Run python test_mail.py (or set env vars) to verify SMTP connectivity before sending campaigns.
  4. Start the server: python app.py and open http://localhost:9000.

DB schema & common queries

Simple SQLite schema used in the example (campaigns + events). Here are useful queries.

.sqlite
phishsim.db / schema
-- events table
CREATE TABLE events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  campaign_id TEXT,
  recipient TEXT,
  event_type TEXT,  -- sent, opened, clicked, feedback
  event_data TEXT,
  ts REAL
);

-- Sample reports:

-- 1) Per-recipient timeline
SELECT recipient, event_type, datetime(ts, 'unixepoch') ts FROM events WHERE campaign_id = '...'
ORDER BY recipient, ts;

-- 2) Open rate (unique opens / sent)
SELECT
  COUNT(DISTINCT CASE WHEN event_type='opened' THEN recipient END) as opens,
  COUNT(DISTINCT CASE WHEN event_type='sent' THEN recipient END) as sent,
  (1.0 * opens / sent) as open_rate
FROM events WHERE campaign_id='...';

Dockerfile & deployment

A minimal Dockerfile for local/staging use (see earlier in the post).

Testing plan

  1. Create a staging SMTP account with app password and only test addresses.
  2. Run the Flask app locally with python app.py or via Docker.
  3. Create a campaign and send to test recipients. Verify sent events appear.
  4. Open emails in the test inbox and click links — verify opened and clicked events.
  5. Submit feedback — ensure feedback is recorded and no credentials are accepted.
  6. Export event data via SQLite queries to validate metrics.

Reporting & analysis

Use simple SQL or export CSV for managers. Example: export event rows to CSV for a given campaign.

# export (bash)
sqlite3 phishsim.db -header -csv "SELECT * FROM events WHERE campaign_id='...' ORDER BY ts;" > campaign-events.csv

Troubleshooting checklist

  • SMTP login fails — ensure app password & 2FA are enabled.
  • Pixel not recording — check the image URL is reachable and not blocked by mail client (fallback to click tracking).
  • Redirects not working — ensure Flask host/port and request.url_root are correct.
  • Events missing — verify DB path and file permissions.

Reflections

The project reinforced secure-by-design principles: minimize data collection, store minimal event metadata, and be transparent to recipients. The educational impact depends on clear feedback and follow-up training, not just metrics.

Internship Certificate

Elevate Labs Internship Certificate

Certificate of Internship completion at Elevate Labs.

Conclusion

This guide gives a safe, practical blueprint for building a phishing simulation environment. It focuses on education and measurable outcomes while emphasizing ethical constraints and privacy.

Appendix — quick commands

# run locally
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python app.py

# Docker build & run
docker build -t phishsim .
docker run -e SMTP_USER=you@example.com -e SMTP_PASS=app-pass -p 8000:8000 phishsim
Login