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

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"| flowsSystem 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
endDecisions & 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.