Documentation
¶
Overview ¶
Package marketing is lifecycle email: drip sequences that reach the right people.
Audiences resolve from the IAM roster, promo codes redeem as wallet credit, and every send passes through the per-org suppression list.
Subsystems (all org-scoped, /v1 only):
- Campaigns — named campaign on a delivery Channel (email/sms/social/meta/ google/tiktok), a lifecycle Status (draft/scheduled/active/paused/ completed), Budget/Spend in cents, and a send time (scheduled_at). This is a SECOND campaign record beside apps/campaign (the go-to-market plane) and apps/ads (the paid one); apps/campaign is the canonical owner of a campaign that spans channels, and this one holds only what an email send needs.
- Email sequences — ordered drip Steps sent as DURABLE tasks on the embedded hanzoai/tasks engine (drip.go): each enrollment's next_run_at lives in SQLite, a per-minute engine schedule sweeps due steps, every step is claimed once (idempotent) and delivered through the ONE send gate.
- Audiences — who to reach, resolved to real mailboxes through Hanzo IAM (roster.go): an audience with no event filter is EVERY mailable customer in the org, and one with an event narrows that roster to the cohort the analytics warehouse (hanzo.events) selected. Honest-empty when the roster or warehouse cannot be read — never a fabricated number, never a send to nobody reported as a success.
- Promo codes — the "First 1,000: 90% off month 1" launch promo (discounts.md) realized as a non-cash wallet credit through the finance ledger, with the hard 1,000 cap, one-per-org, one-per-instrument and team-seat-cap guards.
- Content calendar — scheduled posts as documents, published by a task-executed hook; social publish returns an honest 501 because no publisher is wired here (see calendar.go). It targets the SAME networks as apps/social, which owns the connected accounts and the publish edge — a scheduled social post has two stores today and apps/social is the one.
- Suppression / unsubscribe — a per-org opt-out list enforced at the ONE send seam (suppress.go); every send path passes through it, plus a signed public one-click unsubscribe.
THE ONE SEND SEAM. Every marketing delivery funnels through state.deliver, which checks the per-org suppression list and then hands off to the platform notify rail (notify.Send) — marketing never builds a second sender.
A PRODUCT ANNOUNCEMENT ("a new model is available") is therefore not a feature of its own: it is a one-step sequence with an audience enrolled into it — POST /v1/marketing/sequences/:id/enroll with an audienceId instead of an address. It reuses the drip engine and so inherits every guarantee already proven there — claimed-once delivery, the suppression gate, the signed unsubscribe footer — which is precisely why there is no blast engine beside it.
EVERY ROUTE IS A TYPED OP. Each one registers through zip.Get/Post/Put/Delete with concrete In/Out structs, so the surface is ONE registry with N projections: REST, the OpenAPI document, the MCP tool list and the CLI are all derived from these same registrations. Nothing about them is written twice — the prose in each handler's doc comment is lifted into the spec by the build-time cmd/zipdoc pass, because Go does not keep comments at run time.
TENANT ISOLATION is enforced SERVER-SIDE on every request: the org is the value SanitizeIdentity minted from the VALIDATED bearer owner claim (HIP-0026), carried to the typed seam by cloud.Bridge and read back with principal.OrgFrom — NEVER a client-supplied header and never an In field. Every store query filters WHERE org=?, so one tenant can never read or mutate another's data. The MCP projection carries no principal, so every org-scoped op refuses there through that same gate. serve.go auto-registers GET /v1/marketing/health (no OwnsHealth here).
Index ¶
- func Mount(app cloud.Router, deps cloud.Deps) error
- func Shutdown() error
- type Audience
- type AudienceList
- type AudiencePreview
- type AudienceRef
- type CalendarPost
- type Campaign
- type CampaignList
- type CampaignQuery
- type CampaignRef
- type EnrollInput
- type EnrollResult
- type Enrollment
- type EnrollmentList
- type EnrollmentQuery
- type EnrollmentRef
- type Page
- type PostList
- type PostQuery
- type PostRef
- type Promo
- type PromoList
- type PromoRef
- type PromoStatus
- type Quote
- type QuoteQuery
- type RedeemInput
- type RedeemResult
- type Redemption
- type ScheduleInput
- type Sequence
- type SequenceList
- type SequenceRef
- type SequenceStatus
- type SequenceView
- type Step
- type StepInput
- type StepList
- type Store
- func (s *Store) AddStep(ctx context.Context, st Step) (Step, error)
- func (s *Store) AdvanceEnrollment(ctx context.Context, id string, nextStep int, nextRunAt, now int64) error
- func (s *Store) CancelEnrollment(ctx context.Context, org, id string, now int64) (bool, error)
- func (s *Store) ClaimCalendarPost(ctx context.Context, org, id string, now int64) (bool, error)
- func (s *Store) ClaimStep(ctx context.Context, enrollID string, stepIdx int, address string, now int64) (bool, error)
- func (s *Store) Close() error
- func (s *Store) CountRedemptions(ctx context.Context, code string) (int, error)
- func (s *Store) Counts(ctx context.Context, org string) (total, active int, budget, spend int64, err error)
- func (s *Store) CreateAudience(ctx context.Context, a Audience) (Audience, error)
- func (s *Store) CreateCalendarPost(ctx context.Context, p CalendarPost) (CalendarPost, error)
- func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (Campaign, error)
- func (s *Store) CreateSequence(ctx context.Context, seq Sequence) (Sequence, error)
- func (s *Store) DeleteAudience(ctx context.Context, org, id string) (bool, error)
- func (s *Store) DeleteCalendarPost(ctx context.Context, org, id string) (bool, error)
- func (s *Store) DeleteCampaign(ctx context.Context, org, id string) (bool, error)
- func (s *Store) DueCalendarPosts(ctx context.Context, now int64, limit int) ([]CalendarPost, error)
- func (s *Store) DueEnrollments(ctx context.Context, now int64, limit int) ([]Enrollment, error)
- func (s *Store) Enroll(ctx context.Context, e Enrollment) (Enrollment, error)
- func (s *Store) FinishEnrollment(ctx context.Context, id, status string, now int64) error
- func (s *Store) FinishStep(ctx context.Context, enrollID string, stepIdx int, status, errMsg string) error
- func (s *Store) GetAudience(ctx context.Context, org, id string) (Audience, error)
- func (s *Store) GetCalendarPost(ctx context.Context, org, id string) (CalendarPost, error)
- func (s *Store) GetCampaign(ctx context.Context, org, id string) (Campaign, error)
- func (s *Store) GetEnrollment(ctx context.Context, org, id string) (Enrollment, error)
- func (s *Store) GetPromo(ctx context.Context, code string) (Promo, error)
- func (s *Store) GetRedemption(ctx context.Context, code, org string) (Redemption, bool, error)
- func (s *Store) GetSequence(ctx context.Context, org, id string) (Sequence, error)
- func (s *Store) GetStep(ctx context.Context, org, seqID string, idx int) (Step, bool, error)
- func (s *Store) ListAudiences(ctx context.Context, org string, limit int) ([]Audience, error)
- func (s *Store) ListCalendarPosts(ctx context.Context, org, status string, limit int) ([]CalendarPost, error)
- func (s *Store) ListCampaigns(ctx context.Context, org, status string, limit int) ([]Campaign, error)
- func (s *Store) ListEnrollments(ctx context.Context, org, seqID string, limit int) ([]Enrollment, error)
- func (s *Store) ListPromos(ctx context.Context) ([]Promo, error)
- func (s *Store) ListSequences(ctx context.Context, org string, limit int) ([]Sequence, error)
- func (s *Store) ListSteps(ctx context.Context, org, seqID string) ([]Step, error)
- func (s *Store) ListSuppressions(ctx context.Context, org string, limit int) ([]Suppression, error)
- func (s *Store) MarkCalendarFailed(ctx context.Context, org, id, msg string, now int64) error
- func (s *Store) MarkCalendarPublished(ctx context.Context, org, id string, now int64) error
- func (s *Store) SetSequenceStatus(ctx context.Context, org, id, status string, now int64) (bool, error)
- func (s *Store) Suppress(ctx context.Context, sup Suppression) error
- func (s *Store) Suppressed(ctx context.Context, org, channel, address string) (bool, error)
- func (s *Store) Unsuppress(ctx context.Context, org, channel, address string) (bool, error)
- func (s *Store) UpdateCalendarPost(ctx context.Context, p CalendarPost) (CalendarPost, error)
- func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) (Campaign, error)
- type Summary
- type Suppression
- type SuppressionList
- type SweepResult
- type UnsubscribeInput
- type Unsubscribed
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
Types ¶
type Audience ¶
type Audience struct {
// ID is the server-assigned audience id ("aud_" + 128 random bits).
ID string `json:"id"`
Org string `json:"-"`
// Name is the audience's label. Required, trimmed, capped at 1024 bytes.
Name string `json:"name"`
// Event is the analytics event a member must have fired. EMPTY MEANS NO
// FILTER: the audience is then every mailable customer in the org, and no
// warehouse is consulted.
Event string `json:"event"`
// WindowDays is how far back the event counts, ending now. 0 means 30 and
// nothing above 3650 is honoured. Ignored when Event is empty.
WindowDays int `json:"windowDays"`
// CreatedAt and UpdatedAt are unix seconds, both server-assigned.
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
Audience is a saved cohort filter. It is also the INPUT of create — the wire shape is the same record either way — with ID/CreatedAt/UpdatedAt assigned by the server.
type AudienceList ¶
type AudienceList struct {
// Data is the page; an empty array when the org has saved no audience.
Data []Audience `json:"data"`
}
AudienceList is a page of audiences, most recently updated first.
type AudiencePreview ¶
type AudiencePreview struct {
// Available is false when the roster or the warehouse could not be read; the
// counts are then zero because nothing was measured, not because the cohort
// is empty, and Reason says which read failed.
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
// Count is the cohort size: distinct warehouse identifiers for an event
// audience, mailable customers for an event-less (whole-org) one.
Count int64 `json:"count"`
// Deliverable is how many de-duplicated addresses a send would reach, and
// Unmatched how many cohort identifiers named no customer. Unmatched is
// reported rather than hidden: it is the honest explanation for a cohort of
// 500 that mails 3.
Deliverable int `json:"deliverable"`
Unmatched int `json:"unmatched"`
// Sample is up to 1000 cohort IDENTIFIERS — never addresses, which product
// analytics does not hold. Empty for an event-less (whole-org) audience.
Sample []string `json:"sample"`
// Source names where the cohort was read: the events table for an event
// audience, "iam:<org>" for the whole-org one.
Source string `json:"source"`
}
AudiencePreview is a live audience evaluation: how big the cohort is, and — the question that decides whether a send is worth making — how many real customers it actually reaches.
type AudienceRef ¶
type AudienceRef struct {
// ID is the audience id from the path, as returned by create.
ID string `json:"id"`
}
AudienceRef addresses one audience.
type CalendarPost ¶
type CalendarPost struct {
// ID is the server-assigned post id ("cal_" + 128 random bits).
ID string `json:"id"`
Org string `json:"-"`
// Title is the post's internal label, capped at 1024 bytes.
Title string `json:"title"`
// Body is the post text. Required.
Body string `json:"body"`
// Channel is the target network: x, facebook, instagram, linkedin, tiktok,
// youtube or threads. Required — a post must name where it goes.
Channel string `json:"channel"`
// ScheduledAt is the unix publish time; 0 leaves the post a draft, and any
// value makes it "scheduled" for the durable sweep to pick up.
ScheduledAt int64 `json:"scheduledAt"`
// Status is draft, scheduled, published, failed or canceled. Server-owned.
Status string `json:"status"`
// PublishedAt is when the publish succeeded; 0 until it does.
PublishedAt int64 `json:"publishedAt"`
// Error is the exact reason the last publish attempt failed — the honest
// record behind a "failed" status, never a faked success.
Error string `json:"error,omitempty"`
// CreatedAt and UpdatedAt are unix seconds, both server-assigned.
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
CalendarPost is a scheduled content document. It is also the INPUT of create and update: the wire shape is the same record either way. On create the server assigns ID/CreatedAt/UpdatedAt; on update ID comes from the path. Status, PublishedAt and Error are always server-owned — a caller cannot declare a post published.
type Campaign ¶
type Campaign struct {
// ID is the server-assigned campaign id ("camp_" + 128 random bits).
ID string `json:"id"`
Org string `json:"-"`
// Name is the campaign's label. Required, trimmed, capped at 1024 bytes.
Name string `json:"name"`
// Channel is the delivery surface: email, sms, social, meta, google or
// tiktok. Empty means email.
Channel string `json:"channel"`
// Status is the lifecycle: draft, scheduled, active, paused or completed.
// Empty means draft.
Status string `json:"status"`
// Objective is the free-text goal ("signups"), capped at 1024 bytes.
Objective string `json:"objective"`
// Budget and Spend are minor units (USD cents), clamped to >= 0.
Budget int64 `json:"budget"`
Spend int64 `json:"spend"`
// ScheduledAt is the unix send time; 0 means unscheduled. Setting it on a
// campaign with no explicit status makes that status "scheduled".
ScheduledAt int64 `json:"scheduledAt"`
// CreatedAt and UpdatedAt are unix seconds, both server-assigned.
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
Campaign is an org-scoped marketing campaign. Budget and Spend are minor units (cents). Channel is the delivery surface (email/sms/social/meta/google/tiktok); Status is the lifecycle (draft/active/paused/completed) — both validated at the write layer against the fixed vocabularies in marketing.go. It is also the INPUT of create and update: the wire shape is the same record either way, so there is one type and one set of field docs rather than two that drift. On create the server assigns ID/CreatedAt/UpdatedAt and ignores whatever the body carried; on update ID comes from the path.
type CampaignList ¶
type CampaignList struct {
// Data is the page; an empty array when the org has no matching campaign.
Data []Campaign `json:"data"`
}
CampaignList is a page of campaigns, most recently updated first.
type CampaignQuery ¶
type CampaignQuery struct {
// Status keeps only campaigns in that lifecycle state (draft, scheduled,
// active, paused, completed). Empty means every campaign.
Status string `json:"status"`
// Limit caps the rows returned; 0 means 200 and nothing above 1000 is honoured.
Limit int `json:"limit"`
}
CampaignQuery filters the campaign list.
type CampaignRef ¶
type CampaignRef struct {
// ID is the campaign id from the path, as returned by create.
ID string `json:"id"`
}
CampaignRef addresses one campaign.
type EnrollInput ¶
type EnrollInput struct {
// ID is the sequence id from the path.
ID string `json:"id"`
// Address is a single recipient, normalized (lower-cased, trimmed) before
// use. Give this OR audienceId, never both and never neither.
Address string `json:"address"`
// AudienceID fans the sequence out over a saved audience, resolved live to
// the org's mailable customers. Email only.
AudienceID string `json:"audienceId"`
// Channel is the delivery surface; empty means email. An audience resolves
// mailboxes, so an audience enroll must be email.
Channel string `json:"channel"`
}
EnrollInput names WHO to enroll: exactly one of a single address or an AUDIENCE — the org's own IAM customers, optionally narrowed to an event cohort. One endpoint, one enrollment path: "announce to every model user" is an audience fanned into a one-step sequence, NOT a second blast engine, so every message it produces still walks the drip engine and the ONE send gate.
type EnrollResult ¶
type EnrollResult struct {
// Resolved is how many addresses the request named — 1 for an address, the
// audience's deliverable count for an audience.
Resolved int `json:"resolved"`
// Enrolled is how many started a walk on this call.
Enrolled int `json:"enrolled"`
// AlreadyEnrolled is how many this sequence had already taken and were left
// alone.
AlreadyEnrolled int `json:"alreadyEnrolled"`
// EnrollmentID names the walk, and is present ONLY for a single-address
// enroll — a fan-out has many, and reporting one of them would be a lie.
EnrollmentID string `json:"enrollmentId,omitempty"`
}
EnrollResult reports the fan-out. EnrollmentID is set only when a single address was named, so the one-recipient caller can still address its walk (cancel). AlreadyEnrolled counts addresses this sequence had already taken — the idempotence that makes re-POSTing a partially-applied announcement safe: it resumes rather than double-drips anyone.
type Enrollment ¶
type Enrollment struct {
// ID is the server-assigned enrollment id ("enr_" + 128 random bits).
ID string `json:"id"`
Org string `json:"-"`
// SequenceID is the sequence being walked.
SequenceID string `json:"sequenceId"`
// Address is the normalized (lower-cased, trimmed) recipient.
Address string `json:"address"`
// Channel is the delivery surface the steps go out on.
Channel string `json:"channel"`
// CurrentStep is the index of the step that sends next.
CurrentStep int `json:"currentStep"`
// Status is active, completed or canceled.
Status string `json:"status"`
// NextRunAt is the unix time the current step comes due; 0 once the walk has
// ended. It IS the schedule — durable in SQLite, so it survives restarts.
NextRunAt int64 `json:"nextRunAt"`
// EnrolledAt and UpdatedAt are unix seconds.
EnrolledAt int64 `json:"enrolledAt"`
UpdatedAt int64 `json:"updatedAt"`
}
Enrollment is one contact walking one sequence.
type EnrollmentList ¶
type EnrollmentList struct {
Data []Enrollment `json:"data"`
}
EnrollmentList is a page of enrollments, most recently enrolled first.
type EnrollmentQuery ¶
type EnrollmentQuery struct {
// ID is the sequence id from the path.
ID string `json:"id"`
// Limit caps the rows returned; 0 means 200 and nothing above 1000 is honoured.
Limit int `json:"limit"`
}
EnrollmentQuery pages one sequence's enrollments.
type EnrollmentRef ¶
type EnrollmentRef struct {
// ID is the sequence id from the path.
ID string `json:"id"`
// EID is the enrollment id from the path, as returned by a single-address
// enroll.
EID string `json:"eid"`
}
EnrollmentRef addresses one enrollment within its sequence.
type Page ¶
type Page struct {
// Limit caps the rows returned; 0 means 200 and nothing above 1000 is honoured.
Limit int `json:"limit"`
}
Page is the bound shared by every list that filters on nothing but size.
type PostList ¶
type PostList struct {
Data []CalendarPost `json:"data"`
}
PostList is a page of calendar posts, soonest scheduled first.
type PostQuery ¶
type PostQuery struct {
// Status keeps only posts in that state (draft, scheduled, published,
// failed, canceled). Empty means every post.
Status string `json:"status"`
// Limit caps the rows returned; 0 means 200 and nothing above 1000 is honoured.
Limit int `json:"limit"`
}
PostQuery filters the calendar list.
type PostRef ¶
type PostRef struct {
// ID is the post id from the path, as returned by create.
ID string `json:"id"`
}
PostRef addresses one calendar post.
type Promo ¶
type Promo struct {
// Code is the promo id, e.g. "first1000".
Code string `json:"code"`
// Description is the human-readable offer.
Description string `json:"description"`
// PercentOff is the discount applied to ONE month's list price.
PercentOff int `json:"percentOff"`
// MaxRedemptions is the hard fleet-wide cap; the redemption past it is
// declined.
MaxRedemptions int `json:"maxRedemptions"`
// TeamSeatCap is how many Team seats bill at the promo rate; seats beyond it
// bill at list.
TeamSeatCap int `json:"teamSeatCap"`
// Plans is the csv of eligible plan ids ("pro,max,team").
Plans string `json:"plans"`
// Active is false for a promo that is no longer offered; an inactive promo
// quotes as ineligible and refuses to redeem.
Active bool `json:"active"`
// CreatedAt is unix seconds.
CreatedAt int64 `json:"createdAt"`
}
Promo is a launch-promo definition (a small, seeded set — today just the one).
type PromoList ¶
type PromoList struct {
Data []PromoStatus `json:"data"`
}
PromoList is every promo the deployment offers.
type PromoRef ¶
type PromoRef struct {
// Code is the promo code from the path, e.g. "first1000".
Code string `json:"code"`
}
PromoRef addresses one promo.
type PromoStatus ¶
type PromoStatus struct {
Promo Promo `json:"promo"`
// Redeemed is how many orgs have taken it, Remaining how many are left under
// the fleet-wide cap.
Redeemed int `json:"redeemed"`
Remaining int `json:"remaining"`
}
PromoStatus is one promo with its live redemption counters.
type Quote ¶
type Quote struct {
// Code, Plan and Seats echo what was quoted.
Code string `json:"code"`
Plan string `json:"plan"`
Seats int `json:"seats"`
// Eligible says whether a redeem would be accepted right now; Reason says
// why not when it would not.
Eligible bool `json:"eligible"`
Reason string `json:"reason,omitempty"`
// ListCents is the undiscounted month price, ChargeCents what would be
// charged, DiscountCents the difference — all in USD cents.
ListCents int64 `json:"listCents"`
ChargeCents int64 `json:"chargeCents"`
DiscountCents int64 `json:"discountCents"`
// Remaining is how many redemptions are left under the fleet-wide cap.
Remaining int `json:"remaining"`
}
Quote is a pure eligibility + math result (no side effects).
type QuoteQuery ¶
type QuoteQuery struct {
// Code is the promo code from the path.
Code string `json:"code"`
// Plan is the plan being priced: pro, max or team. Anything else (including
// the free Developer plan) has no list price and so nothing to discount.
Plan string `json:"plan"`
// Seats is the Team seat count; 0 means 1, and it is ignored for the
// single-seat plans.
Seats int `json:"seats"`
}
QuoteQuery asks what a promo would cost a given plan and seat count.
type RedeemInput ¶
type RedeemInput struct {
// Code is the promo code from the path.
Code string `json:"code"`
// Instrument identifies the payment method. It is the anti-farming key: one
// redemption per instrument, fleet-wide, and it is REQUIRED — an absent
// instrument is refused, never waved through.
Instrument string `json:"instrument"`
}
RedeemInput redeems a promo for the caller's org.
IT CARRIES NO PLAN AND NO SEATS, deliberately. Both used to be read straight off the request body and multiplied into a wallet deposit, which let a caller name their own price. They are now derived from the org's live subscription, and the fields are GONE rather than validated — a field that does not exist cannot be trusted by the next person to touch this handler.
type RedeemResult ¶
type RedeemResult struct {
Redemption Redemption `json:"redemption"`
// ChargeCents is what month one costs after the discount, DiscountCents the
// discount that produced it. Both are quoted figures against the org's
// derived plan — NOTHING WAS CREDITED and no wallet moved.
ChargeCents int64 `json:"chargeCents"`
DiscountCents int64 `json:"discountCents"`
// AlreadyRedeemed is true when this org had already taken the promo and the
// call was an idempotent replay.
AlreadyRedeemed bool `json:"alreadyRedeemed"`
}
RedeemResult is a completed redemption and the month-one math behind it.
type Redemption ¶
type Redemption struct {
// Code is the promo redeemed.
Code string `json:"code"`
Org string `json:"-"`
Instrument string `json:"-"`
// Plan and Seats are what was redeemed against. Both are DERIVED server-side
// — Plan from the org's live paid subscription, Seats from claimSeats — and
// neither is ever read from the request.
Plan string `json:"plan"`
Seats int `json:"seats"`
// DiscountCents is the month-one discount this redemption CLAIMS, in USD
// cents. It is a recorded figure, NOT a balance: nothing was credited and no
// wallet moved. An admin granting against this claim is what would make it
// money, and that decision happens on the admin surface, not here.
DiscountCents int64 `json:"discountCents"`
// RedeemedAt is unix seconds.
RedeemedAt int64 `json:"redeemedAt"`
}
Redemption is one org's use of a promo.
type ScheduleInput ¶
type ScheduleInput struct {
// ID is the campaign id from the path.
ID string `json:"id"`
// ScheduledAt is the unix send time. 0 clears the schedule.
ScheduledAt int64 `json:"scheduledAt"`
}
ScheduleInput sets or clears a campaign's send time.
type Sequence ¶
type Sequence struct {
// ID is the server-assigned sequence id ("seq_" + 128 random bits).
ID string `json:"id"`
Org string `json:"-"`
// Name is the sequence's label. Required, trimmed, capped at 1024 bytes.
Name string `json:"name"`
// Status is the lifecycle: draft, active or archived. Empty means draft, and
// ONLY an active sequence accepts enrollments.
Status string `json:"status"`
// CreatedAt and UpdatedAt are unix seconds, both server-assigned.
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
Sequence is a drip campaign definition. It is also the INPUT of create, with ID/CreatedAt/UpdatedAt assigned by the server.
type SequenceList ¶
type SequenceList struct {
// Data is the page; an empty array when the org has no sequence.
Data []Sequence `json:"data"`
}
SequenceList is a page of sequences, most recently updated first.
type SequenceRef ¶
type SequenceRef struct {
// ID is the sequence id from the path, as returned by create.
ID string `json:"id"`
}
SequenceRef addresses one sequence.
type SequenceStatus ¶
type SequenceStatus struct {
// ID is the sequence id from the path.
ID string `json:"id"`
// Status is draft, active or archived. Required; there is no default here,
// unlike on create. Only an active sequence accepts enrollments.
Status string `json:"status"`
}
SequenceStatus is a sequence's lifecycle state — the input AND the result of setting it, because the wire shape is the same fact either way.
type SequenceView ¶
type SequenceView struct {
Sequence Sequence `json:"sequence"`
// Steps are in send order (idx ascending); empty for a sequence with no
// messages yet, which enrolls fine and completes immediately.
Steps []Step `json:"steps"`
}
SequenceView is one sequence together with its ordered steps.
type Step ¶
type Step struct {
// ID is the server-assigned step id ("step_" + 128 random bits).
ID string `json:"id"`
Org string `json:"-"`
// SequenceID is the sequence this step belongs to.
SequenceID string `json:"sequenceId"`
// Idx is the step's 0-based position, assigned by appending: a new step
// always lands after the last one.
Idx int `json:"idx"`
// DelaySeconds is how long after the previous step this one sends (after
// enrollment, for step 0).
DelaySeconds int64 `json:"delaySeconds"`
// Subject is the email subject line, capped at 1024 bytes.
Subject string `json:"subject"`
// Body is the message text. Required. The signed one-click unsubscribe link
// is appended to it at send time.
Body string `json:"body"`
// CreatedAt is unix seconds, server-assigned.
CreatedAt int64 `json:"createdAt"`
}
Step is one message in a sequence. DelaySeconds is measured from the previous step's send (from enrollment for the first step).
type StepInput ¶
type StepInput struct {
// SequenceID is the sequence id from the path (the route's :id).
SequenceID string `json:"id"`
// DelaySeconds is how long after the previous step this one sends (after
// enrollment, for the first step). Must be >= 0.
DelaySeconds int64 `json:"delaySeconds"`
// Subject is the email subject line, capped at 1024 bytes.
Subject string `json:"subject"`
// Body is the message text. Required.
Body string `json:"body"`
}
StepInput appends one message to a sequence.
type StepList ¶
type StepList struct {
Data []Step `json:"data"`
}
StepList is a sequence's steps in send order.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the marketing database. ONE SQLite file — the system namespace's "marketing" — holds every org's records; tenant isolation is the `org` column, enforced on EVERY query. This mirrors clients/crm exactly (the ONE storage pattern). MaxOpenConns(1) serializes writes against the single-writer file.
func (*Store) AdvanceEnrollment ¶
func (s *Store) AdvanceEnrollment(ctx context.Context, id string, nextStep int, nextRunAt, now int64) error
AdvanceEnrollment moves an enrollment to its next step + due time.
func (*Store) CancelEnrollment ¶
CancelEnrollment stops an org's enrollment mid-walk.
func (*Store) ClaimCalendarPost ¶
ClaimCalendarPost transitions scheduled → publishing for exactly one publisher. Only the claimer (rows==1) publishes, so overlapping sweeps never double-post.
func (*Store) ClaimStep ¶
func (s *Store) ClaimStep(ctx context.Context, enrollID string, stepIdx int, address string, now int64) (bool, error)
ClaimStep atomically reserves (enrollment, step) for exactly one sender. Only the first caller gets claimed=true; every redelivery/re-sweep gets false and MUST NOT send. This is the idempotence key of the whole engine.
func (*Store) CountRedemptions ¶
func (*Store) Counts ¶
func (s *Store) Counts(ctx context.Context, org string) (total, active int, budget, spend int64, err error)
Counts returns the per-org campaign roll-up: total campaigns, how many are active, and the summed budget + spend (cents) — a real, non-fabricated summary for the marketing module's overview cards.
func (*Store) CreateAudience ¶
func (*Store) CreateCalendarPost ¶
func (s *Store) CreateCalendarPost(ctx context.Context, p CalendarPost) (CalendarPost, error)
func (*Store) CreateCampaign ¶
func (*Store) CreateSequence ¶
func (*Store) DeleteAudience ¶
func (*Store) DeleteCalendarPost ¶
func (*Store) DeleteCampaign ¶
func (*Store) DueCalendarPosts ¶
DueCalendarPosts returns scheduled posts whose time has arrived, across all orgs (each carries its own org; publish is org-scoped).
func (*Store) DueEnrollments ¶
DueEnrollments returns active enrollments whose next step is due at or before now, across ALL orgs (the sweep is a platform durable task; each row still carries its own org, and every downstream send is org-scoped through deliver).
func (*Store) Enroll ¶
func (s *Store) Enroll(ctx context.Context, e Enrollment) (Enrollment, error)
Enroll inserts an enrollment. Idempotent on (org, sequence_id, address): a duplicate enroll returns errConflict rather than starting a second walk, so a contact can never be double-dripped by the same sequence.
func (*Store) FinishEnrollment ¶
FinishEnrollment terminates an enrollment (completed or canceled).
func (*Store) FinishStep ¶
func (s *Store) FinishStep(ctx context.Context, enrollID string, stepIdx int, status, errMsg string) error
FinishStep records a claimed step's delivery outcome.
func (*Store) GetAudience ¶
func (*Store) GetCalendarPost ¶
func (*Store) GetCampaign ¶
func (*Store) GetEnrollment ¶
GetEnrollment loads one org-scoped enrollment.
func (*Store) GetRedemption ¶
func (*Store) GetSequence ¶
func (*Store) GetStep ¶
GetStep loads the step at idx, ok=false when there is none (walked past the end).
func (*Store) ListAudiences ¶
func (*Store) ListCalendarPosts ¶
func (*Store) ListCampaigns ¶
func (s *Store) ListCampaigns(ctx context.Context, org, status string, limit int) ([]Campaign, error)
ListCampaigns lists the org's campaigns, optionally filtered by status (status=="" means all). Most-recently-updated first.
func (*Store) ListEnrollments ¶
func (*Store) ListSequences ¶
func (*Store) ListSuppressions ¶
ListSuppressions returns the org's opt-outs, newest first.
func (*Store) MarkCalendarFailed ¶
func (*Store) MarkCalendarPublished ¶
func (*Store) SetSequenceStatus ¶
func (s *Store) SetSequenceStatus(ctx context.Context, org, id, status string, now int64) (bool, error)
SetSequenceStatus flips a sequence's lifecycle (e.g. draft → active).
func (*Store) Suppress ¶
func (s *Store) Suppress(ctx context.Context, sup Suppression) error
Suppress records an opt-out. Idempotent: re-suppressing the same tuple keeps the original record (ON CONFLICT DO NOTHING), so a double-click never errors.
func (*Store) Suppressed ¶
Suppressed reports whether (org, channel, address) has opted out. This is the predicate the send gate consults before every delivery.
func (*Store) Unsuppress ¶
Unsuppress removes an opt-out (re-subscribe). Reports whether a row was removed.
func (*Store) UpdateCalendarPost ¶
func (s *Store) UpdateCalendarPost(ctx context.Context, p CalendarPost) (CalendarPost, error)
type Summary ¶
type Summary struct {
// Campaigns is how many campaigns the org has, Active how many are running.
Campaigns int `json:"campaigns"`
Active int `json:"active"`
// Budget and Spend are the summed campaign budget and spend, in cents.
Budget int64 `json:"budget"`
Spend int64 `json:"spend"`
}
Summary is the org's campaign roll-up — the marketing overview cards.
type Suppression ¶
type Suppression struct {
Org string `json:"-"`
// Channel is the surface opted out of: email, sms, social, meta, google or
// tiktok. Empty means email. Opting out of one leaves the others reachable.
Channel string `json:"channel"`
// Address is the recipient, normalized (lower-cased, trimmed) so an opt-out
// cannot be slipped past on a case or whitespace difference. Required.
Address string `json:"address"`
// Reason is a free-text note, capped at 1024 bytes. The public one-click
// endpoint records "one-click unsubscribe".
Reason string `json:"reason"`
// CreatedAt is unix seconds, server-assigned.
CreatedAt int64 `json:"createdAt"`
}
Suppression is one opt-out record: (org, channel, address) is the key. It is also the INPUT of add and remove — the same tuple names the record either way.
type SuppressionList ¶
type SuppressionList struct {
Data []Suppression `json:"data"`
}
SuppressionList is a page of opt-outs, newest first.
type SweepResult ¶
type SweepResult struct {
Advanced int `json:"advanced"` // drip steps advanced
Published int `json:"published"` // calendar posts published
}
SweepResult surfaces a sweep's outcome in the Tasks console execution.
func DripSweepActivity ¶
func DripSweepActivity(ctx context.Context) (SweepResult, error)
DripSweepActivity advances every due enrollment by one step. It resolves the live mounted service (the same one serving HTTP) so the sweep shares the store and KMS with the request path — one engine, one store, one send gate.
func DripSweepWorkflow ¶
func DripSweepWorkflow(ctx workflow.Context) (SweepResult, error)
DripSweepWorkflow runs one drip sweep as a single retried activity. The sweep is idempotent (per-step claim), so a retry within a tick is safe; across ticks the next minute's fire is the natural retry.
type UnsubscribeInput ¶
type UnsubscribeInput struct {
// Org is the org the link was minted for.
Org string `json:"org"`
// Channel is the surface to opt out of.
Channel string `json:"channel"`
// Address is the recipient to opt out.
Address string `json:"address"`
// Token is the HMAC over (org, channel, address). It is the ONLY authority
// here — there is no principal — so it binds the request to one tuple and
// nothing else.
Token string `json:"token"`
}
UnsubscribeInput is the signed one-click link's query: the tuple to opt out plus the MAC that authorizes exactly that tuple.
type Unsubscribed ¶
type Unsubscribed struct {
Unsubscribed bool `json:"unsubscribed"`
Address string `json:"address"`
Channel string `json:"channel"`
}
Unsubscribed confirms a one-click opt-out.