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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-21 15:03:06 -07:00
parent 57e03f3049
commit 7c29ccea41
5 changed files with 193 additions and 13 deletions

View File

@ -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 {
</div>
`;
} 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 ? '&#128231; Newsletter Approval' : '&#9993; Multi-Sig Email';
contentEl.innerHTML = html`
<div class="header" style="background:${this.status === 'sent' || this.status === 'approved' ? '#16a34a' : this.status === 'rejected' ? '#dc2626' : '#6366f1'}">
<div class="header-title">&#9993; Multi-Sig Email</div>
<div class="header" style="background:${headerBg}">
<div class="header-title">${headerTitle}</div>
<span class="status-badge status-${this.status}">${this.status}</span>
</div>
<div class="body">
@ -391,10 +400,21 @@ export class FolkMultisigEmail extends FolkShape {
<span class="field-label">From:</span>
<span class="field-value" style="color:#6366f1">${this.mailboxSlug || "team"}@rspace.online</span>
</div>
${isNewsletter ? html`
<div class="field">
<span class="field-label">Type:</span>
<span class="field-value">Newsletter Campaign</span>
</div>
${this.listmonkCampaignId ? html`
<div class="field">
<span class="field-label">Campaign:</span>
<span class="field-value" style="font-weight:600">#${this.listmonkCampaignId}</span>
</div>` : ''}
` : html`
<div class="field">
<span class="field-label">To:</span>
<span class="field-value">${this.toAddresses.join(', ') || '(none)'}</span>
</div>
</div>`}
<div class="field">
<span class="field-label">Subject:</span>
<span class="field-value" style="font-weight:600">${this.subject || '(no subject)'}</span>
@ -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 {

View File

@ -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<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(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;

View File

@ -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 ──

View File

@ -28,6 +28,20 @@ export async function getListmonkConfig(spaceSlug: string): Promise<ListmonkConf
return { url: url.replace(/\/+$/, ''), user, password };
}
/** Start a Listmonk campaign (set status to "running"). */
export async function startListmonkCampaign(
config: ListmonkConfig,
campaignId: number,
): Promise<{ ok: boolean; error?: string }> {
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,

View File

@ -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,