EVERJUST Mass Mailing — Agent Skill
Operate the Email Marketing application (mass_mailing + mass_mailing_crm,
link_tracker, utm) of an everjust.app Odoo tenant as a running agent: manage
mailing lists and contacts, compose and A/B a campaign, send a test, schedule or
launch a blast, and read per-recipient delivery/engagement traces — all through
the Odoo MCP (search, get, create, update, find, call,
describe_model; see [[everjust-agent-mcp]] for the toolset and how to open a
session against the right tenant DB).
This app is stock Odoo 19 mass_mailing — there is no everjust_mail_mass
addon (it deliberately does not exist yet). What is everjust-specific is the
transport underneath it: bulk uses the same per-tenant SES ir.mail_server,
the same mail.blacklist suppression, and the same account-wide SES
reputation as the native webmail. So the models are vanilla, but the operating
rules are not. Canonical infra reference:
<ww.everjust.app>/docs/mail/EMAIL_INFRASTRUCTURE.md §6
and docs/mail/MAIL_MASS_MAILING_REVIEW.md.
When to use this skill
- Bulk / campaign email to a list of recipients on an everjust tenant:
build a
mailing.list, add/import mailing.contacts, draft a mailing.mailing,
send a test, then schedule or launch.
- Read campaign results — per-recipient
mailing.trace (delivered / opened /
clicked / bounced / replied), aggregate ratios, link.tracker click counts.
- Group campaigns under a UTM — create/attach a
utm.campaign, source, medium.
- Target a CRM segment (
mass_mailing_crm) — a mailing whose recipient model
is crm.lead instead of mailing.list/mailing.contact.
- Manage opt-out / blacklist hygiene on a list before a send.
Do NOT use this skill for, and switch to the sibling if the task is really:
- A single or low-volume transactional send as a mailbox address (e.g. reply
to a customer, send an invite) — that is
everjust.mail.account.compose_send,
see [[everjust-mail-ops]]. Do not build a one-recipient mailing.mailing.
- Odoo Discuss / chatter /
message_post — unrelated (see [[everjust-mail-ops]]).
- Sending-domain verification, DKIM/DNS, or provisioning a new identity — that
is the transport layer ([[everjust-mail-ops]] + EMAIL_INFRASTRUCTURE.md §7),
not this app. This skill assumes the tenant's sending domain is already
verified and its ir.mail_server exists.
- Firing a real blast when the human hasn't approved it. See the gate below.
The one gate you must respect (read before any send)
Mass mailing on everjust rides shared, reputation-sensitive infrastructure and
the bulk safety layer is not built yet (EMAIL_INFRASTRUCTURE.md §6,
MAIL_MASS_MAILING_REVIEW.md). Specifically:
- SES reputation is ACCOUNT-WIDE. One bad list (bounces/complaints) can pause
sending for every everjust tenant — customagents.io, tcstartupweek.com,
everjust.app all share the one SES account (us-east-2). A blast to a dirty
list is not a local mistake; it is a platform-wide outage risk.
- SES production access was granted for TRANSACTIONAL mail (
MailType=TRANSACTIONAL),
not bulk. The five bulk gates — dispatcher /send, everjust.email.warmup
ceiling, complaint auto-pause circuit-breaker, the everjust_mail_mass glue,
and per-tenant SES config-set event destinations — are UNBUILT.
- So: draft, list-manage, import, and send TESTS freely. But treat
action_send_mail / action_launch / action_put_in_queue on a real list as
a human-gated action — confirm explicitly with the operator, verify the
list is clean (opt-out/blacklist scrubbed) and small, before you launch. The
MCP call tool already requires confirm: true for non-read methods; do not
set it on a launch without human sign-off.
This does not block you from operating the app — you can do everything up to the
actual blast, and you can read results all day. It blocks you from turning a
draft into account-wide reputation damage.
Where it's installed (as of 2026-07-05)
Full stack (mass_mailing, mass_mailing_crm, link_tracker, utm) installed
on: connectdomain, tcstartupweek, headsup, riftline-labs.
weldon has only utm (a transitive dep). burekraft-llc and
trust-works-company have none — do not assume the models exist; check
ir.module.module state first. Always confirm you are on the intended tenant DB
and env.company before acting (see [[everjust-platform]] — one Postgres DB per
tenant, everything per-company_id).
Key models (real fields, from the live connectdomain tenant)
Everything is stock Odoo mass_mailing; these are the fields an operating agent
actually touches. (Introspect any of them live with the MCP describe_model.)
| Model |
Role |
Key fields |
mailing.mailing |
A campaign — one email blast (subject/body → a recipient set). |
subject (required), body_arch/body_html, email_from, reply_to, reply_to_mode (update|new), mailing_type (only mail), state (draft|in_queue|sending|done), schedule_type (now|scheduled), schedule_date, mailing_model_id/mailing_model_name (recipient model: mailing.list, mailing.contact, or crm.lead), contact_list_ids (m2m → mailing.list, when list-based), mailing_domain (extra recipient filter), use_exclusion_list, campaign_id/source_id/medium_id (UTM), mail_server_id (leave NULL — see pitfalls), ab_testing_enabled/ab_testing_pc, plus computed counters total/sent/delivered/opened/clicked/bounced/failed and *_ratio, mailing_trace_ids. use_leads/crm_lead_count from mass_mailing_crm. |
mailing.list |
An audience — a named set of contacts. |
name (required), contact_ids (m2m → mailing.contact), subscription_ids (→ mailing.subscription), is_public, and computed hygiene: contact_count, contact_count_email, contact_count_opt_out, contact_count_blacklisted, contact_pct_opt_out/_blacklisted/_bounce. |
mailing.contact |
A recipient row (list-scoped, distinct from res.partner). |
name, first_name, last_name, email, email_normalized, company_name, country_id, list_ids (m2m → mailing.list), subscription_ids, opt_out (per-contact global), is_blacklisted (computed from mail.blacklist), message_bounce, tag_ids. |
mailing.subscription |
The list↔contact join with opt-out state (this is the real M2M model; the old name mailing.contact.subscription does NOT exist). |
contact_id, list_id, opt_out, opt_out_reason_id (→ mailing.subscription.optout), opt_out_datetime, is_blacklisted, message_bounce. |
mailing.trace |
Per-recipient result of one mailing (the analytics spine). |
mass_mailing_id, email, model/res_id (the recipient record), trace_type (mail), trace_status (outgoing|process|pending(=queued/sent-to-SMTP)|sent(=delivered)|open|reply|bounce|error|cancel), failure_type (e.g. mail_bounce, mail_bl, mail_optout, mail_smtp), failure_reason, sent_datetime/open_datetime/reply_datetime, mail_mail_id, links_click_ids. |
link.tracker (+.click, .code) |
A tracked link inside a mailing → click analytics; UTM-tagged. |
url, short_url, code, count (clicks), mass_mailing_id, campaign_id/source_id/medium_id. |
utm.campaign / utm.source / utm.medium |
Grouping + attribution for mailings and links. |
utm.campaign: name, title (required), user_id, stage_id, tag_ids, aggregate ratios. utm.source/utm.medium: name. |
Note the trace-status vocabulary trap: pending means handed to SMTP, sent
means delivered (SES accepted for delivery). They are not intuitive — read
them off the selection above, not from the English label.
Recipes
Route each through the Odoo MCP (search/get/create/update/find/call).
Domains are Odoo triple-lists. See [[everjust-agent-mcp]] for opening the session.
1. Build a list and add contacts
// Create the audience:
create("mailing.list", { "name": "Connect Domain — waitlist" })
// → returns list_id, e.g. 7
// Add contacts (one call each, or loop). email is the identity; list_ids links it:
create("mailing.contact", {
"name": "Ada Lovelace", "email": "ada@example.com",
"company_name": "Analytical Co", "list_ids": [[6, 0, [7]]] // 6,0,[ids] = replace links
})
// Bulk import: create many contacts with the same list_ids one-liner, OR add
// existing contacts to a list via the list side:
update("mailing.list", [7], { "contact_ids": [[4, <contact_id>]] }) // 4,id = link one
Before you send to a list, inspect its hygiene — do not blast a dirty list:
get("mailing.list", [7], [
"name","contact_count","contact_count_email",
"contact_count_opt_out","contact_count_blacklisted",
"contact_pct_opt_out","contact_pct_blacklisted","contact_pct_bounce"])
High contact_pct_bounce/_blacklisted is a reputation red flag (see the gate) —
clean it before launching. Contacts already on mail.blacklist or opt_out=True
are dropped at send automatically, but a list full of them still signals a bad
source. Cross-check suppression the same way [[everjust-mail-ops]] does
(mail.blacklist is the shared list both apps enforce).
2. Draft a campaign (list-based) — do NOT send yet
// mailing_model_id must reference the ir.model for the recipient model.
find("ir.model", "mailing.list") // → [[id, "Mailing List"]] resolve once
create("mailing.mailing", {
"subject": "Bring your own domain — automatically",
"email_from": "Connect Domain <hello@connectdomain.app>", // MUST be on the verified sending domain
"reply_to_mode": "new",
"reply_to": "hello@connectdomain.app",
"mailing_model_id": <id of mailing.list>,
"contact_list_ids": [[6, 0, [7]]],
"body_arch": "<p>Hello %(first_name)s, ...</p>", // body_arch is the editable source; body_html is rendered
"schedule_type": "now"
// mail_server_id: OMIT — default selection picks the tenant SES server by from_filter
})
email_from must match the tenant's verified sending domain (its
ir.mail_server.from_filter, e.g. connectdomain.app) or SES rewrites/rejects it
(EMAIL_INFRASTRUCTURE.md §6: gmail-From authors get rewritten toward
noreply@<domain> — deliverable but not the From they expect; dry-run one first).
Read the tenant's server before drafting:
search("ir.mail_server", [], ["name","from_filter","active"]).
To target a CRM segment instead (mass_mailing_crm): set
mailing_model_id → the crm.lead model, leave contact_list_ids empty, and use
mailing_domain as the lead filter, e.g.
"mailing_domain": "[('stage_id.name','=','New')]", "use_leads": true.
3. Send a TEST, then read the render
Always test before a real send — this mails only the addresses you pass, and files
a test mailing.trace (is_test_trace=True), not a blast:
call("mailing.mailing", "action_test",
ids=[<mailing_id>],
kwargs={ "context": { "default_email_to": "you@connectdomain.app" } },
confirm=true)
// Odoo 19 test wizard is mailing.mailing.test; the simplest path is the action
// above with default_email_to in context. Then confirm what actually happened:
get("mailing.mailing", [<mailing_id>], ["state","total","warning_message"])
Inspect the resulting mail.mail state exactly as [[everjust-mail-ops]] teaches —
a filed trace is not proof SES accepted it. If the test bounces or errors, fix
before you touch the real send.
4. Schedule or launch a blast — HUMAN-GATED
Only after (a) the human approved, (b) the list is clean, (c) the test rendered and
delivered. Two paths:
// (a) Schedule for later — sets schedule_date and puts it in the queue:
update("mailing.mailing", [<mailing_id>],
{ "schedule_type": "scheduled", "schedule_date": "2026-07-10 14:00:00" })
call("mailing.mailing", "action_schedule", ids=[<mailing_id>], confirm=true)
// (b) Send now — enqueue; the mass_mailing queue cron (active) processes it:
call("mailing.mailing", "action_put_in_queue", ids=[<mailing_id>], confirm=true)
// action_launch / action_send_mail are the "send immediately" variants.
After launch, poll the campaign state and counters rather than assuming it sent:
get("mailing.mailing", [<mailing_id>], [
"state","sent","delivered","opened","clicked","bounced","failed",
"received_ratio","opened_ratio","bounced_ratio","next_departure"])
state walks draft → in_queue → sending → done. Sending is asynchronous via the
queue cron; done with bounced/failed > 0 needs follow-up (recipes below).
5. Read per-recipient results and click analytics
// Every recipient's outcome for a mailing:
search("mailing.trace",
[["mass_mailing_id","=",<mailing_id>]],
["email","trace_status","failure_type","failure_reason",
"sent_datetime","open_datetime","reply_datetime"],
order="sent_datetime desc")
// Just the failures/bounces to investigate (and feed suppression):
search("mailing.trace",
[["mass_mailing_id","=",<mailing_id>],
["trace_status","in",["bounce","error"]]],
["email","failure_type","failure_reason"])
// Link click analytics for the campaign:
search("link.tracker",
[["mass_mailing_id","=",<mailing_id>]],
["url","short_url","count"], order="count desc")
To retry just the failed recipients after fixing the cause:
call("mailing.mailing", "action_retry_failed", ids=[<mailing_id>], confirm=true).
To cancel a queued/sending mailing:
call("mailing.mailing", "action_cancel", ids=[<mailing_id>], confirm=true).
6. Group under a UTM campaign (attribution)
create("utm.campaign", { "name": "Launch Q3", "title": "Launch Q3" }) // title required
update("mailing.mailing", [<mailing_id>], {
"campaign_id": <campaign_id>,
"source_id": <utm.source id>, // find/create utm.source by name
"medium_id": <utm.medium id> // usually "Email"
})
// Links inside the body inherit these; convert_links() (called at send) tracks them.
Pitfalls (everjust-specific)
The account-wide reputation gate is the whole ballgame. A blast to a dirty
or large list can pause SES for every everjust tenant (§6). Bulk safety
(warmup, complaint auto-pause, dispatcher) is UNBUILT. Never fire
action_send_mail/action_launch/action_put_in_queue on a real list without
explicit human approval and a clean, small list. Drafting, list-management,
imports, and tests are always fine.
This is not the tool for a one-off send. A single-recipient mailing.mailing
is the wrong shape — use everjust.mail.account.compose_send ([[everjust-mail-ops]]).
Mass mailing is for lists/segments; the webmail is for interactive/transactional.
email_from must be on the verified sending domain. It has to satisfy the
tenant ir.mail_server.from_filter (e.g. connectdomain.app). A gmail/other
From gets rewritten toward noreply@<domain> at send (deliverable, wrong From)
— the three admins who log in with gmail addresses trip this by default (§6).
Set email_from to a real hello@/sam@/weldon@<domain> address explicitly,
and dry-run one gmail-authored mailing before relying on the rewrite.
Leave mail_server_id NULL. All live mailings have it unset, and
mass_mailing.mail_server_id is unset, so default selection picks the tenant's
SES server by from_filter — the correct, IAM-scoped identity. Pinning a wrong
mail_server_id can send from an identity SES won't authorize (see the
2026-07-04 incident in EMAIL_INFRASTRUCTURE.md §8: a mismatched IAM key → 554).
Suppression is shared and mirror-driven. mailing.mailing enforces native
mail.blacklist pre-send; bounces/complaints flow in automatically via the
everjust suppression loop (everjust.mail.suppression._ingest → write-through to
mail.blacklist). To suppress an address by hand, go through _ingest
([[everjust-mail-ops]]), not a raw mail.blacklist create and not a raw
mailing.contact.opt_out toggle — those don't do the atomic mirror.
Trace-status wording lies. pending = handed to SMTP, sent = delivered.
Judge success by trace_status='sent' (or open/reply), and treat pending
as "left the queue, not confirmed delivered." bounce/error need action.
Sending is async — state and counters are the truth, not the return value.
action_put_in_queue/action_schedule only enqueue; the mass_mailing queue
cron (active on connectdomain) processes later. Poll state
(draft→in_queue→sending→done) and the sent/delivered/bounced counters;
don't assume the launch call means it went out.
Not installed everywhere. Only connectdomain/tcstartupweek/headsup/riftline-labs
have the full app; some tenants have none. Check ir.module.module state before
assuming mailing.mailing exists, and confirm the tenant DB + env.company
first (per-tenant isolation — [[everjust-platform]]).
web.base.url may be off-domain for tracking/unsubscribe links. On
connectdomain it is https://connectdomain.everjust.app, so unsubscribe and
click-tracking links render on that host, not a connectdomain.app surface
(EMAIL_INFRASTRUCTURE.md §3). One-click List-Unsubscribe is provided by native
mass_mailing — do not strip it; the SES re-route must forward full MIME.
mailing.subscription is the real join model (opt-out lives there), not the
nonexistent mailing.contact.subscription. Global opt-out is
mailing.contact.opt_out; per-list opt-out is mailing.subscription.opt_out.
1---2name: everjust-mass-mailing3description: EVERJUST Mass Mailing — Agent Skill4---56# EVERJUST Mass Mailing — Agent Skill78Operate the **Email Marketing** application (`mass_mailing` + `mass_mailing_crm`,9`link_tracker`, `utm`) of an everjust.app Odoo tenant as a running agent: manage10mailing lists and contacts, compose and A/B a campaign, send a test, schedule or11launch a blast, and read per-recipient delivery/engagement traces — all through12the Odoo MCP (`search`, `get`, `create`, `update`, `find`, `call`,13`describe_model`; see [[everjust-agent-mcp]] for the toolset and how to open a14session against the right tenant DB).1516This app is **stock Odoo 19 `mass_mailing`** — there is no `everjust_mail_mass`17addon (it deliberately does not exist yet). What is everjust-specific is the18**transport underneath it**: bulk uses the same per-tenant SES `ir.mail_server`,19the same `mail.blacklist` suppression, and the same **account-wide** SES20reputation as the native webmail. So the models are vanilla, but the operating21rules are not. Canonical infra reference:22`<ww.everjust.app>/docs/mail/EMAIL_INFRASTRUCTURE.md` §623and `docs/mail/MAIL_MASS_MAILING_REVIEW.md`.2425## When to use this skill2627- **Bulk / campaign email** to a list of recipients on an everjust tenant:28 build a `mailing.list`, add/import `mailing.contact`s, draft a `mailing.mailing`,29 send a **test**, then schedule or launch.30- **Read campaign results** — per-recipient `mailing.trace` (delivered / opened /31 clicked / bounced / replied), aggregate ratios, `link.tracker` click counts.32- **Group campaigns under a UTM** — create/attach a `utm.campaign`, source, medium.33- **Target a CRM segment** (`mass_mailing_crm`) — a mailing whose recipient model34 is `crm.lead` instead of `mailing.list`/`mailing.contact`.35- **Manage opt-out / blacklist hygiene** on a list before a send.3637**Do NOT use this skill for**, and switch to the sibling if the task is really:38- A **single or low-volume transactional send** as a mailbox address (e.g. reply39 to a customer, send an invite) — that is `everjust.mail.account.compose_send`,40 see **[[everjust-mail-ops]]**. Do not build a one-recipient `mailing.mailing`.41- **Odoo Discuss / chatter / `message_post`** — unrelated (see [[everjust-mail-ops]]).42- **Sending-domain verification, DKIM/DNS, or provisioning a new identity** — that43 is the transport layer ([[everjust-mail-ops]] + EMAIL_INFRASTRUCTURE.md §7),44 not this app. This skill assumes the tenant's sending domain is already45 `verified` and its `ir.mail_server` exists.46- **Firing a real blast when the human hasn't approved it.** See the gate below.4748## The one gate you must respect (read before any send)4950Mass mailing on everjust rides shared, reputation-sensitive infrastructure and51the **bulk safety layer is not built yet** (EMAIL_INFRASTRUCTURE.md §6,52MAIL_MASS_MAILING_REVIEW.md). Specifically:53541. **SES reputation is ACCOUNT-WIDE.** One bad list (bounces/complaints) can pause55 sending for **every** everjust tenant — customagents.io, tcstartupweek.com,56 everjust.app all share the one SES account (us-east-2). A blast to a dirty57 list is not a local mistake; it is a platform-wide outage risk.582. **SES production access was granted for TRANSACTIONAL mail** (`MailType=TRANSACTIONAL`),59 not bulk. The five bulk gates — dispatcher `/send`, `everjust.email.warmup`60 ceiling, complaint auto-pause circuit-breaker, the `everjust_mail_mass` glue,61 and per-tenant SES config-set event destinations — are **UNBUILT**.623. So: **draft, list-manage, import, and send TESTS freely.** But treat63 `action_send_mail` / `action_launch` / `action_put_in_queue` on a real list as64 a **human-gated** action — confirm explicitly with the operator, verify the65 list is clean (opt-out/blacklist scrubbed) and small, before you launch. The66 MCP `call` tool already requires `confirm: true` for non-read methods; do not67 set it on a launch without human sign-off.6869This does not block you from operating the app — you can do everything up to the70actual blast, and you can read results all day. It blocks you from turning a71draft into account-wide reputation damage.7273## Where it's installed (as of 2026-07-05)7475Full stack (`mass_mailing`, `mass_mailing_crm`, `link_tracker`, `utm`) installed76on: **connectdomain**, **tcstartupweek**, **headsup**, **riftline-labs**.77`weldon` has only `utm` (a transitive dep). `burekraft-llc` and78`trust-works-company` have **none** — do not assume the models exist; check79`ir.module.module` state first. Always confirm you are on the intended tenant DB80and `env.company` before acting (see [[everjust-platform]] — one Postgres DB per81tenant, everything per-`company_id`).8283## Key models (real fields, from the live `connectdomain` tenant)8485Everything is stock Odoo `mass_mailing`; these are the fields an operating agent86actually touches. (Introspect any of them live with the MCP `describe_model`.)8788| Model | Role | Key fields |89|---|---|---|90| `mailing.mailing` | **A campaign** — one email blast (subject/body → a recipient set). | `subject` (**required**), `body_arch`/`body_html`, `email_from`, `reply_to`, `reply_to_mode` (`update`\|`new`), `mailing_type` (only `mail`), `state` (`draft`\|`in_queue`\|`sending`\|`done`), `schedule_type` (`now`\|`scheduled`), `schedule_date`, `mailing_model_id`/`mailing_model_name` (recipient model: `mailing.list`, `mailing.contact`, or `crm.lead`), `contact_list_ids` (m2m → `mailing.list`, when list-based), `mailing_domain` (extra recipient filter), `use_exclusion_list`, `campaign_id`/`source_id`/`medium_id` (UTM), `mail_server_id` (**leave NULL** — see pitfalls), `ab_testing_enabled`/`ab_testing_pc`, plus computed counters `total`/`sent`/`delivered`/`opened`/`clicked`/`bounced`/`failed` and `*_ratio`, `mailing_trace_ids`. `use_leads`/`crm_lead_count` from `mass_mailing_crm`. |91| `mailing.list` | **An audience** — a named set of contacts. | `name` (**required**), `contact_ids` (m2m → `mailing.contact`), `subscription_ids` (→ `mailing.subscription`), `is_public`, and computed hygiene: `contact_count`, `contact_count_email`, `contact_count_opt_out`, `contact_count_blacklisted`, `contact_pct_opt_out`/`_blacklisted`/`_bounce`. |92| `mailing.contact` | **A recipient row** (list-scoped, distinct from `res.partner`). | `name`, `first_name`, `last_name`, `email`, `email_normalized`, `company_name`, `country_id`, `list_ids` (m2m → `mailing.list`), `subscription_ids`, `opt_out` (per-contact global), `is_blacklisted` (computed from `mail.blacklist`), `message_bounce`, `tag_ids`. |93| `mailing.subscription` | The list↔contact join **with opt-out state** (this is the real M2M model; the old name `mailing.contact.subscription` does NOT exist). | `contact_id`, `list_id`, `opt_out`, `opt_out_reason_id` (→ `mailing.subscription.optout`), `opt_out_datetime`, `is_blacklisted`, `message_bounce`. |94| `mailing.trace` | **Per-recipient result** of one mailing (the analytics spine). | `mass_mailing_id`, `email`, `model`/`res_id` (the recipient record), `trace_type` (`mail`), `trace_status` (`outgoing`\|`process`\|`pending`(=queued/sent-to-SMTP)\|`sent`(=delivered)\|`open`\|`reply`\|`bounce`\|`error`\|`cancel`), `failure_type` (e.g. `mail_bounce`, `mail_bl`, `mail_optout`, `mail_smtp`), `failure_reason`, `sent_datetime`/`open_datetime`/`reply_datetime`, `mail_mail_id`, `links_click_ids`. |95| `link.tracker` (+`.click`, `.code`) | A tracked link inside a mailing → click analytics; UTM-tagged. | `url`, `short_url`, `code`, `count` (clicks), `mass_mailing_id`, `campaign_id`/`source_id`/`medium_id`. |96| `utm.campaign` / `utm.source` / `utm.medium` | Grouping + attribution for mailings and links. | `utm.campaign`: `name`, `title` (**required**), `user_id`, `stage_id`, `tag_ids`, aggregate ratios. `utm.source`/`utm.medium`: `name`. |9798Note the trace-status vocabulary trap: `pending` means **handed to SMTP**, `sent`99means **delivered** (SES accepted for delivery). They are not intuitive — read100them off the selection above, not from the English label.101102## Recipes103104Route each through the Odoo MCP (`search`/`get`/`create`/`update`/`find`/`call`).105Domains are Odoo triple-lists. See [[everjust-agent-mcp]] for opening the session.106107### 1. Build a list and add contacts108109```jsonc110// Create the audience:111create("mailing.list", { "name": "Connect Domain — waitlist" })112// → returns list_id, e.g. 7113114// Add contacts (one call each, or loop). email is the identity; list_ids links it:115create("mailing.contact", {116 "name": "Ada Lovelace", "email": "ada@example.com",117 "company_name": "Analytical Co", "list_ids": [[6, 0, [7]]] // 6,0,[ids] = replace links118})119// Bulk import: create many contacts with the same list_ids one-liner, OR add120// existing contacts to a list via the list side:121update("mailing.list", [7], { "contact_ids": [[4, <contact_id>]] }) // 4,id = link one122```123124Before you send to a list, **inspect its hygiene** — do not blast a dirty list:125126```jsonc127get("mailing.list", [7], [128 "name","contact_count","contact_count_email",129 "contact_count_opt_out","contact_count_blacklisted",130 "contact_pct_opt_out","contact_pct_blacklisted","contact_pct_bounce"])131```132133High `contact_pct_bounce`/`_blacklisted` is a reputation red flag (see the gate) —134clean it before launching. Contacts already on `mail.blacklist` or `opt_out=True`135are dropped at send automatically, but a list *full* of them still signals a bad136source. Cross-check suppression the same way [[everjust-mail-ops]] does137(`mail.blacklist` is the shared list both apps enforce).138139### 2. Draft a campaign (list-based) — do NOT send yet140141```jsonc142// mailing_model_id must reference the ir.model for the recipient model.143find("ir.model", "mailing.list") // → [[id, "Mailing List"]] resolve once144create("mailing.mailing", {145 "subject": "Bring your own domain — automatically",146 "email_from": "Connect Domain <hello@connectdomain.app>", // MUST be on the verified sending domain147 "reply_to_mode": "new",148 "reply_to": "hello@connectdomain.app",149 "mailing_model_id": <id of mailing.list>,150 "contact_list_ids": [[6, 0, [7]]],151 "body_arch": "<p>Hello %(first_name)s, ...</p>", // body_arch is the editable source; body_html is rendered152 "schedule_type": "now"153 // mail_server_id: OMIT — default selection picks the tenant SES server by from_filter154})155```156157`email_from` **must** match the tenant's verified sending domain (its158`ir.mail_server.from_filter`, e.g. `connectdomain.app`) or SES rewrites/rejects it159(EMAIL_INFRASTRUCTURE.md §6: gmail-From authors get rewritten toward160`noreply@<domain>` — deliverable but not the From they expect; dry-run one first).161Read the tenant's server before drafting:162`search("ir.mail_server", [], ["name","from_filter","active"])`.163164To target a **CRM segment** instead (`mass_mailing_crm`): set165`mailing_model_id` → the `crm.lead` model, leave `contact_list_ids` empty, and use166`mailing_domain` as the lead filter, e.g.167`"mailing_domain": "[('stage_id.name','=','New')]"`, `"use_leads": true`.168169### 3. Send a TEST, then read the render170171Always test before a real send — this mails only the addresses you pass, and files172a test `mailing.trace` (`is_test_trace=True`), not a blast:173174```jsonc175call("mailing.mailing", "action_test",176 ids=[<mailing_id>],177 kwargs={ "context": { "default_email_to": "you@connectdomain.app" } },178 confirm=true)179// Odoo 19 test wizard is mailing.mailing.test; the simplest path is the action180// above with default_email_to in context. Then confirm what actually happened:181get("mailing.mailing", [<mailing_id>], ["state","total","warning_message"])182```183184Inspect the resulting `mail.mail` state exactly as [[everjust-mail-ops]] teaches —185a filed trace is not proof SES accepted it. If the test bounces or errors, fix186before you touch the real send.187188### 4. Schedule or launch a blast — HUMAN-GATED189190Only after (a) the human approved, (b) the list is clean, (c) the test rendered and191delivered. Two paths:192193```jsonc194// (a) Schedule for later — sets schedule_date and puts it in the queue:195update("mailing.mailing", [<mailing_id>],196 { "schedule_type": "scheduled", "schedule_date": "2026-07-10 14:00:00" })197call("mailing.mailing", "action_schedule", ids=[<mailing_id>], confirm=true)198199// (b) Send now — enqueue; the mass_mailing queue cron (active) processes it:200call("mailing.mailing", "action_put_in_queue", ids=[<mailing_id>], confirm=true)201// action_launch / action_send_mail are the "send immediately" variants.202```203204After launch, poll the campaign state and counters rather than assuming it sent:205206```jsonc207get("mailing.mailing", [<mailing_id>], [208 "state","sent","delivered","opened","clicked","bounced","failed",209 "received_ratio","opened_ratio","bounced_ratio","next_departure"])210```211212`state` walks `draft → in_queue → sending → done`. Sending is asynchronous via the213queue cron; `done` with `bounced`/`failed` > 0 needs follow-up (recipes below).214215### 5. Read per-recipient results and click analytics216217```jsonc218// Every recipient's outcome for a mailing:219search("mailing.trace",220 [["mass_mailing_id","=",<mailing_id>]],221 ["email","trace_status","failure_type","failure_reason",222 "sent_datetime","open_datetime","reply_datetime"],223 order="sent_datetime desc")224225// Just the failures/bounces to investigate (and feed suppression):226search("mailing.trace",227 [["mass_mailing_id","=",<mailing_id>],228 ["trace_status","in",["bounce","error"]]],229 ["email","failure_type","failure_reason"])230231// Link click analytics for the campaign:232search("link.tracker",233 [["mass_mailing_id","=",<mailing_id>]],234 ["url","short_url","count"], order="count desc")235```236237To **retry** just the failed recipients after fixing the cause:238`call("mailing.mailing", "action_retry_failed", ids=[<mailing_id>], confirm=true)`.239To **cancel** a queued/sending mailing:240`call("mailing.mailing", "action_cancel", ids=[<mailing_id>], confirm=true)`.241242### 6. Group under a UTM campaign (attribution)243244```jsonc245create("utm.campaign", { "name": "Launch Q3", "title": "Launch Q3" }) // title required246update("mailing.mailing", [<mailing_id>], {247 "campaign_id": <campaign_id>,248 "source_id": <utm.source id>, // find/create utm.source by name249 "medium_id": <utm.medium id> // usually "Email"250})251// Links inside the body inherit these; convert_links() (called at send) tracks them.252```253254## Pitfalls (everjust-specific)2552561. **The account-wide reputation gate is the whole ballgame.** A blast to a dirty257 or large list can pause SES for **every** everjust tenant (§6). Bulk safety258 (warmup, complaint auto-pause, dispatcher) is UNBUILT. Never fire259 `action_send_mail`/`action_launch`/`action_put_in_queue` on a real list without260 explicit human approval and a clean, small list. Drafting, list-management,261 imports, and **tests** are always fine.2622632. **This is not the tool for a one-off send.** A single-recipient `mailing.mailing`264 is the wrong shape — use `everjust.mail.account.compose_send` ([[everjust-mail-ops]]).265 Mass mailing is for lists/segments; the webmail is for interactive/transactional.2662673. **`email_from` must be on the verified sending domain.** It has to satisfy the268 tenant `ir.mail_server.from_filter` (e.g. `connectdomain.app`). A gmail/other269 From gets rewritten toward `noreply@<domain>` at send (deliverable, wrong From)270 — the three admins who log in with gmail addresses trip this by default (§6).271 Set `email_from` to a real `hello@`/`sam@`/`weldon@<domain>` address explicitly,272 and dry-run one gmail-authored mailing before relying on the rewrite.2732744. **Leave `mail_server_id` NULL.** All live mailings have it unset, and275 `mass_mailing.mail_server_id` is unset, so default selection picks the tenant's276 SES server by `from_filter` — the correct, IAM-scoped identity. Pinning a wrong277 `mail_server_id` can send from an identity SES won't authorize (see the278 2026-07-04 incident in EMAIL_INFRASTRUCTURE.md §8: a mismatched IAM key → 554).2792805. **Suppression is shared and mirror-driven.** `mailing.mailing` enforces native281 `mail.blacklist` pre-send; bounces/complaints flow in automatically via the282 everjust suppression loop (`everjust.mail.suppression._ingest` → write-through to283 `mail.blacklist`). To suppress an address by hand, go through `_ingest`284 ([[everjust-mail-ops]]), **not** a raw `mail.blacklist` create and **not** a raw285 `mailing.contact.opt_out` toggle — those don't do the atomic mirror.2862876. **Trace-status wording lies.** `pending` = handed to SMTP, `sent` = *delivered*.288 Judge success by `trace_status='sent'` (or `open`/`reply`), and treat `pending`289 as "left the queue, not confirmed delivered." `bounce`/`error` need action.2902917. **Sending is async — `state` and counters are the truth, not the return value.**292 `action_put_in_queue`/`action_schedule` only enqueue; the `mass_mailing` queue293 cron (active on connectdomain) processes later. Poll `state`294 (`draft→in_queue→sending→done`) and the `sent`/`delivered`/`bounced` counters;295 don't assume the launch call means it went out.2962978. **Not installed everywhere.** Only connectdomain/tcstartupweek/headsup/riftline-labs298 have the full app; some tenants have none. Check `ir.module.module` state before299 assuming `mailing.mailing` exists, and confirm the tenant DB + `env.company`300 first (per-tenant isolation — [[everjust-platform]]).3013029. **`web.base.url` may be off-domain for tracking/unsubscribe links.** On303 connectdomain it is `https://connectdomain.everjust.app`, so unsubscribe and304 click-tracking links render on that host, not a `connectdomain.app` surface305 (EMAIL_INFRASTRUCTURE.md §3). One-click List-Unsubscribe is provided by native306 `mass_mailing` — do not strip it; the SES re-route must forward full MIME.30730810. **`mailing.subscription` is the real join model** (opt-out lives there), not the309 nonexistent `mailing.contact.subscription`. Global opt-out is310 `mailing.contact.opt_out`; per-list opt-out is `mailing.subscription.opt_out`.