posti-email
v0.1.6
Published
Self-hosted email tracking for AWS SES with open and click tracking
Maintainers
Readme
posti-email
Self-hosted email tracking with open and click tracking. Supports AWS SES and Cloudflare Workers Email. No third-party tracking services required.
Features
- 📧 Send HTML + text emails via AWS SES (recommended) or Cloudflare
- 📊 Track opens with a transparent pixel
- 🔗 Track clicks with automatic link rewriting
- 📈 Monitor delivery, bounce, and complaint events (AWS SES only)
- 🗄️ Prisma integration with ready-to-use schema
- ⚡ Next.js App Router handlers included
- 🔌 Framework-agnostic core handlers
- 💰 Cost-effective: AWS SES starts at $0.10 per 1,000 emails
Note: AWS SES is recommended for full feature support. Cloudflare Workers Email works for opens/clicks tracking but lacks webhook support for delivery/bounce/complaint events.
Installation
npm install posti-email
npm install @prisma/client # peer dependencySetup
1. Add Prisma Models
Copy the models from node_modules/posti-email/prisma/schema-snippet.prisma into your schema.prisma.
⚠️ Important: You MUST use Prisma migrations to create the tables. The enums (EmailStatus, EmailEventType) are required and won't work if you create SQL tables manually without proper enum types.
Note: You have full control over your database - use any Prisma-supported database (PostgreSQL, MySQL, SQLite, etc.) and host it anywhere you want (AWS RDS, Railway, Supabase, local, etc.). posti-email just uses your PrismaClient:
model Email {
id String @id
to String
from String
subject String
providerMessageId String?
status EmailStatus @default(SENT)
createdAt DateTime @default(now())
events EmailEvent[]
}
model EmailEvent {
id String @id @default(cuid())
email Email @relation(fields: [emailId], references: [id])
emailId String
type EmailEventType
meta Json?
createdAt DateTime @default(now())
}
enum EmailStatus {
SENT
DELIVERED
BOUNCED
COMPLAINT
}
enum EmailEventType {
SENT
DELIVERED
BOUNCED
COMPLAINT
OPEN
CLICK
}Then run:
npx prisma generate
npx prisma migrate dev --name add_posti_email_models2. Initialize Prisma Client
In your app initialization code (e.g., a lib/db.ts file):
For regular Node.js environments:
import { PrismaClient } from "@prisma/client";
import { setPrismaClient } from "posti-email";
const prisma = new PrismaClient();
setPrismaClient(prisma);
export { prisma };For Vercel/Serverless environments (Recommended):
import { createServerlessPrisma } from "posti-email";
// Automatically creates singleton Prisma Client and configures posti-email
const prisma = createServerlessPrisma();
export { prisma };Alternative manual setup:
import { PrismaClient } from "@prisma/client";
import { setPrismaClient } from "posti-email";
// Prisma Client singleton for serverless (prevents connection exhaustion)
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
setPrismaClient(prisma);
export { prisma };The singleton pattern prevents connection pool exhaustion in serverless environments where functions are frequently created and destroyed.
3. Environment Variables
Add these to your .env:
# Email Provider (AWS SES by default)
# To use AWS SES, provide these credentials:
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
# posti-email Configuration (Required)
# This is your app's base URL - where your tracking endpoints are hosted
# Example: https://myapp.com or https://api.myapp.com
POSTI_TRACKING_BASE_URL=https://myapp.com
# Optional: Only needed if using HTTP send endpoint (/api/send)
# Generate a secure random string (e.g., using `openssl rand -hex 32`)
# This key protects your send endpoint from unauthorized use
POSTI_INTERNAL_SEND_KEY=your_random_secret_key_hereWhere to Get AWS Credentials
AWS Access Keys:
- Go to AWS Console → IAM → Users → Create user or select existing
- Under "Security credentials" tab, click "Create access key"
- Grant SES permissions (e.g.,
AmazonSESFullAccesspolicy) - Copy the Access Key ID and Secret Access Key
AWS Region:
- Choose the region where your SES is set up (e.g.,
us-east-1,eu-west-1) - Verify your domain/email in SES before sending
- Choose the region where your SES is set up (e.g.,
What is POSTI_TRACKING_BASE_URL?
This is your application's public URL where the tracking endpoints (/api/open, /api/click) are hosted. It's used to build tracking pixel URLs and click redirect URLs in emails.
Examples:
- If your app is at
https://myapp.com, set:POSTI_TRACKING_BASE_URL=https://myapp.com - If your API is at
https://api.myapp.com, set:POSTI_TRACKING_BASE_URL=https://api.myapp.com
The tracking endpoints will be:
{POSTI_TRACKING_BASE_URL}/api/open(for open tracking){POSTI_TRACKING_BASE_URL}/api/click(for click tracking)
What is POSTI_INTERNAL_SEND_KEY?
This is an optional security key for the HTTP send endpoint. If you mount the /api/send route, set this to prevent unauthorized email sending.
Generate a secure key:
# Using openssl
openssl rand -hex 32
# Or using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"When calling the endpoint, include it in the Authorization header:
curl -X POST https://myapp.com/api/send \
-H "Authorization: Bearer YOUR_GENERATED_KEY" \
-H "Content-Type: application/json" \
-d '{...}'Note: If you don't set POSTI_INTERNAL_SEND_KEY, the endpoint will be unprotected. Only use the HTTP endpoint for internal services, or always set this key.
4. Add API Routes (Next.js)
Email Events Webhook Handler (AWS SES only)
// app/api/email-events/route.ts
import { createSesWebhookHandler } from "posti-email/next";
export const POST = createSesWebhookHandler();⚠️ AWS SES Only: This endpoint only works with AWS SES. Cloudflare Workers Email does not support webhook notifications.
Configure your AWS SES to send webhooks to this endpoint:
- Create an SNS topic in AWS
- In SES, configure "Configuration sets" → "Event destinations"
- Add your SNS topic as a destination for: Delivery, Bounce, Complaint
- Subscribe your webhook endpoint (
/api/email-events) to the SNS topic - Alternatively, use SES Event Publishing to send events directly to HTTPS
Open Tracking Pixel
// app/api/open/route.ts
import { createOpenPixelHandler } from "posti-email/next";
export const GET = createOpenPixelHandler();Click Redirect Handler
// app/api/click/route.ts
import { createClickRedirectHandler } from "posti-email/next";
export const GET = createClickRedirectHandler();Optional: HTTP Send Endpoint
// app/api/send/route.ts
import { createSendHandler } from "posti-email/next";
export const POST = createSendHandler();If you use this endpoint, make sure to set POSTI_INTERNAL_SEND_KEY and include it in the Authorization header:
curl -X POST https://myapp.com/api/send \
-H "Authorization: Bearer your_secret_key_here" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"from": "[email protected]",
"subject": "Hello",
"html": "<p>Hello world</p>"
}'Usage
Sending Emails
import { sendEmail } from "posti-email";
const result = await sendEmail({
to: "[email protected]",
from: "[email protected]",
subject: "Reset your password",
html: `<p>Click <a href="https://myapp.com/reset?token=abc">here</a> to reset.</p>`,
text: "Reset your password: https://myapp.com/reset?token=abc",
});
console.log(result.id); // UUID of the emailThe function automatically:
- Generates a unique message ID
- Injects a tracking pixel for opens
- Rewrites all links for click tracking
- Sends via your configured provider (AWS SES by default)
- Records the initial email and SENT event in your database
Getting Email Statistics
import { getEmailStats } from "posti-email";
const stats = await getEmailStats(emailId);
if (stats) {
console.log(`Opens: ${stats.opens}`);
console.log(`Clicks: ${stats.clicks}`);
console.log(`Status: ${stats.status}`);
console.log(`Last event: ${stats.lastEventAt}`);
}Framework-Agnostic Handlers
If you're not using Next.js, you can use the core handlers directly:
import {
sesWebhookCore,
openPixelCore,
clickRedirectCore,
} from "posti-email/server";
// Adapt to your framework
app.post("/email-events", async (req, res) => {
const result = await sesWebhookCore(req.body);
res.json(result);
});How It Works
Sending: When you call
sendEmail(), posti-email:- Generates a UUID for the email
- Injects a 1x1 tracking pixel:
<img src="{baseUrl}/api/open?id={messageId}" /> - Rewrites all links to go through your click endpoint:
{baseUrl}/api/click?id={messageId}&url={originalUrl} - Sends via your configured provider (AWS SES or Cloudflare)
- Stores the email record in your database
Open Tracking: When the email is opened, the tracking pixel loads and hits your
/api/openendpoint, which logs an OPEN event.Click Tracking: When a link is clicked, it first goes to your
/api/clickendpoint, logs a CLICK event, then redirects to the original URL.Provider Events (AWS SES only): AWS SES sends webhooks (via SNS) for delivery, bounce, and complaint events. Your
/api/email-eventsendpoint processes these and updates the email status. Note: Cloudflare Workers Email does not support webhooks, so delivery/bounce/complaint tracking won't work with Cloudflare.
Email Providers
AWS SES (Recommended) ⭐
Why AWS SES?
- ✅ Full feature support (opens, clicks, delivery, bounce, complaints)
- ✅ Reliable webhook notifications
- ✅ Production-ready and battle-tested
- ✅ Excellent deliverability
- ✅ Comprehensive analytics
Pricing:
- Free tier: 62,000 emails/month when sent from EC2
- After free tier: $0.10 per 1,000 emails
- Full pricing details
Setup:
Get AWS Credentials:
- Create an IAM user with SES permissions
- Generate Access Key ID and Secret Access Key
- Add to your
.envas shown in step 3
Verify Your Domain/Email:
- Go to AWS SES Console → Verified identities
- Verify your sending domain or email address
- This is required before sending emails
Set Up Webhooks (for delivery/bounce/complaint tracking):
- Create an SNS topic in AWS
- In SES, configure "Configuration sets" → "Event destinations"
- Add your SNS topic as a destination for:
- Delivery
- Bounce
- Complaint
- Subscribe your webhook endpoint (
/api/email-events) to the SNS topic - Alternatively, use SES Event Publishing to send events directly to HTTPS
Cloudflare Workers Email
Status: Currently in private beta
⚠️ Feature Limitations:
Cloudflare Workers Email does NOT support webhook notifications for delivery, bounce, or complaint events. This means:
✅ What Works:
- Sending emails
- Open tracking (via pixel)
- Click tracking (via redirect)
❌ What Doesn't Work:
- Delivery status webhooks (status stays as
SENT, never becomesDELIVERED) - Bounce tracking via webhooks (won't detect bounced emails)
- Complaint tracking via webhooks (won't detect spam complaints)
When to Use Cloudflare:
- You're already using Cloudflare Workers
- You only need opens/clicks tracking (most common use case)
- You prefer the Cloudflare ecosystem
- Cost is a primary concern
When to Use AWS SES:
- You need full event tracking (delivery, bounce, complaints)
- You want production-grade reliability
- You need comprehensive analytics
- Recommended for most use cases ⭐
Setup:
- Join the Cloudflare Email Sending beta: https://developers.cloudflare.com/workers-email/
- Add an email binding to your Cloudflare Worker
- Configure posti-email to use Cloudflare:
import { setEmailProvider, CloudflareProvider } from "posti-email";
// In your Cloudflare Worker
export default {
async fetch(request, env, ctx) {
// Set up Cloudflare provider on first request
if (!env.emailProvider) {
setEmailProvider(new CloudflareProvider(env.EMAIL));
}
// Now you can use sendEmail() normally
// Open and click tracking still work!
const result = await sendEmail({...});
}
}Note: Cloudflare Email Sending is still in beta and lacks webhook support. For full feature parity (especially delivery/bounce/complaint tracking), AWS SES is recommended.
TypeScript
Full TypeScript support with exported types:
import type {
SendEmailOptions,
SendEmailResult,
EmailStats,
} from "posti-email";Custom Database Adapters
By default, posti-email uses Prisma. If you need a custom database adapter, you can implement the DatabaseAdapter interface:
import { setDatabaseAdapter } from "posti-email";
import type { DatabaseAdapter } from "posti-email";
const myAdapter: DatabaseAdapter = {
email: {
// Implement methods
},
emailEvent: {
// Implement methods
},
};
setDatabaseAdapter(myAdapter);Serverless/Vercel Compatibility
Prisma Client Setup
Vercel and other serverless platforms require special Prisma Client initialization to prevent connection pool exhaustion. Use the singleton pattern shown in step 2 above.
Connection Pooling
For production serverless deployments, consider using a connection pooler:
Recommended options:
- Supabase: Built-in connection pooling
- Prisma Data Proxy: Prisma's official solution
- PgBouncer (PostgreSQL): Traditional connection pooler
Example with Prisma Data Proxy:
// schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // Use Data Proxy URL
directUrl = env("DIRECT_URL") // Direct connection for migrations
}AWS SDK in Serverless
The AWS SDK (@aws-sdk/client-ses) is serverless-friendly out of the box. It handles connection pooling and reuse automatically.
Environment Variables on Vercel
Make sure to add all required environment variables in Vercel's dashboard:
AWS_REGIONAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYPOSTI_TRACKING_BASE_URLDATABASE_URL(for Prisma)POSTI_INTERNAL_SEND_KEY(optional)
Troubleshooting
"Too many connections" errors:
- Use the singleton Prisma Client pattern
- Enable connection pooling (Prisma Data Proxy, PgBouncer, etc.)
- Reduce connection pool size in
DATABASE_URL:?connection_limit=5&pool_timeout=10
"Prisma client not set" errors:
- Make sure
setPrismaClient()is called before any posti-email functions - In Next.js, ensure initialization happens in a module that's imported early
- For API routes, initialize in a shared module that loads before route handlers
- For Vercel/serverless: Use
createServerlessPrisma()helper which automatically configures posti-email
Enum type errors (EmailStatus, EmailEventType):
- DO NOT create SQL tables manually - use Prisma migrations
- Run
npx prisma migrate devto create tables with proper enum types - If you created tables manually, drop them and use Prisma migrations instead
- Prisma enums are database-specific types and won't work without proper migration
License
MIT
