CRM
Organisations, people and the links between them; custom fields on both; the timeline, tags and smart lists of organisations and people; broadcasts to member contacts; the CSV and XLSX import, merging, duplicates, trash and erasure; and every CRM command.
engine-dmo
The CRM holds the businesses and bodies a destination deals with (organisations) and the people it deals with (people). A person is linked to an organisation as one of its contacts, with a job title and a partner role. Everything partner-facing in engine-dmo is built on these records: memberships are an organisation’s, portal accounts are a person’s, listings belong to an organisation, and reports and broadcasts go to contacts.
CRM records are not documents: they are not localised, have no revisions and no publish state, and never appear on the public site. Staff work with them in the admin under CRM, and through the commands below.
Organisations
| Field | Notes |
|---|---|
name | Required |
legal_name | Optional |
status | prospect, active (the default), lapsed or archived |
email, website | Optional |
address, phone | Structured, as the address and phone field types store them |
social | A list of social profiles |
hours | Opening hours, as the opening_hours field type stores them |
lat, lon | A point |
custom | The custom fields you declare in config/crm/organisation.yaml |
| network | The network it belongs to; on creation, the default site’s |
An organisation also carries its memberships (one per site, see Partners and the portal), its contacts, its tags, its timeline and the documents it owns.
Create one from the command line, for example in a script that sets up a project:
engine-dmo crm organisation create '{"name": "Hook Norton Brewery", "status": "active", "email": "tours@hooknorton.example", "website": "https://www.hooknorton.example"}' --data - reads the JSON object from standard input. Unknown keys are refused. The keys are name, legal_name, status, email, website, address, phone, social, hours, custom, lat, lon and network (a network key).
People
| Field | Notes |
|---|---|
| given name, family name, display name | Display name is what lists and mail use |
| phonetic given name, phonetic family name | The reading of each part (furigana), which orders Japanese lists |
| Used for sign-in to the portal and for mail | |
| phone | Structured |
| marketing consent | When it was given and its source; required for broadcasts |
custom | The custom fields you declare in config/crm/person.yaml |
| network | As for organisations |
Contacts: linking people to organisations
A contact is a person linked to an organisation. The link survives a change of job: when someone leaves, the link is ended rather than deleted. Each link has:
| Property | Meaning |
|---|---|
| Job title | Free text, such as “Landlady” |
| Partner role | What the person may do for the organisation in the portal: owner, manager, contact, billing or a role of your own. See Partner roles. A link with no role is contact. |
| Primary | The organisation’s main contact |
| Digest opt-out | The person has stopped the monthly report digest |
| Started, ended | An ended link is history; only current links act |
Staff add and edit contacts on the organisation’s page. A contact becomes a portal user by accepting an invitation, which staff send from the organisation’s page or with engine-dmo crm invite, or by an approved claim.
Custom fields
Declare the extra fields of organisations and people in config/crm/organisation.yaml and config/crm/person.yaml, with the ordinary field grammar. The admin’s record pages show them in their own group, smart lists filter on them, the import maps columns to them, and portal actions can let members fill them in.
fields:
- name: coachParking
type: boolean
label: { en-GB: Coach parking }
- name: preferredContactTime
type: select
label: { en-GB: Preferred contact time }
options:
- { value: morning }
- { value: afternoon } fields:
- name: dietaryNeeds
type: text
personal: true
label: { en-GB: Dietary needs } Run engine-dmo check --fix to write the ids, then schema plan and schema apply. engine-dmo dev field add organisation coachParking --type boolean does the same in one step.
Values are stored by field id (a select’s by option id), so renaming a field or an option moves nothing.
| Rule | Detail |
|---|---|
| Types | text, number, boolean, select, date, datetime, phone, money |
| Not allowed | A core field’s name, translate:, child fields:, visible_when:, unique:, a use: of a shared field group |
personal: true | Person fields only. Marks personal data: included in a subject access export and cleared when the person is erased. |
engine-dmo config schema --file crm prints the file’s JSON Schema.
The timeline
Every organisation and person has a timeline in the admin: entries the engine records about the record (for example a message the business sent the team from the portal, or a report digest that could not be delivered because the contact’s address is suppressed) and staff notes. A note has a body with mentions, can be pinned, and is either internal (staff only, the default) or member_visible, in which case the organisation’s people see it in the portal and the member API’s activity feed. Mentions notify the staff mentioned.
Tags
Organisations, people and documents share the install’s tag vocabulary (config/tags/vocabulary.yaml, loaded once by engine-dmo tags import and then owned by the admin). Staff with crm.tag.manage tag records on their pages. See engine-dmo tags for the vocabulary, candidates and the tags commands.
Smart lists of organisations and people
A smart list is a saved query that keeps itself up to date. Lists of documents are a core feature (see smartList() and engine-dmo lists); engine-dmo adds lists whose subject is organisation or person. They are used as working lists in the admin, as a broadcast’s audience, and from the command line. They are never public: public: true is refused for them.
Seed lists live in config/tags/smartlists.yaml, which the seed and engine-dmo tags import load once; after that the admin owns them.
smart_lists:
- key: renewal-risk-90
name: { en-GB: "Renewals due, low engagement" }
subject: organisation
query:
membership: { status: [active], renews_within_days: 90 }
analytics: { metric: portal_login, period: 180d, lt: 1 }
- key: coach-friendly
name: { en-GB: Coach friendly members }
subject: organisation
query:
membership: { tier: [gold] }
where:
- { field: custom.coachParking, eq: true } Query keys that apply to organisation and person lists:
| Key | Meaning |
|---|---|
tags | Tag conditions, as for documents |
where, all, any, none | Field clauses and groups, as for documents. A custom field is named by its handle or as custom.<name>. |
membership | Organisations (or the people linked to them, or the documents they own) with a membership matching every key given; see below |
analytics | Organisations by an analytics event counted over a period: metric (an event name), period (a number of days such as 180d) and exactly one of lt, lte, gt, gte, eq. Not allowed on a person list. The count includes every event recorded against the organisation, such as portal_login or a click on one of its listings. |
membership keys:
| Key | Meaning |
|---|---|
site | A site key, or any. Defaults to the list’s own site, or the site it is run for. |
tier | A list of tier keys. none is not allowed: an organisation with no membership has no membership to match. |
status | A list of pending, active, lapsed, cancelled |
renews_within_days | Renews between today and that many days from now |
renews_after_days | Renews more than that many days from now |
lapsed_within_days | Lapsed, and ended within that many days |
joined_before, joined_after | A date or a relative date |
A list has at most 50 clauses. Organisations and people are always read within the networks the list’s site and its reader can see.
| Command | What it does |
|---|---|
engine-dmo lists check [--key <key>] | Validate every smart list, saved and seeded, against the schema and the vocabulary |
engine-dmo lists count --key <key> [--site <site>] | How many rows the list matches now |
engine-dmo lists run --key <key> --out <file.csv> | Write the full set of ids to a CSV file, recorded in the audit log as an export |
All three need the database. engine-dmo check does not read config/tags/smartlists.yaml at all, so a seeded list cannot be verified without one: after engine-dmo seed apply (or tags import), run engine-dmo lists check against the database to find a bad key, field or tier.
Broadcasts
A broadcast is a message from staff to the contacts a smart list of organisations or people names, such as every member renewing this quarter. It is sent as marketing mail: a person receives it only with recorded marketing consent, and every message carries an unsubscribe link that cannot be removed.
Staff need broadcast.draft on the site to compose one and broadcast.send to schedule, send or cancel it. The admin’s Broadcasts screen (under Members) shows, before anything is sent, how many people the list reaches and how many it leaves out and why.
Who receives it
The recipients are taken in one step when sending starts. A person the list names is left out, and counted against the reason, when they:
| Reason | Meaning |
|---|---|
no_consent | No marketing consent is recorded |
unsubscribed | They unsubscribed |
bounced | Their address bounced |
complained | They marked mail as spam |
suppressed | Their address is on the install’s suppression list |
no_email | They have no address |
removed | The record was trashed or erased |
An address shared by two recipients receives the message once.
Writing one
The body is Markdown (the public site’s safe subset) or Portable Text. Merge fields are written {{ person.given_name }} and filled in as text, never as markup:
| Merge field | Value |
|---|---|
person.display_name | The recipient’s display name |
person.given_name | The recipient’s given name |
organisation.name | The organisation the recipient is a contact of |
membership.tier | That organisation’s tier on the site |
membership.renews_on | Its renewal date, in the site’s default locale and timezone |
A merge field outside this list is refused. Links in the body go through the first-party redirect /_b/c/, so clicks are counted; the unsubscribe link is /_b/u/; and an open is recorded through /_b/o/ only when mail.broadcast.open_tracking is on. The message is rendered with the emails/broadcast.html template, which a project can override in its template root (engine-dmo mail templates shows which file is used).
From the command line, a draft is a YAML or JSON file:
subject: Your membership renews soon
preheader: Three things to check before it does
format: markdown
list: renewal-risk-90
body: |
Hello {{ person.given_name }},
{{ organisation.name }}'s {{ membership.tier }} membership renews on {{ membership.renews_on }}. engine-dmo broadcast draft --file renewals.yaml --site main
engine-dmo broadcast show --id <id>
engine-dmo broadcast test-send --id <id> --to you@example.org
engine-dmo broadcast schedule --id <id> --at 2026-12-01T09:00 --confirm format is markdown (the default) or portabletext, whose body is the blocks as a value or a JSON string. list names a smart list by key or id. draft --id <id> updates that draft.
| Command | What it does |
|---|---|
broadcast list --site <site> [--status <status>] | The site’s broadcasts, newest first |
broadcast show --id <id> | The broadcast, its body and who its list reaches now, with who it leaves out and why |
broadcast draft --file <file> [--id <id>] [--site <site>] | Write a new draft, or update one |
broadcast test-send --id <id> --to <address> | Send a test with [Test] before the subject and placeholder merge fields |
broadcast schedule --id <id> --at <time> --confirm | Schedule it: RFC 3339, or 2006-01-02T15:04 in the site’s timezone |
broadcast send --id <id> --confirm | Send it now; cannot be taken back |
broadcast cancel --id <id> | Cancel a draft, a scheduled broadcast, or the rest of one being sent (messages already queued still go) |
broadcast report --id <id> | Recipients by delivery state, who was left out, unsubscribes, clicks per link and opens |
A broadcast’s statuses are draft, scheduled, sending, sent and cancelled.
Sending settings
Key in engine.yaml | Default | Meaning |
|---|---|---|
mail.broadcast.batch_size | 50 | Messages queued at a time |
mail.broadcast.batch_pause | 5s | Wait between batches, on top of the mailer’s own rate limit |
mail.broadcast.unsubscribe_footer | on | Cannot be turned off; false is refused |
mail.broadcast.open_tracking | false | Record opens |
mail.broadcast.per_recipient_tracking | false | Record clicks and opens per recipient rather than per broadcast; the footer then says so |
Mail delivery itself (the driver, the sender, bounces and the suppression list) is configured under mail:; see Mail.
Importing organisations, people and memberships
The import reads a CSV or XLSX file through a column mapping, matches each row against the records already held, plans every row as a dry run, and writes through the same rules as the admin. Staff with crm.import run it from the admin’s Imports screen (under Members), which saves each source’s mapping so the next file from it maps itself, or from the command line:
engine-dmo crm import --source chamber-2026 --name "Chamber of commerce list" --file members.csv --map mapping.yaml --dry-run
engine-dmo crm import --source chamber-2026 --file members.csv columns:
Business: organisation.name
"Trading email": organisation.email
Postcode: organisation.address.postal_code
Contact: person.display_name
"Contact email": person.email
"Opted in": person.marketing_consent
Level: membership.tier
"Renewal date": membership.renews_on
match: [external_id, email, name_postcode]
country: GB
date_order: dmy
site: main
currency: GBP
consent:
basis: consent
evidence: The opt-in box on the 2025 membership renewal form | Mapping key | Meaning |
|---|---|
columns | Column heading to target. A column not named, or named to "", is not imported. |
match | Keys tried in order to find a record already held: external_id, email, name_postcode. Empty is all three. |
country | ISO 3166-1 code for addresses and phone numbers whose row names none |
date_order | dmy or mdy, to read dates written with slashes or dots. Empty accepts ISO 8601 only. |
site | The site a membership is on when no column names one |
currency | A membership amount’s currency when no column names one |
consent.basis | consent or soft_opt_in: the lawful source of a marketing consent column |
consent.evidence | What the person agreed to, where and when; at least 12 characters. Without a documented basis, a consent column is recorded but never becomes marketing consent. |
Targets:
| Entity | Targets |
|---|---|
| Organisation | organisation.external_id, name, legal_name, status, email, website, phone, address.line1, address.line2, address.locality, address.administrative_area, address.postal_code, address.country, and organisation.custom.<name> |
| Person | person.external_id, display_name, given_name, family_name, email, phone, job_title, primary, marketing_consent, marketing_consent_date, and person.custom.<name> |
| Membership | membership.site, tier, status, joined_on, renews_on, amount, currency, external_ref |
--source names the source by key and creates it when new (--name and --network set a new source’s name and network). --map is saved on the source for its next file. --dry-run prints what would be created, updated, skipped and rejected, and writes nothing.
Duplicates, merging and the trash
| Task | How |
|---|---|
| Find likely duplicates | engine-dmo crm dedupe scores candidate pairs of organisations and offers them for review in the admin. It uses the configured AI provider’s embeddings, which are charged even with --dry-run. See AI assistance. |
| Merge two records | In the admin (Merge under CRM), or engine-dmo crm merge --subject organisation --keep <id> --merge <id> [--dry-run]. Everything that pointed at the merged record moves to the kept one, its blank fields are filled from the merged one, and the merged record goes to the trash. |
| Trash and restore | On the record’s page; the trash is under CRM. Trashed records disappear from lists, smart lists and entry.organisation. |
| Purge | Permanently delete from the trash; needs crm.organisation.purge or crm.person.purge. |
| Erase a person | engine-dmo crm erase --person <id> --confirm, irreversibly: their personal columns and personal: true fields are cleared, their sessions and links ended, their address suppressed, and every mention replaced by a tombstone. |
| Answer a subject access request | engine-dmo sar --email <address> --out <file> writes everything the install holds about the person to one archive, every custom field included. See Data protection duties. |
Commands
| Command | What it does |
|---|---|
crm organisation create [json] [--data json] | Create an organisation |
crm membership set <organisation-id> --tier <tier> [--site] [--status] [--reason] | Give an organisation a tier on a site: changes its pending or active membership there, or starts one (active by default) |
crm invite --organisation <id> --email <address> --name <name> [--role <job title>] [--site] | Link the contact as the organisation’s owner and queue the portal invitation |
crm import ... | See Importing |
crm merge ..., crm dedupe ..., crm erase ... | See Duplicates, merging and the trash |
crm claims list, crm claims show <id>, crm claims rescore | The claim queue; see Claims |
lists check, lists count, lists run | Smart lists |
broadcast ... | Broadcasts |
Every one needs the database, and every one accepts --json. Run engine-dmo crm <command> --help for the details.
Permissions
| Staff permission | Allows |
|---|---|
crm.organisation.view, .edit, .delete, .purge | Organisations |
crm.person.view, .edit, .delete, .purge | People |
crm.membership.view, .edit | Memberships on a site |
crm.tag.manage | Tagging records |
crm.smartlist.manage | Saving and editing smart lists |
crm.claim.review | The claim queue on a site |
crm.import | The import |
broadcast.draft, broadcast.send | Broadcasts on a site |
The built-in partnership-manager role holds the CRM permissions apart from delete and purge; administrator holds them apart from purge. See What engine-dmo adds.
Landing pages
How to declare landing pages (a place crossed with a category, a category alone, or a calendar month) on a route, the threshold that makes one live, the generated and editor-written copy, the template and its landing context, and the redirects the engine keeps.
Partners and the portal
Membership tiers and memberships, what each tier may edit on a listing, partner roles and their permissions, the member portal's sign-in, invitations, reference screens and configuration, and the enquiry inbox businesses work from.