Skip to main content

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)

VarValueNotes
SMTP_HOSTsmtp.gmail.comDefault; works with Google Workspace
SMTP_PORT587STARTTLS
SMTP_SECUREfalsetrue only for port 465
SMTP_USERnoreply@ka-26.comMailbox login
SMTP_PASS(secret)App Password from Google Workspace
EMAIL_FROM_ADDRESSnoreply@ka-26.comFrom: header
EMAIL_FROM_NAMEKA26Display name
EMAIL_ADMIN_ADDRESSadmin@ka-26.comAdmin 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.

  1. Sign in to admin.google.com as a Workspace admin.
  2. Go to Apps → Google Workspace → Gmail → User settings.
  3. Confirm Less secure app access is OFF and 2-Step Verification is ON for the noreply mailbox.
  4. As the noreply@ka-26.com user, go to myaccount.google.com → Security → App passwords.
  5. Create one named "KA26 Cloud Run", copy the 16-character password.
  6. Store as SMTP_PASS Secret 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.

TypeNameContentTTL
TXT@v=spf1 include:_spf.google.com ~all3600

Important: only have one SPF record per domain. If one already exists, merge by adding include:_spf.google.com to 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.com should 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).

TypeNameContentTTL
TXT_dmarcv=DMARC1; p=quarantine; rua=mailto:admin@ka-26.com; pct=100; adkim=s; aspf=s3600

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 inbox
  • pct=100 — apply policy to 100% of failing mail
  • adkim=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: PASS
  • DKIM: PASS
  • DMARC: PASS

6. Application code

All transactional sends go through src/lib/email.ts. It exports:

FunctionPurpose
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 EmailLog table (status: sent | failed)
  • Auto-Submitted: auto-generated header to suppress vacation responders

7. OTP Flow

AspectValueSource
Code length6 digitsgenerateOTP() in email.ts
Expiry10 minutesOTP_EXPIRY_MS in constants.ts
Resend cooldown (per user)60 secondsOTP_RESEND_COOLDOWN_MS
Rate limit (per IP)3 sends per 5 minutessend-verification/route.ts
Verification attempts (per IP)10 attempts per 5 minutesverify-email/route.ts

Verify endpoint returns precise reasonsreason: "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

SymptomLikely causeFix
All sends failing with "Invalid login"Wrong app password, or 2FA offRegenerate app password in Workspace
Emails landing in spam (Gmail)Missing/wrong SPF or DMARCRe-check DNS records section 4
EmailLog.error shows "Connection timeout"Cloud Run egress blockedCheck Cloud Run VPC connector + firewall
EmailLog empty but emails go outMigration not appliedRun prisma/migrations/20260417_add_email_log/migration.sql
One user complaining "no email"Check by query at end of section 8If 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 browse EmailLog without raw SQL.
  • Upgrade DMARC to p=reject after 2-4 weeks of clean reports.