feat(rsocials): newsletter manager + listmonk proxy + backlog tasks

Add Listmonk newsletter management proxy API with role-based auth,
newsletter manager component, password setting type support, and
new backlog task files. Update newsletter subscribe URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 12:24:02 -07:00
parent e1bdc98b98
commit c92ca0fe05
11 changed files with 744 additions and 10 deletions

View File

@ -0,0 +1,55 @@
---
id: TASK-104
title: n8n-style automation canvas for rSchedule
status: Done
assignee: []
created_date: '2026-03-10 18:43'
labels:
- rschedule
- feature
- automation
dependencies: []
references:
- modules/rschedule/schemas.ts
- modules/rschedule/mod.ts
- modules/rschedule/components/folk-automation-canvas.ts
- modules/rschedule/components/automation-canvas.css
- vite.config.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Visual workflow builder at /:space/rschedule/reminders that lets users wire together triggers, conditions, and actions from any rApp — enabling automations like "if my location approaches home, notify family" or "when document sign-off completes, schedule posts and notify comms director."
Built with SVG canvas (pan/zoom/Bezier wiring), 15 node types across 3 categories, REST-persisted CRUD, topological execution engine, cron tick loop integration, and webhook trigger endpoint.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Canvas loads at /:space/rschedule/reminders with node palette
- [ ] #2 Drag nodes from palette, wire ports, configure — auto-saves via REST
- [ ] #3 Run All on manual-trigger workflow — nodes animate, execution log shows results
- [ ] #4 Cron workflows execute on tick loop
- [ ] #5 POST to /api/workflows/webhook/:hookId triggers webhook workflows
- [ ] #6 Demo workflows render correctly on fresh space seed
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented n8n-style automation canvas for rSchedule with 5 files (2490 lines added):
**schemas.ts** — 15 automation node types (5 triggers, 4 conditions, 6 actions), NODE_CATALOG with typed ports and config schemas, Workflow/WorkflowNode/WorkflowEdge types, extended ScheduleDoc.
**folk-automation-canvas.ts** — SVG canvas with pan/zoom, left sidebar node palette (drag-to-add), Bezier edge wiring between typed ports, right sidebar config panel driven by NODE_CATALOG, execution visualization, REST persistence with 1.5s debounced auto-save.
**automation-canvas.css** — Full dark-theme styling, responsive mobile layout.
**mod.ts** — Page route (GET /reminders), CRUD API (GET/POST/PUT/DELETE /api/workflows/*), topological execution engine with condition branching, tick loop integration for cron workflows, webhook trigger endpoint, 2 demo workflows (proximity notification + document sign-off pipeline).
**vite.config.ts** — Build step for component + CSS copy.
Commits: cc6b5a9 (dev), f22bc47 (main)
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,23 @@
---
id: TASK-MEDIUM.2
title: 'Enhanced feed view with cards, sections, and scroll navigation'
status: Done
assignee: []
created_date: '2026-03-10 06:20'
updated_date: '2026-03-10 06:20'
labels: []
dependencies: []
parent_task_id: TASK-MEDIUM
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace minimal feed mode in canvas with polished scroll-through view: shapes wrapped in card containers with icon/title/type headers, grouped by section (type, date, position, alpha) with dividers, sticky scroll summary bar with item counter and clickable section chips for quick navigation. Targets all 35+ shape types.
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented in commit eedf2cf on dev, merged to main. Changes in website/canvas.html: comprehensive CSS for all shape types, feed card wrappers, section headers, scroll summary bar with chips, scroll tracking, and proper enter/exit lifecycle.
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,25 @@
---
id: TASK-MEDIUM.3
title: 'Enhanced rVote landing page, demo page, and dashboard'
status: Done
assignee: []
created_date: '2026-03-10 06:43'
updated_date: '2026-03-10 06:53'
labels: []
dependencies: []
parent_task_id: TASK-MEDIUM
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Upgraded rVote module to match quality of old rvote.online standalone app: added /demo route with interactive poll page (live sync, connection badge, reset), expanded vote.css with full demo card styling, fixed landing page links to use relative /rvote/demo, enhanced folk-vote-dashboard with inline voting on proposal cards, status-grouped views, create-proposal form, tally bars, and downvote support.
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented in commit 192659b on dev, merged to main. Changes: mod.ts (new /demo route), vote.css (full rd-* styling system), landing.ts (fixed demo links), folk-vote-dashboard.ts (major UI enhancement), vite.config.ts (vote-demo.ts build step).
Added Reddit-style vote column (up/down chevrons + score), quadratic weight picker, and Priority Trends SVG line chart with score history tracking. Commit 62a96c1.
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,382 @@
/**
* <folk-newsletter-manager> Newsletter management UI backed by Listmonk API proxy.
*
* Attributes: space, role
* Uses EncryptID access token for auth headers.
* Three tabs: Lists, Subscribers, Campaigns.
*/
import { getAccessToken } from '../../../shared/components/rstack-identity';
type Tab = 'lists' | 'subscribers' | 'campaigns';
export class FolkNewsletterManager extends HTMLElement {
private _space = 'demo';
private _role = 'viewer';
private _tab: Tab = 'lists';
private _configured = false;
private _loading = true;
private _error = '';
// Data
private _lists: any[] = [];
private _subscribers: any[] = [];
private _subscriberTotal = 0;
private _subscriberPage = 1;
private _campaigns: any[] = [];
private _showCreateForm = false;
static get observedAttributes() { return ['space', 'role']; }
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this._space = this.getAttribute('space') || 'demo';
this._role = this.getAttribute('role') || 'viewer';
this.render();
this.checkStatus();
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === 'space') this._space = val;
if (name === 'role') this._role = val;
}
private apiBase(): string {
return `/${this._space}/rsocials/api/newsletter`;
}
private async apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
const headers = new Headers(opts.headers);
const token = getAccessToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
if (opts.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
return fetch(`${this.apiBase()}${path}`, { ...opts, headers });
}
private async checkStatus() {
this._loading = true;
this.render();
try {
const res = await fetch(`${this.apiBase()}/status`);
const data = await res.json();
this._configured = data.configured;
if (this._configured) await this.loadTab();
} catch (e: any) {
this._error = e.message || 'Failed to check status';
}
this._loading = false;
this.render();
}
private async loadTab() {
this._error = '';
this._loading = true;
this.render();
try {
if (this._tab === 'lists') await this.loadLists();
else if (this._tab === 'subscribers') await this.loadSubscribers();
else if (this._tab === 'campaigns') await this.loadCampaigns();
} catch (e: any) {
this._error = e.message || 'Request failed';
}
this._loading = false;
this.render();
}
private async loadLists() {
const res = await this.apiFetch('/lists');
if (!res.ok) throw new Error(`Failed to load lists (${res.status})`);
const data = await res.json();
this._lists = data.data?.results || data.results || [];
}
private async loadSubscribers() {
const res = await this.apiFetch(`/subscribers?page=${this._subscriberPage}&per_page=50`);
if (!res.ok) throw new Error(`Failed to load subscribers (${res.status})`);
const data = await res.json();
this._subscribers = data.data?.results || data.results || [];
this._subscriberTotal = data.data?.total || data.total || 0;
}
private async loadCampaigns() {
const res = await this.apiFetch('/campaigns');
if (!res.ok) throw new Error(`Failed to load campaigns (${res.status})`);
const data = await res.json();
this._campaigns = data.data?.results || data.results || [];
}
private async createCampaign(form: HTMLFormElement) {
const fd = new FormData(form);
const body = JSON.stringify({
name: fd.get('name'),
subject: fd.get('subject'),
body: fd.get('body') || '<p>Newsletter content</p>',
content_type: 'richtext',
type: 'regular',
lists: this._lists.length > 0 ? [this._lists[0].id] : [],
});
const res = await this.apiFetch('/campaigns', { method: 'POST', body });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || err.error || 'Failed to create campaign');
}
this._showCreateForm = false;
await this.loadCampaigns();
}
private async setCampaignStatus(id: number, status: string) {
const res = await this.apiFetch(`/campaigns/${id}/status`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
this._error = err.message || err.error || 'Failed to update status';
this.render();
return;
}
await this.loadCampaigns();
this.render();
}
private get isAdmin(): boolean {
return this._role === 'admin';
}
// ── Rendering ──
private render() {
const root = this.shadowRoot!;
root.innerHTML = `<link rel="stylesheet" href="/modules/rsocials/newsletter.css">${this.renderBody()}`;
this.attachListeners();
}
private renderBody(): string {
if (this._loading && !this._configured && this._lists.length === 0) {
return `<div class="nl-loading">Loading...</div>`;
}
if (!this._configured) {
return this.renderSetup();
}
return `
<div class="nl-header">
<h2>Newsletter Manager</h2>
<p>Manage mailing lists, subscribers, and email campaigns via Listmonk</p>
</div>
${this._error ? `<div class="nl-error">${this.esc(this._error)}</div>` : ''}
<div class="nl-tabs">
<button class="nl-tab ${this._tab === 'lists' ? 'active' : ''}" data-tab="lists">Lists</button>
<button class="nl-tab ${this._tab === 'subscribers' ? 'active' : ''}" data-tab="subscribers">Subscribers</button>
<button class="nl-tab ${this._tab === 'campaigns' ? 'active' : ''}" data-tab="campaigns">Campaigns</button>
</div>
${this._loading ? '<div class="nl-loading">Loading...</div>' : this.renderTabContent()}
`;
}
private renderSetup(): string {
return `
<div class="nl-setup">
<h3>Newsletter Not Configured</h3>
<p>Connect your Listmonk instance to manage newsletters from here.</p>
<ol class="nl-setup-steps">
<li>Open the space settings panel (gear icon in the top bar)</li>
<li>Find <strong>rSocials</strong> and click the settings gear</li>
<li>Enter your Listmonk URL, username, and password</li>
<li>Click <strong>Save Module Config</strong></li>
</ol>
</div>
`;
}
private renderTabContent(): string {
if (this._tab === 'lists') return this.renderLists();
if (this._tab === 'subscribers') return this.renderSubscribers();
return this.renderCampaigns();
}
private renderLists(): string {
if (this._lists.length === 0) return `<div class="nl-empty">No mailing lists found</div>`;
const rows = this._lists.map(l => `
<tr>
<td>${this.esc(l.name)}</td>
<td><span class="nl-badge nl-badge--${l.type === 'public' ? 'active' : 'draft'}">${this.esc(l.type)}</span></td>
<td>${l.subscriber_count ?? '—'}</td>
<td>${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'}</td>
</tr>
`).join('');
return `
<table class="nl-table">
<thead><tr><th>Name</th><th>Type</th><th>Subscribers</th><th>Created</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
private renderSubscribers(): string {
if (this._subscribers.length === 0) return `<div class="nl-empty">No subscribers found</div>`;
const rows = this._subscribers.map(s => `
<tr>
<td>${this.esc(s.email)}</td>
<td>${this.esc(s.name || '—')}</td>
<td><span class="nl-badge nl-badge--${s.status === 'enabled' ? 'enabled' : 'blocklisted'}">${this.esc(s.status)}</span></td>
<td>${(s.lists || []).map((l: any) => this.esc(l.name)).join(', ') || '—'}</td>
<td>${s.created_at ? new Date(s.created_at).toLocaleDateString() : '—'}</td>
</tr>
`).join('');
const totalPages = Math.ceil(this._subscriberTotal / 50);
const pagination = totalPages > 1 ? `
<div class="nl-pagination">
<button class="nl-btn nl-btn--sm" data-action="prev-page" ${this._subscriberPage <= 1 ? 'disabled' : ''}>Prev</button>
<span>Page ${this._subscriberPage} of ${totalPages}</span>
<button class="nl-btn nl-btn--sm" data-action="next-page" ${this._subscriberPage >= totalPages ? 'disabled' : ''}>Next</button>
</div>
` : '';
return `
<table class="nl-table">
<thead><tr><th>Email</th><th>Name</th><th>Status</th><th>Lists</th><th>Created</th></tr></thead>
<tbody>${rows}</tbody>
</table>
${pagination}
`;
}
private renderCampaigns(): string {
const createBtn = this.isAdmin ? `
<div class="nl-toolbar">
<span></span>
<button class="nl-btn nl-btn--primary" data-action="toggle-create">+ New Campaign</button>
</div>
` : '';
const form = this._showCreateForm && this.isAdmin ? `
<form class="nl-form" data-form="create-campaign">
<div class="nl-form-row">
<div>
<label>Campaign Name</label>
<input name="name" required placeholder="My Newsletter #1" />
</div>
<div>
<label>Subject Line</label>
<input name="subject" required placeholder="This week's update" />
</div>
</div>
<label>Body (HTML)</label>
<textarea name="body" placeholder="<p>Newsletter content goes here...</p>"></textarea>
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.5rem;">
<button type="button" class="nl-btn" data-action="toggle-create">Cancel</button>
<button type="submit" class="nl-btn nl-btn--primary">Create Campaign</button>
</div>
</form>
` : '';
if (this._campaigns.length === 0 && !this._showCreateForm) {
return `${createBtn}<div class="nl-empty">No campaigns found</div>`;
}
const statusLabel = (s: string) => {
const map: Record<string, string> = { draft: 'draft', running: 'running', paused: 'paused', finished: 'finished', scheduled: 'active' };
return map[s] || s;
};
const rows = this._campaigns.map(c => {
const actions: string[] = [];
if (c.status === 'draft' && this.isAdmin) {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="start-campaign" data-id="${c.id}">Start</button>`);
} else if (c.status === 'running') {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="pause-campaign" data-id="${c.id}">Pause</button>`);
} else if (c.status === 'paused') {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="resume-campaign" data-id="${c.id}">Resume</button>`);
}
return `
<tr>
<td>${this.esc(c.name)}</td>
<td>${this.esc(c.subject || '—')}</td>
<td><span class="nl-badge nl-badge--${statusLabel(c.status)}">${this.esc(c.status)}</span></td>
<td>${c.sent ?? '—'}</td>
<td>${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td>
<td>${actions.join(' ')}</td>
</tr>
`;
}).join('');
return `
${createBtn}
${form}
<table class="nl-table">
<thead><tr><th>Name</th><th>Subject</th><th>Status</th><th>Sent</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
// ── Event listeners ──
private attachListeners() {
const root = this.shadowRoot!;
// Tab switching
root.querySelectorAll('.nl-tab').forEach(btn => {
btn.addEventListener('click', () => {
this._tab = (btn as HTMLElement).dataset.tab as Tab;
this.loadTab();
});
});
// Pagination
root.querySelector('[data-action="prev-page"]')?.addEventListener('click', () => {
if (this._subscriberPage > 1) { this._subscriberPage--; this.loadTab(); }
});
root.querySelector('[data-action="next-page"]')?.addEventListener('click', () => {
this._subscriberPage++;
this.loadTab();
});
// Create campaign toggle
root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => {
btn.addEventListener('click', () => {
this._showCreateForm = !this._showCreateForm;
this.render();
});
});
// Create campaign form
const form = root.querySelector('[data-form="create-campaign"]') as HTMLFormElement | null;
form?.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await this.createCampaign(form);
this.render();
} catch (err: any) {
this._error = err.message;
this.render();
}
});
// Campaign status actions
root.querySelectorAll('[data-action="start-campaign"]').forEach(btn => {
btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running'));
});
root.querySelectorAll('[data-action="pause-campaign"]').forEach(btn => {
btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'paused'));
});
root.querySelectorAll('[data-action="resume-campaign"]').forEach(btn => {
btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running'));
});
}
private esc(s: string): string {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
}
customElements.define('folk-newsletter-manager', FolkNewsletterManager);

View File

@ -0,0 +1,64 @@
/* folk-newsletter-manager styles */
:host { display: block; max-width: 960px; margin: 0 auto; padding: 1.5rem; color: #e5e5e5; font-family: system-ui, -apple-system, sans-serif; }
.nl-header { margin-bottom: 1.5rem; }
.nl-header h2 { font-size: 1.5rem; margin: 0 0 .25rem; }
.nl-header p { color: #a3a3a3; margin: 0; font-size: .9rem; }
/* Not-configured state */
.nl-setup { text-align: center; padding: 3rem 1.5rem; background: #1e1e2e; border: 1px dashed #404040; border-radius: 12px; }
.nl-setup h3 { font-size: 1.2rem; margin: 0 0 .5rem; }
.nl-setup p { color: #a3a3a3; margin: .5rem 0; font-size: .9rem; }
.nl-setup .nl-setup-steps { text-align: left; max-width: 420px; margin: 1.5rem auto 0; }
.nl-setup .nl-setup-steps li { color: #a3a3a3; margin-bottom: .5rem; font-size: .85rem; }
/* Tabs */
.nl-tabs { display: flex; gap: .5rem; margin-bottom: 1.5rem; border-bottom: 1px solid #333; padding-bottom: 0; }
.nl-tab { padding: .6rem 1.2rem; background: none; border: none; border-bottom: 2px solid transparent; color: #a3a3a3; cursor: pointer; font-size: .9rem; transition: color .15s, border-color .15s; }
.nl-tab:hover { color: #e5e5e5; }
.nl-tab.active { color: #14b8a6; border-bottom-color: #14b8a6; }
/* Tables */
.nl-table { width: 100%; border-collapse: collapse; font-size: .85rem; }
.nl-table th { text-align: left; padding: .6rem .8rem; color: #a3a3a3; border-bottom: 1px solid #333; font-weight: 500; font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; }
.nl-table td { padding: .6rem .8rem; border-bottom: 1px solid #1a1a2e; }
.nl-table tr:hover td { background: #1e1e2e; }
/* Badges */
.nl-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: .75rem; font-weight: 500; }
.nl-badge--active { background: #14b8a622; color: #14b8a6; }
.nl-badge--draft { background: #f59e0b22; color: #f59e0b; }
.nl-badge--finished { background: #6366f122; color: #818cf8; }
.nl-badge--running { background: #14b8a622; color: #14b8a6; }
.nl-badge--paused { background: #ef444422; color: #f87171; }
.nl-badge--enabled { background: #14b8a622; color: #14b8a6; }
.nl-badge--blocklisted { background: #ef444422; color: #f87171; }
/* Buttons */
.nl-btn { padding: .5rem 1rem; border-radius: 6px; border: 1px solid #333; background: #1e1e2e; color: #e5e5e5; cursor: pointer; font-size: .85rem; transition: background .15s, border-color .15s; }
.nl-btn:hover { background: #252538; border-color: #14b8a6; }
.nl-btn--primary { background: #14b8a6; border-color: #14b8a6; color: #0a0a0a; font-weight: 500; }
.nl-btn--primary:hover { background: #0d9488; }
.nl-btn--sm { padding: .3rem .6rem; font-size: .8rem; }
/* Toolbar */
.nl-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; gap: .75rem; flex-wrap: wrap; }
.nl-toolbar input[type="search"] { padding: .4rem .8rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 6px; color: #e5e5e5; font-size: .85rem; min-width: 200px; }
/* Pagination */
.nl-pagination { display: flex; align-items: center; justify-content: center; gap: .75rem; margin-top: 1rem; font-size: .85rem; color: #a3a3a3; }
/* Loading / Empty */
.nl-loading { text-align: center; padding: 3rem; color: #a3a3a3; }
.nl-empty { text-align: center; padding: 2rem; color: #525252; font-size: .9rem; }
/* Create campaign form */
.nl-form { background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; }
.nl-form label { display: block; font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; }
.nl-form input, .nl-form select, .nl-form textarea { width: 100%; padding: .45rem .7rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .85rem; margin-bottom: .75rem; box-sizing: border-box; }
.nl-form textarea { min-height: 80px; resize: vertical; font-family: inherit; }
.nl-form-row { display: flex; gap: .75rem; }
.nl-form-row > * { flex: 1; }
/* Error */
.nl-error { padding: .75rem 1rem; background: #ef444422; border: 1px solid #ef4444; border-radius: 6px; color: #f87171; font-size: .85rem; margin-bottom: 1rem; }

View File

@ -0,0 +1,45 @@
/**
* Listmonk API proxy helper reads per-space credentials from module settings
* and forwards requests with Basic Auth.
*/
import { loadCommunity, getDocumentData } from "../../../server/community-store";
export interface ListmonkConfig {
url: string;
user: string;
password: string;
}
/** Read Listmonk credentials from the space's module settings. */
export async function getListmonkConfig(spaceSlug: string): Promise<ListmonkConfig | null> {
await loadCommunity(spaceSlug);
const data = getDocumentData(spaceSlug);
if (!data) return null;
const settings = data.meta.moduleSettings?.rsocials;
if (!settings) return null;
const url = settings.listmonkUrl as string | undefined;
const user = settings.listmonkUser as string | undefined;
const password = settings.listmonkPassword as string | undefined;
if (!url || !user || !password) return null;
return { url: url.replace(/\/+$/, ''), user, password };
}
/** Proxy a request to the Listmonk API with Basic Auth. */
export async function listmonkFetch(
config: ListmonkConfig,
path: string,
opts: RequestInit = {},
): Promise<Response> {
const auth = Buffer.from(`${config.user}:${config.password}`).toString("base64");
const headers = new Headers(opts.headers);
headers.set("Authorization", `Basic ${auth}`);
if (!headers.has("Content-Type") && opts.body) {
headers.set("Content-Type", "application/json");
}
return fetch(`${config.url}${path}`, { ...opts, headers });
}

View File

@ -30,6 +30,11 @@ import {
deleteOldImage,
} from "./lib/image-gen";
import { DEMO_FEED } from "./lib/types";
import { getListmonkConfig, listmonkFetch } from "./lib/listmonk-proxy";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import type { EncryptIDClaims } from "@encryptid/sdk/server";
import { resolveCallerRole, roleAtLeast } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
let _syncServer: SyncServer | null = null;
@ -407,6 +412,98 @@ routes.delete("/api/threads/:id/images", async (c) => {
return c.json({ ok: true });
});
// ── Newsletter (Listmonk) proxy API ──
async function requireNewsletterRole(c: any, minRole: SpaceRoleString): Promise<{ claims: EncryptIDClaims; role: SpaceRoleString } | Response> {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const result = await resolveCallerRole(space, claims);
if (!result || !roleAtLeast(result.role, minRole)) {
return c.json({ error: "Insufficient permissions" }, 403);
}
return { claims, role: result.role };
}
routes.get("/api/newsletter/status", async (c) => {
const space = c.req.param("space") || "demo";
const config = await getListmonkConfig(space);
return c.json({ configured: !!config });
});
routes.get("/api/newsletter/lists", 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 res = await listmonkFetch(config, "/api/lists");
const data = await res.json();
return c.json(data, res.status as any);
});
routes.get("/api/newsletter/subscribers", 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 url = new URL(c.req.url);
const query = url.search || "";
const res = await listmonkFetch(config, `/api/subscribers${query}`);
const data = await res.json();
return c.json(data, res.status as any);
});
routes.get("/api/newsletter/campaigns", 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 res = await listmonkFetch(config, "/api/campaigns");
const data = await res.json();
return c.json(data, res.status as any);
});
routes.post("/api/newsletter/campaigns", async (c) => {
const auth = await requireNewsletterRole(c, "admin");
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 body = await c.req.text();
const res = await listmonkFetch(config, "/api/campaigns", { method: "POST", body });
const data = await res.json();
return c.json(data, res.status as any);
});
routes.put("/api/newsletter/campaigns/:id/status", 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 = c.req.param("id");
const body = await c.req.text();
const res = await listmonkFetch(config, `/api/campaigns/${campaignId}/status`, { method: "PUT", body });
const data = await res.json();
return c.json(data, res.status as any);
});
// ── Page routes (inject web components) ──
routes.get("/campaign", (c) => {
@ -576,9 +673,6 @@ function renderDemoFeedHTML(): string {
// The /scheduler route renders a full-page iframe shell.
const POSTIZ_URL = process.env.POSTIZ_URL || "https://demo.rsocials.online";
// ── Listmonk newsletter — embedded via iframe ──
// Listmonk admin at newsletter.cosmolocal.world (not behind CF Access).
const LISTMONK_URL = process.env.LISTMONK_URL || "https://newsletter.cosmolocal.world";
routes.get("/scheduler", (c) => {
const space = c.req.param("space") || "demo";
@ -594,16 +688,29 @@ routes.get("/scheduler", (c) => {
}));
});
routes.get("/newsletter-list", (c) => {
routes.get("/newsletter-list", async (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderExternalAppShell({
title: `Newsletter List — rSocials | rSpace`,
// Resolve caller role for UI gating (viewer fallback for unauthenticated)
let role: SpaceRoleString = 'viewer';
const token = extractToken(c.req.raw.headers);
if (token) {
try {
const claims = await verifyEncryptIDToken(token);
const result = await resolveCallerRole(space, claims);
if (result) role = result.role;
} catch { /* keep viewer default */ }
}
return c.html(renderShell({
title: `Newsletter — rSocials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
appName: "Listmonk",
appUrl: LISTMONK_URL,
body: `<folk-newsletter-manager space="${escapeHtml(space)}" role="${escapeHtml(role)}"></folk-newsletter-manager>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/newsletter.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-newsletter-manager.js"></script>`,
}));
});
@ -712,6 +819,11 @@ export const socialsModule: RSpaceModule = {
docSchemas: [{ pattern: "{space}:socials:data", description: "Threads and campaigns", init: socialsSchema.init }],
routes,
publicWrite: true,
settingsSchema: [
{ key: 'listmonkUrl', label: 'Listmonk URL', type: 'string', description: 'Base URL of your Listmonk instance (e.g. https://newsletter.example.com)' },
{ key: 'listmonkUser', label: 'Listmonk Username', type: 'string', description: 'API username for Listmonk' },
{ key: 'listmonkPassword', label: 'Listmonk Password', type: 'password', description: 'API password for Listmonk' },
],
standaloneDomain: "rsocials.online",
landingPage: renderLanding,
seedTemplate: seedTemplateSocials,

View File

@ -915,6 +915,8 @@ export class RStackSpaceSwitcher extends HTMLElement {
html += `</select>`;
} else if (field.type === 'notebook-id') {
html += `<select class="mod-setting scope-select mod-notebook-select" data-mod-id="${m.id}" data-key="${field.key}" data-space="${slug}"><option value="">None</option></select>`;
} else if (field.type === 'password') {
html += `<input type="password" class="mod-setting" data-mod-id="${m.id}" data-key="${field.key}" value="${typeof val === 'string' ? val.replace(/"/g, '&quot;') : ''}" style="width:100%;padding:5px 8px;background:#0a0a0a;border:1px solid #404040;border-radius:4px;color:#e5e5e5;font-size:12px;" autocomplete="off" />`;
} else {
html += `<input type="text" class="mod-setting" data-mod-id="${m.id}" data-key="${field.key}" value="${typeof val === 'string' ? val.replace(/"/g, '&quot;') : ''}" style="width:100%;padding:5px 8px;background:#0a0a0a;border:1px solid #404040;border-radius:4px;color:#e5e5e5;font-size:12px;" />`;
}

View File

@ -54,7 +54,7 @@ export interface SubPageInfo {
// ── Per-Module Settings ──
export type ModuleSettingType = 'string' | 'boolean' | 'select' | 'notebook-id';
export type ModuleSettingType = 'string' | 'boolean' | 'select' | 'notebook-id' | 'password';
export interface ModuleSettingField {
/** Storage key */

View File

@ -718,6 +718,32 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rsocials/campaign-planner.css"),
);
// Build newsletter manager component
await build({
configFile: false,
root: resolve(__dirname, "modules/rsocials/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rsocials"),
lib: {
entry: resolve(__dirname, "modules/rsocials/components/folk-newsletter-manager.ts"),
formats: ["es"],
fileName: () => "folk-newsletter-manager.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-newsletter-manager.js",
},
},
},
});
// Copy newsletter CSS
copyFileSync(
resolve(__dirname, "modules/rsocials/components/newsletter.css"),
resolve(__dirname, "dist/modules/rsocials/newsletter.css"),
);
// Build tube module component
await build({
configFile: false,

View File

@ -579,7 +579,7 @@
newsletterStatus.className = "newsletter-status";
try {
const res = await fetch("https://newsletter.jeffemmett.com/subscribe", {
const res = await fetch("https://newsletter.rspace.online/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({