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.
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.
The raw MIME template in sendRawEmail produced a blank line between Content-Type and List-Unsubscribe whenever no custom headers were passed, because the ternary for custom headers expanded to an empty string surrounded by newlines.
Per RFC 5322 §2.1 a blank line terminates the header section, so List-Unsubscribe ended up as the first line of the body. Lenient clients (Gmail, Outlook) recover; strict clients (Thunderbird) do not, breaking one-click unsubscribe and degrading Gmail/Yahoo bulk-sender deliverability signals.
Collect optional headers (custom + List-Unsubscribe) into an array, filter empties, and append them to the Content-Type line with a single newline separator. No codepath can now produce a blank line inside the header block.