Skip to content

Releases: Notifuse/notifuse

v38.0 - Web Analytics

Choose a tag to compare

@pierre-b pierre-b released this 17 Aug 08:37
web_analytics_dashboard
  • Feature: Web Analytics — the Staminads feature set, rebuilt on PostgreSQL. A ~21 KB gzipped cookieless browser SDK (/na.js) streams sessions, pageviews and goals into monthly-partitioned tables per workspace, with engaged-time metrics (TimeScore, bounce rate), editable channel-attribution rules (40 defaults, with historical backfill), goals carrying a value and a declared type — purchase, subscription, lead, signup, booking, trial or other, because only your site knows which it is — 10 custom dimensions, geo resolution from a bundled MaxMind GeoLite2 City database, and a console section with Dashboard, Explore, Goals, Filters, Annotations and Settings tabs plus a live view. Visitors can be tied to a contact: NotifuseAnalytics.identify(email, hmac) attaches a verified address to a session — the signature is checked against the workspace secret, so only your own server can mint one — and an address it vouches for becomes a contact if it is not one already, while visitors arriving from a tracked email link are identified automatically with no code. An identified visitor's sessions and pageviews then appear on the contact timeline, any page openable in a side panel, and both navigation and goals are usable in segment conditions, with goals also able to trigger automations (navigation never does). Calling identify() is itself the opt-in: a workspace that wants anonymous reporting simply never calls it, and obtaining consent to store browsing history against a contact remains yours to do. Charts can be annotated — a launch, a price change, an outage — each pinned to the timezone it was entered in, and broadcasts annotate themselves the moment a send starts. An AI assistant answers questions about your own traffic from the same reports the dashboard draws, so its answers and the charts beside them can never disagree; it can also drive the screen — period, comparison, filters, Explore reports — needs an LLM integration configured under Settings → Integrations, and never groups or filters by visitor email address. The v38 migration adds the tables to existing workspaces and grants the new web analytics permission to existing members and pending invitations; the feature is off until enabled in a workspace's Web Analytics settings. For high-traffic installs, compose.alloydb.yaml runs the same schema on AlloyDB Omni's columnar engine.

  • Improvement: A sent broadcast links straight to its web analytics reports. Two icons in the broadcast's header open the Explore and Goals tabs filtered to that broadcast's UTM campaign over its send window plus the following week, and every variation row carries the same pair narrowed to its own UTM content — so an A/B test's two creatives can be compared by what they did on the site, not only by opens and clicks. The links need a UTM campaign on the broadcast: without one they stay disabled, because filtering on an empty campaign matches every untagged visit rather than none. A broadcast that sets its own UTM content disables the per-variation links for the same reason, since every variation then ships that one value instead of its template's.

  • Improvement: The General and Blog workspace settings now use the same floating save bar as Web Analytics. It appears only once something has changed, offering Discard and Save Changes plus a Cmd/Ctrl+S shortcut, and leaving the section with unsaved edits asks for confirmation instead of dropping them silently — so the save control is no longer stranded below the fold of a long form. Pressing Enter in a field no longer submits these forms; save from the bar or with Cmd/Ctrl+S.

  • Improvement: Saving blog settings no longer erases the SEO canonical URL. The console never loaded the stored value into the form, so every save sent it back empty and the blog's <link rel="canonical"> tag disappeared. The field is now populated from the saved settings.

  • Improvement: Tracked email links are no longer altered or missed. Appending UTM parameters rewrote the entire query string — dropping pairs Go's URL parser rejects (?sid=1;2, ?discount=50%off) and re-escaping the rest — while uppercase <a HREF="…"> was skipped altogether, leaving those links with no click tracking, UTM parameters or identity token. Links are now matched case-insensitively and left byte-for-byte as authored apart from what Notifuse appends; per-link click reporting groups by the rewritten URL, so an affected link starts a new row from this release.

  • Improvement: analytics.query no longer answers an empty breakdown with one invented row. A grouped query that matched nothing returned a single row with an empty dimension and zero measures, which reads as real data and hides a table's own empty state. Ungrouped totals still answer zero, because a KPI has to render a number.

  • Improvement: A sign-in link that carries an email (/console/signin?email=…) no longer loses it on the first click. A stale token in the browser makes the console's opening user.me call fail, and the 401 handler redirected to a bare /console/signin — discarding the query string that page needs — so the visitor got an empty form and only the second click worked, once the token had been cleared. The redirect is now skipped when the browser is already on the sign-in page.

  • Improvement: The console renders Ant Design's own strings in Japanese, Italian and Brazilian Portuguese. All three were offered in the language picker and their translations shipped, but the map that hands a locale to Ant Design covered only five languages, so date pickers, table filter and sort menus, pagination and empty states silently stayed English for them. Notifuse's own strings were translated throughout, which is why this read as a half-translated screen rather than a missing language.

  • Improvement: Listing, editing and deleting transactional notifications now require the Transactional permission. The three endpoints checked workspace membership only, so a member whose transactional access had been revoked could still read the notifications, repoint one at a different template and delete it — the console offered the permission and the API ignored it.

  • Improvement: analytics.query now covers the whole of a date range's last day. A timeDimensions range ending on a bare date stopped at that day's midnight, so a report ending today showed nothing for today and a single-day range came back empty — the console compensated by asking for tomorrow instead. Bounds given as an explicit timestamp are still used exactly as sent.

  • Improvement: Integration credentials are no longer sent back to the browser. Editing an integration and leaving a credential field blank now keeps the stored one, so only a value you actually type replaces it.

  • Improvement: A send that fails no longer stores its template data in clear. Recording the failure rewrote the whole message history row from the copy held in memory, which still carried the plaintext variables, overwriting the blob that was encrypted when the row was created — magic links, confirmation tokens and API keys included. Transactional and double opt-in sends were affected; broadcasts and automations record failures differently and were not. Rows already written that way are left as they are.

  • Improvement: Deleting a contact now erases every copy of their address. Message history and inbound webhook events had their identifying column replaced with DELETED_EMAIL while the address remained inside the stored payload on the same row — both of which are returned by their list endpoints — and custom events, segment memberships and segment-queue entries were not cleaned at all.

  • Improvement: Deleting a contact now cancels email already queued to them. Queued messages carry the address independently of the contact record, so a broadcast or automation send that had not yet drained would still arrive after the contact was erased.

  • Improvement: A List Status node now renders in an automation's Flow Stats. It appeared there with no icon and no title, and all three of its branches were dropped, so everything downstream of it floated on the canvas disconnected from the flow it belongs to.

  • Improvement: Text that had gone missing from three screens is back, in every language. Broadcast status badges and their tooltips rendered blank, the automation Add node menu listed raw keys like list_status_branch instead of node names, and the toast shown when single sign-on refuses a login carried no message at all. Each passed its translation function into a helper, which silently disabled it and left those strings resolving to nothing — translations most of them already had.

  • Feature: Automation triggers can filter who enters. A trigger now takes entry conditions — the same builder segments use, over contact properties, list memberships, past activity and goals — so an event enrolls only the contacts that match. Set them in the trigger panel under Entry conditions. A contact who does not match is never enrolled, which differs from a Filter node after the trigger: that enrolls first and then exits, and with Once per contact it uses up the contact's single entry. A count of past activity includes the event that fired the trigger, so "opened at least 3 emails" enrolls on the third open.

  • Improvement: Every automation node takes an optional description, not just the Filter node. It appears under the node's title on the canvas and in Flow Stats, so a flow of Email and Delay cards explains itself without opening each node. Removing a condition tree now asks for confirmation first.

  • Improvement: Undo in the automation editor now works in whole edits rather than single characters. Typing in a text field — a webhook URL, a cust...

Read more

v37.2

Choose a tag to compare

@pierre-b pierre-b released this 06 Aug 09:59
  • Improvement: A broadcast no longer dies when the connection that triggered it goes away. Task execution ran on the triggering HTTP request's context, so the dispatcher's own 53s client timeout — only 3s above a 50s broadcast slice — cancelled the run mid-batch, aborting the enqueue transaction and surfacing as sql: transaction has already been committed or rolled back followed by a misleading [BROADCAST_NOT_FOUND] ... context canceled. Execution is now bounded by the task's own deadline instead of the caller's connection, and each dispatch waits for as long as its task may legitimately run rather than a fixed 53s (which also silently truncated the 300s segment-recompute tasks).
  • Improvement: max_retries is now a consecutive-failure budget instead of a lifetime one. It was never reset, and pausing a broadcast counted as a failed attempt, so three pause/resume cycles — or three transient failures hours apart during a long send — permanently killed a broadcast. A run interrupted by a restart or a hung-up caller no longer consumes the budget at all, and resuming a broadcast clears it.
  • Improvement: A failed batch enqueue no longer skips recipients. The sender reported the whole batch as processed even though its single transaction had written nothing, so the orchestrator advanced past those contacts and they were never mailed — with every enqueue failing, a broadcast would march through its entire audience and still report itself as sent. The batch is now retried from the same cursor.
  • Improvement: A broadcast interrupted past its retry limit is now paused with a reason, and resumable from where it stopped, instead of being stranded. The write that finalised its status ran on the very context whose cancellation caused the failure, so it silently did nothing and left the broadcast stuck in "sending" for good with its task dead.
  • Improvement: An A/B test whose first sending run was cut short — a transient database error, a restart — could then send the entire audience as a single blast with variations mixed, instead of only the test sample, and leave the broadcast stuck in "testing" with no way forward. The run that opens a test phase records the phase in the task's state, but a failed run saves no state, and the next run had no way to recover the phase from the broadcast's own status.
  • Change: GET /api/cron returns 202 Accepted immediately instead of holding the caller open for the whole run, restoring the behaviour external cron setups relied on before in-process execution landed. A caller that gives up no longer cancels the tasks it started, and overlapping triggers are ignored while a run is in flight.

v37.1

Choose a tag to compare

@pierre-b pierre-b released this 06 Aug 09:52
  • Feature: Amazon SES integrations can isolate their sending reputation. Notifuse provisions a tenant per integration with its own suppression list and associates the configuration set and sender identities with it, so one workspace's bounces can no longer pause sending or suppress recipients for another. Sends moved to the SES v2 API, which is the only place the tenant parameter exists. Isolation can be switched off again at any time — sends revert to the shared reputation on the next message, while the tenant keeps its suppression list for when it is switched back on. An existing tenant and configuration set can be used instead under Advanced, and everything is off by default — AWS bills per tenant per month (#400)
  • Fix: Emails sent with both CC and BCC through Amazon SES dropped their CC recipients. The attachment path wrote a Cc: header but built the envelope from the To and BCC addresses only, so the CC recipients were never actually delivered to — the message looked correct in the inbox of everyone who did receive it.
  • Fix: Amazon SES sends no longer look up the configuration set on every message. AWS allows that call once per second per account and region, so any send rate above one per second was throttled, and a throttled lookup silently sent the message with no configuration set at all — losing its delivery, bounce and complaint tracking. The name is now recorded when webhooks are registered, and the lookup that remains for older integrations is memoised and de-duplicated.
  • Fix: Updating an email integration through the API no longer erases server-managed SES state. workspaces.updateIntegration replaced the whole provider object, so any client whose payload omitted them wiped the inbound topic ARN — breaking stop-on-reply — along with the configuration set and tenant names. These fields are now preserved server-side.
  • Fix: Unregistering webhooks on an Amazon SES integration no longer deletes the configuration set when a tenant sends through it, which would have made every subsequent send fail. Deleting the integration now removes the whole set of provider resources — the configuration set and, for a Notifuse-managed tenant, the tenant itself — in the order AWS requires, so nothing is left billing after the integration is gone. Reading webhook status also no longer crashes when the configuration set exists with no event destination, which is the normal state of an operator-supplied one.

v37.0

Choose a tag to compare

@pierre-b pierre-b released this 06 Aug 10:00
  • Feature: Goal conditions can be negated. A segment or automation filter can now say "has not purchased in the last 30 days", which also matches contacts with no matching events at all. This was previously inexpressible: the condition compiles to an aggregate grouped by contact, so a contact with zero events produced no group and could never satisfy it — meaning a "count is 0" condition silently matched nobody, and comparisons like sum ≤ 1000 silently excluded everyone who had never converted (#399).
  • Feature: Goal conditions can filter on the event name, the goal name and the event's own properties payload, instead of only the seven goal types. The goal-name filter was already supported by the API but had no input in the console (#399).
  • Feature: Contact datetime properties gain a "not in the last X days" operator. It deliberately includes contacts whose date was never set — someone who never converted has not converted in the last 30 days either — where a plain negation would silently drop them (#399).
  • Feature: The segment editor counts matching contacts while a condition is still open in its form, instead of only once it has been confirmed. The count refreshes on its own as the condition's inputs change, and keeps the last valid number on screen — dimmed — while a condition is too incomplete to be counted.
  • Security: Segment and automation conditions that filter contact-timeline events by a field value built their SQL by splicing the field name straight into the query text. A crafted field_name could close the quote and append arbitrary SQL, and the segment preview count turned that into a boolean oracle — any workspace member could read any table in their workspace database. JSONB keys are now bound as query parameters, and workspace migration v37 recompiles stored segment queries so already-saved segments are repaired without being re-saved.

v36.1

Choose a tag to compare

@pierre-b pierre-b released this 02 Aug 10:01
  • Fix: Deleting a workspace no longer revokes the application user's privileges on the system database. The revoke statements applied to the connected database rather than to the workspace being deleted, so afterwards authentication failed with permission denied for table users (superuser deployments, the default, were unaffected). Workspace databases are now removed with a single DROP DATABASE ... WITH (FORCE) and the system-database cleanup runs in one transaction. To repair an already-affected install, connect to the system database and run GRANT ALL ON ALL TABLES IN SCHEMA public TO <db_user>; plus the same for ALL SEQUENCES (#396)
  • Fix: templates.create and templates.update no longer crash the request when the email object omits visual_editor_tree. Validation called a method on the missing tree and panicked, so the connection was dropped and API clients behind a reverse proxy saw a bodyless 502 Bad Gateway; such payloads now return 400 naming the missing field and pointing to the code-mode alternative (editor_mode: "code" with mjml_source). Also applies to per-language translations entries (#401)
  • Fix: Applying preview text to a template whose MJML already contains a self-closing <mj-preview />, or an mj-preview carrying attributes, no longer injects a second preview element. Only the plain <mj-preview> form was recognised, so the other two shapes were treated as "no preview present" — the attribute form shipped two preheader blocks with the stale text still in the first, and a bare <mj-preview /> (which an mj-liquid block can emit) was duplicated instead of filled. Both shapes are now matched, attributes are preserved, and a self-closing tag is expanded in place (#394)
  • Fix: Preview text containing Liquid no longer breaks sends for code-mode templates, and preview or title text that renders to a value containing < or > no longer fails compilation. Code-mode templates escaped the preview text before rendering it, so a feed value containing & (e.g. a "News & Updates" subject) aborted the whole send with an XML parse error, and Liquid comparisons such as {% if a > b %} silently evaluated the wrong branch because the operator had been entity-encoded. Separately, < and > were escaped with named entities that the MJML parser decodes back into markup before parsing, so an mj-preview or mj-title block whose Liquid resolved to a value containing angle brackets failed to compile (#394)
  • Fix: Liquid in a template's Preview Text is rendered again at send time for visual-editor templates whose body has no mj-preview block — the common case for console-built templates. The preview text was spliced into the MJML after Liquid processing had already run, so expressions like {{ global_feed.subject }} reached recipients literally in the inbox preview text; the injected value is now rendered through Liquid first, and malformed Liquid in preview text surfaces as a compile error instead of being sent raw. Rendered preview values containing <, > or & no longer break the send either: the injected text is escaped with numeric character references, which survive MJML parsing where the named &lt;/&gt; entities are decoded back into markup (#394)

v36.0

Choose a tag to compare

@pierre-b pierre-b released this 25 Jul 15:21
  • Feature: Transactional email attachments support a per-attachment content_id for inline images (e.g. per-recipient QR codes) — set disposition: "inline" plus content_id and reference the image from the template as <img src="cid:...">. Works across all email providers; inline images are assembled in a multipart/related MIME subtree on Amazon SES (previously they could show up as dangling attachments in Gmail/Outlook) and the SMTP provider now emits the RFC 2045 angle-bracketed Content-ID header that strict clients like Outlook require (#393)
  • Fix: SMTP connections on port 465 now use implicit TLS (SMTPS). Workspace SMTP integrations, the setup wizard's "Test SMTP Connection", and system emails (magic codes, invitations, alerts) always spoke plaintext-first SMTP — waiting for a greeting that an SMTPS server never sends before negotiating STARTTLS — so a port-465 + TLS configuration hung or timed out. With TLS enabled and port 465, the client now performs the TLS handshake immediately after connecting and skips STARTTLS. TLS on any other port still negotiates STARTTLS, and use_tls=false remains plaintext, including on port 465. The TLS handshakes in the raw SMTP sender (both implicit and STARTTLS) are also now bounded by the dial timeout instead of hanging indefinitely on an unresponsive server (#385)
  • Fix: OIDC sign-in no longer permanently fails with IdPs that omit the email_verified claim (Microsoft Entra ID, Cloudflare Access): an absent claim decoded as false and rejected every first login. Setting the new env-only OIDC_ALLOW_UNVERIFIED_EMAIL=true (default false) accepts first logins whose id_token omits the claim; an explicit email_verified=false is still always rejected, and the rejection log now names the flag to set (#388)
  • Fix: Enabling OIDC through the setup wizard persisted oidc_scopes="openid" (the wizard has no scopes field), and that stored value overrode the openid email profile default at boot — authorize requests then lacked the email/profile scopes, so IdPs returned tokens without an email. The wizard and the settings screen now persist the full default when scopes are left empty, and the v36 system migration heals existing rows still holding the bare legacy value (when a row is healed the server restarts once so the change takes effect immediately) (#389)
  • Feature: Segments and automation conditions can now target link clicks by broadcast and by URL. A "clicked email" timeline condition can be scoped to a specific broadcast and/or to a clicked link, where the link is matched as a case-insensitive substring of the destination URL (e.g. /pricing matches https://acme.com/pricing?utm_campaign=summer). This makes segments and automation filters like "clicked the pricing link in the Summer Sale broadcast" possible; it builds on the per-link click data already recorded in message_history.clicked_links, so no migration is needed (#339, #311).
  • Fix: Viewing the contacts of a segment no longer returns a server error. The segments.contacts endpoint ordered by a created_at column that contact_segments does not have, so every request failed; it now orders by matched_at (when the contact joined the segment).
  • Fix: Removed the "Photo URL" option from the automation contact updated trigger's field list — contacts have no photo-URL field, so an automation filtered on it could never fire.
  • Fix: Segment/automation conditions that filter contact-timeline events by a field value now work. The query builder referenced a non-existent metadata column on contact_timeline (event fields live in the changes JSONB as {field: {old, new}}), so any such condition failed at SQL execution; it now reads the field's new value from changes.
  • Fix / Change: The contact timeline now records email engagement events in the same dot notation as the rest of the timeline and the webhooks — email.opened, email.clicked, email.bounced, email.complained, email.unsubscribed, plus email.sent and email.updated — replacing the old open_email / click_email / insert_message_history / update_message_history names. This fixes automations triggered by an email engagement event, which previously never fired because the trigger listened for email.clicked while the timeline stored click_email. Workspace migration v36 renames existing timeline rows, rewrites stored segment definitions (both the tree and the compiled query) and regenerates the triggers of already-live automations, so segments and automations keep working without needing to be re-saved. The email.sent and email.delivered options were removed from the automation trigger list — they could never fire, and an email.sent trigger would loop an "every time" automation that sends email. (#339)
  • Security: golang.org/x/crypto is pinned to 0.52.0 in both Go modules, closing the SSH-related advisories (12 critical/high) flagged by Dependabot.
  • Fix: Automation email nodes now display the selected template's name on the canvas for non-marketing templates (e.g. welcome). The canvas resolved names from a reference list restricted to category: 'marketing', while the node picker lets you select an email template of any category — so a validly selected non-marketing template fell back to the generic "Template set" label; the reference list is no longer category-restricted (#391).
  • Change: Mailjet can now only be used as a transactional email provider, not a marketing one, because the Mailjet API overwrites unsubscribe links and breaks Notifuse's unsubscribe links. Newly assigning Mailjet as the marketing provider is rejected at settings-save, and marketing sends are refused at provider resolution — so workspaces that already had Mailjet assigned keep saving unrelated settings normally, but their broadcasts fail with a clear error until they switch the marketing provider.

v35.0

Choose a tag to compare

@pierre-b pierre-b released this 08 Jul 11:11
  • Feature: Broadcast reports now show a per-link click breakdown — total clicks and unique clickers per destination URL (UTM parameters kept, since they are commonly used to tell links apart). Clicks are recorded per URL in a new message_history.clicked_links JSONB column added by workspace migration v35; recording starts immediately after the upgrade, including for emails already sent (the destination URL is embedded in their tracking tokens), so the breakdown only covers post-upgrade clicks and legacy /visit links remain aggregate-only (#339, #311).
  • Feature: Search templates by name on the templates list — a debounced name search beside the category filter, persisted in the URL, mirroring the broadcasts search (the already-loaded list is filtered client-side).
Capture d’écran 2026-07-08 à 11 40 58
  • Improvement: Faster email rendering — Liquid engines are now reused across blocks via a pool instead of being rebuilt for every block, and constant regexes are compiled once, cutting per-block Liquid processing time by ~3× (~8× under concurrent sends) during broadcasts (#381).
  • Improvement: AI Email Designer reliability with reasoning models (e.g. DeepSeek) — thinking now streams into a collapsible panel, the preview refreshes immediately after AI edits, a generated email that doesn't compile surfaces the MJML error instead of silently looking successful, hitting the token limit shows a non-destructive warning instead of wiping the answer, and OpenAI-compatible integrations gain a reasoning-effort selector (#363).
  • Fix: The contacts multi-select no longer gets out of sync when deleting via the row's ⋯ menu — clicking a dropdown item invisibly toggled the row's selection (the click bubbles through the React portal to the row's select-on-click handler), and a deleted contact was never removed from the selection, leaving a phantom "N contacts selected" bar whose stale emails a later bulk Add to list would even re-create via upsert (#374).
  • Fix: One-time authentication emails (the built-in Supabase notifications: magic link, signup confirmation, invite, recovery, email change) are no longer rewritten through the click-tracking redirect — their links previously depended on the API being up and could be consumed by mailbox link scanners before the user clicked. Transactional notifications gain a tri-state tracking_mode (inherit the workspace setting, or disabled — which also suppresses UTM rewriting); an absent field on update keeps the stored value so partial edits cannot wipe an opt-out, and migration v35 sets disabled on existing Supabase notifications.
  • Fix: A/B-test broadcasts no longer attribute most recipients' link clicks and {{ utm_content }} values to the wrong variant. When no explicit utm_content was set, the first processed recipient's template ID was written back onto the shared broadcast object and frozen for every later recipient — and in batch sends the template data even lagged one recipient behind the rewritten links. utm_content is now computed per recipient from the variant actually chosen for them, and utm_term is applied consistently across both send paths.
  • Fix: The automation Add to List node no longer fails silently. Its "Subscribed" option stored an invalid subscribed status that the backend rejected (only active/pending are valid), so contact journeys stalled at that node with no visible error. The editor now stores active, node configurations are validated when the automation is saved so an invalid status is caught immediately, and a data migration repairs existing automations, contact lists, and timeline entries that still carry subscribed (#376).
  • Improvement: Concurrent template edits no longer silently overwrite each other. Template saves were last-writer-wins, so a save based on a stale revision clobbered newer changes. The editor now sends the revision it was based on and the server rejects a stale-base save with 409 Conflict, prompting to reload the latest or overwrite. Covers the email template, blog post, and transactional-notification editors (#378).
  • Security: Bumped the console's echarts dependency to 6.1.0 to resolve a cross-site scripting (XSS) advisory (GHSA-fgmj-fm8m-jvvx) flagged by Dependabot.
  • Fix: Resolved intermittent pq: password authentication failed / "failed to get workspace connection" errors under load. The workspace connection manager health-check-pinged the cached pool on every query and evicted, closed, and rebuilt it (re-hitting the postgres admin database) on any slow ping or transient blip; it now reuses cached pools and creates them without a global lock (#380).
  • Fix: Adding a contact (or adding a contact to a list from the details drawer) now refreshes the contacts list immediately instead of requiring a hard page reload. The "Add" contact drawer never invalidated the React Query cache on success, and the "add to list" action only refreshed the contact details — both now invalidate the contacts list (and total count) so the new contact appears right away (#364).

v34.1

Choose a tag to compare

@pierre-b pierre-b released this 26 Jun 09:03
  • Fix: Workspace SMTP integrations now connect to servers that advertise only AUTH LOGIN (such as Azure Communication Services) — the raw SMTP sender hardcoded AUTH PLAIN and was rejected with a 504 before credentials were ever checked. It now reads the AUTH mechanisms advertised in EHLO and uses LOGIN when PLAIN isn't offered, preferring PLAIN when both are available (#368).
  • Fix: Unsubscribing from the notification center works again. The widget's "Unsubscribe" action and per-list toggle (and the console) now post to a dedicated /unsubscribe endpoint, while /unsubscribe-oneclick is reserved for the RFC 8058 mail-client one-click carried in the List-Unsubscribe header (it still accepts the legacy JSON body as a backward-compatible shim). When v34.0 made /unsubscribe-oneclick strictly RFC 8058 for the Gmail/Yahoo one-click fix, the notification center's JSON request was rejected with 400 "Invalid request" and contacts stayed subscribed (#371).
  • Fix: A freshly installed root account no longer crashes the console on first login. Before any workspace existed, user.me returned "workspaces": null instead of [] for the ROOT_EMAIL user — the root path returns the workspace list straight from the database, which is a nil slice when empty — and the console crashed with Cannot read properties of null (reading 'length') instead of redirecting to workspace creation. The repository now returns an empty (non-nil) slice so the API always serializes [], and the console normalizes a null workspaces to an empty array as a safeguard (#367).
  • Fix: An mj-button (or mj-social) whose inner padding was edited in the visual editor no longer vanishes in Gmail — the inner-padding object was compiled into the CSS as a Go map literal (padding:map[bottom:0px top:0px]) that strict clients reject; it now compiles to a valid CSS shorthand, and the editor no longer stores padding as an object (#369).

v34.0

Choose a tag to compare

@pierre-b pierre-b released this 22 Jun 10:09
  • Feature: Single Sign-On via OpenID Connect (OIDC) alongside magic-code login — off by default and enabled per deployment with OIDC_* env vars, the setup wizard, or Settings → SSO (client secret encrypted at rest), so the sign-in page shows an SSO button only when it is turned on. Invited-users-only by default with opt-in just-in-time provisioning gated by a verified-email domain allowlist; identities are keyed on the durable issuer+subject pair (never email alone) and login still requires a workspace invite or ROOT_EMAIL for access. Uses Authorization Code + PKCE and works with any compliant provider (Google Workspace, Keycloak, Okta, …); adds the federated_identities system table (migration v34).
  • Fix: One-click unsubscribe (RFC 8058) now works end-to-end. The /unsubscribe-oneclick endpoint takes its parameters from the List-Unsubscribe URL query string — it previously tried to JSON-decode the POST body and rejected every mail-client request with 400 "Invalid request body". The emitted URL now also carries the email_hmac the endpoint verifies. And the endpoint no longer applies User-Agent bot detection, which silently dropped the automated POSTs that Gmail/Yahoo/Apple (and tools like curl) actually send (returning 200 while leaving the contact subscribed); it instead requires the RFC 8058 List-Unsubscribe=One-Click body token to deflect bare prefetch/scanner POSTs (#362).
  • Feature: Google Gemini is now a selectable LLM provider for the AI agent (blog & email generation), alongside Anthropic and OpenAI — configure it under Settings → Integrations with a Gemini API key and model (default Gemini 3.1 Pro); when multiple LLM integrations are configured, a provider dropdown in the AI chat selects which to use.
  • Feature: Search broadcasts by name and filter by status on the broadcasts list — a grouped status filter (All/Draft/Scheduled/Sending/Sent/Failed) plus a debounced name search beside it, both persisted in the URL; the broadcasts.list API now accepts multiple statuses and a name search (#335).

v33.0

Choose a tag to compare

@pierre-b pierre-b released this 19 Jun 09:20

Features

  • Feature: Stop-on-reply for automations (#346). When a contact replies to a sequence email, the journey stops. Replies are ingested via a new public endpoint POST /webhooks/email/inbound?workspace_id={id}&integration_id={id} (Mailgun first; provider-agnostic parser registry for the rest). For Mailgun, the inbound Route that forwards replies to this endpoint is now created automatically by the same Register Webhooks action used for delivery/bounce webhooks (only the DNS MX records remain a manual step); the route is non-preemptive (no stop(), lower priority) so it never silently overrides other inbound consumers on a shared Mailgun domain. The public endpoint is rate-limited per source IP and per workspace, and permanent client errors return 4xx (not 5xx) so providers don't retry-loop. Inbound mail is classified so bounces and out-of-office auto-replies never count as a reply. A genuine reply is matched to the send strictly by In-Reply-To/References → the Message-ID stored at send time (persisted before the email is dispatched so even an instant reply matches), which both identifies the contact and scopes the exit to the exact automation that sent the replied-to email; replies that don't match a stored send are ignored, and a replayed/duplicate reply is deduplicated so it can't re-exit a re-enrolled journey. The matched reply is recorded on the contact timeline as email.replied and — when the automation has Exit on reply enabled — exits the contact's journey (bounded to journeys entered before the reply). The stop is enforced by a layered guarantee (event-driven interrupt + an active-guarded optimistic lock on the executor's happy and error paths + a just-in-time guard in the email queue worker) so it holds whether the contact is mid-delay or the next email is already queued, and a concurrent exit is never resurrected by a retry. The feature is free for workspaces that don't enable it (no extra per-send queries or index maintenance).
  • Feature: Stop-on-reply also supports Amazon SES — inbound replies arrive as RSA-signature-verified, topic-bound Amazon SNS notifications; Register Webhooks auto-provisions the SNS topic + a coexistence-safe SES receipt rule (scoped to verified identities, region-validated), and the Message-ID SES returns at send is captured and matched host-independently (#346).
  • Feature: ROOT_EMAIL now accepts multiple comma/semicolon-separated emails, so a shared self-hosted instance can have more than one root administrator without displacing the first. Root-gated actions (workspace creation, system settings, root HMAC sign-in) now check list membership instead of a single equality, and a user row is created on startup for every listed root so each can sign in immediately. Matching is case-sensitive and a single email behaves exactly as before. The console System Settings drawer edits the list as tags; the setup wizard still establishes the primary (first) root. Example: ROOT_EMAIL=alice@example.com,bob@example.com (#361).

Fixes

  • Fix: Translated email templates now send with their own inbox preview text (preheader) instead of the default language's. The inbox preview is rendered from the mj-preview block embedded in the email tree, but the metadata-sync that stamps it from the subject_preview field only ran for the default template — never for translations — so a translation kept the preview value it was cloned with, even after its preview was edited and saved. The sync now stamps every language variant, and all send paths (broadcast, automation, transactional) additionally inject each resolved variant's subject_preview at compile time, which also corrects already-saved translations without a re-save (#359).

Database Schema Changes

  • Migration v33.0 (workspace): adds message_history.smtp_message_id (with a partial index) for reply matching, automations.exit_on_reply, a partial unique index on inbound_webhook_events to dedup replayed replies, and redefines the track_inbound_webhook_event_changes() trigger so inbound events of type reply/auto_reply surface on the contact timeline as email.replied/email.auto_reply. Column adds are nullable/constant-default (instant, no table rewrite).