Uh oh!
There was an error while loading. Please reload this page.
fix(contacts): make concurrent first-contact upserts safe - #469
Open
vladbisceanu wants to merge 718 commits into
Open
fix(contacts): make concurrent first-contact upserts safe#469vladbisceanu wants to merge 718 commits into
vladbisceanu wants to merge 718 commits into
Conversation
…lowing null to delete fields
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.
…s--next--components--plunk
…rnatives to enhance clarity and appeal
…hance social sharing
The menu covered if/unless/for and stopped there, so the multi-way branch people reach for most after a plain conditional — case/when — could only be written from memory, which is the thing this menu exists to avoid. Added, each still inserting complete and balanced: - case/when/else, for picking a version per value. Offered for text, number and date fields but not booleans, where a multi-way branch on two states is noise. - contains, for matching within a value. - for/else, which renders the fallback when the list is empty. - comment, for notes that never send. - raw, for showing template markup as literal text. The docs already tell authors to reach for it and the menu did not offer it. Also a below-a-value comparison for numbers and dates, which only had above. The block definitions are now checked against the same parser the API validates with, both filled and left empty. A typo in a closing tag would otherwise ship as a menu entry that produces an unsaveable template, and nothing in the type system would catch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Templates used as workflow steps are edited on the same screen as campaign templates, and are rendered with the trigger's event alongside contact data (WorkflowExecutionService). Reading `event.plan` is correct there, but the field lint only knew about contact fields and reported it as a missing one — a warning firing on a working template, which is the failure mode this lint most needs to avoid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat!: implement more expressive templates via liquidjs
…erve-reply-to fix(smtp): preserve Reply-To when relaying to /v1/send
…/js-yaml-4.3.1 build(deps): bump js-yaml from 4.3.0 to 4.3.1
…/nanoid-3.3.18 build(deps): bump nanoid from 3.3.11 to 3.3.18
…/dompurify-3.4.13 build(deps): bump dompurify from 3.4.12 to 3.4.13
The copy across the dashboard was written screen by screen, so the same
concept was phrased several different ways and a lot of text explained
things the UI already said. This is a pass over every user-facing string
in apps/web against one standard, applied consistently.
Systemic patterns removed:
- 25 confirmation dialogs opened with "Are you sure you want to...", then
repeated the question the title already asked. Titles now name the
object ("Delete john@acme.com?"), bodies state only the consequence,
and buttons are verb + object. Cancel dialogs previously rendered
[Cancel] [Cancel Campaign]; the dismiss button now reads "Keep sending".
- 42 success toasts ended in "successfully". The toast is already green.
- 87 "Failed to X" strings gave no cause and no next step. Most sites
already surfaced error.message and only used these as a fallback, so
the fallbacks now name what did not happen and what to do.
- ~20 CardDescriptions restated their own CardTitle. Deleted. The ones
carrying an actual rule were kept and sharpened instead.
- BillingConsumption, BillingInvoices and QuickStart each wrote their
header 3-4 times across loading/error/empty branches. Hoisted to one
header per component.
- 243 Title Case UI strings across 190 distinct labels moved to sentence
case. The app previously shipped both "Send To" and "Send to".
- Required-field markers used three different mechanisms. Now one.
- Terminology: "API Credentials" and "API Keys" were the same card under
two names; cancelled/canceled and .../… were both in use; the templates
page title disagreed with its nav item.
Structural changes:
- Removed the analytics "Performance Insights" card. It dispensed advice
the product cannot substantiate ("above industry average" against no
cited benchmark, "add more compelling calls-to-action") in a product
positioned against exactly that tone. Replaced with two counters drawn
from real data: events triggered and workflow runs started.
- Subscription activation copy was ~75 words over two paragraphs. It is
load-bearing (it prevents billing surprises), so it was restructured
rather than cut: one summary line plus a "Why two charges?" disclosure.
- Consolidated the DKIM instructions that appeared both above and below
the DNS record table.
- Trimmed the workflow active-executions notice and the HTML-to-visual
editor warning to their load-bearing lists.
Deliberately untouched:
- packages/shared/src/i18n/locales/* and the public subscribe,
unsubscribe and manage pages. Their copy is translated into 16 locales
and editing English there would desync 15 of them.
- The onboarding flows and the dashboard greeting, whose copy is already
the voice everything else was moved toward.
- console.error strings, which are developer-facing.
PRODUCT.md replaces the legacy .impeccable.md, with the register made
explicit and a terminology section recording that user-facing nouns
(Contact, Event, Campaign, Workflow, Template, Segment) track the API.
Typecheck and production build pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>refactor(web): rewrite UX copy for clarity and consistency
…s--next--components--plunk chore(next): release 0.14.0
This was referenced Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Concurrent first-seen tracking requests can all observe a missing contact, then race on
ContactService.upsert. The unique(projectId, email)constraint correctly selects one row, but every losingcontact.createcurrently surfaces Prisma P2002 and turns an otherwise valid track request into a 500.This change treats P2002 as the election result: losers read and continue with the elected contact. They never replay their stale data over the winning row. An explicit concurrent unsubscribe is preserved with a compare-and-set update, and subscription events are emitted only by the request that actually changes the stored state.
The service regression suite forces eight first-seen upserts through the same missing-row observation, covers both winner orderings, and verifies that stale concurrent unsubscribe observers emit only one state-change event. Existing normalization and cross-project isolation coverage remains in the same suite.
Verification:
yarn vitest run apps/api/src/services/__tests__/ContactService.test.ts— 63 passedyarn eslint --config apps/api/eslint.config.mjs apps/api/src/services/ContactService.ts apps/api/src/services/__tests__/ContactService.test.tsyarn workspace api buildThis is the portable counterpart to vladbisceanu#10.