← All work
Case study · 2025

Beacon

Secure email delivery with PIN-locked links, open tracking beacons, automatic revocation on suspicious opens and Gemini-powered security reports.

Next.jsTypeScriptFirebaseFirestoreAppwriteGenkitGeminiNodemailerTailwind CSSLive site ↗Source ↗
beacon-secure.vercel.app
Beacon — screenshot of the live site

Overview

Beacon (BeaconMail in the UI) is an email security product for companies that send sensitive documents. Instead of the content, recipients get a secure link that unlocks only with their 6-digit PIN, and every open is tracked with a beacon that records IP, device, browser and location. Admins and company owners get dashboards with alerts, logs and AI-generated reports.

The problem

Once a sensitive attachment is emailed, the sender loses control of it: it can be forwarded, opened anywhere, and there is no record of who read it. I wanted a sender to know when and where a message was opened, and to have the link shut itself off if it is opened from somewhere it should not be.

What I built

  • A compose flow (Genkit flow on the server) that stores the email in Firestore with a random 16-byte token and expiry, then sends a link to /secure/{token} with a tracking pixel URL embedded, over SMTP via Nodemailer. Guest recipients get a 24-hour link.
  • A PIN unlock flow that checks the link has not been revoked or expired, compares the PIN against a bcrypt hash, logs every attempt, and raises a "Multiple Failed PINs" alert after three failures.
  • A beacon endpoint (/api/beacon, mirrored as an Appwrite Function) that returns a 1x1 PNG, parses the user agent, resolves the IP to a location, and writes a log to Appwrite.
  • Suspicious-open detection: each new open is compared with the first open of the same email. A different IP, device, user agent, country or city marks the email revoked and creates an alert with the reasons.
  • Genkit flows on Gemini 2.5 Flash for natural-language admin queries (translated into database filters), summarized security reports from logs, and incident reports.
  • Admin, company and employee dashboards for users, companies, emails, PIN reset requests, beacon analytics and real-time alerts, with invite-based onboarding and PIN setup.

Architecture

A Next.js app hosts the UI, server flows and API routes. Firestore holds app data and Appwrite stores beacon logs.

flowchart LR
  subgraph client["Client"]
    sender["Sender and admin dashboards"]
    rcpt["Recipient secure page"]
    mail["Recipient mail client"]
  end
  subgraph server["Next.js server"]
    flows["Genkit flows"]
    beacon["Beacon API route"]
    fn["Appwrite beacon function"]
  end
  subgraph data["Data"]
    fs[("Firestore users, emails, logs, alerts")]
    aw[("Appwrite beacon logs")]
  end
  subgraph ext["External"]
    smtp["SMTP via Nodemailer"]
    gem["Gemini 2.5 Flash"]
    geo["ipapi geolocation"]
  end
  sender --> flows
  flows --> fs
  flows --> smtp
  flows --> gem
  smtp --> mail
  mail -->|"pixel load"| beacon
  mail -.->|"alt endpoint"| fn
  beacon --> geo
  beacon --> aw
  fn --> aw
  rcpt -->|"PIN"| flows

System design

What happens when a recipient opens the email and then tries to unlock it.

sequenceDiagram
  participant R as Recipient
  participant B as Beacon API
  participant G as ipapi
  participant W as Appwrite
  participant S as Secure page
  participant F as Unlock flow
  participant D as Firestore
  R->>B: Mail client loads tracking pixel
  B->>G: Look up IP location
  G-->>B: Country and city
  B->>W: Store beacon log
  B->>W: Load earlier opens of this email
  alt IP, device or location differs from first open
    B->>W: Mark email revoked and create alert
  end
  B-->>R: 1x1 PNG
  R->>S: Open secure link and enter PIN
  S->>F: verifyPinAndGetContent
  F->>D: Load email and recipient PIN hash
  alt PIN matches and link active
    F-->>S: Decrypted content and attachment
  else Wrong PIN
    F->>D: Log failed attempt and alert after three
    F-->>S: Error
  end

Decisions & trade-offs

  • A tracking pixel for opens. It needs nothing from the recipient, but mail clients that block or proxy images hide real opens, and proxies can look like a new location. That is why revocation compares against the first open rather than every open.
  • Revoke first, ask later. Any mismatch in IP, device or location shuts the link, which is safe for sensitive content but strict for someone who opens on a phone after a laptop.
  • PINs are hashed with bcrypt and checked server-side, and the beacon always returns a pixel even on bad input so a tracking failure never breaks the email.
  • Two backends (Firestore and Appwrite) came from building the beacon separately as an Appwrite Function. It works, but it splits data that would be easier to query in one place.

What I'd do next

  • Consolidate beacon logs into the same store as emails and access logs.
  • Replace header string matching with a proper user-agent parser and allow trusted devices per recipient.
  • Encrypt stored content and attachments at rest instead of relying on access checks alone.