// The post_to_postiz action. Attach it to a table trigger (row Insert / Update) // or a row button: it maps the triggering row into a Postiz post and publishes // through the configured channels. The body follows the documented public API // (docs.postiz.com/public-api, create post): type, date, shortLink, tags, and per // channel integration.id, value[{ content, image[{ id, path }] }] and settings // with the provider's __type. // // The Postiz API key is stored sealed in the plugin config (lib/apiKey.js); it // is decrypted only at the point of use here. const { makePostizClient } = require("./postizClient"); const { resolveApiKey } = require("./apiKey"); // Saltcorn renders a field sublabel as raw HTML; channel names come from Postiz. const { escapeHtml } = require("./common/htmlEscape"); const { PLUGIN_NAME, ACTION_NAME, POST_TYPES, PROVIDER_DEFAULT_SETTINGS, CONFIG_LIST_TIMEOUT_MS, CHANNEL_CACHE_TTL_MS, MEDIA_PLACEHOLDER } = require("./constants"); // Row fields offered as content / media URL sources, and as a schedule date. const TEXT_FIELD_TYPES = ["String"]; const DATE_FIELD_TYPES = ["Date", "String"]; // Free-text schedule dates must name their zone (Z, +hh:mm, UTC/GMT); without // one, Date parses them in the server's local time zone. const ZONED_DATE_RE = /(Z|UTC|GMT|[+-]\d{2}:?\d{2})$/i; // Why an MCP token needs admin scope to create, change or run a trigger using // the action (saltcorn-mcp docs/pluginTools.md). const MCP_ADMIN_REASON = "it publishes posts to social networks through Postiz"; // The run() result message per timing. const NOTIFY_TEXT = { [POST_TYPES.NOW]: "Posted to Postiz", [POST_TYPES.SCHEDULE]: "Scheduled on Postiz", [POST_TYPES.DRAFT]: "Saved as a Postiz draft" }; // Last config-page channel listing: { key, at, channels, error }. let channelListing = null; const isConfigured = (pluginCfg) => { return !!(pluginCfg && pluginCfg.baseUrl && pluginCfg.apiKey); }; // A Postiz client from saved config with the key decrypted for use. Throws an // actionable error when the connection is not configured. const configuredClient = (pluginCfg, opts) => { if (!isConfigured(pluginCfg)) { throw new Error(`${PLUGIN_NAME}: set the Postiz base URL and API key in the plugin configuration`); } return makePostizClient({ baseUrl: pluginCfg.baseUrl, apiKey: resolveApiKey(pluginCfg) }, opts); }; const parseIds = (text) => { return String(text || "").split(",").map((s) => s.trim()).filter(Boolean); }; // The connected channels for the config page, reused for CHANNEL_CACHE_TTL_MS // (Saltcorn rebuilds every action's config fields on each view-builder load, and // on each list-row action click) and fetched with a short timeout; a failure is // cached too, so an unreachable Postiz does not stall every page. const listChannelsForConfig = async (pluginCfg) => { const key = `${pluginCfg.baseUrl}\n${pluginCfg.apiKey}`; if (channelListing && channelListing.key === key && Date.now() - channelListing.at < CHANNEL_CACHE_TTL_MS) { return channelListing; } let channels = []; let error = null; try { channels = await configuredClient(pluginCfg, { timeoutMs: CONFIG_LIST_TIMEOUT_MS }).listIntegrations(); } catch (e) { error = e.message; } channelListing = { key: key, at: Date.now(), channels: channels, error: error }; return channelListing; }; const channelSublabel = (listing) => { if (listing.error) { return `Could not list channels from Postiz (${escapeHtml(listing.error)}).`; } if (listing.channels.length === 0) { return "No channels are connected in Postiz yet."; } return "Connected: " + listing.channels.map((c) => `${escapeHtml(c.name)} (${escapeHtml(c.identifier)}) = ${escapeHtml(c.id)}`).join("; "); }; // The ISO date the post is published / scheduled at. const postDate = (configuration, row, nowIso) => { if (configuration.when !== POST_TYPES.SCHEDULE) { return nowIso; } if (!configuration.scheduleField) { throw new Error(`${PLUGIN_NAME}: timing is "schedule" but no schedule date field is set`); } // A Date-typed field arrives as a Date; free text is row data that may be // blank, unparseable, or zone-less -- fail with an actionable message. const raw = row[configuration.scheduleField]; const zoned = raw instanceof Date || ZONED_DATE_RE.test(String(raw === null || raw === undefined ? "" : raw).trim()); const parsed = new Date(raw); if (!zoned || Number.isNaN(parsed.getTime())) { throw new Error(`${PLUGIN_NAME}: schedule field "${configuration.scheduleField}" is not a valid date with a time zone (${JSON.stringify(raw)}); use e.g. 2026-12-25T09:00:00Z or a Date field`); } return parsed.toISOString(); }; // Pure: (action config, row, uploaded media [{ id, path }], now, the account's // Postiz integrations) -> create-post body. Side-effect free so the test suite // asserts the documented shape without a live server. Throws an actionable error // for an empty content field, no / unknown / disabled channels, or a bad // schedule date. const buildPostPayload = (configuration, row, media, nowIso, integrations) => { if (!Object.values(POST_TYPES).includes(configuration.when)) { throw new Error(`${PLUGIN_NAME}: timing must be one of ${Object.values(POST_TYPES).join(", ")}`); } const content = row[configuration.contentField]; if (content === undefined || content === null || String(content).trim() === "") { throw new Error(`${PLUGIN_NAME}: content field "${configuration.contentField}" is empty`); } const ids = parseIds(configuration.integrationIds); if (ids.length === 0) { throw new Error(`${PLUGIN_NAME}: no Postiz channel ids configured`); } const byId = new Map(integrations.map((i) => [String(i.id), i])); const channels = ids.map((id) => { const channel = byId.get(id); if (!channel || channel.disabled) { throw new Error(`${PLUGIN_NAME}: Postiz channel "${id}" is not connected (or is disabled) on this Postiz account`); } return channel; }); const value = [{ content: String(content), image: media.map((m) => ({ id: m.id, path: m.path })) }]; return { type: configuration.when, shortLink: false, date: postDate(configuration, row, nowIso), tags: [], posts: channels.map((channel) => ({ integration: { id: channel.id }, value: value, settings: { __type: channel.identifier, ...PROVIDER_DEFAULT_SETTINGS[channel.identifier] } })) }; }; // Builds the per-action config form. Channels are a free-text, comma-separated // list of Postiz integration ids, with the live connected channels (name, // provider, id) listed in the sublabel. A select is not used: Saltcorn's String // select is single-choice, and a required select with no options (Postiz // unreachable) renders no input at all, leaving the trigger unsavable. const buildConfigFields = (pluginCfg) => async ({ table }) => { const fields = table && table.fields ? table.fields : []; const namesOf = (types) => fields.filter((f) => f.type && types.includes(f.type.name)).map((f) => f.name); const textFields = namesOf(TEXT_FIELD_TYPES); const listing = await listChannelsForConfig(pluginCfg); return [ { name: "contentField", label: "Content field", type: "String", required: true, attributes: { options: textFields } }, { name: "mediaUrlField", label: "Media URL field (optional)", type: "String", attributes: { options: textFields } }, { name: "integrationIds", label: "Post to channels", type: "String", required: true, sublabel: "Comma-separated Postiz integration ids. " + channelSublabel(listing) }, { name: "when", label: "Timing", type: "String", required: true, attributes: { options: Object.values(POST_TYPES) } }, { name: "scheduleField", label: "Schedule date field", type: "String", attributes: { options: namesOf(DATE_FIELD_TYPES) }, showIf: { when: POST_TYPES.SCHEDULE } } ]; }; // One post of a row through the configured Postiz: list the account's channels, // build and validate the create-post body, have Postiz fetch the media, create // the post. Returns { payload, mediaUrl, notify, response } (response is the // raw Postiz answer: remote data, for callers to allow-list). A dry run // (opts.dryRun) makes only the channel listing GET -- no upload (Postiz would // fetch an arbitrary URL) and no post; its payload carries MEDIA_PLACEHOLDER. const publish = async (pluginCfg, configuration, row, opts) => { const dryRun = !!(opts && opts.dryRun); const client = configuredClient(pluginCfg); const integrations = await client.listIntegrations(); const nowIso = new Date().toISOString(); // Validate everything before uploading media, so a bad config or row // does not leave an orphan upload on Postiz. buildPostPayload(configuration, row, [], nowIso, integrations); const field = configuration.mediaUrlField; const mediaUrl = field && row[field] ? String(row[field]) : null; let media = []; if (mediaUrl) { media = [dryRun ? MEDIA_PLACEHOLDER : await client.uploadFromUrl(mediaUrl)]; } const payload = buildPostPayload(configuration, row, media, nowIso, integrations); const response = dryRun ? null : await client.createPost(payload); return { payload: payload, mediaUrl: mediaUrl, notify: `${NOTIFY_TEXT[configuration.when]} (${payload.posts.length} channel(s))`, response: response }; }; const makePostAction = (pluginCfg) => ({ requireRow: true, mcp_admin_reason: MCP_ADMIN_REASON, configFields: buildConfigFields(pluginCfg), // Returns only a fixed { notify } shape: Saltcorn merges an action's result // into the response it acts on (goto, eval_js, ...), so the remote Postiz // response must not pass through. run: async ({ row, configuration }) => { if (!row) { throw new Error(`${PLUGIN_NAME}: ${ACTION_NAME} needs a row (attach it to a table trigger or a row button)`); } const posted = await publish(pluginCfg, configuration, row); return { notify: posted.notify }; } }); module.exports = { buildPostPayload, configuredClient, isConfigured, makePostAction, publish };