Skip to content

Commit 9fc63ca

Browse files
committed
feat: add Mailpit for email testing and SMTP support
- Integrated Mailpit service into the development environment for local email testing. - Updated Caddyfile to reverse proxy Mailpit UI. - Modified backend to support SMTP email sending using Nodemailer. - Enhanced API key management to include key types (test/live). - Updated database schema to accommodate new fields for API keys and email logs. - Implemented backfill script to sync Cloudflare email sending state with the database. - Added environment variables for SMTP configuration. - Improved frontend to display key types and email logs with test/live indicators.
1 parent e359424 commit 9fc63ca

20 files changed

Lines changed: 625 additions & 36 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: Build & Publish Docker Image
2+
3+
on:
4+
push:
5+
branches:
6+
- trunk
7+
tags:
8+
- "v*.*.*"
9+
workflow_dispatch:
10+
11+
env:
12+
REGISTRY: ghcr.io
13+
IMAGE_NAME: ${{ github.repository }}
14+
15+
jobs:
16+
build-and-push:
17+
name: Build and push image
18+
runs-on: ubuntu-latest
19+
permissions:
20+
contents: read
21+
packages: write
22+
23+
steps:
24+
- name: Checkout repository
25+
uses: actions/checkout@v4
26+
27+
- name: Set up QEMU (for multi-platform builds)
28+
uses: docker/setup-qemu-action@v3
29+
30+
- name: Set up Docker Buildx
31+
uses: docker/setup-buildx-action@v3
32+
33+
- name: Log in to GitHub Container Registry
34+
uses: docker/login-action@v3
35+
with:
36+
registry: ${{ env.REGISTRY }}
37+
username: ${{ github.actor }}
38+
password: ${{ secrets.GITHUB_TOKEN }}
39+
40+
- name: Extract Docker metadata
41+
id: meta
42+
uses: docker/metadata-action@v5
43+
with:
44+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
45+
tags: |
46+
# tag trunk branch as "latest"
47+
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/trunk' }}
48+
# tag semver releases (v1.2.3 → 1.2.3, 1.2, 1)
49+
type=semver,pattern={{version}}
50+
type=semver,pattern={{major}}.{{minor}}
51+
# short sha for every build
52+
type=sha,prefix=sha-
53+
54+
- name: Build and push
55+
uses: docker/build-push-action@v6
56+
with:
57+
context: .
58+
file: ./Dockerfile
59+
platforms: linux/amd64,linux/arm64
60+
push: true
61+
tags: ${{ steps.meta.outputs.tags }}
62+
labels: ${{ steps.meta.outputs.labels }}
63+
cache-from: type=gha
64+
cache-to: type=gha,mode=max

Dockerfile

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
# ── Stage: build-core ────────────────────────────────────────────────────────
2+
# Builds the mesahub-server Go binary (CGO required — uses sqlite3).
3+
# Override MESAHUB_CORE_VERSION at build time to pin a specific tag or commit:
4+
# docker build --build-arg MESAHUB_CORE_VERSION=v0.2.0 .
5+
FROM golang:1.24-alpine AS build-core
6+
RUN apk add --no-cache gcc musl-dev sqlite-dev
7+
ARG MESAHUB_CORE_VERSION=latest
8+
RUN CGO_ENABLED=1 go install \
9+
github.com/0xdps/mesahub-core/cmd/server@${MESAHUB_CORE_VERSION}
10+
RUN cp /go/bin/server /go/bin/mesahub-server
11+
112
# ── Stage: build-backend ──────────────────────────────────────────────────────
213
FROM node:22-alpine AS build-backend
314
WORKDIR /app/backend
@@ -22,14 +33,28 @@ RUN pnpm install --frozen-lockfile
2233
COPY services/admin/ ./
2334
RUN pnpm exec vite build
2435

25-
# ── Stage: prod (Caddy + Node) ────────────────────────────────────────────────
36+
# ── Stage: prod (Caddy + Node + Mailpit) ─────────────────────────────────────
2637
FROM caddy:2-alpine AS caddy-bin
2738

2839
FROM node:22-alpine AS prod
2940
COPY --from=caddy-bin /usr/bin/caddy /usr/bin/caddy
30-
RUN apk add --no-cache curl
41+
RUN apk add --no-cache curl openssl
3142
WORKDIR /app
3243

44+
# mesahub-server (for embedded mode — skipped if MESAHUB_URL points to external)
45+
COPY --from=build-core /go/bin/mesahub-server /usr/local/bin/mesahub-server
46+
RUN mkdir -p /data
47+
VOLUME ["/data"]
48+
49+
# Install Mailpit binary
50+
ARG TARGETARCH
51+
RUN set -e; \
52+
MAILPIT_VERSION="v1.21.5"; \
53+
if [ "$TARGETARCH" = "arm64" ]; then ARCH="arm64"; else ARCH="amd64"; fi; \
54+
curl -fsSL "https://github.com/axllent/mailpit/releases/download/${MAILPIT_VERSION}/mailpit-linux-${ARCH}.tar.gz" \
55+
| tar -xz -C /usr/local/bin mailpit; \
56+
chmod +x /usr/local/bin/mailpit
57+
3358
RUN mkdir -p /usr/share/caddy
3459

3560
# Backend runtime

compose.dev.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,25 @@ services:
1111
environment:
1212
- NODE_ENV=development
1313
- SQLITE_HUB_API_URL=http://host.docker.internal:4004
14+
- SMTP_HOST=mailpit
15+
- SMTP_PORT=1025
1416
extra_hosts:
1517
- host.docker.internal:host-gateway
1618
volumes:
1719
- ./services/backend/src:/app/src
1820
- ./services/backend/package.json:/app/package.json
1921
- backend-node-modules:/app/node_modules
22+
depends_on:
23+
- mailpit
24+
25+
mailpit:
26+
container_name: emailflare-mailpit
27+
image: axllent/mailpit:latest
28+
environment:
29+
- MP_UI_BASE_PATH=/mailpit
30+
ports:
31+
- "2025:1025"
32+
- "8026:8025"
2033

2134
admin:
2235
container_name: emailflare-admin

infra/Caddyfile.dev

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
reverse_proxy backend:3000
1010
}
1111

12+
handle /mailpit/* {
13+
reverse_proxy mailpit:8025
14+
}
15+
1216
handle {
1317
# Vite dev server in dev compose
1418
reverse_proxy admin:5173

justfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ dev:
4343

4444
echo "Starting EmailFlare Development Environment"
4545
echo ""
46+
47+
# Free Mailpit ports if already bound (e.g. leftover processes)
48+
for port in 2025 8026; do
49+
pid=$(lsof -ti tcp:$port 2>/dev/null || true)
50+
if [ -n "$pid" ]; then
51+
echo "Killing process on port $port (pid $pid)..."
52+
kill -9 $pid 2>/dev/null || true
53+
fi
54+
done
55+
4656
echo "Starting containers (Docker will assign a random port)..."
4757
docker compose --env-file .env -f compose.yaml -f compose.dev.yaml up -d --build --force-recreate {{EDGE_SERVICE}}
4858

services/admin/src/pages/Keys.tsx

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface ApiKey {
1919
id: string;
2020
name: string;
2121
key_prefix: string;
22+
key_type: 'test' | 'live';
2223
scope: 'global' | 'domain' | 'multi';
2324
active: number;
2425
last_used_at: string | null;
@@ -44,7 +45,7 @@ export default function KeysPage() {
4445
const [creating, setCreating] = useState(false);
4546
const [newKey, setNewKey] = useState<NewKey | null>(null);
4647
const [copied, setCopied] = useState(false);
47-
const [form, setForm] = useState({ name: '', scope: 'global' as const });
48+
const [form, setForm] = useState({ name: '', type: 'live' as 'test' | 'live', scope: 'global' as const });
4849
const [revokeId, setRevokeId] = useState<string | null>(null);
4950

5051
async function load() {
@@ -61,7 +62,7 @@ export default function KeysPage() {
6162
const { data } = await api.post<NewKey>('/api/keys', form);
6263
setNewKey(data);
6364
setCreating(false);
64-
setForm({ name: '', scope: 'global' });
65+
setForm({ name: '', type: 'live', scope: 'global' });
6566
load();
6667
}
6768

@@ -117,7 +118,9 @@ export default function KeysPage() {
117118
<Alert className="mb-6 border-emerald-200 bg-emerald-50">
118119
<AlertDescription className="flex items-start justify-between gap-4">
119120
<div>
120-
<p className="text-sm font-semibold text-emerald-700 mb-1">Copy your new API key — it won't be shown again</p>
121+
<p className="text-sm font-semibold text-emerald-700 mb-1">
122+
{newKey.key_type === 'test' ? 'Test key created — ' : ''}Copy your new API key — it won't be shown again
123+
</p>
121124
<code className="text-xs font-mono text-emerald-800 break-all">{newKey.key}</code>
122125
<div>
123126
<Button variant="ghost" size="sm" onClick={() => setNewKey(null)} className="mt-2 h-auto p-0 text-xs text-muted-foreground">
@@ -155,22 +158,37 @@ export default function KeysPage() {
155158
/>
156159
</div>
157160
<div className="flex flex-col gap-1.5">
158-
<Label>Scope</Label>
161+
<Label>Type</Label>
159162
<Select
160-
value={form.scope}
161-
onValueChange={v => setForm(f => ({ ...f, scope: v as typeof form.scope }))}
163+
value={form.type}
164+
onValueChange={v => setForm(f => ({ ...f, type: v as typeof form.type }))}
162165
>
163166
<SelectTrigger>
164167
<SelectValue />
165168
</SelectTrigger>
166169
<SelectContent>
167-
<SelectItem value="global">Global</SelectItem>
168-
<SelectItem value="domain">Domain</SelectItem>
169-
<SelectItem value="multi">Multi-domain</SelectItem>
170+
<SelectItem value="live">Live</SelectItem>
171+
<SelectItem value="test">Test</SelectItem>
170172
</SelectContent>
171173
</Select>
172174
</div>
173175
</div>
176+
<div className="flex flex-col gap-1.5">
177+
<Label>Scope</Label>
178+
<Select
179+
value={form.scope}
180+
onValueChange={v => setForm(f => ({ ...f, scope: v as typeof form.scope }))}
181+
>
182+
<SelectTrigger>
183+
<SelectValue />
184+
</SelectTrigger>
185+
<SelectContent>
186+
<SelectItem value="global">Global</SelectItem>
187+
<SelectItem value="domain">Domain</SelectItem>
188+
<SelectItem value="multi">Multi-domain</SelectItem>
189+
</SelectContent>
190+
</Select>
191+
</div>
174192
<div className="flex gap-2">
175193
<Button type="submit">Create</Button>
176194
<Button type="button" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
@@ -194,8 +212,10 @@ export default function KeysPage() {
194212
<div className="flex-1 min-w-0">
195213
<div className="flex items-center gap-2">
196214
<span className="font-medium text-sm">{k.name}</span>
197-
<Badge variant="secondary" className="font-mono text-xs">{k.scope}</Badge>
198-
</div>
215+
<Badge variant="secondary" className="font-mono text-xs">{k.scope}</Badge> {k.key_type === 'test'
216+
? <Badge className="text-[10px] bg-amber-500/10 text-amber-600 border-amber-200">test</Badge>
217+
: <Badge className="text-[10px] bg-emerald-500/10 text-emerald-600 border-emerald-200">live</Badge>
218+
} </div>
199219
<div className="flex items-center gap-3 mt-0.5">
200220
<code className="text-xs text-muted-foreground font-mono">{k.key_prefix}</code>
201221
<span className="text-xs text-muted-foreground">{k.send_count.toLocaleString()} sends</span>

services/admin/src/pages/Logs.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface LogRow {
2020
domain_id: string | null;
2121
template_id: string | null;
2222
error: string | null;
23+
is_test: number;
2324
sent_at: string;
2425
}
2526

@@ -286,9 +287,14 @@ export default function LogsPage() {
286287
: <XCircle size={13} className="text-destructive" />}
287288
</div>
288289
<div className="flex-1 min-w-0">
289-
<p className="text-[13px] font-medium truncate leading-tight text-foreground">
290-
{log.subject || <span className="text-muted-foreground italic font-normal">No subject</span>}
291-
</p>
290+
<div className="flex items-center gap-1.5">
291+
<p className="text-[13px] font-medium truncate leading-tight text-foreground">
292+
{log.subject || <span className="text-muted-foreground italic font-normal">No subject</span>}
293+
</p>
294+
{log.is_test === 1 && (
295+
<Badge className="text-[9px] px-1 py-0 h-4 bg-amber-500/10 text-amber-600 border-amber-200 flex-shrink-0">test</Badge>
296+
)}
297+
</div>
292298
<p className="text-[11px] text-muted-foreground font-mono truncate mt-0.5">{log.to_address}</p>
293299
{log.error && (
294300
<p className="text-[11px] text-destructive/70 truncate mt-0.5">{log.error}</p>
@@ -351,6 +357,7 @@ interface LogRow {
351357
domain_id: string | null;
352358
template_id: string | null;
353359
error: string | null;
360+
is_test: number;
354361
sent_at: string;
355362
}
356363

services/admin/src/pages/Playground.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ export default function PlaygroundPage() {
114114
const [previewHtml, setPreviewHtml] = useState('');
115115
const [rightTab, setRightTab] = useState<RightTab>('preview');
116116

117+
const keyMode: 'test' | 'live' | null = apiKey.startsWith('eftest_') ? 'test' : apiKey.startsWith('eflive_') || apiKey.startsWith('emailflair_') ? 'live' : null;
118+
117119
useEffect(() => {
118120
Promise.all([
119121
api.get<Template[]>('/api/templates'),
@@ -299,7 +301,15 @@ export default function PlaygroundPage() {
299301

300302
<div className="flex flex-col gap-1.5">
301303
<Label className="text-xs">API key</Label>
302-
<Input type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder="ef_live_…" autoComplete="off" />
304+
<div className="flex gap-2 items-center">
305+
<Input type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder="eftest_… or eflive_…" autoComplete="off" className="flex-1" />
306+
{keyMode === 'test' && (
307+
<Badge className="text-[10px] bg-amber-500/10 text-amber-600 border-amber-200 whitespace-nowrap">Test mode</Badge>
308+
)}
309+
{keyMode === 'live' && (
310+
<Badge className="text-[10px] bg-emerald-500/10 text-emerald-600 border-emerald-200 whitespace-nowrap">Live mode</Badge>
311+
)}
312+
</div>
303313
</div>
304314

305315
<div className="flex flex-col gap-1.5">

services/backend/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"dev": "tsx watch src/index.ts",
88
"build": "tsc",
99
"start": "node dist/index.js",
10-
"seed": "tsx src/seed.ts"
10+
"seed": "tsx src/seed.ts",
11+
"backfill": "tsx src/scripts/backfill-cf.ts"
1112
},
1213
"dependencies": {
1314
"@hono/node-server": "^1.13.7",
@@ -19,12 +20,14 @@
1920
"iron-session": "^8.0.3",
2021
"jose": "^5.10.0",
2122
"nanoid": "^5.1.5",
23+
"nodemailer": "^8.0.5",
2224
"react": "^19.1.0",
2325
"react-dom": "^19.1.0",
2426
"zod": "^3.24.3"
2527
},
2628
"devDependencies": {
2729
"@types/node": "^22.15.3",
30+
"@types/nodemailer": "^8.0.0",
2831
"@types/react": "^19.1.2",
2932
"@types/react-dom": "^19.1.2",
3033
"tsx": "^4.19.3",

0 commit comments

Comments
 (0)