From 2391a88e3542f3b8bbe27590bb217c3ab13ad791 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 00:36:07 +0200 Subject: [PATCH] docs: add Gmail SMTP implementation plan Co-Authored-By: Claude Co-authored-by: Orca --- .../plans/2026-08-03-gmail-smtp.md | 660 ++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-gmail-smtp.md diff --git a/docs/superpowers/plans/2026-08-03-gmail-smtp.md b/docs/superpowers/plans/2026-08-03-gmail-smtp.md new file mode 100644 index 00000000..a881a3df --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-gmail-smtp.md @@ -0,0 +1,660 @@ +# Gmail SMTP Email Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Resend with Gmail SMTP through Nodemailer so RJ Bulk sends customer statements and test emails from `rjbulk01@gmail.com` without storing Gmail credentials in PocketBase. + +**Architecture:** Add a small server-side mailer in `lib/email.ts` that owns Gmail SMTP configuration, sender selection, safe configuration errors, and attachment handling. Both existing server actions call this helper; statement generation, recipient validation, logging, and the test-email UI remain in their current layers. Remove the obsolete editable email settings and keep Gmail credentials in deployment environment variables only. + +**Tech Stack:** Next.js 16 App Router and Server Actions, TypeScript strict mode, Nodemailer SMTP transport, Node `node:test` via the existing `tsx` dev dependency, pnpm, Gmail SMTP with STARTTLS on port 587. + +--- + +## File map + +- Create `lib/email.ts` — typed Gmail SMTP transport and provider-independent message sender. +- Create `lib/email.test.ts` — unit tests for configuration validation, message mapping, attachment forwarding, and safe transport errors. +- Modify `package.json` — add `nodemailer`, `@types/nodemailer`, and a `test` script; remove `resend` after migration. +- Modify `pnpm-lock.yaml` — update the lockfile through pnpm commands. +- Modify `actions/statements.ts:1-157` — replace the Resend send call while preserving statement business logic and success-only logging. +- Modify `actions/settings.ts:1-128` — remove the PocketBase email-settings update action and route test email through the shared mailer. +- Modify `components/forms/settings-forms.tsx:1-168` — replace editable Resend/SMTP fields with read-only Gmail delivery information and the existing test-recipient control. +- Modify `AGENTS.md:19,72-74` — document Gmail SMTP and the two environment variables. +- Modify `Dockerfile:4-6` — update the runtime environment comment. +- Leave `lib/types.ts` and the PocketBase schema unchanged — legacy fields remain for compatibility but are no longer read by any send path. + +--- + +### Task 1: Add Gmail mailer dependencies and test command + +**Files:** +- Modify: `package.json:4-43` +- Modify: `pnpm-lock.yaml` + +- [ ] **Step 1: Add Nodemailer and its TypeScript declarations** + +Run: + +```bash +pnpm add nodemailer +pnpm add -D @types/nodemailer +``` + +Expected: `package.json` contains `nodemailer` under `dependencies` and `@types/nodemailer` under `devDependencies`; `pnpm-lock.yaml` contains both packages. + +- [ ] **Step 2: Add the test script** + +Add this entry to the existing `scripts` object in `package.json`: + +```json +"test": "tsx --test lib/email.test.ts" +``` + +Keep the existing `dev`, `build`, `start`, `lint`, and `pb:setup` scripts unchanged. + +- [ ] **Step 3: Verify the dependency setup** + +Run: + +```bash +pnpm install --frozen-lockfile +``` + +Expected: pnpm completes without changing the lockfile. If the lockfile changed during dependency installation, inspect the diff and keep the generated lockfile changes. + +- [ ] **Step 4: Commit the dependency setup** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "build: add Gmail SMTP dependencies" +``` + +--- + +### Task 2: Write failing tests for the mailer contract + +**Files:** +- Create: `lib/email.test.ts` +- Test target: `lib/email.ts` (not created until Task 3) + +- [ ] **Step 1: Write the failing tests** + +Create `lib/email.test.ts` with this complete content: + +```ts +import assert from "node:assert/strict"; +import test from "node:test"; +import { getGmailCredentials, sendEmail, type MailTransport } from "./email"; + +const env = { + GMAIL_SMTP_USER: "rjbulk01@gmail.com", + GMAIL_SMTP_APP_PASSWORD: "test-app-password", +}; + +test("getGmailCredentials rejects missing Gmail configuration", () => { + assert.throws( + () => getGmailCredentials({}), + { message: "Gmail email credentials are not configured" }, + ); +}); + +test("sendEmail uses the authenticated Gmail user and forwards the message", async () => { + const sent: unknown[] = []; + const transport: MailTransport = { + async sendMail(message) { + sent.push(message); + return {}; + }, + }; + + await sendEmail( + { + to: "customer@example.com", + subject: "Delivery statement", + html: "

Statement attached.

", + attachments: [ + { + filename: "statement.pdf", + content: Buffer.from("pdf-bytes"), + }, + ], + }, + { env, transport }, + ); + + assert.deepEqual(sent, [ + { + from: "rjbulk01@gmail.com", + to: "customer@example.com", + subject: "Delivery statement", + html: "

Statement attached.

", + attachments: [ + { + filename: "statement.pdf", + content: Buffer.from("pdf-bytes"), + }, + ], + }, + ]); +}); + +test("sendEmail hides provider error details and credentials", async () => { + const secret = "test-app-password"; + const transport: MailTransport = { + async sendMail() { + throw new Error(`authentication failed for ${secret}`); + }, + }; + + await assert.rejects( + sendEmail( + { + to: "customer@example.com", + subject: "Delivery statement", + html: "

Statement attached.

", + }, + { env, transport }, + ), + (error: unknown) => + error instanceof Error && + error.message === "Gmail email send failed" && + !error.message.includes(secret), + ); +}); +``` + +The injected `MailTransport` keeps the tests independent of Gmail and prevents tests from sending real mail. + +- [ ] **Step 2: Run the tests and verify they fail for the intended reason** + +Run: + +```bash +pnpm test +``` + +Expected: FAIL because `lib/email.ts` does not exist yet. Do not add a mock module or skip the tests; Task 3 supplies the implementation. + +- [ ] **Step 3: Commit the failing tests** + +```bash +git add lib/email.test.ts +git commit -m "test: define Gmail mailer behavior" +``` + +--- + +### Task 3: Implement the shared Gmail mailer + +**Files:** +- Create: `lib/email.ts` +- Test: `lib/email.test.ts` + +- [ ] **Step 1: Implement configuration validation and SMTP transport** + +Create `lib/email.ts` with this complete implementation: + +```ts +import nodemailer, { type SendMailOptions } from "nodemailer"; + +export type EmailAttachment = { + filename: string; + content: Buffer; +}; + +export type EmailMessage = { + to: string; + subject: string; + html: string; + attachments?: EmailAttachment[]; +}; + +export type MailTransport = { + sendMail(message: SendMailOptions): Promise; +}; + +type EmailEnvironment = { + GMAIL_SMTP_USER?: string; + GMAIL_SMTP_APP_PASSWORD?: string; +}; + +export function getGmailCredentials(env: EmailEnvironment = process.env) { + const user = env.GMAIL_SMTP_USER?.trim(); + const appPassword = env.GMAIL_SMTP_APP_PASSWORD?.trim(); + + if (!user || !appPassword) { + throw new Error("Gmail email credentials are not configured"); + } + + return { user, appPassword }; +} + +export function createGmailTransport(env: EmailEnvironment = process.env): MailTransport { + const { user, appPassword } = getGmailCredentials(env); + + return nodemailer.createTransport({ + host: "smtp.gmail.com", + port: 587, + secure: false, + requireTLS: true, + auth: { + user, + pass: appPassword, + }, + }); +} + +export async function sendEmail( + message: EmailMessage, + options: { env?: EmailEnvironment; transport?: MailTransport } = {}, +): Promise { + const env = options.env ?? process.env; + const { user } = getGmailCredentials(env); + const transport = options.transport ?? createGmailTransport(env); + + try { + await transport.sendMail({ + from: user, + to: message.to, + subject: message.subject, + html: message.html, + attachments: message.attachments, + }); + } catch { + throw new Error("Gmail email send failed"); + } +} +``` + +The helper fixes the sender to the authenticated Gmail user, enables STARTTLS on port 587, does not accept CC values, and never propagates raw provider error text. + +- [ ] **Step 2: Run the focused tests** + +Run: + +```bash +pnpm test +``` + +Expected: all three tests PASS. + +- [ ] **Step 3: Run TypeScript validation** + +Run: + +```bash +pnpm exec tsc --noEmit +``` + +Expected: TypeScript exits successfully. If Nodemailer's type declarations require a different import form, adjust only the import/types in `lib/email.ts` and rerun the focused tests. + +- [ ] **Step 4: Commit the mailer implementation** + +```bash +git add lib/email.ts lib/email.test.ts +git commit -m "feat: add Gmail SMTP mailer" +``` + +--- + +### Task 4: Migrate statement and test-email actions + +**Files:** +- Modify: `actions/statements.ts:1-5,107-138` +- Modify: `actions/settings.ts:1-70` + +- [ ] **Step 1: Replace the provider import in the statement action** + +In `actions/statements.ts`, remove: + +```ts +import { Resend } from "resend"; +``` + +Add: + +```ts +import { sendEmail } from "@/lib/email"; +``` + +- [ ] **Step 2: Replace the statement Resend call** + +Replace the existing block from `// Email via Resend` through the `if (result.error)` return with: + +```ts + // Email via Gmail SMTP + await sendEmail({ + to: parsed.data.recipient_email, + subject: `Delivery Statement — ${cust.name} (${start} to ${end})`, + html: `

Hi ${cust.name},

+

Please find your delivery statement attached for the period ${start} to ${end}.

+

Total litres delivered: ${totalQty.toFixed(2)} L
+Total value: R ${totalValue.toFixed(2)}

+

Thank you for your business.

+

— RJ Bulk Supplies

`, + attachments: [ + { + filename: `delivery_statement_${cust.name.replace(/\s+/g, "_")}_${start.replace(/-/g, "")}.pdf`, + content: Buffer.from(pdfBytes), + }, + ], + }); +``` + +Do not add a `cc` property. Leave the existing `statement_log` creation immediately after this block so it still runs only after `sendEmail` resolves. + +- [ ] **Step 3: Replace the test-email provider call** + +In `actions/settings.ts`, remove: + +```ts +import { Resend } from "resend"; +``` + +Add: + +```ts +import { sendEmail } from "@/lib/email"; +``` + +Replace the body of `sendTestEmail` with: + +```ts +export async function sendTestEmail(to: string): Promise { + if (!to) return { ok: false, error: "Recipient email required" }; + + try { + await sendEmail({ + to, + subject: "RJ Bulk test email", + html: "

This is a test email from your RJ Bulk back-office. If you got this, email is working correctly.

", + }); + } catch (err: any) { + return { ok: false, error: err?.message || "Send failed" }; + } + + return { ok: true }; +} +``` + +- [ ] **Step 4: Remove the obsolete email-settings action code** + +Delete `emailSettingsSchema`, `clean`, and `updateEmailSettings` from `actions/settings.ts`. Remove now-unused imports `pbCreate`, `pbList`, and `pbUpdate`; keep `revalidatePath`, `z`, `PocketBase`, `getAuth`, `pbId` only if they are still used by the remaining user actions, and keep `PB_URL` for PocketBase admin operations. + +- [ ] **Step 5: Run focused verification for the action migration** + +Run: + +```bash +pnpm exec tsc --noEmit +``` + +Expected: no TypeScript errors. The existing statement code still owns PDF generation and `statement_log`; only the provider call changed. + +- [ ] **Step 6: Commit the action migration** + +```bash +git add actions/statements.ts actions/settings.ts +git commit -m "feat: send statements through Gmail SMTP" +``` + +--- + +### Task 5: Simplify the Settings email panel + +**Files:** +- Modify: `components/forms/settings-forms.tsx:31-163` + +- [ ] **Step 1: Remove the old email-settings state and form wiring** + +Delete these declarations from `SettingsForms`: + +```ts +const [emailState, emailAction, emailPending] = useActionState(updateEmailSettings, initial); +const emailFormRef = useRef(null); +``` + +Delete the `useEffect` that watches `emailState`, and change: + +```ts +const [testEmail, setTestEmail] = useState(settings?.statement_from_email || ""); +``` + +to: + +```ts +const [testEmail, setTestEmail] = useState(""); +``` + +Remove `updateEmailSettings` from the settings action import. Keep `sendTestEmail`, `addUser`, `deleteUser`, `ActionResult`, and the existing hooks required by the user dialog and other settings controls. + +- [ ] **Step 2: Replace the SMTP/Resend fields with a read-only Gmail panel** + +Replace the existing email `` from `` before `Email settings` through its closing `` with: + +```tsx + + + Email delivery + Customer statements are sent through Gmail SMTP. + + +
+

Configured sender

+

rjbulk01@gmail.com

+

+ Gmail credentials are managed by the deployment environment and are not stored in PocketBase. +

+
+
+
+ + setTestEmail(e.target.value)} /> +
+ +
+
+
+``` + +This removes sender, CC, fallback, and password inputs while retaining the test-email flow. + +- [ ] **Step 3: Run the settings type check** + +Run: + +```bash +pnpm exec tsc --noEmit +``` + +Expected: no references remain to `emailAction`, `emailPending`, `emailFormRef`, or `updateEmailSettings` in the component. + +- [ ] **Step 4: Commit the Settings cleanup** + +```bash +git add components/forms/settings-forms.tsx +git commit -m "ui: simplify Gmail delivery settings" +``` + +--- + +### Task 6: Remove Resend and update project documentation + +**Files:** +- Modify: `package.json` +- Modify: `pnpm-lock.yaml` +- Modify: `AGENTS.md:19,72-74` +- Modify: `Dockerfile:4-6` + +- [ ] **Step 1: Remove the unused Resend dependency** + +Run: + +```bash +pnpm remove resend +``` + +Expected: `resend` is removed from `package.json` and `pnpm-lock.yaml`. + +- [ ] **Step 2: Update the stack and email documentation** + +Change the stack bullet in `AGENTS.md` from: + +```md +- **Resend** for transactional email +``` + +to: + +```md +- **Gmail SMTP via Nodemailer** for transactional email +``` + +Replace the email section with: + +```md +## Email (Gmail SMTP) +- Set `GMAIL_SMTP_USER` to `rjbulk01@gmail.com` and `GMAIL_SMTP_APP_PASSWORD` to a Google app password in `.env.local` or the production environment. +- Gmail SMTP uses `smtp.gmail.com` on port 587 with STARTTLS. +- The app sends statements and test emails from `rjbulk01@gmail.com` and does not use a default CC. +- Without the Gmail variables, statement sending and test-email sending fail with a clear error; statement preview still works. +``` + +Do not include an actual app-password value. + +- [ ] **Step 3: Update Docker runtime guidance** + +Change the required environment comment in `Dockerfile` from: + +```dockerfile +# POCKETBASE_URL, RESEND_API_KEY, RESEND_FROM +``` + +to: + +```dockerfile +# POCKETBASE_URL, GMAIL_SMTP_USER, GMAIL_SMTP_APP_PASSWORD +``` + +- [ ] **Step 4: Check active code and docs for stale provider references** + +Run: + +```bash +grep -RInE 'Resend|resend|RESEND_API_KEY|RESEND_FROM' \ + actions components lib AGENTS.md Dockerfile package.json \ + --exclude='*.test.ts' || true +``` + +Expected: no output. References in the approved migration design under `docs/superpowers/` are historical documentation and do not count as active provider references. + +- [ ] **Step 5: Commit provider cleanup and documentation** + +```bash +git add package.json pnpm-lock.yaml AGENTS.md Dockerfile +git commit -m "docs: document Gmail SMTP configuration" +``` + +--- + +### Task 7: Run automated verification + +**Files:** +- No source changes expected. + +- [ ] **Step 1: Run the focused mailer tests** + +```bash +pnpm test +``` + +Expected: all three mailer tests pass. + +- [ ] **Step 2: Run lint** + +```bash +pnpm lint +``` + +Expected: ESLint exits with status 0. Fix only migration-related unused imports or formatting issues. + +- [ ] **Step 3: Run a strict TypeScript check** + +```bash +pnpm exec tsc --noEmit +``` + +Expected: no type errors. + +- [ ] **Step 4: Run the production build** + +```bash +pnpm build +``` + +Expected: the Next.js production build completes successfully. No email is sent during the build because mail actions execute only when invoked by an authenticated request. + +- [ ] **Step 5: Check the final working tree and diff** + +```bash +git status --short +git diff origin/main...HEAD --check +``` + +Expected: only the intended Gmail migration commits/files are present, and `git diff --check` produces no output. + +--- + +### Task 8: Configure Gmail and perform manual delivery verification + +**Files:** +- Local `.env.local` or the deployment provider's secret environment configuration; never commit this file. + +- [ ] **Step 1: Revoke the previously exposed app password** + +In the Google account security settings for `rjbulk01@gmail.com`, revoke the app password that was previously pasted into chat. Do not reuse it. + +- [ ] **Step 2: Create a fresh Google app password** + +Enable two-step verification if necessary, then create a new app password for the RJ Bulk application. Keep the value private. + +- [ ] **Step 3: Configure the application environment** + +Set these values in the local/deployment environment: + +```text +GMAIL_SMTP_USER=rjbulk01@gmail.com +GMAIL_SMTP_APP_PASSWORD=(the newly generated private Google app password) +``` + +Do not paste the value into source files, PocketBase, or chat. Restart the development server or redeploy after changing environment variables. + +- [ ] **Step 4: Send a test email from Settings** + +Open Settings, enter a mailbox that can be checked, and click `Send test`. + +Expected: the recipient receives the test email from `rjbulk01@gmail.com`. If it fails, the UI returns a safe Gmail configuration/send error and does not expose the app password. + +- [ ] **Step 5: Send a real statement** + +Use Send Statement with a customer and date range containing delivery notes. + +Expected: the recipient receives the existing HTML message with the generated PDF attached, the sender is `rjbulk01@gmail.com`, no CC is added, and the Statements log records `Email Sent`. + +- [ ] **Step 6: Verify preview independence** + +Use the statement preview without Gmail variables present in a separate environment or after temporarily removing them. + +Expected: preview still renders because PDF generation does not depend on the email provider. + +--- + +## Self-review checklist + +- **Spec coverage:** Gmail SMTP/Nodemailer architecture is covered by Tasks 1–3; action migration by Task 4; Settings cleanup by Task 5; Resend removal and docs by Task 6; automated checks by Task 7; secure credentials and real delivery by Task 8. +- **Security coverage:** The plan never requests, stores, or commits an actual app password; sender is fixed to the authenticated user; raw transport errors are hidden. +- **Type consistency:** `MailTransport`, `EmailMessage`, `getGmailCredentials`, `createGmailTransport`, and `sendEmail` are defined in Task 3 and used consistently in Tasks 2 and 4. +- **No unresolved placeholders:** The only parenthesized environment text is an explicit instruction that the secret belongs outside the repository; no task relies on an unspecified implementation or test. +- **Scope:** This is one provider migration subsystem with a shared helper, two callers, UI cleanup, dependency/documentation updates, and manual verification.