backbone-mailing 0.6.1

Email marketing — mass-mail engine: audiences, subscriptions, hand-set mailing lifecycle, seeded A/B testing, per-recipient traces, cycle-44 bridge targets (Odoo mass_mailing port)
# =============================================================================
# mailing — the flagship model (Odoo `mailing.mailing`). A composed bulk-email
#   campaign: content, scheduling, recipient resolution, A/B fragment fields,
#   and the hand-set send-pipeline lifecycle drained by the ONE daily send
#   cron (schema/hooks/index.hook.yaml, mailing-send-queue).
#
# PORT DECISIONS (source: docs/odoo/marketing/mass_mailing — cycle 4):
#  - state is EXACTLY draft → in_queue → sending → done (machine
#    `mailing_state`, schema/hooks/mailing_state.hook.yaml). NO `canceled`
#    (cancel reverts to draft, keeps traces for audit) and NO `scheduled`
#    (scheduled-ness is the schedule_type/schedule_date pair — plain columns
#    whose coupling lives in the launch/cancel verbs, not a compute).
#  - HEADLESS RENDERING: body_html is the stored RENDERED html. There is no
#    body_arch and no qweb/html_builder engine — the webapp owns editing;
#    this module stores what it is told to send.
#  - mailing_domain is a DECLARATIVE json DSL, never a literal Python domain
#    text and never eval'd. The typed parser in the write service returns
#    Result<CompiledDomain, DomainInvalid> — malformed input refuses loudly
#    (422) at create/update and parks the mailing at send with zero sends
#    (the silent zero-recipient send class is closed by construction).
#  - target_model is the polymorphic recipient marker modeled as a CLOSED
#    ENUM + a per-target resolver contract (mailing-local contact resolver;
#    every external target — party, crm_lead, crm_deal, selling_customer —
#    via its own host-composed resolver port) — NOT a meta-model registry
#    column. The cycle-44 bridges (mass_mailing_crm, mass_mailing_sale)
#    land as enum values + typed default-domain providers, never twin
#    modules; the *_sms twins ride the same targets on the sms channel.
#  - campaign/medium/source are one-way logical uuid refs into engagement's
#    attribution masters (the v19 collapse: EngagementCampaign IS the campaign
#    entity). The write verb resolves-or-requires them; NO create-inside-a-
#    read (the upstream medium auto-create compute does not port).
#  - A/B fragment fields live HERE (mailing-local, chair design call b); the
#    campaign-grain control entity is MailingAbTest (abtesting.model.yaml)
#    citing the same campaign one-way.
#  - The ~30 non-stored KPI computes are the stats READ SERVICE (one grouped
#    query per mailing-set) — none are persisted fields. kpi_mail_required
#    rides as a flag, unconsumed until the digest increment.
#  - email_from is required plain storage; its upstream hybrid compute does
#    not port. The G-MM2 shape survives as a DB CHECK in the hand-written
#    hardening migration (fires on raw SQL).
# =============================================================================

models:

  - name: Mailing
    collection: mailings
    description: "Mass Mailing (Odoo `mailing.mailing`). The flagship — a composed bulk-email campaign with the hand-set draft/in_queue/sending/done lifecycle (NO canceled/scheduled values), the schedule_type/schedule_date pair, declarative-json recipient domains, A/B fragment fields, and one-way logical campaign attribution into engagement. The send engine is ONE daily cron claiming due mailings FOR UPDATE SKIP LOCKED through the pickup verb. KPI counts are read-side (stats service), never persisted."
    fields:
      id:
        type: uuid
        attributes: ["@id", "@default(uuid)"]
        description: "Unique record id"

      # ---- identity & content ------------------------------------------------
      subject:
        type: string
        attributes: ["@required", "@max(255)"]
        description: "Email subject (the record's display name)."
      preview:
        type: string?
        attributes: ["@max(255)"]
        description: "Preview text spliced at the top of the body by the sending host (short inbox preheader)."
      body_html:
        type: string
        attributes: ["@required"]
        description: "The RENDERED html body to send — stored as-is. No builder/architecture column and no server-side render engine exists here (headless port: the webapp owns editing and rendering). The mail channel's body; an SMS mailing leaves it as the placeholder it must still carry (@required) and sends body_plaintext instead."
      body_plaintext:
        type: string?
        description: "The SMS body (Odoo mailing.mailing.body_plaintext). REQUIRED at launch for mailing_type='sms' — the launch verb refuses without it and the hardening CHECK (mailing_sms_body_present) refuses raw SQL; never consumed by the mail channel."
      email_from:
        type: string
        attributes: ["@required", "@max(255)"]
        description: "Sender address, plain storage (the upstream hybrid compute does not port). The G-MM2 CHECK (email_from present for mail-type mailings) is installed by the hardening migration and fires on raw SQL."
      reply_to:
        type: string?
        attributes: ["@max(255)"]
        description: "Optional reply-to address (replies route here when set; inbound reply routing itself is a later mail increment)."
      keep_archives:
        type: boolean
        attributes: ["@default(true)"]
        description: "If false, the per-recipient mail rows the engine enqueues are auto-deleted after the verdict lands — traces survive via their denormalized mail_id (the GC seam)."

      # ---- the send-pipeline lifecycle (machine mailing_state) ----------------
      state:
        type: MailingState
        attributes: ["@required", "@default(draft)"]
        lifecycle:
          shape: hand_set
          state_machine: mailing_state
        description: "THE lifecycle — draft → in_queue → sending → done, and ONLY those four values. NO canceled (cancel reverts to draft) and NO scheduled (that is the schedule pair). Every edge is a named verb on the write service — including the cron's in_queue→sending claim, which is a state-guarded conditional UPDATE inside the SKIP LOCKED pickup transaction (never a raw attribute write)."

      # ---- scheduling: the hybrid pair collapsed to plain columns ------------
      schedule_type:
        type: MailingScheduleType
        attributes: ["@required", "@default(immediate)"]
        lifecycle:
          shape: hand_set
        description: "immediate = send on the next sweep; scheduled = defer until schedule_date. Hand-set; the launch/cancel verbs clear or keep schedule_date accordingly (write-path coupling, no compute)."
      schedule_date:
        type: datetime?
        description: "Deferred-send time. Claim predicate arm: due mailings are state=in_queue AND (schedule_date IS NULL OR schedule_date <= now()) — the same shape the mail queue's own claim uses."
      sent_date:
        type: datetime?
        description: "Stamped by the complete/complete_empty verbs (all recipient mail rows enqueued, or zero recipients resolved). Never written elsewhere."

      # ---- channel + recipient resolution ------------------------------------
      mailing_type:
        type: MailingType
        attributes: ["@required", "@default(mail)"]
        lifecycle:
          shape: inert
        description: "Channel selector — 'mail' sends through the mail module's queue with SYNCHRONOUS done (the walk completes the mailing itself); 'sms' re-routes onto the mail module's sms gateway with ASYNCHRONOUS done (the walk stamps the sms_walk_complete metadata marker and NEVER completes the mailing — the delivery-tracker pump closes it once no trace remains un-dispatched; the marker + state are the durable inference inputs, restart-safe)."
      target_model:
        type: MailingTargetModel
        attributes: ["@required", "@default(mailing_contact)"]
        lifecycle:
          shape: inert
        description: "The recipient population: mailing_contact (resolved mailing-locally), party (host-composed party resolver port), or one of the cycle-44 bridge targets — crm_lead / crm_deal (the mass_mailing_crm overlay) and selling_customer (the mass_mailing_sale overlay), each resolved through its own host-composed per-target resolver port. A closed capability enum — NOT a meta-model registry (upstream's ir.model column does not port; MVX-1 forbids any registry scan)."
      mailing_domain:
        type: json
        attributes: ["@required", "@default('{}')"]
        description: "The declarative recipient-filter DSL as typed json (never a literal Python domain, never eval'd). The write service's parser returns Result<CompiledDomain, DomainInvalid>: malformed input is a typed 422 at create/update; at send the mailing is PARKED (state stays sending, error recorded, zero recipients touched) — never a silent zero-recipient send."
      use_exclusion_list:
        type: boolean
        attributes: ["@default(true)"]
        description: "When true (default) the send pass applies all suppression layers: the global email blacklist (mail module), per-audience opt-out (opt-out-wins across targeted audiences), and the seen-list (existing live traces — keyed per campaign when A/B is on)."

      # ---- attribution (one-way logical refs into engagement) -----------------
      campaign_id:
        type: uuid?
        attributes: ["@foreign_key(engagement.EngagementCampaign.id)", "@exclude_from_foreign_key_check", "@indexed"]
        description: "Attribution: campaign # logical FK engagement.EngagementCampaign.id — THE single campaign entity (v19 collapse; no second campaign table here). Clearing it while ab_testing_enabled is refused (rule R-M8)."
      medium_id:
        type: uuid?
        attributes: ["@foreign_key(engagement.EngagementMedium.id)", "@exclude_from_foreign_key_check"]
        description: "Attribution: medium # logical FK engagement.EngagementMedium.id — the write verb resolves-or-requires; it never creates a master inside a read (the upstream auto-create compute does not port)."
      source_id:
        type: uuid?
        attributes: ["@foreign_key(engagement.EngagementSource.id)", "@exclude_from_foreign_key_check"]
        description: "Attribution: source # logical FK engagement.EngagementSource.id."
      user_id:
        type: uuid?
        attributes: ["@exclude_from_foreign_key_check"]
        description: "Responsible user # logical FK sapiens.User.id (no context-rebind machinery — rendering happens host-side)."

      # ---- A/B fragment fields (campaign-grain control: MailingAbTest) --------
      ab_testing_enabled:
        type: boolean
        attributes: ["@default(false)"]
        description: "A/B gate. When true this mailing sends to a deterministic fragment (not the whole audience) and seen-list dedup keys by campaign across siblings."
      ab_testing_pc:
        type: integer
        attributes: ["@default(10)"]
        description: "A/B fragment percentage 0..100 — the G-MM1 CHECK is installed by the hardening migration and fires on raw SQL (rule R-M1)."
      ab_test_id:
        type: uuid?
        attributes: ["@foreign_key(MailingAbTest.mailings)", "@exclude_from_foreign_key_check", "@indexed"]
        description: "The campaign-grain A/B control row this mailing belongs to # logical FK MailingAbTest.id (sampling seed, winner selection, promotion state)."
      kpi_mail_required:
        type: boolean
        attributes: ["@default(false)"]
        description: "Set on first send; rides UNCONSUMED until the KPI digest increment owns its reader (the 24h report tail does not port here)."

      metadata:
        type: Metadata
        attributes: ["@audit_metadata"]
        description: "Audit metadata"

    indexes:
      - type: index
        fields: [state, schedule_date]
        description: "The send-cron claim arm — due mailings are probed by (state, schedule_date) inside the SKIP LOCKED claim"
      - type: index
        fields: [campaign_id]
        description: "Campaign mailings rollup + A/B sibling lookup"
      - type: index
        fields: [ab_test_id]
        description: "Sibling fragment lookup per A/B test"

# =============================================================================
# Enums (mailing)
# =============================================================================

enums:
  - name: MailingState
    description: "The send-pipeline lifecycle — hand-set through the transition verbs (machine `mailing_state`); the cron's claim moves the SAME edges through the pickup verb, never raw writes"
    variants:
      - name: draft
        description: "Being composed (the only state edits happen here; cancel reverts here)"
        default: true
      - name: in_queue
        description: "Armed — the send cron's claim predicate owns it from here"
      - name: sending
        description: "Claimed by a sweep — recipients resolving/minting under the claim transaction"
      - name: done
        description: "All recipient mail rows enqueued (or zero recipients resolved) — sent_date stamped; a resting state, not a dead end (retry_failed still exits it)"

  - name: MailingScheduleType
    description: "Orthogonal to state (there is no 'scheduled' state value) — the pair's coupling lives in the launch/cancel verbs. Odoo's literal value 'now' is renamed 'immediate' (recorded deviation): the generator maps an unquoted default ident 'now' to SQL NOW() — invalid on an enum column — while the quoted form emits a String default the entity builder cannot use for an enum-typed field"
    variants:
      - name: immediate
        description: "Send on the next sweep (schedule_date cleared by the launch verb)"
        default: true
      - name: scheduled
        description: "Defer until schedule_date; the claim's schedule_date predicate is the gate"

  - name: MailingType
    description: "Channel selector — the mail channel completes synchronously in its send walk; the sms channel's done is INFERRED asynchronously by the delivery-tracker pump"
    variants:
      - name: mail
        description: "Outbound email through the mail module's queue (synchronous done — the walk's complete verb)"
        default: true
      - name: sms
        description: "Outbound SMS through the mail module's sms gateway (asynchronous done — the delivery-tracker pump infers it from sms_trackers once no trace remains un-dispatched)"

  - name: MailingTargetModel
    description: "The recipient population as a closed capability enum + per-target resolver contract (mailing-local contact resolver; every external target through its own host-composed resolver port) — not a meta-model registry. The mass_mailing_crm / mass_mailing_sale overlays (cycle 44) land here as enum values, never as twin modules: the *_sms twins ride the SAME targets on the existing sms channel (single-value channel enums)."
    variants:
      - name: mailing_contact
        description: "mailing.mailing_contacts — resolved mailing-locally"
        default: true
      - name: party
        description: "party parties — resolved through the host-composed party resolver port"
      - name: crm_lead
        description: "crm leads — the mass_mailing_crm bridge target; resolved through the host-composed crm-lead resolver port. Upstream ships NO default domain for leads (the mailing's own domain applies) — the typed default-domain provider mirrors that."
      - name: crm_deal
        description: "crm deals — the mass_mailing_crm bridge target's second arm (no upstream twin exists; follows the lead posture); resolved through the host-composed crm-deal resolver port."
      - name: selling_customer
        description: "selling customers — the mass_mailing_sale bridge target; resolved through the host-composed selling-customer resolver port. The typed default-domain provider applies the exclusion policy (customers without a mail address) when the domain arrives empty."
      - name: event_registration
        description: "event registrations — the mass_mailing_event bridge target; resolved through the host-composed event-registration resolver port (a typed port with a fail-closed refusing default — never a silent zero-recipient sweep). Upstream's default domain [('state','!=','cancel')] filters a column the whitelisted DSL cannot express; the honest typed equivalent is the resolver's own population predicate (the events module's declared mail-eligibility law: state IN open,done AND active, plus not soft-deleted — recorded deviation). The *_sms twin rides the SAME value on the existing sms channel (no twin enum, no twin module). No track-shaped targets exist: the track twins (mass_mailing_event_track / _track_sms) are deferred by owner ruling until a tracks substrate is ratified in the events module."