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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
The preceding commits taught the import worker to coerce custom CSV
column values into JSON booleans and numbers via `coerceCustomValue`
in `apps/api/src/jobs/import-processor.ts`. Without a corresponding
docs update, users can't predict whether a cell like `01234` lands as
a string or as the number `1234`, or which segment-filter operators a
field will expose after import.
Add two bullets to the existing "Rules and limits" list in the
contact-import guide, adjacent to the **Date columns** bullet that
already documents value typing for ISO 8601 dates. The bullets mirror
that style and brevity: one names the boolean keyword set and the
toggle it unlocks in segment filters, the other names the numeric
pattern, the `gt`/`lt` operators it unlocks, and the deliberately
preserved-as-string forms (leading zeros, `+`-prefixed, scientific
notation) so users keep their IDs, zip codes, and phone numbers
intact.
No other content is touched. Closes the documentation gap for
useplunk/plunk#390.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous coercion treated "1"/"0" as booleans for parity with the
reserved `subscribed` column parser, but in custom columns those values
are far more often counts, quantities, or ids than true/false flags.
Coercing them to booleans hid the numeric segment-filter operators
(`gt`, `lt`) for fields that semantically are numbers, and miscategorised
them in `ContactService.getAvailableFields()` via `jsonb_typeof()`.
Drop "1"/"0" from the truthy/falsy keyword sets in `coerceCustomValue`.
With those entries gone the existing `NUMERIC_RE` branch picks both up
unchanged (the pattern already matches single-digit `0` and `1`), so
they land in `Contact.data` as JSON numbers. Boolean keyword coverage
remains for `true`/`false`/`yes`/`no` (case-insensitive, trimmed).
The reserved `subscribed` column parser at the top of the worker keeps
its own inline keyword set and continues to accept "1"/"0" — that
column is explicitly boolean by contract, so the asymmetry is
intentional.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the helper introduced for boolean coercion so numeric values
(`42`, `3.14`) become JSON numbers in `Contact.data` instead of strings.
With the right JSON primitive in place, post-import inference via
`jsonb_typeof()` classifies the field as `number`, the dashboard's
segment-filter UI renders a numeric input, and comparison operators
(`gt`, `lt`) become available.
Use a strict integer-or-decimal regex rather than `Number()` to
preserve string-shaped numerics that users intend as identifiers:
leading-zero IDs (`"01234"`), zip codes, phone numbers, and signed or
scientific-notation forms (`"+42"`, `"1e10"`, `".5"`, `"42."`) are left
as strings. Boolean coercion still takes precedence, so `"1"` and `"0"`
remain booleans for consistency with how `subscribed` is parsed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Custom CSV columns with values like "true"/"false" were stored as
strings, which made `ContactService.getAvailableFields()` report them
as string fields and lost the boolean toggle in the dashboard's
segment-filter UI. Only the reserved `subscribed` column was coerced.
Mirror the truthy keyword set already used for `subscribed`
(`true`/`1`/`yes`) and add its symmetric falsy counterpart
(`false`/`0`/`no`) for custom columns. Values outside that keyword set
are returned unchanged so names, IDs, or arbitrary strings are not
corrupted into `false`. Coercion runs over `Object.entries(customData)`
right before the `upsert()` call; no other code path needs to change
because `ContactService.upsert()` already accepts
`Record<string, unknown>` and `mergeContactData()` accepts mixed JSON
primitives. Post-import inference via `jsonb_typeof()` then classifies
the stored JSON boolean as type `boolean` automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.