models:
- name: MailingAudience
collection: mailing_audiences
description: "Mailing Audience (Odoo `mailing.list`, renamed — see the NAMING note in this file's header) — the audience bucket. Memberships resolve through mailing_subscriptions (the real through-entity); no anonymous m2m exists. Archive-guarded: the write path refuses to archive an audience referenced by any mailing with state != done (rule R-M7 — cross-table, service-enforced)."
fields:
id:
type: uuid
attributes: ["@id", "@default(uuid)"]
description: "Unique record id"
name:
type: string
attributes: ["@required", "@max(120)"]
description: "List name."
active:
type: boolean
attributes: ["@default(true)"]
description: "Archive flag. Archiving is REFUSED while any non-done mailing targets the audience (rule R-M7 — the guard is procedural/cross-table; the flag itself stays plain)."
is_public:
type: boolean
attributes: ["@default(false)"]
description: "If true, the audience appears on the recipient-facing subscription-preferences surface (self-service subscriptions only create onto public audiences)."
metadata:
type: Metadata
attributes: ["@audit_metadata"]
description: "Audit metadata"
indexes:
- type: index
fields: [active]
description: "Default-list filter arm (inactive lists drop out)"
- name: MailingContact
collection: mailing_contacts
description: "Mailing Contact (Odoo `mailing.contact`) — a subscriber record. email is the dedup/join key, stored lowercased; the global blacklist, the seen-list, and the bounce window all join on it. name derives from first/last when present but stays a plain editable column (no view-toggle machinery)."
fields:
id:
type: uuid
attributes: ["@id", "@default(uuid)"]
description: "Unique record id"
name:
type: string?
attributes: ["@max(120)"]
description: "Display name — plain editable column; hosts commonly fill it from first/last at import."
first_name:
type: string?
attributes: ["@max(120)"]
description: "Given name (always available — upstream's split-name config toggle is UI presentation, not schema)."
last_name:
type: string?
attributes: ["@max(120)"]
description: "Family name."
email:
type: string
attributes: ["@required", "@max(255)"]
description: "Recipient email, STORED LOWERCASED (case-folding is app-layer on write — the same fold the mail blacklist applies). Unique among live rows; THE dedup/join key."
company_name:
type: string?
attributes: ["@max(120)"]
description: "Free-text contact company (not a foreign key)."
country_code:
type: string?
attributes: ["@max(2)"]
description: "Optional ISO 3166-1 alpha-2 country code — plain column, no geo FK at this scope. Doubles as the SANITIZE HINT for phone: a national-format phone value resolves against it at send time."
phone:
type: string?
attributes: ["@max(64)"]
description: "RAW as-entered mobile number (Odoo's `mobile` — the load-bearing SMS enabler). Canonicalization happens AT SEND through mail's public phone_format(raw, country_code); the canonical form lands ONLY on the trace (recipient_phone). NO stored phone_sanitized twin: the recorded no-stored-cache deviation explicitly names mailing contact statistics as the write-your-own-model consumer."
metadata:
type: Metadata
attributes: ["@audit_metadata"]
description: "Audit metadata"
indexes:
- type: unique
fields: [email]
where: "deleted_at IS NULL"
description: "One live contact per email (the dedup grain; fires on raw SQL)"
- name: MailingSubscription
collection: mailing_subscriptions
description: "Mailing Subscription (Odoo `mailing.subscription`) — THE m2m-through as a real entity: both audience and contact sides resolve memberships through these rows, which carry the canonical per-audience opt-out state. Opt-out NEVER deletes: suppression is the flag + timestamp + reason; re-subscribe flips the flag back with audit history intact. UNIQUE(contact, audience) among live rows (the upstream composite unique, kept)."
fields:
id:
type: uuid
attributes: ["@id", "@default(uuid)"]
description: "Unique record id"
contact_id:
type: uuid
attributes: ["@required", "@foreign_key(MailingContact.subscriptions)", "@exclude_from_foreign_key_check", "@indexed"]
description: "The subscriber # logical FK MailingContact.id — half of the unique membership pair."
mailing_audience_id:
type: uuid
attributes: ["@required", "@foreign_key(MailingAudience.subscriptions)", "@exclude_from_foreign_key_check", "@indexed"]
description: "The audience # logical FK MailingAudience.id — the other half of the unique membership pair (Odoo column name: mailing_list_id)."
opt_out:
type: boolean
attributes: ["@default(false)"]
description: "THE canonical per-audience opt-out flag — hand-set DRIVER of the split pair. Force-set true when opt_out_reason_id is set (setting a reason is an implicit opt-out). Suppression policy across targeted audiences is OPT-OUT-WINS (recorded deviation from upstream's opt-in-wins TODO)."
opt_out_datetime:
type: datetime?
lifecycle:
shape: split
driver: opt_out
description: "DERIVED half of the split: cleared when opt_out=false, stamped DB-now() when true — coupling lives in the subscription create/update verbs (never inverse magic)."
opt_out_reason_id:
type: uuid?
attributes: ["@foreign_key(OptOutReason.subscriptions)", "@exclude_from_foreign_key_check"]
description: "Why they opted out # logical FK OptOutReason.id. Setting it forces opt_out=true."
metadata:
type: Metadata
attributes: ["@audit_metadata"]
description: "Audit metadata"
indexes:
- type: unique
fields: [contact_id, mailing_audience_id]
where: "deleted_at IS NULL"
description: "One live membership per (contact, audience) — the upstream composite unique, fires on raw SQL (rule R-M4)"
- type: index
fields: [mailing_audience_id, opt_out]
description: "Per-audience opt-out suppression arm (the send pass probes targeted audiences' opt-outs)"
- name: OptOutReason
collection: mailing_opt_out_reasons
description: "Opt-out Reason (Odoo `mailing.subscription.optout`) — pure lookup catalog (name/sequence/is_feedback). SHARED: serves subscription opt_out_reason_id at full fidelity now, and mail.blacklist.opt_out_reason_id references it by logical uuid from the mail side (cross-addon logical ref — upstream's own shape)."
fields:
id:
type: uuid
attributes: ["@id", "@default(uuid)"]
description: "Unique record id"
name:
type: string
attributes: ["@required", "@max(120)"]
description: "Reason label."
sequence:
type: integer
attributes: ["@default(10)"]
description: "Sort order."
is_feedback:
type: boolean
attributes: ["@default(false)"]
description: "If true, choosing this reason triggers the free-text feedback flow on the unsubscribe surface."
metadata:
type: Metadata
attributes: ["@audit_metadata"]
description: "Audit metadata"