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`
-
`}
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,