Email Newsletters
Leadmetrics has a built-in newsletter feature that lets tenants send email campaigns to their contacts. Newsletters are written by the email-writer agent, reviewed by the DM team, approved by the client, and then sent via Resend.
Status Machine
draft → dm_review → approved → sending → sent
↓ ↓
(reject) (fail)
draft failed| Status | Who sets it | What it means |
|---|---|---|
draft | email-writer agent (or DM rejection) | Initial state; editable |
dm_review | Agent completes → auto-transitions | Waiting for DM team review |
approved | DM reviewer via portal | Ready for the client to send |
sending | System when send is queued | Send in progress — no more edits |
sent | System after Resend confirms delivery | Sent; immutable |
failed | System on send error | Delivery failed; failedReason populated |
Data Model
EmailNewsletter
├── id cuid
├── tenantId FK → Tenant
├── activityId FK → Activity (nullable, @unique)
├── subject String
├── previewText String? (shown in email client preview)
├── bodyText String (source Markdown — agent writes this)
├── bodyHtml String? (auto-generated from bodyText on save)
├── fromEmail String? (overrides NEWSLETTER_FROM_EMAIL)
├── fromName String? (overrides NEWSLETTER_FROM_NAME)
├── status String (see state machine above)
├── audienceSegment "all" | "leads" | "customers"
├── recipientCount Int (populated after send)
├── sentAt DateTime?
├── failedReason String?
├── dmApprovedBy String? (userId)
├── dmApprovedAt DateTime?
├── clientApprovedBy String?
├── clientApprovedAt DateTime?
├── dmRejectionNote String?
└── clientRejectionNote String?Audience Segments
| Segment | Who receives the email |
|---|---|
all | All contacts with isSubscriber: true |
leads | Contacts where type = "lead" and isSubscriber: true |
customers | Contacts where type = "customer" and isSubscriber: true |
API Endpoints
Tenant (client-facing) — prefix /tenant/v1/newsletters
| Method | Path | Auth | Description |
|---|---|---|---|
GET | / | Tenant user | List newsletters; filter by ?status= |
GET | /:id | Tenant user | Get one newsletter |
POST | /:id/send | Tenant user | Trigger send (must be approved) |
GET | /unsubscribe?token= | Public | Unsubscribe a contact by token |
DM Portal — prefix /dm/v1/newsletters
| Method | Path | Auth | Description |
|---|---|---|---|
GET | / | DM user | List newsletters for a tenant (?tenantId=) |
GET | /:id | DM user | Get one newsletter |
PATCH | /:id/dm-approve | DM user | Approve (must be dm_review) → sets status to approved |
PATCH | /:id/dm-reject | DM user | Reject (must be dm_review) → sets status to draft |
PATCH | /:id | DM user | Edit subject, body, fromEmail, fromName, audienceSegment |
Send Flow
- Client clicks “Send” on an approved newsletter in the Dashboard.
POST /tenant/v1/newsletters/:id/sendis called — enqueues anewsletter_sendjob viaenqueueNewsletterSend().- The agents worker picks up the job and calls Resend to send to all matching contacts.
- Newsletter status transitions:
approved → sending → sent(orfailed). recipientCountandsentAtare populated on success.
Unsubscribe
Each contact has a unique unsubscribeToken (generated on creation). Newsletter footers include a link:
/unsubscribe?token={contact.unsubscribeToken}Clicking the link calls GET /tenant/v1/newsletters/unsubscribe?token=... (public, no auth). This sets contact.isSubscriber = false and contact.unsubscribedAt = now. The contact is excluded from all future sends.
Configuration
| Env var | Required | Description |
|---|---|---|
RESEND_API_KEY | Yes | Resend API key for sending |
NEWSLETTER_FROM_EMAIL | Yes | Default from address (newsletters@yourdomain.com) |
NEWSLETTER_FROM_NAME | Yes | Default sender name |
APP_URL | Yes | Used to construct unsubscribe links in the footer |
All four live in .env (used by both apps/api and apps/servers/agents).
UI
Dashboard (/newsletters)
List/Calendar toggle in the header. Calendar view uses ContentCalendar from @leadmetrics/ui with orange tiles (bg-orange-100 text-orange-800). Date placed from sentAt ?? createdAt. Clicking a tile opens a floating popup with subject, previewText, audience segment, recipient count, and a “View Newsletter” link. Both views share the same data (all newsletters fetched in one query, no pagination).
DM Portal (/newsletters)
Same list/calendar toggle. Calendar uses the same ContentCalendar component. Status filter tabs (All / In Review / Approved / Sent) and pagination are list-only. A second API call (limit=200, no status filter) feeds the calendar. Orange tile color and popup structure are identical to the dashboard.
Notes
bodyHtmlis auto-generated frombodyTextwhen the DM edits and saves the body via the portal. The conversion handles Markdown headings, bold, italic, and paragraph breaks.- Newsletters in
sendingorsentstatus cannot be edited. - The DM can edit
subject,previewText,bodyText,fromEmail,fromName, andaudienceSegmentwhile the newsletter is indraft,dm_review, orapprovedstate.