Concepts, value, and typical clone scenarios — less code.
Concepts, value, and typical clone scenarios — less code.
Preparing Ring Platform content
Preparing Ring Platform content
Preparing Ring Platform content
All /api/admin/email/* routes require an authenticated platform admin session (admin or superadmin role).
Review UI lives at /admin/crm/* (CrmAdminShell). API paths stay /api/admin/email/* — see Email AI-CRM.
/api/cron/* and /api/webhooks/email/inbound use CRON_SECRET or WEBHOOK_EMAIL_SECRET — not user sessions. Fail-closed when secret missing.
Draft send uses EmailSenderService + channel SMTP (CRM_CHANNEL_*). Auth OTP uses lib/mailer.ts / SMTP_*. Do not conflate the two in ops runbooks.
GET /api/admin/email/channelsRead-only CRM channel status (no passwords). Backs the inbox channel filter UI.
Response:
{
"channels": [
{
"id": "primary",
"name": "Primary",
"flow": "standard",
"mailbox": "INBOX",
"imapHost": "mail.ringdom.org",
"imapUser": "info@ringdom.org",
"smtpHost": "mail.ringdom.org",
"smtpUser": "info@ringdom.org",
"hasImapPassword": true,
"hasSmtpPassword": true
}
],
"validation": { "ok": true, "errors": [] }
}Implementation: loadCrmChannels() + validateCrmChannels() from features/email-crm/pipeline/imap/config.ts.
GET /api/admin/email/threadsList conversation threads.
| Query | Type | Description |
|---|---|---|
status | string | Filter: new, ongoing, waiting, resolved, or omit for all |
sourceChannel | string | Filter by channel id/name (multi-mailbox) |
Response: { threads: EmailThreadRecord[] }
PATCH /api/admin/email/threadsUpdate thread status.
Body: { "id": "<threadId>", "status": "resolved" }
GET /api/admin/email/threads/[id]Thread detail with messages, drafts, and open tasks.
Response: { thread, messages, drafts, tasks }
GET /api/admin/email/draftsPending drafts (status: pending), newest first.
POST /api/admin/email/drafts/[id]/approveApprove draft for sending. Uses session user id as reviewer.
POST /api/admin/email/drafts/[id]/rejectBody: { "reason": "optional string" }
POST /api/admin/email/drafts/[id]/sendSend approved draft via CRM channel SMTP (EmailSenderService).
Body (optional): { "toEmail": "...", "subject": "..." } — defaults from thread.
Response: { "success": true, "messageId": "<smtp-message-id>" }
Persists outbound row in email_messages and updates thread status to waiting.
GET /api/admin/email/contacts| Query | Description |
|---|---|
email, name, company, type | Search filters |
POST /api/admin/email/contactsBody: { "email": "required", "name?", "company?", "type?" }
GET /api/admin/email/tasks| Query | Description |
|---|---|
status | open, in_progress, overdue, completed, etc. |
POST /api/admin/email/tasksBody: { "threadId", "title", "taskType", ... } — see TaskCreateInput in task-service.ts.
POST /api/admin/email/tasks/[id]/completeBody: { "completionNotes?": "string" }
GET /api/admin/email/analytics| Query | Values |
|---|---|
range | 7d (default), 30d, 90d |
Response: intent/sentiment distributions, costStats from email_api_usage, dailyStats, draft/task summaries.
POST or GET /api/cron/email-processorAuth: Authorization: Bearer $CRON_SECRET (fail-closed)
Body or query action:
| Action | Behavior |
|---|---|
poll (default) | pollInboundBatch() — fetch UNSEEN per channel, process, disconnect |
status | Processor + IMAP stats (getEmailProcessor() for this action) |
stop | Stop IDLE listener |
start | Start IDLE (requires EMAIL_PROCESSOR_ALLOW_HTTP_START=true) |
mark-overdue-tasks | Run EmailTaskService.processOverdueTasks() |
Processor instances are constructed per action in the route handler (not a single top-level always-on instance).
Example:
GET /api/cron/email-analyticsReturns 7-day dashboard snapshot (same shape as admin analytics). Cron-auth only.
GET/POST /api/cron/cleanup-email-tokens — purges expired email_login_tokens. Same fail-closed CRON_SECRET pattern. Documented under Ring Mailer; not an Email CRM route.
POST /api/webhooks/email/inboundAuth: Authorization: Bearer $WEBHOOK_EMAIL_SECRET or HMAC-SHA256 hex in X-Email-Webhook-Signature over raw body.
Body (JSON):
Calls EmailProcessor.ingestEvent() with uid: 0 (no IMAP mark-seen).
Prerequisite: operator setup, channel secrets, Auth vs CRM SMTP.
Deep-dive: EmailProcessor and jsonb-collection persistence.
Same-workflow: local poll + draft send smoke test.
See-also: Auth cleanup-email-tokens cron and SMTP_* plane.
All /api/admin/email/* routes require an authenticated platform admin session (admin or superadmin role).
Review UI lives at /admin/crm/* (CrmAdminShell). API paths stay /api/admin/email/* — see Email AI-CRM.
/api/cron/* and /api/webhooks/email/inbound use CRON_SECRET or WEBHOOK_EMAIL_SECRET — not user sessions. Fail-closed when secret missing.
Draft send uses EmailSenderService + channel SMTP (CRM_CHANNEL_*). Auth OTP uses lib/mailer.ts / SMTP_*. Do not conflate the two in ops runbooks.
GET /api/admin/email/channelsRead-only CRM channel status (no passwords). Backs the inbox channel filter UI.
Response:
{
"channels": [
{
"id": "primary",
"name": "Primary",
"flow": "standard",
"mailbox": "INBOX",
"imapHost": "mail.ringdom.org",
"imapUser": "info@ringdom.org",
"smtpHost": "mail.ringdom.org",
"smtpUser": "info@ringdom.org",
"hasImapPassword": true,
"hasSmtpPassword": true
}
],
"validation": { "ok": true, "errors": [] }
}Implementation: loadCrmChannels() + validateCrmChannels() from features/email-crm/pipeline/imap/config.ts.
GET /api/admin/email/threadsList conversation threads.
| Query | Type | Description |
|---|---|---|
status | string | Filter: new, ongoing, waiting, resolved, or omit for all |
sourceChannel | string | Filter by channel id/name (multi-mailbox) |
Response: { threads: EmailThreadRecord[] }
PATCH /api/admin/email/threadsUpdate thread status.
Body: { "id": "<threadId>", "status": "resolved" }
GET /api/admin/email/threads/[id]Thread detail with messages, drafts, and open tasks.
Response: { thread, messages, drafts, tasks }
GET /api/admin/email/draftsPending drafts (status: pending), newest first.
POST /api/admin/email/drafts/[id]/approveApprove draft for sending. Uses session user id as reviewer.
POST /api/admin/email/drafts/[id]/rejectBody: { "reason": "optional string" }
POST /api/admin/email/drafts/[id]/sendSend approved draft via CRM channel SMTP (EmailSenderService).
Body (optional): { "toEmail": "...", "subject": "..." } — defaults from thread.
Response: { "success": true, "messageId": "<smtp-message-id>" }
Persists outbound row in email_messages and updates thread status to waiting.
GET /api/admin/email/contacts| Query | Description |
|---|---|
email, name, company, type | Search filters |
POST /api/admin/email/contactsBody: { "email": "required", "name?", "company?", "type?" }
GET /api/admin/email/tasks| Query | Description |
|---|---|
status | open, in_progress, overdue, completed, etc. |
POST /api/admin/email/tasksBody: { "threadId", "title", "taskType", ... } — see TaskCreateInput in task-service.ts.
POST /api/admin/email/tasks/[id]/completeBody: { "completionNotes?": "string" }
GET /api/admin/email/analytics| Query | Values |
|---|---|
range | 7d (default), 30d, 90d |
Response: intent/sentiment distributions, costStats from email_api_usage, dailyStats, draft/task summaries.
POST or GET /api/cron/email-processorAuth: Authorization: Bearer $CRON_SECRET (fail-closed)
Body or query action:
| Action | Behavior |
|---|---|
poll (default) | pollInboundBatch() — fetch UNSEEN per channel, process, disconnect |
status | Processor + IMAP stats (getEmailProcessor() for this action) |
stop | Stop IDLE listener |
start | Start IDLE (requires EMAIL_PROCESSOR_ALLOW_HTTP_START=true) |
mark-overdue-tasks | Run EmailTaskService.processOverdueTasks() |
Processor instances are constructed per action in the route handler (not a single top-level always-on instance).
Example:
GET /api/cron/email-analyticsReturns 7-day dashboard snapshot (same shape as admin analytics). Cron-auth only.
GET/POST /api/cron/cleanup-email-tokens — purges expired email_login_tokens. Same fail-closed CRON_SECRET pattern. Documented under Ring Mailer; not an Email CRM route.
POST /api/webhooks/email/inboundAuth: Authorization: Bearer $WEBHOOK_EMAIL_SECRET or HMAC-SHA256 hex in X-Email-Webhook-Signature over raw body.
Body (JSON):
Calls EmailProcessor.ingestEvent() with uid: 0 (no IMAP mark-seen).
Prerequisite: operator setup, channel secrets, Auth vs CRM SMTP.
Deep-dive: EmailProcessor and jsonb-collection persistence.
Same-workflow: local poll + draft send smoke test.
See-also: Auth cleanup-email-tokens cron and SMTP_* plane.
All /api/admin/email/* routes require an authenticated platform admin session (admin or superadmin role).
Review UI lives at /admin/crm/* (CrmAdminShell). API paths stay /api/admin/email/* — see Email AI-CRM.
/api/cron/* and /api/webhooks/email/inbound use CRON_SECRET or WEBHOOK_EMAIL_SECRET — not user sessions. Fail-closed when secret missing.
Draft send uses EmailSenderService + channel SMTP (CRM_CHANNEL_*). Auth OTP uses lib/mailer.ts / SMTP_*. Do not conflate the two in ops runbooks.
GET /api/admin/email/channelsRead-only CRM channel status (no passwords). Backs the inbox channel filter UI.
Response:
{
"channels": [
{
"id": "primary",
"name": "Primary",
"flow": "standard",
"mailbox": "INBOX",
"imapHost": "mail.ringdom.org",
"imapUser": "info@ringdom.org",
"smtpHost": "mail.ringdom.org",
"smtpUser": "info@ringdom.org",
"hasImapPassword": true,
"hasSmtpPassword": true
}
],
"validation": { "ok": true, "errors": [] }
}Implementation: loadCrmChannels() + validateCrmChannels() from features/email-crm/pipeline/imap/config.ts.
GET /api/admin/email/threadsList conversation threads.
| Query | Type | Description |
|---|---|---|
status | string | Filter: new, ongoing, waiting, resolved, or omit for all |
sourceChannel | string | Filter by channel id/name (multi-mailbox) |
Response: { threads: EmailThreadRecord[] }
PATCH /api/admin/email/threadsUpdate thread status.
Body: { "id": "<threadId>", "status": "resolved" }
GET /api/admin/email/threads/[id]Thread detail with messages, drafts, and open tasks.
Response: { thread, messages, drafts, tasks }
GET /api/admin/email/draftsPending drafts (status: pending), newest first.
POST /api/admin/email/drafts/[id]/approveApprove draft for sending. Uses session user id as reviewer.
POST /api/admin/email/drafts/[id]/rejectBody: { "reason": "optional string" }
POST /api/admin/email/drafts/[id]/sendSend approved draft via CRM channel SMTP (EmailSenderService).
Body (optional): { "toEmail": "...", "subject": "..." } — defaults from thread.
Response: { "success": true, "messageId": "<smtp-message-id>" }
Persists outbound row in email_messages and updates thread status to waiting.
GET /api/admin/email/contacts| Query | Description |
|---|---|
email, name, company, type | Search filters |
POST /api/admin/email/contactsBody: { "email": "required", "name?", "company?", "type?" }
GET /api/admin/email/tasks| Query | Description |
|---|---|
status | open, in_progress, overdue, completed, etc. |
POST /api/admin/email/tasksBody: { "threadId", "title", "taskType", ... } — see TaskCreateInput in task-service.ts.
POST /api/admin/email/tasks/[id]/completeBody: { "completionNotes?": "string" }
GET /api/admin/email/analytics| Query | Values |
|---|---|
range | 7d (default), 30d, 90d |
Response: intent/sentiment distributions, costStats from email_api_usage, dailyStats, draft/task summaries.
POST or GET /api/cron/email-processorAuth: Authorization: Bearer $CRON_SECRET (fail-closed)
Body or query action:
| Action | Behavior |
|---|---|
poll (default) | pollInboundBatch() — fetch UNSEEN per channel, process, disconnect |
status | Processor + IMAP stats (getEmailProcessor() for this action) |
stop | Stop IDLE listener |
start | Start IDLE (requires EMAIL_PROCESSOR_ALLOW_HTTP_START=true) |
mark-overdue-tasks | Run EmailTaskService.processOverdueTasks() |
Processor instances are constructed per action in the route handler (not a single top-level always-on instance).
Example:
GET /api/cron/email-analyticsReturns 7-day dashboard snapshot (same shape as admin analytics). Cron-auth only.
GET/POST /api/cron/cleanup-email-tokens — purges expired email_login_tokens. Same fail-closed CRON_SECRET pattern. Documented under Ring Mailer; not an Email CRM route.
POST /api/webhooks/email/inboundAuth: Authorization: Bearer $WEBHOOK_EMAIL_SECRET or HMAC-SHA256 hex in X-Email-Webhook-Signature over raw body.
Body (JSON):
Calls EmailProcessor.ingestEvent() with uid: 0 (no IMAP mark-seen).
Prerequisite: operator setup, channel secrets, Auth vs CRM SMTP.
Deep-dive: EmailProcessor and jsonb-collection persistence.
Same-workflow: local poll + draft send smoke test.
See-also: Auth cleanup-email-tokens cron and SMTP_* plane.
See-also: CRM orders desk sharing the admin shell.
curl -X POST "$BASE_URL/api/cron/email-processor" \
-H "Authorization: Bearer $CRON_SECRET" \
-H "Content-Type: application/json" \
-d '{"action":"poll"}'
{
"messageId": "<rfc5322-message-id>",
"from": "sender@example.com",
"fromName": "Optional Name",
"to": "info@example.com",
"subject": "Subject line",
"bodyText": "Plain text body",
"bodyHtml": "<div>optional</div>",
"date": "2026-06-10T12:00:00.000Z",
"inReplyTo": "<parent-message-id>",
"references": ["<ref1>", "<ref2>"]
}See-also: CRM orders desk sharing the admin shell.
curl -X POST "$BASE_URL/api/cron/email-processor" \
-H "Authorization: Bearer $CRON_SECRET" \
-H "Content-Type: application/json" \
-d '{"action":"poll"}'
{
"messageId": "<rfc5322-message-id>",
"from": "sender@example.com",
"fromName": "Optional Name",
"to": "info@example.com",
"subject": "Subject line",
"bodyText": "Plain text body",
"bodyHtml": "<div>optional</div>",
"date": "2026-06-10T12:00:00.000Z",
"inReplyTo": "<parent-message-id>",
"references": ["<ref1>", "<ref2>"]
}See-also: CRM orders desk sharing the admin shell.
curl -X POST "$BASE_URL/api/cron/email-processor" \
-H "Authorization: Bearer $CRON_SECRET" \
-H "Content-Type: application/json" \
-d '{"action":"poll"}'
{
"messageId": "<rfc5322-message-id>",
"from": "sender@example.com",
"fromName": "Optional Name",
"to": "info@example.com",
"subject": "Subject line",
"bodyText": "Plain text body",
"bodyHtml": "<div>optional</div>",
"date": "2026-06-10T12:00:00.000Z",
"inReplyTo": "<parent-message-id>",
"references": ["<ref1>", "<ref2>"]
}