KA26 Email Infrastructure
Production transactional email setup. As of 2026-04-17, all automated emails are sent from noreply@ka-26.com via Google Workspace SMTP, replacing the previous personal Gmail (siddugkattimani@gmail.com).
1. Architecture
App (Cloud Run)
│
│ uses src/lib/email.ts
▼
Generic SMTP transport (smtp.gmail.com:587 / STARTTLS)
│
│ authenticates as noreply@ka-26.com (Google Workspace app password)
▼
Google Workspace
│
│ signs with DKIM (google._domainkey.ka-26.com)
│ authorized by SPF (TXT @)
│ reported via DMARC (TXT _dmarc)
▼
Recipient inbox (Gmail / Outlook / etc.)
Every send is logged to the EmailLog Postgres table:
SELECT kind, status, count(*) FROM "EmailLog"
WHERE "createdAt" > NOW() - INTERVAL '24 hours'
GROUP BY 1, 2 ORDER BY 1, 2;
2. Environment Variables (Cloud Run)
| Var | Value | Notes |
|---|---|---|
SMTP_HOST | smtp.gmail.com | Default; works with Google Workspace |
SMTP_PORT | 587 | STARTTLS |
SMTP_SECURE | false | true only for port 465 |
SMTP_USER | noreply@ka-26.com | Mailbox login |
SMTP_PASS | (secret) | App Password from Google Workspace |
EMAIL_FROM_ADDRESS | noreply@ka-26.com | From: header |
EMAIL_FROM_NAME | KA26 | Display name |
EMAIL_ADMIN_ADDRESS | admin@ka-26.com | Admin recipient (feedback, approvals, etc.) |
Set these on Cloud Run:
gcloud run services update ka26-marketplace \
--region us-central1 \
--update-env-vars SMTP_HOST=smtp.gmail.com,SMTP_PORT=587,SMTP_SECURE=false \
--update-env-vars SMTP_USER=noreply@ka-26.com,EMAIL_FROM_ADDRESS=noreply@ka-26.com \
--update-env-vars EMAIL_FROM_NAME=KA26,EMAIL_ADMIN_ADDRESS=admin@ka-26.com
# Set the password as a Secret Manager secret first:
echo -n "YOUR-APP-PASSWORD" | gcloud secrets create smtp-pass --data-file=-
gcloud run services update ka26-marketplace \
--region us-central1 \
--update-secrets SMTP_PASS=smtp-pass:latest
3. Generate the App Password (noreply@ka-26.com)
App Passwords are required because Google Workspace 2FA blocks plain SMTP login.
- Sign in to admin.google.com as a Workspace admin.
- Go to Apps → Google Workspace → Gmail → User settings.
- Confirm Less secure app access is OFF and 2-Step Verification is ON for the noreply mailbox.
- As the
noreply@ka-26.comuser, go to myaccount.google.com → Security → App passwords. - Create one named "KA26 Cloud Run", copy the 16-character password.
- Store as
SMTP_PASSSecret Manager secret (above).
If 2FA cannot be enabled on a shared mailbox, use Google Workspace Service Account + domain-wide delegation with gmail.send scope instead (more secure but more setup).
4. DNS Records (Hostinger)
These three records protect your domain reputation and prevent your emails from landing in spam.
4.1 SPF (Sender Policy Framework)
Authorizes Google Workspace to send mail as @ka-26.com.
| Type | Name | Content | TTL |
|---|---|---|---|
| TXT | @ | v=spf1 include:_spf.google.com ~all | 3600 |
Important: only have one SPF record per domain. If one already exists, merge by adding
include:_spf.google.comto the existing string.
4.2 DKIM (DomainKeys Identified Mail)
Signs every outbound email with a cryptographic key Google Workspace controls.
✅ Already configured — you have a google._domainkey TXT record on Hostinger from when Google Workspace was set up.
To verify it's active:
- Go to admin.google.com → Apps → Google Workspace → Gmail → Authenticate email.
- Status for
ka-26.comshould be Authenticating email.
If status shows "Not authenticating", click Start Authentication.
4.3 DMARC (Domain-based Message Authentication, Reporting & Conformance)
Tells receivers what to do with mail that fails SPF/DKIM. Start in quarantine mode (safer than reject while we're new).
| Type | Name | Content | TTL |
|---|---|---|---|
| TXT | _dmarc | v=DMARC1; p=quarantine; rua=mailto:admin@ka-26.com; pct=100; adkim=s; aspf=s | 3600 |
What this means:
p=quarantine— failing mail goes to spam (not rejected outright)rua=mailto:admin@ka-26.com— receive daily aggregate reports at this inboxpct=100— apply policy to 100% of failing mailadkim=s; aspf=s— strict alignment (header from = signed domain)
Once we're stable for 2-4 weeks, upgrade to p=reject for full protection.
5. Verify the setup
# Check DNS records propagated
dig ka-26.com TXT +short # should show v=spf1 include:_spf.google.com ~all
dig google._domainkey.ka-26.com TXT +short # should show v=DKIM1; k=rsa; p=...
dig _dmarc.ka-26.com TXT +short # should show v=DMARC1; p=quarantine; ...
# Send a test email (mail-tester.com gives you a unique address + a 0-10 score)
# Aim for 9.0+ before going live with marketing volume.
You can also send a test verification email to a Gmail account, then in Gmail click ⋮ → Show original. You should see:
SPF: PASSDKIM: PASSDMARC: PASS
6. Application code
All transactional sends go through src/lib/email.ts. It exports:
| Function | Purpose |
|---|---|
sendEmailVerification(to, code, name) | OTP for email verification (10-min expiry) |
sendPasswordReset(to, link, name) | Password reset (15-min link expiry) |
sendOrderConfirmation({to, name, orderId, total, ...}) | Order placed |
sendSellerInvite(to, link, inviter) | Invite a new seller (7-day expiry) |
sendSellerRegistrationNotification(seller) | Notify admin of new seller |
sendSellerApprovalEmail(seller, approved) | Approve/reject seller |
sendFeedbackEmail(feedback) | User feedback → admin |
sendAdminNotification({subject, html, ...}) | Generic admin notification |
Built-in protections:
- 3-attempt retry with exponential backoff (500ms → 1s → 2s)
- Connection pooling (3 parallel, max 100 messages per connection)
- 10s connection / 20s socket timeout
- All sends logged to
EmailLogtable (status: sent | failed) - Auto-Submitted: auto-generated header to suppress vacation responders
7. OTP Flow
| Aspect | Value | Source |
|---|---|---|
| Code length | 6 digits | generateOTP() in email.ts |
| Expiry | 10 minutes | OTP_EXPIRY_MS in constants.ts |
| Resend cooldown (per user) | 60 seconds | OTP_RESEND_COOLDOWN_MS |
| Rate limit (per IP) | 3 sends per 5 minutes | send-verification/route.ts |
| Verification attempts (per IP) | 10 attempts per 5 minutes | verify-email/route.ts |
Verify endpoint returns precise reasons — reason: "invalid" | "used" | "expired" — so the UI can show the right message.
8. Monitoring
Quick health queries:
-- Last 24 hours: send success rate by kind
SELECT kind,
count(*) FILTER (WHERE status='sent') AS sent,
count(*) FILTER (WHERE status='failed') AS failed,
round(100.0 * count(*) FILTER (WHERE status='sent') / count(*), 1) AS pct_ok
FROM "EmailLog"
WHERE "createdAt" > NOW() - INTERVAL '24 hours'
GROUP BY kind
ORDER BY kind;
-- Recent failures with error messages
SELECT "createdAt", kind, "to", error
FROM "EmailLog"
WHERE status='failed' AND "createdAt" > NOW() - INTERVAL '24 hours'
ORDER BY "createdAt" DESC
LIMIT 50;
-- Suspicious activity: same recipient hit hard
SELECT "to", count(*)
FROM "EmailLog"
WHERE "createdAt" > NOW() - INTERVAL '1 hour'
GROUP BY "to"
HAVING count(*) > 10;
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| All sends failing with "Invalid login" | Wrong app password, or 2FA off | Regenerate app password in Workspace |
| Emails landing in spam (Gmail) | Missing/wrong SPF or DMARC | Re-check DNS records section 4 |
EmailLog.error shows "Connection timeout" | Cloud Run egress blocked | Check Cloud Run VPC connector + firewall |
EmailLog empty but emails go out | Migration not applied | Run prisma/migrations/20260417_add_email_log/migration.sql |
| One user complaining "no email" | Check by query at end of section 8 | If sent → check their spam; if failed → check error |
10. Future Improvements
- Move to Resend or AWS SES when monthly volume exceeds Google Workspace soft limit (~2,000/day per user).
- Add bounce / complaint webhook handler when we move to Resend/SES.
- Add admin UI page (
/admin/emails) to browseEmailLogwithout raw SQL. - Upgrade DMARC to
p=rejectafter 2-4 weeks of clean reports.