@frank/email (0.2.1)
Installation
@frank:registry=npm install @frank/email@0.2.1"@frank/email": "0.2.1"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 durablemautic_syncjob 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 therecord_email_replydatabase 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 andresolveWorkspaceContactEmailare exported too; the trial reminder guarddecideTrialReminderSendcomes 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.1 |
Development dependencies
| ID | Version |
|---|---|
| @types/node | ^22.20.4 |