Communications Module
A generic, counterparty-agnostic communication layer for dForge. comm depends on
nothing — it is a peer spine to parties, not a dependent. Business modules
(crm, fin, wms) reach it through a bridge (parties-comm) or via a raw
source pointer on message.
Design principle
A contact owns its contact points (email, phone, messenger handle) the way an
address book does — points are a directly-editable 1:N child of contact, typed
straight into the contact card. No global endpoint registry, no link tables, no
lookup-to-add-a-phone: address-book UX first.
Inbound resolution works by value: a message from anna@acme.example matches
every contact that has that (channel, value) point. A sender that matches nothing
gets an orphan point row (contact_id empty) so the message is still stored and
can be linked to a contact later.
| Case | representation |
|---|---|
| Unknown inbound sender | orphan contact_point, no contact |
Same info@ used by two contacts |
two point rows, one per contact |
| Named buyer at a customer | point → contact → party (via bridge) |
Entities
| Entity | Role |
|---|---|
| contact_list | Personal / shared / synced lists of contacts |
| contact | A person or org you communicate with — deliberately thin (identity + list membership only) |
| contact_point | 1:N child of contact: channel + value + label + primary; contact_id nullable for unlinked inbound senders. Per-channel format CHECKs (email regex, phone shape) |
| channel_account | Mailbox / sender config; credentials referenced via admin.secret code, never stored inline |
| message | The communication log — delivery/retry shape modeled on admin.webhook_delivery |
| message_attachment | 1:N files on a message |
Keep comm.contact thin
comm.contact carries only identity and list membership. Business attributes belong
to crm.contact (sales) and parties.party (master data). The three concepts:
parties.party— who we do business with (master data,tax_id,is_customer/is_vendor)crm.contact— who we're selling to (sales record: owner, activities)comm.contact— who we can reach and how (address book)
Record linkage
message links to a source record via the nullable, indexed pair
source_entity_cd + source_record_id (e.g. fin.invoice + pk). This needs no
dependency on the consuming module. A reply inherits its parent's source pointer via
in_reply_to → so a customer's reply to an invoice email lands back on the invoice.
The party timeline ("everything we've said to Acme") is the parties-comm
bridge's job: it extends contact/contact_point with party_id and materializes
a party_id breadcrumb on message for a single-index timeline query.
Seed data
Two lists (shared + synced), four contacts with directly-owned points, two orphan
points (a shared info@ inbox and an unknown newsletter sender), one email channel
account, and four messages exercising the demo-relevant states: sent, received-reply
(threaded via in_reply_to), unmatched inbound, and failed-with-retries.
Sending ("comm.send()")
Sending is an insert, not an API: any module queues an outbound email by
inserting a message row — cross-module from any action DSL:
insert('comm.message', {
channel: 'email',
direction: 'out',
to_addr: 'ap@acme.example', // status defaults to 'queued'
subject: 'Invoice ' + [invoice_no],
body_html: html,
source_entity_cd: 'fin.invoice',
source_record_id: [invoice_id]
})
The dispatch_messages scheduled job (every minute, 10 per tick, oldest first)
hands queued email to the platform mailer via the sendEmail() built-in and
marks rows sent. A Send Now action on message skips the wait. Composing a
message by hand in the Messages grid queues it the same way (status defaults
to queued).
v1 transport semantics, stated honestly:
sentmeans handed to the platform mailer —sendEmail()delivers post-execution, so SMTP failures are not observable from DSL (server logs only).- Transport uses the platform SMTP config;
channel_accountis declared sender identity, not yet a per-account transport (needs a platform email-sender that readschannel_account+admin.secret). - Attachments are not carried (raw
sendEmail()has no attachment support). - Non-email channels stay
queueduntil an adapter exists.
Deliberate non-goals (for now)
- Global per-endpoint opt-out — points are per-contact, so a single global
suppression flag has no home. When bulk mail arrives, add a
suppressiontable keyed by(channel, value); do not re-introduce a global endpoint registry for it. - Message templates — print-template envelopes are reused instead.
Roadmap
- v0.1.0 (this) — address book, message store, management grids, and the
outbound email pipeline: queued-insert sending, the
dispatch_messagesscheduled job, and theSend Nowaction — hand-off to the platform mailer as described under Sending above. Still missing within outbound: per-account transport (channel_account+admin.secretdriving real SMTP), delivery / failure observability (currentlysent= handed off), attachments, and non-email channel adapters. - v0.2.0 — inbound ingest (mailbox polling), by-value sender matching, and a link-orphan-to-contact flow.
- v0.3.0 —
parties-commbridge: contact/point↔party resolution + party timeline. - The timeline view type ships in
@dforge/data-ui, not in this module.