docs: add Gmail SMTP implementation plan

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 00:36:07 +02:00
parent 19620cc1c4
commit 2391a88e35

View file

@ -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: "<p>Statement attached.</p>",
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: "<p>Statement attached.</p>",
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: "<p>Statement attached.</p>",
},
{ 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<unknown>;
};
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<void> {
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: `<p>Hi ${cust.name},</p>
<p>Please find your delivery statement attached for the period <strong>${start}</strong> to <strong>${end}</strong>.</p>
<p>Total litres delivered: <strong>${totalQty.toFixed(2)} L</strong><br/>
Total value: <strong>R ${totalValue.toFixed(2)}</strong></p>
<p>Thank you for your business.</p>
<p>— RJ Bulk Supplies</p>`,
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<ActionResult> {
if (!to) return { ok: false, error: "Recipient email required" };
try {
await sendEmail({
to,
subject: "RJ Bulk test email",
html: "<p>This is a test email from your RJ Bulk back-office. If you got this, email is working correctly.</p>",
});
} 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<HTMLFormElement>(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 `<Card>` from `<Card>` before `Email settings` through its closing `</Card>` with:
```tsx
<Card>
<CardHeader>
<CardTitle>Email delivery</CardTitle>
<CardDescription>Customer statements are sent through Gmail SMTP.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border bg-muted/30 p-3 text-sm">
<p className="font-medium">Configured sender</p>
<p className="text-muted-foreground">rjbulk01@gmail.com</p>
<p className="mt-2 text-muted-foreground">
Gmail credentials are managed by the deployment environment and are not stored in PocketBase.
</p>
</div>
<div className="flex items-end gap-2 pt-2 border-t">
<div className="space-y-2 flex-1">
<Label htmlFor="test">Send test email to:</Label>
<Input id="test" type="email" value={testEmail} onChange={(e) => setTestEmail(e.target.value)} />
</div>
<Button type="button" variant="outline" onClick={onSendTest}>
Send test
</Button>
</div>
</CardContent>
</Card>
```
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.