Fix magic-link verify URLs in email and related create-flow QA #70

Merged
an.di merged 4 commits from adilallo/fix/QaFeedback into main 2026-09-01 16:09:31 +00:00
10 changed files with 258 additions and 112 deletions
Showing only changes of commit d920e39f09 - Show all commits
+1 -1
View File
@@ -4,7 +4,7 @@
"title": "Community Rule",
"author": "MEDLab",
"description": "Community governance and rule-building app",
"version": "0.1.11",
"version": "0.1.12",
"httpPort": 3000,
"healthCheckPath": "/api/health",
"memoryLimit": 805306368,
+1 -1
View File
@@ -227,7 +227,7 @@ npm run dev
1. TLS certificates and hostnames. _On Cloudron: handled by the platform per chosen subdomain._
2. PostgreSQL backups and restore drill. _On Cloudron: daily snapshots; configure retention in admin UI._
3. SMTP DNS (SPF, DKIM). _On Cloudron: handled for the platform-managed domain._
3. SMTP DNS (SPF, DKIM). _TLS for the app hostname is Cloudron/Let's Encrypt. Mail is SES-relayed: publish SES DKIM (and SPF `include:amazonses.com`) via Cloudron Domains → Namecheap. Cloudron skips SPF/DKIM checks when a relay is configured. See [`ops-runbook.md`](ops-runbook.md) §8.1._
4. Health check URL for reverse proxy (`/api/health`). _On Cloudron: set `healthCheckPath` in `CloudronManifest.json`._
5. Log retention and alerts for 5xx errors. _On Cloudron: app log viewer; export off-platform if longer retention is needed._
+11 -3
View File
@@ -101,8 +101,16 @@ per-app in the manifest and provisioned at install time.
- Backups: Cloudron's automatic backups are already on for the host
(legacy app shows weekly snapshots ~451 MB each). Same default
applies to new apps.
- TLS / DNS / SPF / DKIM: handled by Cloudron for any subdomain of
`communityrule.info`.
- TLS for Cloudron app hostnames: handled by Cloudron (Let's Encrypt).
- **Mail DNS (SPF/DKIM):** *not* automatic for this domain. Cloudron's
DNS provider for `communityrule.info` is Namecheap, but outbound mail
is **Amazon SES relay**. Cloudron's own mail-status check **skips**
SPF and DKIM and says to configure them on the relay. Add the SES
identity's **DKIM CNAME** records (and optionally
`include:amazonses.com` on SPF) in Cloudron → *Domains*
`communityrule.info` → DNS so they publish to Namecheap. See
[`ops-runbook.md`](ops-runbook.md) §8.1. DMARC on the domain is
currently `p=reject`.
## 5. Cutover plan (side-by-side, never in-place)
@@ -476,7 +484,7 @@ steps below are still required.
| ------- | ------------ | ----- |
| Image pull error on install | Repo still private, or wrong tag in manifest | §6.3; `docker pull --platform linux/amd64 …` from laptop |
| Health `503` / `database: disconnected` | Postgres addon not provisioned or URL missing | Cloudron app → Environment; expect `CLOUDRON_POSTGRESQL_URL` |
| Magic link not sent | Mail addon or `SMTP_FROM` | Cloudron mail logs; `CLOUDRON_MAIL_SMTP_*` vars |
| Magic link not sent | Mail addon, `SMTP_FROM`, or SES DNS | Cloudron mail logs; `CLOUDRON_MAIL_SMTP_*`; [ops-runbook §8.1](ops-runbook.md#81-mail-dns-when-ses-is-the-relay) |
| Upload `server_misconfigured` | `UPLOAD_ROOT` unset | Set to `/app/data/uploads` (§3) |
| Container crash on start | Migration failure | App logs around `prisma migrate deploy` |
| No "Recommended" on method cards | `MethodFacet` not seeded | §10 step 6; API should return `matches.score > 0` for some methods when `facet.*` set |
+17 -1
View File
@@ -256,13 +256,29 @@ Full detail: [`ops-backend-deploy.md` §3](ops-backend-deploy.md#3-environment-v
| Image pull error on update | Private repo, wrong tag, or amd64 manifest missing | Confirm repo is public; verify pull with `--platform linux/amd64` (§3.1) |
| Health `503` / `database: disconnected` | Postgres addon or `CLOUDRON_POSTGRESQL_URL` missing | Cloudron app → Environment |
| Container crash on start | Migration failure | App logs around `prisma migrate deploy`; fix forward with new migration |
| Magic link not sent | Mail addon or `SMTP_FROM` | Cloudron mail logs; `CLOUDRON_MAIL_SMTP_*` vars |
| Magic link not sent | Mail addon, `SMTP_FROM`, or SES DNS | Cloudron mail logs (`CLOUDRON_MAIL_SMTP_*`); inbox/spam; SPF/DKIM for SES (§8.1) |
| Upload `server_misconfigured` | `UPLOAD_ROOT` unset | `cloudron env set --app <app> UPLOAD_ROOT=/app/data/uploads` |
| No “Recommended” on method cards | Seed not run | §3.4 — `node prisma/seed.bundle.cjs` |
| Rate limit too aggressive after deploy | Expected per §6.1 | Single instance only; limits reset on container restart |
App logs: Cloudron dashboard → *Logs* tab, or `cloudron logs --app <app> -f`.
### 8.1 Mail DNS when SES is the relay
`communityrule.info` outbound mail is **Amazon SES SMTP** (`email-smtp.us-east-2.amazonaws.com:587`), not Cloudron's own MTA. Cloudron Mail → domain status therefore **skips SPF and DKIM** ("configure the relay provider") and only checks MX, DMARC (`v=DMARC1; p=reject; pct=100`), and that the SES connection works.
That is expected. Recipients still authenticate the visible `From:` (`staging.app@communityrule.info` on staging) against **SES DKIM/SPF**, not `a:my.medlab.host`.
**Operator steps (AWS + Cloudron DNS, not app code):**
1. In **AWS SES** (us-east-2), open the verified identity for `communityrule.info` (create one if missing). Copy the **DKIM CNAME** records SES shows (three `*._domainkey.communityrule.info` names).
2. In **Cloudron** → *Domains* → `communityrule.info` → DNS, add those CNAMEs. Cloudron's Namecheap provider publishes them to the registrar. Confirm with `dig +short CNAME <selector>._domainkey.communityrule.info`.
3. Optional but recommended for SPF alignment: add `include:amazonses.com` to the existing TXT SPF, e.g. `v=spf1 include:amazonses.com a:my.medlab.host ~all`. Do not remove `a:my.medlab.host` until you know nothing still sends directly from the box.
4. Leave DMARC at `p=reject` once DKIM verifies in SES; if a provider still quarantines after DKIM is live, inspect that provider's headers before relaxing DMARC.
5. Retest: request a magic link to Gmail **and** a non-Gmail inbox (May First / university). Check spam. Staging From is `Community Rule <staging.app@communityrule.info>`.
`SMTP_FROM` should stay the Cloudron mailbox (`staging.app@communityrule.info` on staging, `hello@communityrule.info` on the apex app). The app falls back to `CLOUDRON_MAIL_FROM` if `SMTP_FROM` is unset.
## 9. Related docs
- [`ops-backend-deploy.md`](ops-backend-deploy.md) — first install, cutover
+23 -36
View File
@@ -27,6 +27,26 @@ function readApiErrorMessage(data: unknown): string {
return "Request failed";
}
function retryAfterFromResponse(
res: Response,
data: unknown,
): number | undefined {
if (res.status !== 429) return undefined;
if (data && typeof data === "object" && "details" in data) {
const d = (data as { details?: unknown }).details;
if (d && typeof d === "object" && "retryAfterMs" in d) {
const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
if (typeof ms === "number" && ms > 0) return ms;
}
}
const h = res.headers.get("retry-after");
if (h) {
const sec = Number.parseInt(h, 10);
if (!Number.isNaN(sec)) return sec * 1000;
}
return undefined;
}
export async function fetchAuthSession(): Promise<{
user: { id: string; email: string } | null;
}> {
@@ -54,13 +74,12 @@ export async function requestMagicLink(
...(draft && Object.keys(draft).length > 0 ? { draft } : {}),
}),
});
const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res);
const data: unknown = await parseJson(res);
if (!res.ok) {
return {
ok: false,
error: readApiErrorMessage(data),
retryAfterMs:
typeof data.retryAfterMs === "number" ? data.retryAfterMs : undefined,
retryAfterMs: retryAfterFromResponse(res, data),
};
}
return { ok: true };
@@ -85,22 +104,10 @@ export async function requestEmailChange(
});
const data: unknown = await res.json().catch(() => ({}));
if (!res.ok) {
let retryAfterMs: number | undefined;
if (
res.status === 429 &&
data &&
typeof data === "object" &&
"details" in data
) {
const d = (data as { details?: { retryAfterMs?: unknown } }).details;
if (d && typeof d.retryAfterMs === "number") {
retryAfterMs = d.retryAfterMs;
}
}
return {
ok: false,
error: readApiErrorMessage(data),
retryAfterMs,
retryAfterMs: retryAfterFromResponse(res, data),
};
}
return { ok: true };
@@ -438,26 +445,6 @@ export type RuleStakeholderMutationResult =
| { ok: true }
| { ok: false; error: string; status: number; retryAfterMs?: number };
function retryAfterFromResponse(
res: Response,
data: unknown,
): number | undefined {
if (res.status !== 429) return undefined;
if (data && typeof data === "object" && "details" in data) {
const d = (data as { details?: unknown }).details;
if (d && typeof d === "object" && "retryAfterMs" in d) {
const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
if (typeof ms === "number" && ms > 0) return ms;
}
}
const h = res.headers.get("retry-after");
if (h) {
const sec = Number.parseInt(h, 10);
if (!Number.isNaN(sec)) return sec * 1000;
}
return undefined;
}
export async function addRuleStakeholder(
ruleId: string,
email: string,
+99 -68
View File
@@ -2,62 +2,108 @@ import nodemailer from "nodemailer";
import { logger } from "../logger";
import { getSmtpUrl } from "./env";
export async function sendMagicLinkEmail(
to: string,
verifyUrl: string,
): Promise<void> {
const url = getSmtpUrl();
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
if (!url) {
export function resolveMailFrom(): string {
return (
process.env.SMTP_FROM?.trim() ||
process.env.CLOUDRON_MAIL_FROM?.trim() ||
"noreply@localhost"
);
}
/** Plaintext + HTML for one-time verify URLs. HTML `href` survives quoted-printable wrapping. */
export function buildVerifyLinkParts(
verifyUrl: string,
intro: string,
outro: string,
linkLabel: string,
): { text: string; html: string } {
const text = `${intro}\n\n${verifyUrl}\n\n${outro}`;
const html =
`<p>${escapeHtml(intro).replace(/\n/g, "<br />")}</p>` +
`<p><a href="${escapeHtml(verifyUrl)}">${escapeHtml(linkLabel)}</a></p>` +
`<p>${escapeHtml(outro)}</p>`;
return { text, html };
}
async function sendHtmlMail(opts: {
to: string;
subject: string;
text: string;
html: string;
from?: string;
replyTo?: string;
devLog: string;
}): Promise<void> {
const smtpUrl = getSmtpUrl();
if (!smtpUrl) {
if (process.env.NODE_ENV === "development") {
logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`);
logger.info(opts.devLog);
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
const from = process.env.SMTP_FROM ?? "noreply@localhost";
const transporter = nodemailer.createTransport(smtpUrl);
await transporter.sendMail({
from,
to,
subject: "Sign in to Community Rule",
text: `Open this link to sign in (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this, you can ignore this email.`,
from: opts.from ?? resolveMailFrom(),
to: opts.to,
subject: opts.subject,
text: opts.text,
html: opts.html,
replyTo: opts.replyTo,
});
}
export async function sendMagicLinkEmail(
to: string,
verifyUrl: string,
): Promise<void> {
const { text, html } = buildVerifyLinkParts(
verifyUrl,
"Open this link to sign in (it expires in 15 minutes):",
"If you did not request this, you can ignore this email.",
"Sign in",
);
await sendHtmlMail({
to,
subject: "Sign in to Community Rule",
text,
html,
devLog: `[dev] Magic link for ${to}: ${verifyUrl}`,
});
}
/** CR-103: confirm control of the new inbox before `User.email` is updated. */
/** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */
export async function sendRuleStakeholderInviteEmail(
to: string,
verifyUrl: string,
ruleTitle: string,
): Promise<void> {
const url = getSmtpUrl();
if (!url) {
if (process.env.NODE_ENV === "development") {
logger.info(
`[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
);
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
const from = process.env.SMTP_FROM ?? "noreply@localhost";
await transporter.sendMail({
from,
const { text, html } = buildVerifyLinkParts(
verifyUrl,
`You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:`,
"If you did not expect this, you can ignore this email.",
"Open the rule",
);
await sendHtmlMail({
to,
subject: `You're invited to view a Community Rule: ${ruleTitle}`,
text: `You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:\n\n${verifyUrl}\n\nIf you did not expect this, you can ignore this email.`,
text,
html,
devLog: `[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
});
}
/** CR-107: notify support/organizers when a visitor submits the Ask an organizer form. */
/** Notify support/organizers when a visitor submits the Ask an organizer form. */
export async function sendOrganizerInquiryNotification(params: {
/** Destination inbox (e.g. from ORGANIZER_INQUIRY_TO). */
to: string;
@@ -67,26 +113,18 @@ export async function sendOrganizerInquiryNotification(params: {
requestId: string;
}): Promise<void> {
const { to, fromEmail, visitorEmail, message, requestId } = params;
const url = getSmtpUrl();
if (!url) {
if (process.env.NODE_ENV === "development") {
logger.info(
`[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
);
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
await transporter.sendMail({
from: fromEmail,
const text = `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`;
const html =
`<p>Request ID: ${escapeHtml(requestId)}<br />From: ${escapeHtml(visitorEmail)}</p>` +
`<pre>${escapeHtml(message)}</pre>`;
await sendHtmlMail({
to,
from: fromEmail,
replyTo: visitorEmail,
subject: `Ask an organizer inquiry from ${visitorEmail}`,
text: `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`,
text,
html,
devLog: `[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
});
}
@@ -94,24 +132,17 @@ export async function sendEmailChangeEmail(
to: string,
verifyUrl: string,
): Promise<void> {
const url = getSmtpUrl();
if (!url) {
if (process.env.NODE_ENV === "development") {
logger.info(`[dev] Email change verify for ${to}: ${verifyUrl}`);
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
const from = process.env.SMTP_FROM ?? "noreply@localhost";
await transporter.sendMail({
from,
const { text, html } = buildVerifyLinkParts(
verifyUrl,
"You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):",
"If you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.",
"Confirm email",
);
await sendHtmlMail({
to,
subject: "Confirm your new Community Rule email",
text: `You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.`,
text,
html,
devLog: `[dev] Email change verify for ${to}: ${verifyUrl}`,
});
}
@@ -4,6 +4,6 @@
"placeholder": "email@domain.com",
"characterCountTemplate": "{current}/{max}",
"magicLinkSuccessTitle": "Check your email to log in",
"magicLinkSuccessDescription": "Your account has been created. A login link has been emailed to you.",
"magicLinkSuccessDescription": "We emailed a sign-in link. Open it on this device to continue — check spam or promotions if you don't see it.",
"magicLinkErrorTitle": "Could not send link"
}
+1 -1
View File
@@ -7,7 +7,7 @@
"emailPlaceholder": "you@example.com",
"sendMagicLink": "Send me a magic link",
"successTitle": "Check your email",
"successBody": "We sent a sign-in link. Open it on this device to continue.",
"successBody": "We sent a sign-in link. Open it on this device to continue. If you don't see it, check spam or promotions.",
"legalPrefix": "By continuing, you agree to our ",
"legalAnd": " and ",
"legalSuffix": ".",
+1
View File
@@ -125,6 +125,7 @@ describe("LoginForm", () => {
await screen.findByRole("heading", { name: /check your email/i }),
).toBeInTheDocument();
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
expect(screen.getByText(/check spam or promotions/i)).toBeInTheDocument();
});
it("submits a long email without treating length as invalid", async () => {
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it } from "vitest";
import nodemailer from "nodemailer";
import {
buildVerifyLinkParts,
resolveMailFrom,
} from "../../lib/server/mail";
const VERIFY_URL =
"https://staging.communityrule.info/api/auth/magic-link/verify?token=5IdE_BHowaw-QJj7Rwue7CbB8wDXvYITvnxRb1FGqxA";
function decodeQuotedPrintable(value: string): string {
return value
.replace(/=\r?\n/g, "")
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
);
}
const MAIL_FROM_KEYS = ["SMTP_FROM", "CLOUDRON_MAIL_FROM"] as const;
const ORIGINAL_FROM = Object.fromEntries(
MAIL_FROM_KEYS.map((key) => [key, process.env[key]]),
) as Record<(typeof MAIL_FROM_KEYS)[number], string | undefined>;
afterEach(() => {
for (const key of MAIL_FROM_KEYS) {
const original = ORIGINAL_FROM[key];
if (original === undefined) delete process.env[key];
else process.env[key] = original;
}
});
describe("buildVerifyLinkParts", () => {
it("puts the exact verify URL in both text and the HTML href", () => {
const { text, html } = buildVerifyLinkParts(
VERIFY_URL,
"Open this link to sign in (it expires in 15 minutes):",
"If you did not request this, you can ignore this email.",
"Sign in",
);
expect(text).toContain(VERIFY_URL);
expect(html).toContain(`href="${VERIFY_URL}"`);
expect(html).toContain(">Sign in</a>");
});
it("escapes HTML in the intro and href", () => {
const { html } = buildVerifyLinkParts(
'https://example.test/verify?token=a&b="c"',
'View "Rule <beta>"',
"Ignore if unexpected.",
"Open",
);
expect(html).toContain("View &quot;Rule &lt;beta&gt;&quot;");
expect(html).toContain(
'href="https://example.test/verify?token=a&amp;b=&quot;c&quot;"',
);
});
});
describe("MIME encoding of verify-link mail", () => {
it("keeps a clickable href after quoted-printable encoding", async () => {
const { text, html } = buildVerifyLinkParts(
VERIFY_URL,
"Open this link to sign in (it expires in 15 minutes):",
"If you did not request this, you can ignore this email.",
"Sign in",
);
const transporter = nodemailer.createTransport({
streamTransport: true,
buffer: true,
newline: "unix",
});
const info = await transporter.sendMail({
from: "Community Rule <staging.app@communityrule.info>",
to: "member@example.com",
subject: "Sign in to Community Rule",
text,
html,
});
const raw = Buffer.isBuffer(info.message)
? info.message.toString("utf8")
: String(info.message);
const decoded = decodeQuotedPrintable(raw);
expect(decoded).toContain(`href="${VERIFY_URL}"`);
expect(decoded).toContain(VERIFY_URL);
expect(decoded).not.toContain("token=3D");
});
});
describe("resolveMailFrom", () => {
it("prefers SMTP_FROM, then CLOUDRON_MAIL_FROM", () => {
delete process.env.SMTP_FROM;
delete process.env.CLOUDRON_MAIL_FROM;
expect(resolveMailFrom()).toBe("noreply@localhost");
process.env.CLOUDRON_MAIL_FROM = "staging.app@communityrule.info";
expect(resolveMailFrom()).toBe("staging.app@communityrule.info");
process.env.SMTP_FROM = "Community Rule <hello@communityrule.info>";
expect(resolveMailFrom()).toBe(
"Community Rule <hello@communityrule.info>",
);
});
});