Merge branch 'dev'
This commit is contained in:
commit
8809dda6b8
|
|
@ -220,6 +220,8 @@ export class FolkMultisigEmail extends FolkShape {
|
||||||
bodyHtml = "";
|
bodyHtml = "";
|
||||||
replyToThreadId: string | null = null;
|
replyToThreadId: string | null = null;
|
||||||
replyType: 'new' | 'reply' | 'forward' = 'new';
|
replyType: 'new' | 'reply' | 'forward' = 'new';
|
||||||
|
approvalType: 'email' | 'newsletter' = 'email';
|
||||||
|
listmonkCampaignId: number | null = null;
|
||||||
approvalId: string | null = null;
|
approvalId: string | null = null;
|
||||||
status: 'draft' | 'pending' | 'approved' | 'sent' | 'rejected' = 'draft';
|
status: 'draft' | 'pending' | 'approved' | 'sent' | 'rejected' = 'draft';
|
||||||
requiredSignatures = 2;
|
requiredSignatures = 2;
|
||||||
|
|
@ -237,6 +239,8 @@ export class FolkMultisigEmail extends FolkShape {
|
||||||
if (data.bodyHtml !== undefined) shape.bodyHtml = data.bodyHtml;
|
if (data.bodyHtml !== undefined) shape.bodyHtml = data.bodyHtml;
|
||||||
if (data.replyToThreadId !== undefined) shape.replyToThreadId = data.replyToThreadId;
|
if (data.replyToThreadId !== undefined) shape.replyToThreadId = data.replyToThreadId;
|
||||||
if (data.replyType !== undefined) shape.replyType = data.replyType;
|
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.approvalId !== undefined) shape.approvalId = data.approvalId;
|
||||||
if (data.status !== undefined) shape.status = data.status;
|
if (data.status !== undefined) shape.status = data.status;
|
||||||
if (typeof data.requiredSignatures === "number") shape.requiredSignatures = data.requiredSignatures;
|
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.bodyHtml !== undefined && data.bodyHtml !== this.bodyHtml) this.bodyHtml = data.bodyHtml;
|
||||||
if (data.replyToThreadId !== undefined && data.replyToThreadId !== this.replyToThreadId) this.replyToThreadId = data.replyToThreadId;
|
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.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.approvalId !== undefined && data.approvalId !== this.approvalId) this.approvalId = data.approvalId;
|
||||||
if (data.status !== undefined && data.status !== this.status) this.status = data.status;
|
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;
|
if (typeof data.requiredSignatures === "number" && data.requiredSignatures !== this.requiredSignatures) this.requiredSignatures = data.requiredSignatures;
|
||||||
|
|
@ -380,10 +386,13 @@ export class FolkMultisigEmail extends FolkShape {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
|
const isNewsletter = this.approvalType === 'newsletter';
|
||||||
const snippet = this.bodyText.length > 120 ? this.bodyText.slice(0, 120) + '...' : this.bodyText;
|
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`
|
contentEl.innerHTML = html`
|
||||||
<div class="header" style="background:${this.status === 'sent' || this.status === 'approved' ? '#16a34a' : this.status === 'rejected' ? '#dc2626' : '#6366f1'}">
|
<div class="header" style="background:${headerBg}">
|
||||||
<div class="header-title">✉ Multi-Sig Email</div>
|
<div class="header-title">${headerTitle}</div>
|
||||||
<span class="status-badge status-${this.status}">${this.status}</span>
|
<span class="status-badge status-${this.status}">${this.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
|
|
@ -391,10 +400,21 @@ export class FolkMultisigEmail extends FolkShape {
|
||||||
<span class="field-label">From:</span>
|
<span class="field-label">From:</span>
|
||||||
<span class="field-value" style="color:#6366f1">${this.mailboxSlug || "team"}@rspace.online</span>
|
<span class="field-value" style="color:#6366f1">${this.mailboxSlug || "team"}@rspace.online</span>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<span class="field-label">To:</span>
|
<span class="field-label">To:</span>
|
||||||
<span class="field-value">${this.toAddresses.join(', ') || '(none)'}</span>
|
<span class="field-value">${this.toAddresses.join(', ') || '(none)'}</span>
|
||||||
</div>
|
</div>`}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Subject:</span>
|
<span class="field-label">Subject:</span>
|
||||||
<span class="field-value" style="font-weight:600">${this.subject || '(no 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
|
// Submit
|
||||||
root.querySelector("[data-action='submit']")?.addEventListener("click", async () => {
|
root.querySelector("[data-action='submit']")?.addEventListener("click", async () => {
|
||||||
if (!this.subject || this.toAddresses.length === 0) {
|
if (!this.subject || (this.approvalType !== 'newsletter' && this.toAddresses.length === 0)) {
|
||||||
alert("Please fill To and Subject fields.");
|
alert("Please fill Subject" + (this.approvalType !== 'newsletter' ? " and To fields." : "."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,8 @@ function approvalToRest(a: ApprovalItem) {
|
||||||
in_reply_to: a.inReplyTo || null,
|
in_reply_to: a.inReplyTo || null,
|
||||||
references: a.references || [],
|
references: a.references || [],
|
||||||
reply_type: a.replyType || 'new',
|
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');
|
].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 ──
|
// ── Execute Approved Email ──
|
||||||
|
|
||||||
async function executeApproval(docId: string, approvalId: string) {
|
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);
|
const doc = _syncServer!.getDoc<MailboxDoc>(docId);
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
|
||||||
const approval = doc.approvals[approvalId];
|
const approval = doc.approvals[approvalId];
|
||||||
if (!approval || approval.status !== 'APPROVED') return;
|
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
|
// Find the mailbox to get the from address
|
||||||
const mailboxEmail = doc.mailbox.email;
|
const mailboxEmail = doc.mailbox.email;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ export interface ApprovalItem {
|
||||||
inReplyTo: string | null;
|
inReplyTo: string | null;
|
||||||
references: string[];
|
references: string[];
|
||||||
replyType: 'reply' | 'reply-all' | 'forward' | 'new';
|
replyType: 'reply' | 'reply-all' | 'forward' | 'new';
|
||||||
|
// Newsletter approval bridge
|
||||||
|
approvalType?: 'email' | 'newsletter';
|
||||||
|
listmonkCampaignId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Personal Inbox ──
|
// ── Personal Inbox ──
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,20 @@ export async function getListmonkConfig(spaceSlug: string): Promise<ListmonkConf
|
||||||
return { url: url.replace(/\/+$/, ''), user, password };
|
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. */
|
/** Proxy a request to the Listmonk API with Basic Auth. */
|
||||||
export async function listmonkFetch(
|
export async function listmonkFetch(
|
||||||
config: ListmonkConfig,
|
config: ListmonkConfig,
|
||||||
|
|
|
||||||
|
|
@ -563,6 +563,41 @@ routes.delete("/api/newsletter/campaigns/:id", async (c) => {
|
||||||
return c.json(data, res.status as any);
|
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 ──
|
// ── Postiz API proxy routes ──
|
||||||
|
|
||||||
routes.get("/api/postiz/status", async (c) => {
|
routes.get("/api/postiz/status", async (c) => {
|
||||||
|
|
@ -986,7 +1021,7 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Create Listmonk campaign as draft
|
// 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 nlBody = (cfg.body as string) || '';
|
||||||
const nlListIds = Array.isArray(cfg.listIds) ? cfg.listIds as number[] : [1];
|
const nlListIds = Array.isArray(cfg.listIds) ? cfg.listIds as number[] : [1];
|
||||||
const createRes = await listmonkFetch(listmonkConfig, '/api/campaigns', {
|
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 { createNewsletterApproval } = await import('../../modules/rinbox/mod');
|
||||||
const approvalResult = createNewsletterApproval({
|
const approvalResult = createNewsletterApproval({
|
||||||
space: dataSpace,
|
space: dataSpace,
|
||||||
authorId: claims?.did || 'workflow',
|
authorId: 'workflow',
|
||||||
subject: nlSubject,
|
subject: nlSubject,
|
||||||
bodyText: nlBody,
|
bodyText: nlBody,
|
||||||
bodyHtml: nlBody,
|
bodyHtml: nlBody,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue