Plunk has three sources of truth for environment variables:
1. apps/api/.env.example — local development defaults
2. .env.self-host.example — self-hosting/production template
3. apps/wiki/content/docs/self-hosting/environment-variables.mdx — user-facing reference
They have drifted repeatedly when new vars land in one location only. Codify
the requirement to update all three in the same change as part of CLAUDE.md so
future Claude-assisted (and human) contributions don't reintroduce the drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
CLAUDE.md listed the default as 85, but the source of truth
(apps/api/src/app/constants.ts:137) and the env vars wiki both use 95. Aligning
CLAUDE.md so future contributors don't propagate the stale value into new code
or docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Both variables exist in .env.self-host.example but were missing from the
user-facing env vars reference, so self-hosters had to read the example file's
inline comments to discover them.
- MAIL_FROM_SUBDOMAIN: added a row to the AWS SES section explaining how the
prefix combines with a verified domain to construct the MAIL FROM hostname,
and when to override the default.
- NGINX_PORT: added a new "Advanced" section for variables that almost never
need tuning, with the host-port override use-case spelled out.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Brings the dev template into parity with the env vars wiki for variables a
developer might toggle locally:
- PORT (in Environment & Security)
- PLUNK_FROM_ADDRESS (in Plunk API)
- New sections: Notifications (NTFY_URL), Attachments, User Management
(DISABLE_SIGNUPS, VERIFY_EMAIL_ON_SIGNUP), Phishing Detection
All additions are commented so default local behaviour is unchanged. Vars that
only make sense in the bundled Docker stack (SMTP relay ports, Minio internals,
NEXT_PUBLIC_*, NGINX_PORT) are intentionally omitted.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Brings the self-hosting template into parity with apps/api/.env.example and the
env vars wiki. Adds (mostly commented for visibility without changing default
behaviour):
- NODE_ENV, DATABASE_URL, DIRECT_DATABASE_URL, REDIS_URL, PORT
(Docker auto-configures these via DB_PASSWORD; the lines document overrides)
- Stripe billing block (STRIPE_SK, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_*,
STRIPE_METER_EVENT_NAME)
- Attachments (MAX_ATTACHMENT_SIZE_MB, MAX_ATTACHMENTS_COUNT)
- SMTP_ENABLED in the SMTP Server block
- VERIFY_EMAIL_ON_SIGNUP in User Management
- Phishing Detection block (OPENROUTER_*, PHISHING_*)
Each was either present in apps/api/.env.example or documented in the wiki but
missing from the production template, forcing self-hosters to read source or
the wiki to discover them.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The previous detection tripped on ANY inline `style=` attribute and on
`<span>` elements specifically, which forced templates into HTML-only
editing mode whenever the user had used Visual mode features like text
color. TipTap's TextStyle + Color + Link extensions (configured in
EmailEditor.tsx) natively round-trip exactly that markup -- TipTap emits
`<span style="color: rgb(...)">…</span>` itself when you change a text
color, then the detection rejected it as "custom HTML" on the very next
load. Empirically about 21% of a 156-template corpus tripped this purely
on TipTap-export artifacts (`background-color: initial`, color spans).
This rewrite permits what TipTap can represent and rejects only what it
can't:
* Drop the broad inline-style check entirely (TextStyle/Color/Link
preserve inline styles on spans and links).
* Remove `<span>` from the custom-elements list (TextStyle handles it).
* Expand the custom-elements list to explicitly cover everything TipTap
has no extension for: `<div>`, `<section>`, `<article>`, `<header>`,
`<footer>`, `<nav>`, `<aside>`, `<main>`, full table family
(`<table>`, `<tr>`, `<td>`, `<th>`, `<tbody>`, `<thead>`, `<tfoot>`,
`<colgroup>`, `<col>` -- no Table extension is loaded), form/embed/
media/interactive (`<form>`, `<input>`, `<button>`, `<select>`,
`<textarea>`, `<iframe>`, `<video>`, `<audio>`, `<svg>`, `<object>`,
`<embed>`, `<details>`, `<summary>`, `<dialog>`). Single-table is now
enough to opt out (previously needed nested tables -- harmless
tightening, single `<table>` already isn't TipTap content).
* Tighten the custom-attributes regex with a leading `[\s"']` boundary so
query strings like `<a href="…?id=…">` no longer false-match as an
HTML `id=` attribute.
* `<style>` tags, `@media` queries, and the class allowlist (`prose`,
`variable-`, `email-image`, `ProseMirror`, `resizable-image`,
`selected`, `resize-handle`) are unchanged.
Mirrors the same logic in apps/api/src/services/EmailService.ts so the
server-side wrap decision in `EmailService.compile()` stays in lockstep
with the client-side editor-mode decision.
Side-effect on `wrapEmailWithStyles` / `EmailService.compile`: templates
that previously kept their own (unwrapped) shell because they contained
a colored `<span>` or an inline-styled `<a>` will now flow through the
prose wrapper. This is the correct behavior -- those templates ARE
visual-editor output and SHOULD get the same wrapper the preview modal
applies.
Tests: new vitest suite at apps/web/src/lib/__tests__/emailStyles.test.ts
covers 24 cases including the TipTap-export artifacts above, the
href-URL-with-id false-match, and the rejected-element set.
The Input atom defaults to h-9 (36px) while the size="sm" filter
Buttons in the same row are h-8 (32px). On /workflows the search bar
is alone in the row so the mismatch isn't visible. On /templates and
/campaigns the type/status filter buttons sit next to the search bar
and the row stretches to the Input's 36px, visibly offsetting the
buttons. Shrink the search Input to h-8 text-xs on all three pages
and add sm:items-center to the filter rows so the controls vertically
center.
Currently the only entry point from the /contacts list to a contact's
detail page is the small Edit icon-button in the actions column. Make
the email itself a Link to /contacts/:id so the obvious affordance
("click the thing that identifies the row") works too. Applied to both
the desktop table cell and the mobile card variant.
PR review (#371): scope the self-host baseline to envs that make sense
without reselling Plunk.
docker-compose.yml: pass through only DISABLE_SIGNUPS (private/single-
admin self-host) and EMAIL_RATE_LIMIT_PER_SECOND (works around the
silent 14/sec fallback when ses:GetSendQuota is denied). Drop the
VERIFY_EMAIL_ON_SIGNUP and OPENROUTER_API_KEY passthroughs — signup
hygiene and phishing detection are reselling concerns.
.env.self-host.example: drop the entire Stripe Billing block (billing
only matters when reselling), drop the VERIFY_EMAIL_ON_SIGNUP doc
block for the same reason, and add an EMAIL_RATE_LIMIT_PER_SECOND
section explaining the silent-14/sec fallback so operators know why
they'd set it.
Replace per-route apiKey/jwt branching with auth.projectId from middleware,
matching the contacts controller pattern. Preserve JWT admin gating on
POST/DELETE; API keys are project-scoped by design and skip the role check.
Cross-project domain access by ID now returns 404 instead of 403 to avoid
leaking existence.
Switch /domains controller from `isAuthenticated` (cookie-only) to
`requireAuth` (cookie OR API key), matching the pattern used by other
project-scoped API endpoints (/v1/send, /contacts, etc.).
API keys are project-scoped credentials with full access; for write
operations the projectId in the request must equal the API key's
projectId. JWT (dashboard) auth retains role-based checks
(requireAdminAccess for POST/DELETE).
Also: improve UX when a domain is already linked to the same project
by returning a clear error instead of the generic "linked to another
project" message.
Refactor DomainService.checkDomainOwnership to make `userId` optional
(needed for API key path) while preserving its existing return shape
and adding `projectId` to the result.