Icon
internships

First Internship — Ethical Phishing Simulation Platform

Suraj Vishwanath
September 15, 2025
17 min read

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.

Walkthrough

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.

hljs 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

  • 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.

test_mail.py

hljs 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: Fill in config.py with real values (or set env vars) for SMTP and DB path. 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. Run python test_mail.py (or set env vars) to verify SMTP connectivity before sending campaigns. Start the server: python app.py and open http://localhost:9000.

DB schema & common queries

Dockerfile & deployment

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

Reporting & analysis

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

export:

hljs 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.

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.

Phishing Simulation Concept

Appendix — quick commands

hljs bash
# 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