From 7c29ccea415f0597368a30e7a0359f049fc99494 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 21 Mar 2026 15:03:06 -0700 Subject: [PATCH] feat(rinbox): bridge Listmonk newsletter campaigns with multisig approval Newsletter campaigns now require N-of-M team member approval via rInbox before Listmonk starts sending. The send-newsletter workflow node creates a draft campaign and gates it through the team inbox; a manual submit- approval route is also available. The multisig email UI shows a green "Newsletter Approval" badge with campaign ID for newsletter approvals. Co-Authored-By: Claude Opus 4.6 --- lib/folk-multisig-email.ts | 30 +++++-- modules/rinbox/mod.ts | 120 +++++++++++++++++++++++-- modules/rinbox/schemas.ts | 3 + modules/rsocials/lib/listmonk-proxy.ts | 14 +++ modules/rsocials/mod.ts | 39 +++++++- 5 files changed, 193 insertions(+), 13 deletions(-) diff --git a/lib/folk-multisig-email.ts b/lib/folk-multisig-email.ts index 606ca64..4ab753c 100644 --- a/lib/folk-multisig-email.ts +++ b/lib/folk-multisig-email.ts @@ -220,6 +220,8 @@ export class FolkMultisigEmail extends FolkShape { bodyHtml = ""; replyToThreadId: string | null = null; replyType: 'new' | 'reply' | 'forward' = 'new'; + approvalType: 'email' | 'newsletter' = 'email'; + listmonkCampaignId: number | null = null; approvalId: string | null = null; status: 'draft' | 'pending' | 'approved' | 'sent' | 'rejected' = 'draft'; requiredSignatures = 2; @@ -237,6 +239,8 @@ export class FolkMultisigEmail extends FolkShape { if (data.bodyHtml !== undefined) shape.bodyHtml = data.bodyHtml; if (data.replyToThreadId !== undefined) shape.replyToThreadId = data.replyToThreadId; if (data.replyType !== undefined) shape.replyType = data.replyType; + if (data.approvalType !== undefined) shape.approvalType = data.approvalType; + if (data.listmonkCampaignId !== undefined) shape.listmonkCampaignId = data.listmonkCampaignId; if (data.approvalId !== undefined) shape.approvalId = data.approvalId; if (data.status !== undefined) shape.status = data.status; if (typeof data.requiredSignatures === "number") shape.requiredSignatures = data.requiredSignatures; @@ -254,6 +258,8 @@ export class FolkMultisigEmail extends FolkShape { if (data.bodyHtml !== undefined && data.bodyHtml !== this.bodyHtml) this.bodyHtml = data.bodyHtml; if (data.replyToThreadId !== undefined && data.replyToThreadId !== this.replyToThreadId) this.replyToThreadId = data.replyToThreadId; if (data.replyType !== undefined && data.replyType !== this.replyType) this.replyType = data.replyType; + if (data.approvalType !== undefined && data.approvalType !== this.approvalType) this.approvalType = data.approvalType; + if (data.listmonkCampaignId !== undefined && data.listmonkCampaignId !== this.listmonkCampaignId) this.listmonkCampaignId = data.listmonkCampaignId; if (data.approvalId !== undefined && data.approvalId !== this.approvalId) this.approvalId = data.approvalId; if (data.status !== undefined && data.status !== this.status) this.status = data.status; if (typeof data.requiredSignatures === "number" && data.requiredSignatures !== this.requiredSignatures) this.requiredSignatures = data.requiredSignatures; @@ -380,10 +386,13 @@ export class FolkMultisigEmail extends FolkShape { `; } else { + const isNewsletter = this.approvalType === 'newsletter'; const snippet = this.bodyText.length > 120 ? this.bodyText.slice(0, 120) + '...' : this.bodyText; + const headerBg = this.status === 'sent' || this.status === 'approved' ? '#16a34a' : this.status === 'rejected' ? '#dc2626' : isNewsletter ? '#059669' : '#6366f1'; + const headerTitle = isNewsletter ? '📧 Newsletter Approval' : '✉ Multi-Sig Email'; contentEl.innerHTML = html` -
-
✉ Multi-Sig Email
+
+
${headerTitle}
${this.status}
@@ -391,10 +400,21 @@ export class FolkMultisigEmail extends FolkShape { From: ${this.mailboxSlug || "team"}@rspace.online
+ ${isNewsletter ? html` +
+ Type: + Newsletter Campaign +
+ ${this.listmonkCampaignId ? html` +
+ Campaign: + #${this.listmonkCampaignId} +
` : ''} + ` : html`
To: ${this.toAddresses.join(', ') || '(none)'} -
+
`}
Subject: ${this.subject || '(no subject)'} @@ -466,8 +486,8 @@ export class FolkMultisigEmail extends FolkShape { // Submit root.querySelector("[data-action='submit']")?.addEventListener("click", async () => { - if (!this.subject || this.toAddresses.length === 0) { - alert("Please fill To and Subject fields."); + if (!this.subject || (this.approvalType !== 'newsletter' && this.toAddresses.length === 0)) { + alert("Please fill Subject" + (this.approvalType !== 'newsletter' ? " and To fields." : ".")); return; } try { diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index 209323c..abd9f52 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -291,6 +291,8 @@ function approvalToRest(a: ApprovalItem) { in_reply_to: a.inReplyTo || null, references: a.references || [], reply_type: a.replyType || 'new', + approval_type: a.approvalType || 'email', + listmonk_campaign_id: a.listmonkCampaignId ?? null, }; } @@ -320,21 +322,127 @@ function forwardBlock(thread: ThreadItem): string { ].join('\n'); } +// ── Newsletter Approval Bridge ── + +export function createNewsletterApproval(params: { + space: string; + authorId: string; + subject: string; + bodyText: string; + bodyHtml: string; + listmonkCampaignId: number; +}): { id: string; status: string; requiredSignatures: number } | null { + if (!_syncServer) return null; + const result = findMailboxBySlug(params.space); + if (!result) return null; + + const [space, mailboxId, doc] = result; + const docId = mailboxDocId(space, mailboxId); + const approvalId = generateId(); + const threshold = doc.mailbox.approvalThreshold || 1; + + _syncServer.changeDoc(docId, `newsletter approval for campaign ${params.listmonkCampaignId}`, (d) => { + d.approvals[approvalId] = { + id: approvalId, + mailboxId: d.mailbox.id, + threadId: null, + authorId: params.authorId, + subject: params.subject, + bodyText: params.bodyText, + bodyHtml: params.bodyHtml, + toAddresses: [], + ccAddresses: [], + status: 'PENDING', + requiredSignatures: threshold, + safeTxHash: null, + createdAt: Date.now(), + resolvedAt: 0, + signatures: [], + inReplyTo: null, + references: [], + replyType: 'new', + approvalType: 'newsletter', + listmonkCampaignId: params.listmonkCampaignId, + } as any; + }); + + return { id: approvalId, status: 'PENDING', requiredSignatures: threshold }; +} + +async function executeNewsletterApproval(docId: string, approvalId: string, approval: ApprovalItem) { + const space = docId.split(':')[0]; + try { + const { getListmonkConfig, startListmonkCampaign } = await import('../rsocials/lib/listmonk-proxy'); + const config = await getListmonkConfig(space); + if (!config) throw new Error('Listmonk not configured for space ' + space); + + const result = await startListmonkCampaign(config, approval.listmonkCampaignId!); + if (!result.ok) throw new Error(result.error || 'Listmonk API error'); + + _syncServer!.changeDoc(docId, `Newsletter sent: campaign ${approval.listmonkCampaignId}`, (d) => { + const a = d.approvals[approvalId]; + if (!a || a.status !== 'APPROVED') return; + a.status = 'SENT'; + + // Audit trail thread + const threadId = generateId(); + d.threads[threadId] = { + id: threadId, + mailboxId: d.mailbox.id, + messageId: null, + subject: a.subject, + fromAddress: d.mailbox.email, + fromName: d.mailbox.name, + toAddresses: [], + ccAddresses: [], + bodyText: a.bodyText, + bodyHtml: a.bodyHtml, + tags: ['newsletter', 'sent'], + status: 'closed', + isRead: true, + isStarred: false, + assignedTo: null, + hasAttachments: false, + receivedAt: Date.now(), + createdAt: Date.now(), + comments: [], + inReplyTo: null, + references: [], + direction: 'outbound', + parentThreadId: null, + }; + }); + + console.log(`[Inbox] Newsletter campaign ${approval.listmonkCampaignId} started via approval ${approvalId}`); + } catch (e: any) { + console.error(`[Inbox] Newsletter send failed for approval ${approvalId}:`, e.message); + _syncServer!.changeDoc(docId, `Newsletter send error: ${approvalId}`, (d) => { + const a = d.approvals[approvalId]; + if (a) a.status = 'SEND_ERROR'; + }); + } +} + // ── Execute Approved Email ── async function executeApproval(docId: string, approvalId: string) { - const transport = await getSmtpTransport(); - if (!transport) { - console.error(`[Inbox] No SMTP transport — cannot send approval ${approvalId}`); - return; - } - const doc = _syncServer!.getDoc(docId); if (!doc) return; const approval = doc.approvals[approvalId]; if (!approval || approval.status !== 'APPROVED') return; + // Newsletter approvals → Listmonk, not SMTP + if (approval.approvalType === 'newsletter') { + return executeNewsletterApproval(docId, approvalId, approval); + } + + const transport = await getSmtpTransport(); + if (!transport) { + console.error(`[Inbox] No SMTP transport — cannot send approval ${approvalId}`); + return; + } + // Find the mailbox to get the from address const mailboxEmail = doc.mailbox.email; diff --git a/modules/rinbox/schemas.ts b/modules/rinbox/schemas.ts index c347147..7942b0b 100644 --- a/modules/rinbox/schemas.ts +++ b/modules/rinbox/schemas.ts @@ -83,6 +83,9 @@ export interface ApprovalItem { inReplyTo: string | null; references: string[]; replyType: 'reply' | 'reply-all' | 'forward' | 'new'; + // Newsletter approval bridge + approvalType?: 'email' | 'newsletter'; + listmonkCampaignId?: number | null; } // ── Personal Inbox ── diff --git a/modules/rsocials/lib/listmonk-proxy.ts b/modules/rsocials/lib/listmonk-proxy.ts index d61c9cd..2559c37 100644 --- a/modules/rsocials/lib/listmonk-proxy.ts +++ b/modules/rsocials/lib/listmonk-proxy.ts @@ -28,6 +28,20 @@ export async function getListmonkConfig(spaceSlug: string): Promise { + const res = await listmonkFetch(config, `/api/campaigns/${campaignId}/status`, { + method: 'PUT', + body: JSON.stringify({ status: 'running' }), + }); + if (res.ok) return { ok: true }; + const data = await res.json().catch(() => ({ message: `HTTP ${res.status}` })); + return { ok: false, error: data.message || `Listmonk returned ${res.status}` }; +} + /** Proxy a request to the Listmonk API with Basic Auth. */ export async function listmonkFetch( config: ListmonkConfig, diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index ff3faab..15f6e61 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -563,6 +563,41 @@ routes.delete("/api/newsletter/campaigns/:id", async (c) => { return c.json(data, res.status as any); }); +// ── Newsletter Approval Bridge ── + +routes.post("/api/newsletter/campaigns/:id/submit-approval", async (c) => { + const auth = await requireNewsletterRole(c, "moderator"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const config = await getListmonkConfig(space); + if (!config) return c.json({ error: "Listmonk not configured" }, 404); + + const campaignId = parseInt(c.req.param("id")); + if (!campaignId) return c.json({ error: "Invalid campaign ID" }, 400); + + // Fetch campaign content for preview + const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`); + if (!res.ok) return c.json({ error: "Campaign not found in Listmonk" }, 404); + const campaign = await res.json() as { data?: { subject?: string; body?: string; name?: string } }; + const subject = campaign.data?.subject || campaign.data?.name || `Campaign #${campaignId}`; + const body = campaign.data?.body || ''; + + const { createNewsletterApproval } = await import('../../modules/rinbox/mod'); + const result = createNewsletterApproval({ + space, + authorId: auth.claims.did as string, + subject, + bodyText: body.replace(/<[^>]+>/g, ''), + bodyHtml: body, + listmonkCampaignId: campaignId, + }); + + if (!result) return c.json({ error: "No team inbox configured for this space" }, 404); + + return c.json({ ok: true, approval_id: result.id, required_signatures: result.requiredSignatures }); +}); + // ── Postiz API proxy routes ── routes.get("/api/postiz/status", async (c) => { @@ -986,7 +1021,7 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => { break; } // Create Listmonk campaign as draft - const nlSubject = (cfg.subject as string) || workflow.name || 'Newsletter'; + const nlSubject = (cfg.subject as string) || wf.name || 'Newsletter'; const nlBody = (cfg.body as string) || ''; const nlListIds = Array.isArray(cfg.listIds) ? cfg.listIds as number[] : [1]; const createRes = await listmonkFetch(listmonkConfig, '/api/campaigns', { @@ -1013,7 +1048,7 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => { const { createNewsletterApproval } = await import('../../modules/rinbox/mod'); const approvalResult = createNewsletterApproval({ space: dataSpace, - authorId: claims?.did || 'workflow', + authorId: 'workflow', subject: nlSubject, bodyText: nlBody, bodyHtml: nlBody,