@frank/email (0.2.0)

Published 2026-10-04 23:32:03 +11:00 by steve

Installation

@frank:registry=
npm install @frank/email@0.2.0
"@frank/email": "0.2.0"

About this package

@frank/email

Mautic marketing automation over its REST API, plus the inbound reply verifier. Ported from Blockwise src/lib/mautic/* and src/lib/email/* with behaviour unchanged. No network or database is touched at import.

Public API

  • identify(input) - upsert one Mautic contact now (search, then PATCH edit or POST new). Idempotent: a replayed transition returns the contact id.
  • setStage(input, { enqueue }) - queue a stage change (signed_up, trial_ending, trial_ended, paid, payment_failed, cancelled) as a durable mautic_sync job with a sha256 dedupe key.
  • fireEvent(input, { enqueue }) - queue an event (campaign_live, budget_alert, new_leads) the same way.
  • executeMauticSync(payload, { config, workspaceRow }) - what the job worker runs; rechecks trial and billing guards before writing.
  • parseEmailReplyIngest / recordEmailReply - the inbound reply contract; dedupe by sha256 key, consent suppression lives in the record_email_reply database function.
  • verifyEmailReplyRequest(request, body, secrets, verifier) - HMAC auth that refuses legacy bearer and any shared secret.
  • configFromEnv(env?) - the only process.env reader; everything else takes config as a parameter. Formatters and resolveWorkspaceContactEmail are exported too; the trial reminder guard decideTrialReminderSend comes from @frank/billing.

Config

MAUTIC_API_URL, MAUTIC_API_USER, MAUTIC_API_PASSWORD; dedicated BLOCKWISE_EMAIL_REPLY_AUTH_SECRET. Mautic field aliases use a fieldPrefix parameter (default blockwise_).

Example

import { configFromEnv, identify, setStage } from "@frank/email";
const config = configFromEnv();
await setStage({ email, workspaceId, stage: "paid" }, { enqueue });
await identify({ email, fields: {}, config });

Transactional outbox (source-only, no provider chosen)

The transactional/ slice is disconnected source only: it ships the provider-neutral outbox contract, a dependency-injected bridge over the CURRENT transactional sender, and the durable hold-review helper. It does NOT choose a provider, does NOT read the environment, does NOT open network or database connections at import, and exposes no concrete provider adapter, migration or OUTBOX_PROVIDER/Plunk surface.

Policy is deliberately split:

  • Product account, service and fulfilment notifications may use the default-off outbox bridge and remain blocked by bounce, complaint or administrative suppression. Auth mail and Stripe receipts stay outside it.
  • Marketing sends stay on Mautic and are governed by a marketing opt-out (unsubscribe), which is NOT a deliverability block and never blocks a transactional send.

Existing auth and human mail are unchanged and outside this bridge: authentication mail is still sent directly via Resend, and human mail is still sent via Purelymail.

The outbox contract references the durable-hold SQL (supabase/migrations/..._transactional_email_outbox.sql) only for its fencing vocabulary; no SQL prerequisite is applied by this package. The package supplies injected claim/settle RPC orchestration through createOutboxStore; consumer schema, scheduling, runtime binding and live migration remain outside this package.

Timeout ownership is explicit and minimal: the injected EmailProvider / ExistingSendFn owns the finite transport submission deadline and any abort behaviour, and a caller-owned optional EmailSendContext.signal is forwarded through unchanged. The package owns exactly one send call, the static unknown-acceptance classification and the fenced durable settlement - no timer, scheduler, retry loop, transport or HTTP code. A consumer must finish or abort within the claim lease and must NOT report retry for a transport timeout or abort once the submission might already have been accepted; that outcome is held. The isEnabled gate is re-read on every operation and enables only on a literal true; a truthy non-boolean or a throwing gate fails closed to disabled with zero RPC/provider calls. guard_hold from the existing-sender bridge is an intentional static reason and is preserved through the bridge; the store maps it conservatively to its SQL-accepted ambiguous hold, while any arbitrary held reason is redacted.

Dependencies

Dependencies

ID Version
@frank/billing 0.2.0

Development dependencies

ID Version
@types/node ^22.20.4
Details
npm
2026-10-04 23:32:03 +11:00
15
45 KiB
Assets (1)
Versions (5) View all
0.2.1 2026-10-08
0.2.0 2026-10-04
0.1.2 2026-09-26
0.1.1 2026-09-26
0.1.0 2026-09-26