@materializecms/server 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.js CHANGED
@@ -9,11 +9,12 @@ import { getConnInfo } from '@hono/node-server/conninfo';
9
9
  import { describeCollection, prepare, SLUG_PATTERN, slugify, titleOf, validate, } from '@materializecms/core';
10
10
  import { documentSlots, isLocaleCode, mergeTranslation, nodeSlots, untranslatedIds, withLocales, } from '@materializecms/core/i18n';
11
11
  import { senderDomain } from '@materializecms/core/mail';
12
- import { expandComponents, expandGlobals, formDefinition, normalizeTheme, sameDesign, walk, } from '@materializecms/core/page';
13
- import { addMove, normalizePath } from '@materializecms/core/seo';
12
+ import { expandComponents, expandGlobals, formDefinition, normalizeTheme, richTextHtml, sameDesign, walk, } from '@materializecms/core/page';
13
+ import { addMove, feedCollections, normalizePath } from '@materializecms/core/seo';
14
14
  import { TRACKING_PATTERNS, trackingBlocks } from '@materializecms/core/tracking';
15
15
  import { DEFAULT_UPDATES, isPatch, } from '@materializecms/core/updates';
16
- import { createActivityStore, createAuthStore, createCommentStore, createContentStore, createMediaStore, createPreviewLinkStore, createScheduleStore, createSettingsStore, createSubmissionStore, EmailTakenError, SlugTakenError, } from '@materializecms/db';
16
+ import { VOICE_TONES, voicePrompt } from '@materializecms/core/voice';
17
+ import { createActivityStore, createAuthStore, createCommentStore, createContentStore, createMediaStore, createNewsletterStore, createPreviewLinkStore, createReleaseStore, createScheduleStore, createSettingsStore, createSubmissionStore, EmailTakenError, SlugTakenError, } from '@materializecms/db';
17
18
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
18
19
  import { Hono } from 'hono';
19
20
  import { bodyLimit } from 'hono/body-limit';
@@ -24,22 +25,25 @@ import { secureHeaders } from 'hono/secure-headers';
24
25
  import { streamSSE } from 'hono/streaming';
25
26
  import { z } from 'zod';
26
27
  import { serveAdmin } from './admin.js';
27
- import { AiError, complete, EMPTY_AI, isReady, publicAi, runTurn, translateTexts, } from './ai.js';
28
+ import { AiError, complete, EMPTY_AI, isReady, languageName, publicAi, runTurn, translateTexts, } from './ai.js';
28
29
  import { folderEntries, tarGz } from './archive.js';
29
30
  import { createBackups, DEFAULT_BACKUPS } from './backups.js';
31
+ import { briefSystem, briefTools, plain } from './brief.js';
30
32
  import { createFonts, FONT_FILE, FONT_ID } from './fonts.js';
31
33
  import { fingerprintOf, readSubmission, removeFiles, saveFiles, sendWebhook, spamReason, } from './forms.js';
32
34
  import { checkSenderDomain, explainMailError, isConfigured, publicMail, sendMail, submissionText, } from './mail.js';
33
35
  import { createMcpServer } from './mcp.js';
34
36
  import { createMediaFiles, fileBody, MAX_UPLOAD, MediaError, mediaUrl } from './media.js';
37
+ import { confirmEmail, langOf, linkSignature, newsletterHtml, newsletterText, PIXEL, parseSubscribersCsv, validLink, } from './newsletter.js';
35
38
  import { DEFAULT_OFFSITE, newRecoveryKey, runOffsite, testOffsite, } from './offsite.js';
36
39
  import { hashPassword, verifyPassword } from './password.js';
37
40
  import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
38
41
  import { statusPage } from './status-page.js';
39
- import { apiCaller, assistantTools } from './tools.js';
42
+ import { studioSystem, studioTools } from './studio.js';
43
+ import { apiCaller, assistantTools, contentTools, pageTools } from './tools.js';
40
44
  import { newSecret, otpauthUri, recoveryCodes, verifyCode } from './totp.js';
41
45
  import { readRest, readWxr, WordPressError } from './wordpress.js';
42
- import { fold, optionsOf, runImport, sourcesFor, suggest, } from './wpimport.js';
46
+ import { fold, optionsOf, relayoutProblem, restoreAlts, runImport, sourcesFor, suggest, } from './wpimport.js';
43
47
  /** The installed version, from this package's package.json (same place in the sources and in the release). */
44
48
  export const VERSION = createRequire(import.meta.url)('../package.json').version;
45
49
  const SESSION_COOKIE = 'mz_session';
@@ -190,6 +194,8 @@ export function createApp(base) {
190
194
  const comments = createCommentStore(ctx.database.db);
191
195
  const activityLog = createActivityStore(ctx.database.db);
192
196
  const schedules = createScheduleStore(ctx.database.db);
197
+ const releases = createReleaseStore(ctx.database.db);
198
+ const newsletter = createNewsletterStore(ctx.database.db);
193
199
  const previewLinks = createPreviewLinkStore(ctx.database.db);
194
200
  const backups = createBackups(ctx.database, () => ctx.config().media.dir);
195
201
  const backupSettings = async () => ({
@@ -461,6 +467,12 @@ export function createApp(base) {
461
467
  issues: check && !check.ok ? check.issues : [],
462
468
  /** When it goes on or off the site by itself, and the last scheduled action that failed. */
463
469
  schedule: await schedules.forDocument(doc.id),
470
+ /** The release it goes out with, if any. */
471
+ release: await releases
472
+ .forDocument(doc.id)
473
+ .then((r) => r
474
+ ? { id: r.id, name: r.name, runAt: r.runAt, action: r.items.find((i) => i.documentId === doc.id)?.action }
475
+ : null),
464
476
  };
465
477
  };
466
478
  /**
@@ -494,7 +506,9 @@ export function createApp(base) {
494
506
  app.use('*', secureHeaders());
495
507
  // Requests with a bearer token carry no cookie, so a forged form cannot ride on them.
496
508
  const checkOrigin = csrf();
497
- app.use('/api/*', (c, next) => (bearerOf(c) ? next() : checkOrigin(c, next)));
509
+ app.use('/api/*', (c, next) =>
510
+ // Leaving the newsletter with one click from the email app comes with no Origin: the token is the proof.
511
+ bearerOf(c) || c.req.path.startsWith('/api/newsletter/unsubscribe/') ? next() : checkOrigin(c, next));
498
512
  // Answers change with every edit: browsers must not reuse them.
499
513
  app.use('/api/*', async (c, next) => {
500
514
  await next();
@@ -888,7 +902,7 @@ export function createApp(base) {
888
902
  const ACTIVITY_GROUPS = {
889
903
  access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.', 'password.'],
890
904
  people: ['user.'],
891
- content: ['document.', 'media.', 'message.'],
905
+ content: ['document.', 'release.', 'site.', 'newsletter.', 'import.', 'media.', 'message.'],
892
906
  settings: ['settings.', 'backup.', 'update.'],
893
907
  };
894
908
  app.get('/api/activity', requireUser, requireAdmin, async (c) => {
@@ -1347,7 +1361,11 @@ export function createApp(base) {
1347
1361
  return c.json({ ok: true });
1348
1362
  });
1349
1363
  app.patch('/api/auth/me', requireUser, async (c) => {
1350
- const input = await body(c, z.object({ name: z.string().trim().min(1).max(80) }));
1364
+ const input = await body(c, z.object({
1365
+ name: z.string().trim().min(1).max(80).optional(),
1366
+ /** The panel's language; null: follow the browser. */
1367
+ locale: z.enum(['it', 'en']).nullable().optional(),
1368
+ }));
1351
1369
  return c.json({ user: await auth.updateUser(c.get('user').id, input) });
1352
1370
  });
1353
1371
  /** A changed sign-in address: the old one is told, in case it was not its owner who changed it. */
@@ -1826,6 +1844,203 @@ export function createApp(base) {
1826
1844
  await complete(ai, { ...probe, fast: true }).catch(aiFailed);
1827
1845
  return c.json({ ok: true, reply });
1828
1846
  });
1847
+ // ---------- brand voice ----------
1848
+ /** `style` when the AI changes a text someone wrote, `full` when it writes one (see voicePrompt). */
1849
+ const voiceText = async (use = 'full') => voicePrompt(await settings.get('voice'), use);
1850
+ const voiceSchema = z.object({
1851
+ about: z.string().trim().max(1000).optional(),
1852
+ tones: z.array(z.enum(VOICE_TONES)).max(3).optional(),
1853
+ address: z.enum(['tu', 'voi', 'lei']).nullable().optional(),
1854
+ prefer: z.string().trim().max(1000).optional(),
1855
+ avoid: z.string().trim().max(1000).optional(),
1856
+ rules: z.string().trim().max(1500).optional(),
1857
+ example: z.string().trim().max(3000).optional(),
1858
+ });
1859
+ app.get('/api/voice', requireUser, async (c) => c.json({ voice: (await settings.get('voice')) ?? {} }));
1860
+ app.put('/api/voice', requireUser, requireAdmin, async (c) => {
1861
+ const input = await body(c, z.object({ voice: voiceSchema }));
1862
+ await settings.set('voice', input.voice, c.get('user').id);
1863
+ await record(c, 'settings.voice');
1864
+ return c.json({ voice: input.voice });
1865
+ });
1866
+ /**
1867
+ * A brand voice read from the texts already on the site, to start from: nothing is saved, the
1868
+ * panel puts it in the form to check.
1869
+ */
1870
+ app.post('/api/voice/suggest', requireUser, requireAdmin, async (c) => {
1871
+ if (!allowAi(c.get('user').id))
1872
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
1873
+ const ai = await readyAi();
1874
+ const config = ctx.config();
1875
+ const texts = [];
1876
+ let size = 0;
1877
+ for (const def of config.collections) {
1878
+ for (const doc of await content.published(def.name, config.locales.default)) {
1879
+ for (const slot of documentSlots(def.fields, doc.publishedData ?? {})) {
1880
+ const t = slot.text.replace(/<[^>]+>/g, '').trim();
1881
+ if (t.length < 20 || size > 14_000)
1882
+ continue;
1883
+ texts.push(t);
1884
+ size += t.length;
1885
+ }
1886
+ }
1887
+ }
1888
+ if (size < 300)
1889
+ throw new ApiError(400, 'too_little_text', 'Il sito ha ancora pochi testi pubblicati: scrivi la voce a mano');
1890
+ const reply = await complete(ai, {
1891
+ system: 'You describe the voice of a brand from the texts of its website, for a style guide the brand will reuse. Reply with JSON only, no code fences.',
1892
+ prompt: `Read these texts of the website "${config.site.name}" and describe how it speaks, writing in the language of the texts.
1893
+ Reply with this JSON object:
1894
+ {"about": "who the brand is and who it writes for, one or two sentences",
1895
+ "tones": [up to three of ${JSON.stringify(VOICE_TONES)}],
1896
+ "address": "tu" | "voi" | "lei" | null (how readers are addressed),
1897
+ "prefer": "words and phrases typical of these texts, comma separated",
1898
+ "avoid": "words that would sound wrong for this brand, comma separated",
1899
+ "rules": "two or three rules of style seen in the texts, as short sentences",
1900
+ "example": "a short paragraph of new text in this voice, about the brand"}
1901
+
1902
+ <texts>
1903
+ ${texts.join('\n---\n')}
1904
+ </texts>`,
1905
+ }).catch(aiFailed);
1906
+ let raw = null;
1907
+ try {
1908
+ raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
1909
+ }
1910
+ catch { }
1911
+ const parsed = voiceSchema.safeParse(raw && {
1912
+ ...raw,
1913
+ tones: Array.isArray(raw.tones)
1914
+ ? raw.tones.filter((t) => VOICE_TONES.includes(t)).slice(0, 3)
1915
+ : [],
1916
+ address: ['tu', 'voi', 'lei'].includes(raw.address) ? raw.address : null,
1917
+ });
1918
+ if (!parsed.success)
1919
+ throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
1920
+ return c.json({ voice: parsed.data, texts: texts.length });
1921
+ });
1922
+ // ---------- AI review before publishing ----------
1923
+ const REVIEW_KINDS = ['spelling', 'grammar', 'voice', 'consistency', 'link', 'clarity'];
1924
+ /** The site's pages by address, with their titles: what a link on the page points to. */
1925
+ const pageTitles = async () => {
1926
+ const out = new Map();
1927
+ const config = ctx.config();
1928
+ for (const def of config.collections) {
1929
+ if (!def.url)
1930
+ continue;
1931
+ for (const doc of await content.published(def.name)) {
1932
+ const url = publicUrl(def, doc.locale, doc.publishedData ?? {});
1933
+ if (url && out.size < 200)
1934
+ out.set(url, doc.title);
1935
+ }
1936
+ }
1937
+ return out;
1938
+ };
1939
+ /**
1940
+ * The AI reads the draft as it is now (saved or not) and lists what to fix before publishing:
1941
+ * typos, grammar, sentences out of the brand voice, facts that contradict each other, links whose
1942
+ * words promise something else than where they go, unclear sentences. Each with the exact words
1943
+ * and a replacement. Nothing is changed.
1944
+ */
1945
+ app.post('/api/documents/:id/review', requireUser, async (c) => {
1946
+ const existing = await loadDocument(c.req.param('id'));
1947
+ const input = await body(c, z.object({ data: z.record(z.string(), z.unknown()).optional() }));
1948
+ if (!allowAi(c.get('user').id))
1949
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
1950
+ const ai = await readyAi();
1951
+ const def = collection(existing.collection);
1952
+ const slots = documentSlots(def.fields, structuredClone(input.data ?? existing.data));
1953
+ if (!slots.length)
1954
+ throw new ApiError(400, 'empty_text', 'Non c’è testo da rivedere');
1955
+ const titles = await pageTitles();
1956
+ const linkTo = (href) => {
1957
+ const path = href.split('#')[0] || href;
1958
+ const title = titles.get(path.replace(/\/+$/, '') || '/');
1959
+ return title ? `${href} (page "${title}")` : href;
1960
+ };
1961
+ let size = 0;
1962
+ const listed = slots.flatMap((s, i) => {
1963
+ if (size > 30_000)
1964
+ return [];
1965
+ size += s.text.length;
1966
+ const links = s.links?.length ? `\n links: ${s.links.map((l, n) => `${n + 1} → ${linkTo(l)}`).join('; ')}` : '';
1967
+ return [`[${i}] ${s.text}${links}`];
1968
+ });
1969
+ const voice = await voiceText('full');
1970
+ const reply = await complete(ai, {
1971
+ system: `${voice ? `${voice}\n\n` : ''}You review a web page before it is published, as a careful editor. Reply with JSON only, no code fences.`,
1972
+ prompt: `These are the texts of the page "${existing.title}", numbered. Tags like <b>, <i> and <a1>…</a1> mark formatting and links; "links" says where each numbered link goes.
1973
+
1974
+ Find what really needs fixing before publishing, and nothing else:
1975
+ - spelling: typos and misspelled words
1976
+ - grammar: grammar, agreement, punctuation
1977
+ - voice: sentences clearly out of the brand voice above (skip if there is none)
1978
+ - consistency: facts on the page that contradict each other (prices, dates, names, numbers)
1979
+ - link: link words that promise something different from the page they go to
1980
+ - clarity: a sentence a reader would not understand
1981
+
1982
+ Reply with {"issues": [{"slot": number, "kind": one of ${JSON.stringify(REVIEW_KINDS)}, "find": "the exact words to change, copied from that text with its tags", "replace": "the corrected words, or \\"\\" when only a person can fix it", "note": "why, in one short sentence, in the language of the page"}]}.
1983
+ At most 25 issues, the most important first. An empty list is a fine answer when the page is good. Do not report style preferences, the length of texts, or things that are fine.
1984
+
1985
+ <texts>
1986
+ ${listed.join('\n')}
1987
+ </texts>`,
1988
+ }).catch(aiFailed);
1989
+ let raw;
1990
+ try {
1991
+ raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
1992
+ }
1993
+ catch {
1994
+ throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
1995
+ }
1996
+ const issue = z.object({
1997
+ slot: z.number().int().min(0),
1998
+ kind: z.enum(REVIEW_KINDS),
1999
+ find: z.string().min(1).max(2000),
2000
+ replace: z.string().max(4000),
2001
+ note: z.string().max(500),
2002
+ });
2003
+ const items = Array.isArray(raw?.issues) ? raw.issues : [];
2004
+ const issues = items.flatMap((it) => {
2005
+ const parsed = issue.safeParse(it);
2006
+ if (!parsed.success)
2007
+ return [];
2008
+ const slot = slots[parsed.data.slot];
2009
+ // Only what is really in the text: a quote the model made up cannot be applied.
2010
+ if (!slot?.text.includes(parsed.data.find))
2011
+ return [];
2012
+ return [{ ...parsed.data, node: slot.node ?? null, context: slot.text.replace(/<[^>]+>/g, '').slice(0, 160) }];
2013
+ });
2014
+ await record(c, 'document.reviewed', existing.title, {
2015
+ collection: existing.collection,
2016
+ id: existing.id,
2017
+ issues: issues.length,
2018
+ });
2019
+ return c.json({ issues: issues.slice(0, 25) });
2020
+ });
2021
+ /**
2022
+ * Applies one suggestion of the review to the draft as the editor holds it, and gives it back:
2023
+ * the editor takes it as one change, which ⌘Z undoes. Nothing is saved here.
2024
+ */
2025
+ app.post('/api/documents/:id/review/apply', requireUser, async (c) => {
2026
+ const existing = await loadDocument(c.req.param('id'));
2027
+ const input = await body(c, z.object({
2028
+ data: z.record(z.string(), z.unknown()),
2029
+ slot: z.number().int().min(0),
2030
+ find: z.string().min(1).max(2000),
2031
+ replace: z.string().max(4000),
2032
+ }));
2033
+ const data = structuredClone(input.data);
2034
+ const slots = documentSlots(collection(existing.collection).fields, data);
2035
+ // The same text, or the first one that still has those words (the page may have changed since).
2036
+ const slot = slots[input.slot]?.text.includes(input.find)
2037
+ ? slots[input.slot]
2038
+ : slots.find((s) => s.text.includes(input.find));
2039
+ if (!slot)
2040
+ throw new ApiError(409, 'changed', 'Quel testo è cambiato: rifai la revisione');
2041
+ slot.set(slot.text.replace(input.find, input.replace));
2042
+ return c.json({ data });
2043
+ });
1829
2044
  const TEXT_ACTIONS = {
1830
2045
  rewrite: 'Rewrite it so it reads better: same meaning, similar length.',
1831
2046
  shorten: 'Make it shorter, keeping what matters most.',
@@ -1854,8 +2069,12 @@ export function createApp(base) {
1854
2069
  if (!allowAi(c.get('user').id))
1855
2070
  throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
1856
2071
  const ai = await readyAi();
2072
+ // Writing from scratch takes the whole voice; changing a text, only its style.
2073
+ const voice = await voiceText(['seo_title', 'seo_description', 'custom'].includes(input.action) ? 'full' : 'style');
1857
2074
  const lang = input.locale ?? ctx.config().locales.default;
1858
- const system = `You edit texts for a website. Reply with the resulting text only: no quotes, no preamble, no notes. Keep the language of the text${input.action === 'translate' ? ' unless asked to translate' : ''}; when writing from scratch, write in the language with code "${lang}". Keep line breaks where they make sense; never add Markdown.`;
2075
+ const system = `You edit texts for a website. Reply with the resulting text only: no quotes, no preamble, no notes. Keep the language of the text${input.action === 'translate' ? ' unless asked to translate' : ''}; when writing from scratch, write in the language with code "${lang}". Keep line breaks where they make sense; never add Markdown.${
2076
+ // Spelling fixes and alternative texts describe: they do not speak with the brand's voice.
2077
+ input.action === 'fix' || input.action === 'alt' || !voice ? '' : `\n\n${voice}`}`;
1859
2078
  if (input.action === 'alt') {
1860
2079
  const key = /^\/media\/([^/?#@]+)/.exec(input.src ?? '')?.[1];
1861
2080
  const file = key ? await files.open(decodeURIComponent(key), { width: 1024, format: 'jpg' }) : null;
@@ -1948,8 +2167,12 @@ export function createApp(base) {
1948
2167
  const chatId = input.chatId ?? crypto.randomUUID();
1949
2168
  const key = `${user.id}:${chatId}`;
1950
2169
  const entry = chats.get(key);
1951
- const conv = entry && entry.conv.provider === ai.provider ? entry.conv : { provider: ai.provider, messages: [] };
1952
- chats.set(key, { conv, at: now });
2170
+ const same = !!entry && entry.conv.provider === ai.provider;
2171
+ const conv = same && entry ? entry.conv : { provider: ai.provider, messages: [] };
2172
+ // Fixed when the conversation starts (its instructions may not change): a voice changed
2173
+ // meanwhile applies to the next conversation.
2174
+ const voice = same && entry?.voice !== undefined ? entry.voice : await voiceText();
2175
+ chats.set(key, { conv, at: now, voice });
1953
2176
  const before = input.nodes;
1954
2177
  const page = { nodes: before };
1955
2178
  const auth = {};
@@ -1964,7 +2187,7 @@ export function createApp(base) {
1964
2187
  // The instructions stay the same for the whole conversation: the model's earlier reasoning is
1965
2188
  // tied to them (a change is refused) and they are read from the prompt cache. What changes
1966
2189
  // from turn to turn, the page and what is selected, goes with each message instead.
1967
- const system = `You are the assistant inside Materialize, the visual editor of the website "${ctx.config().site.name}". The person (${user.name}) is editing a page; each of their messages starts with a <context> note on the page and on what is selected on the canvas.
2190
+ const system = `${voice ? `${voice}\n\n` : ''}You are the assistant inside Materialize, the visual editor of the website "${ctx.config().site.name}". The person (${user.name}) is editing a page; each of their messages starts with a <context> note on the page and on what is selected on the canvas.
1968
2191
  Use the tools to look at the page (get_page, get_node) and change it (edit_page). Your changes go into the editor, where the person sees them at once and can undo them; they are not published.
1969
2192
  Read the page before changing it. Make the changes asked, consistent with the design already there (theme colors and text styles, spacing, the look of nearby sections); prefer the section library for new sections. Keep ids of existing nodes.
1970
2193
  ${limited ? 'This person may change only contents (texts, links, images, alt texts), not the layout or the design: do not add, remove or move nodes, and do not change styles.\n' : ''}Pictures the person attaches are references (a site they like, a mockup, a sketch): take their layout, hierarchy and mood, and build it with this site's sections, theme and texts; never copy their brand, logos or texts, and do not put the pictures on the page.
@@ -1982,7 +2205,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
1982
2205
  tools,
1983
2206
  onEvent: (e) => void send(e),
1984
2207
  });
1985
- chats.set(key, { conv, at: Date.now() });
2208
+ chats.set(key, { conv, at: Date.now(), voice });
1986
2209
  if (page.nodes !== before) {
1987
2210
  if (limited && !sameDesign(before, page.nodes))
1988
2211
  await send({
@@ -2005,6 +2228,195 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2005
2228
  }
2006
2229
  });
2007
2230
  });
2231
+ // A first version of the site from a few lines about the business: new drafts only, theme and
2232
+ // voice proposed. Admins, since the proposal touches the design of the whole site.
2233
+ /** Runs of the site builder, per person, kept two hours: the person may answer the AI and go on. */
2234
+ const briefRuns = new Map();
2235
+ app.post('/api/ai/site', requireUser, requireSession, requireAdmin, async (c) => {
2236
+ const input = await body(c, z
2237
+ .object({
2238
+ brief: z.string().trim().min(20, 'Scrivi qualche riga in più sull’attività').max(6000).optional(),
2239
+ /** A reply in a run already started, with its id. */
2240
+ runId: z.string().max(64).optional(),
2241
+ message: z.string().trim().min(1).max(4000).optional(),
2242
+ theme: z.boolean().default(true),
2243
+ images: z
2244
+ .array(z.object({
2245
+ mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
2246
+ data: z.base64().max(4_000_000),
2247
+ }))
2248
+ .max(2)
2249
+ .default([]),
2250
+ })
2251
+ .refine((v) => (v.runId ? !!v.message : !!v.brief), { message: 'Scrivi il brief' }));
2252
+ const user = c.get('user');
2253
+ if (!allowAi(user.id))
2254
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2255
+ const ai = await readyAi();
2256
+ const now = Date.now();
2257
+ for (const [k, v] of briefRuns)
2258
+ if (now - v.at > 2 * 60 * 60 * 1000)
2259
+ briefRuns.delete(k);
2260
+ const runId = input.runId ?? crypto.randomUUID();
2261
+ const key = `${user.id}:${runId}`;
2262
+ let run = input.runId ? briefRuns.get(key) : undefined;
2263
+ if (input.runId && (!run || run.conv.provider !== ai.provider))
2264
+ throw new ApiError(409, 'run_expired', 'Questa conversazione non c’è più: parti da un nuovo brief');
2265
+ if (!run) {
2266
+ const config = ctx.config();
2267
+ const pageCollections = config.collections.filter((d) => !d.singleton && Object.values(d.fields).some((f) => f.kind === 'page'));
2268
+ if (!pageCollections.length)
2269
+ throw new ApiError(400, 'no_pages', 'Il sito non ha una raccolta di pagine costruite sulla tela');
2270
+ const locale = config.locales.default;
2271
+ const existing = [];
2272
+ for (const d of config.collections.filter((x) => !x.singleton)) {
2273
+ const { items } = await content.list(d.name, { locale, limit: 100 });
2274
+ for (const i of items)
2275
+ existing.push(`${d.name}: "${i.title}"${i.slug ? ` (slug ${i.slug})` : ''}`);
2276
+ }
2277
+ // Fixed for the whole run, like the assistant's: the model's reasoning is tied to them.
2278
+ const voice = await voiceText();
2279
+ const askVoice = !voice;
2280
+ run = {
2281
+ conv: { provider: ai.provider, messages: [] },
2282
+ system: briefSystem({
2283
+ site: config.site.name,
2284
+ language: languageName(locale),
2285
+ collections: pageCollections.map((d) => `${d.name} (${d.label ?? d.name})`).join(', '),
2286
+ existing: existing.length ? existing.join('; ') : 'none: the site is empty',
2287
+ voice,
2288
+ askVoice,
2289
+ theme: input.theme,
2290
+ }),
2291
+ result: { created: [], theme: null, voice: null },
2292
+ askVoice,
2293
+ theme: input.theme,
2294
+ at: now,
2295
+ };
2296
+ }
2297
+ const current = run;
2298
+ current.at = now;
2299
+ briefRuns.set(key, current);
2300
+ const auth = {};
2301
+ const authorization = c.req.header('authorization');
2302
+ const cookie = c.req.header('cookie');
2303
+ if (authorization)
2304
+ auth.authorization = authorization;
2305
+ else if (cookie)
2306
+ auth.cookie = cookie;
2307
+ return streamSSE(c, async (stream) => {
2308
+ const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
2309
+ const stop = new AbortController();
2310
+ stream.onAbort(() => stop.abort());
2311
+ const { tools, result } = briefTools(apiCaller(app.fetch, auth), {
2312
+ askVoice: current.askVoice,
2313
+ result: current.result,
2314
+ onCreated: (doc) => void send({ type: 'created', doc }),
2315
+ });
2316
+ const before = result.created.length;
2317
+ try {
2318
+ const reply = await runTurn(ai, current.conv, {
2319
+ system: current.system,
2320
+ text: input.runId ? (input.message ?? '') : `<brief>\n${input.brief}\n</brief>`,
2321
+ images: input.images,
2322
+ tools: current.theme ? tools : tools.filter((t) => t.name !== 'propose_theme'),
2323
+ onEvent: (e) => void send(e),
2324
+ maxSteps: 80,
2325
+ signal: stop.signal,
2326
+ });
2327
+ if (result.created.length > before)
2328
+ await record(c, 'site.generated', '', { pages: result.created.length - before });
2329
+ await send({ type: 'result', ...result });
2330
+ await send({ type: 'text', text: reply });
2331
+ await send({ type: 'done', runId });
2332
+ }
2333
+ catch (error) {
2334
+ if (!(error instanceof AiError))
2335
+ console.error(error);
2336
+ // What was made before the failure stays, as drafts: the panel lists it. The run ends.
2337
+ briefRuns.delete(key);
2338
+ await send({ type: 'result', ...result });
2339
+ await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
2340
+ await send({ type: 'done' });
2341
+ }
2342
+ });
2343
+ });
2344
+ /** Requests from the dashboard, per person, kept two hours so the person can answer and go on. */
2345
+ const studioRuns = new Map();
2346
+ app.post('/api/ai/studio', requireUser, async (c) => {
2347
+ const input = await body(c, z.object({
2348
+ runId: z.string().max(64).optional(),
2349
+ message: z.string().trim().min(1).max(6000),
2350
+ images: z
2351
+ .array(z.object({
2352
+ mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
2353
+ data: z.base64().max(4_000_000),
2354
+ }))
2355
+ .max(4)
2356
+ .default([]),
2357
+ }));
2358
+ const user = c.get('user');
2359
+ if (!allowAi(user.id))
2360
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2361
+ const ai = await readyAi();
2362
+ const now = Date.now();
2363
+ for (const [k, v] of studioRuns)
2364
+ if (now - v.at > 2 * 60 * 60 * 1000)
2365
+ studioRuns.delete(k);
2366
+ const runId = input.runId ?? crypto.randomUUID();
2367
+ const key = `${user.id}:${runId}`;
2368
+ const found = input.runId ? studioRuns.get(key) : undefined;
2369
+ const run = found && found.conv.provider === ai.provider
2370
+ ? found
2371
+ : {
2372
+ conv: { provider: ai.provider, messages: [] },
2373
+ // Fixed for the whole conversation: the model's reasoning is tied to it.
2374
+ system: studioSystem({
2375
+ site: ctx.config().site.name,
2376
+ user: user.name,
2377
+ voice: await voiceText(),
2378
+ limited: await contentOnly(user),
2379
+ }),
2380
+ at: now,
2381
+ };
2382
+ run.at = now;
2383
+ studioRuns.set(key, run);
2384
+ const credentials = credentialsOf(c);
2385
+ return streamSSE(c, async (stream) => {
2386
+ const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
2387
+ const stop = new AbortController();
2388
+ stream.onAbort(() => stop.abort());
2389
+ const seen = new Set();
2390
+ const tools = studioTools(apiCaller(app.fetch, credentials), (doc) => {
2391
+ if (seen.has(doc.id))
2392
+ return;
2393
+ seen.add(doc.id);
2394
+ void send({ type: 'touched', doc });
2395
+ });
2396
+ try {
2397
+ const reply = await runTurn(ai, run.conv, {
2398
+ system: run.system,
2399
+ text: input.message,
2400
+ images: input.images,
2401
+ tools,
2402
+ onEvent: (e) => void send(e),
2403
+ maxSteps: 40,
2404
+ signal: stop.signal,
2405
+ });
2406
+ if (seen.size)
2407
+ await record(c, 'site.ai_request', '', { documents: seen.size });
2408
+ await send({ type: 'text', text: reply });
2409
+ await send({ type: 'done', runId });
2410
+ }
2411
+ catch (error) {
2412
+ if (!(error instanceof AiError))
2413
+ console.error(error);
2414
+ studioRuns.delete(key);
2415
+ await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
2416
+ await send({ type: 'done' });
2417
+ }
2418
+ });
2419
+ });
2008
2420
  // ---------- forms ----------
2009
2421
  /**
2010
2422
  * Every form on the published site, by id, as the server checks it: canvas pages with their
@@ -2081,6 +2493,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2081
2493
  const fingerprint = fingerprintOf(form, data);
2082
2494
  const spam = spamReason(data, elapsed) ??
2083
2495
  ((await submissions.seen(fingerprint, new Date(Date.now() - 86_400_000))) ? 'Messaggio ripetuto' : null);
2496
+ // A signup form: no message, a subscriber waiting to confirm. Spam gets the same answer.
2497
+ if (def?.newsletter) {
2498
+ if (!spam)
2499
+ await signUp(c, def, data, page);
2500
+ return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
2501
+ }
2084
2502
  const formName = def?.name?.trim() || def?.subject?.trim() || '';
2085
2503
  const saved = await submissions.add({ form, formName, page, data, spam, fingerprint });
2086
2504
  if (files.length)
@@ -2563,6 +2981,17 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2563
2981
  });
2564
2982
  return c.json(await toJson(existing));
2565
2983
  });
2984
+ /** Why a document's draft cannot go on the site now, in words; null when it can. */
2985
+ const publishProblem = (doc) => {
2986
+ const def = ctx.config().collections.find((x) => x.name === doc.collection);
2987
+ if (!def)
2988
+ return 'La raccolta non esiste più';
2989
+ const check = validate(def, prepare(def, doc.data));
2990
+ if (check.ok)
2991
+ return null;
2992
+ const fields = [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))];
2993
+ return `Alcuni campi vanno completati${fields.length ? `: ${fields.join(', ')}` : ''}`;
2994
+ };
2566
2995
  /**
2567
2996
  * Runs the actions whose time has come, as the person who set them. A server that was off at the
2568
2997
  * time catches up when it starts. A publication whose fields are no longer complete fails with a
@@ -2593,14 +3022,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2593
3022
  }
2594
3023
  try {
2595
3024
  if (item.action === 'publish') {
2596
- const def = ctx.config().collections.find((x) => x.name === existing.collection);
2597
- const check = def ? validate(def, prepare(def, existing.data)) : null;
2598
- if (!def || !check?.ok) {
2599
- const fields = def && check && !check.ok
2600
- ? [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))].join(', ')
2601
- : '';
2602
- throw new Error(def ? `Alcuni campi vanno completati${fields ? `: ${fields}` : ''}` : 'La raccolta non esiste più');
2603
- }
3025
+ const problem = publishProblem(existing);
3026
+ if (problem)
3027
+ throw new Error(problem);
2604
3028
  const { doc, url } = await publishDocument(existing, item.createdBy ?? undefined);
2605
3029
  await schedules.finish(item.id, 'done');
2606
3030
  await note('document.published', doc.title, { collection: doc.collection, id: doc.id, url });
@@ -2623,6 +3047,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2623
3047
  });
2624
3048
  }
2625
3049
  }
3050
+ for (const release of await releases.due(now)) {
3051
+ const by = release.scheduledBy ? { id: release.scheduledBy, name: release.scheduledByName } : null;
3052
+ if (await releaseOut(release, by))
3053
+ ran++;
3054
+ }
2626
3055
  }
2627
3056
  catch (error) {
2628
3057
  console.error('Materialize: scheduled publishing failed', error);
@@ -2648,6 +3077,677 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2648
3077
  }),
2649
3078
  });
2650
3079
  });
3080
+ /** What stops a release from going out: every document must be ready, or none goes. */
3081
+ const releaseProblems = async (release) => {
3082
+ const out = [];
3083
+ for (const item of release.items) {
3084
+ const doc = await content.get(item.documentId);
3085
+ if (!doc)
3086
+ out.push({ documentId: item.documentId, title: '', message: 'La pagina non esiste più' });
3087
+ else if (item.action === 'publish') {
3088
+ const message = publishProblem(doc);
3089
+ if (message)
3090
+ out.push({ documentId: doc.id, title: doc.title, message });
3091
+ }
3092
+ }
3093
+ if (!release.items.length)
3094
+ out.push({ documentId: '', title: '', message: 'Il rilascio è vuoto' });
3095
+ return out;
3096
+ };
3097
+ /**
3098
+ * Puts a release on the site: checked first, all or nothing. Returns whether it went out; when
3099
+ * it did not, the release is marked failed with the reason, and the panel shows it.
3100
+ */
3101
+ const releaseOut = async (release, by) => {
3102
+ const note = (action, target, detail) => activityLog
3103
+ .record({ userId: by?.id ?? null, userName: by?.name ?? '', action, target, detail, ip: null })
3104
+ .catch((error) => console.error('Materialize: activity not recorded', error));
3105
+ const problems = await releaseProblems(release);
3106
+ if (problems.length) {
3107
+ const message = problems.map((p) => (p.title ? `${p.title}: ${p.message}` : p.message)).join('; ');
3108
+ await releases.finish(release.id, 'failed', message);
3109
+ await note('release.failed', release.name, { id: release.id, message });
3110
+ return false;
3111
+ }
3112
+ const done = [];
3113
+ try {
3114
+ for (const item of release.items) {
3115
+ const doc = await content.get(item.documentId);
3116
+ if (!doc)
3117
+ continue;
3118
+ if (item.action === 'publish') {
3119
+ await publishDocument(doc, by?.id);
3120
+ await schedules.cancel(doc.id, 'publish');
3121
+ }
3122
+ else if (doc.publishedAt) {
3123
+ await unpublishDocument(doc, by?.id);
3124
+ await schedules.cancel(doc.id, 'unpublish');
3125
+ }
3126
+ done.push(doc.title);
3127
+ }
3128
+ }
3129
+ catch (error) {
3130
+ const why = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
3131
+ const message = `${why}${done.length ? ` (già uscite: ${done.join(', ')})` : ''}`;
3132
+ await releases.finish(release.id, 'failed', message);
3133
+ await note('release.failed', release.name, { id: release.id, message });
3134
+ return false;
3135
+ }
3136
+ await releases.finish(release.id, 'published');
3137
+ await note('release.published', release.name, { id: release.id, items: release.items.length });
3138
+ return true;
3139
+ };
3140
+ /** A release as the panel shows it: each document with its title, state and what stops it. */
3141
+ const releaseJson = async (release) => {
3142
+ const problems = release.status === 'published' ? [] : await releaseProblems(release);
3143
+ const items = [];
3144
+ for (const item of release.items) {
3145
+ const doc = await content.get(item.documentId);
3146
+ if (!doc)
3147
+ continue;
3148
+ items.push({
3149
+ ...item,
3150
+ title: doc.title,
3151
+ collection: doc.collection,
3152
+ locale: doc.locale,
3153
+ status: doc.status,
3154
+ problem: problems.find((p) => p.documentId === doc.id)?.message ?? null,
3155
+ });
3156
+ }
3157
+ return { ...release, items, ready: problems.length === 0 };
3158
+ };
3159
+ const loadRelease = async (id) => {
3160
+ const release = await releases.get(id);
3161
+ if (!release)
3162
+ throw new ApiError(404, 'not_found', 'Questo rilascio non esiste');
3163
+ return release;
3164
+ };
3165
+ const openRelease = async (id) => {
3166
+ const release = await loadRelease(id);
3167
+ if (release.status === 'published' || release.status === 'cancelled')
3168
+ throw new ApiError(409, 'release_closed', 'Questo rilascio è già uscito o è stato annullato');
3169
+ return release;
3170
+ };
3171
+ app.get('/api/releases', requireUser, async (c) => {
3172
+ const { active, done } = await releases.list();
3173
+ return c.json({
3174
+ releases: await Promise.all(active.map(releaseJson)),
3175
+ published: await Promise.all(done.map(releaseJson)),
3176
+ });
3177
+ });
3178
+ app.post('/api/releases', requireUser, async (c) => {
3179
+ const input = await body(c, z.object({ name: z.string().trim().min(1).max(120) }));
3180
+ const release = await releases.create(input.name, c.get('user'));
3181
+ await record(c, 'release.created', release.name, { id: release.id });
3182
+ return c.json(await releaseJson(release), 201);
3183
+ });
3184
+ app.get('/api/releases/:id', requireUser, async (c) => c.json(await releaseJson(await loadRelease(c.req.param('id')))));
3185
+ app.patch('/api/releases/:id', requireUser, async (c) => {
3186
+ const release = await openRelease(c.req.param('id'));
3187
+ const input = await body(c, z.object({ name: z.string().trim().min(1).max(120) }));
3188
+ await releases.rename(release.id, input.name);
3189
+ return c.json(await releaseJson(await loadRelease(release.id)));
3190
+ });
3191
+ /** Cancels a release: its documents stay as they are. */
3192
+ app.delete('/api/releases/:id', requireUser, async (c) => {
3193
+ const release = await openRelease(c.req.param('id'));
3194
+ await releases.finish(release.id, 'cancelled');
3195
+ await record(c, 'release.cancelled', release.name, { id: release.id });
3196
+ return c.body(null, 204);
3197
+ });
3198
+ /** Adds a document (or changes what happens to it): it leaves any other release. */
3199
+ app.put('/api/releases/:id/items/:documentId', requireUser, async (c) => {
3200
+ const release = await openRelease(c.req.param('id'));
3201
+ const doc = await loadDocument(c.req.param('documentId'));
3202
+ const input = await body(c, z.object({ action: z.enum(['publish', 'unpublish']).default('publish') }));
3203
+ if (input.action === 'unpublish' && !doc.publishedAt)
3204
+ throw new ApiError(409, 'not_published', 'La pagina non è sul sito: non c’è niente da togliere');
3205
+ await releases.put(release.id, doc.id, input.action);
3206
+ await record(c, 'release.item_added', doc.title, { id: release.id, release: release.name, action: input.action });
3207
+ return c.json(await releaseJson(await loadRelease(release.id)));
3208
+ });
3209
+ app.delete('/api/releases/:id/items/:documentId', requireUser, async (c) => {
3210
+ const release = await openRelease(c.req.param('id'));
3211
+ await releases.remove(release.id, c.req.param('documentId'));
3212
+ return c.json(await releaseJson(await loadRelease(release.id)));
3213
+ });
3214
+ /** Everything in the release goes on the site now, or nothing does (the reasons come back). */
3215
+ app.post('/api/releases/:id/publish', requireUser, async (c) => {
3216
+ const release = await openRelease(c.req.param('id'));
3217
+ const problems = await releaseProblems(release);
3218
+ if (problems.length)
3219
+ return c.json({
3220
+ error: {
3221
+ code: 'release_not_ready',
3222
+ message: 'Alcune pagine non sono pronte: non è uscito niente',
3223
+ problems,
3224
+ },
3225
+ }, 422);
3226
+ const user = c.get('user');
3227
+ const ok = await releaseOut(release, user);
3228
+ const after = await loadRelease(release.id);
3229
+ if (!ok)
3230
+ throw new ApiError(422, 'release_failed', after.error ?? 'Il rilascio non è uscito');
3231
+ return c.json(await releaseJson(after));
3232
+ });
3233
+ /** When the release goes out by itself; at null: only by hand. Checked now, and again at that moment. */
3234
+ app.put('/api/releases/:id/schedule', requireUser, async (c) => {
3235
+ const release = await openRelease(c.req.param('id'));
3236
+ const input = await body(c, z.object({ at: z.iso.datetime({ offset: true }).nullable() }));
3237
+ const user = c.get('user');
3238
+ if (input.at === null) {
3239
+ await releases.schedule(release.id, null, null);
3240
+ await record(c, 'release.schedule_cancelled', release.name, { id: release.id });
3241
+ return c.json(await releaseJson(await loadRelease(release.id)));
3242
+ }
3243
+ const at = new Date(input.at);
3244
+ if (at.getTime() <= Date.now() + 30_000)
3245
+ throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
3246
+ if (!release.items.length)
3247
+ throw new ApiError(400, 'empty_release', 'Aggiungi prima qualcosa al rilascio');
3248
+ await releases.schedule(release.id, at, user);
3249
+ await record(c, 'release.scheduled', release.name, { id: release.id, at: at.toISOString() });
3250
+ return c.json(await releaseJson(await loadRelease(release.id)));
3251
+ });
3252
+ // ---------- newsletter ----------
3253
+ const escapeHtml = (v) => v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3254
+ const newsletterSettings = async () => (await settings.get('newsletter')) ?? {};
3255
+ /** The secret that signs tracked links, made the first time it is needed. */
3256
+ const newsletterSecret = async () => {
3257
+ const found = await settings.get('newsletter:secret');
3258
+ if (found)
3259
+ return found;
3260
+ const secret = randomBytes(24).toString('base64url');
3261
+ await settings.set('newsletter:secret', secret);
3262
+ return secret;
3263
+ };
3264
+ /** Where links in newsletters point: the site's address, or the one last used in the panel. */
3265
+ const newsletterOrigin = async (c) => {
3266
+ if (c) {
3267
+ const origin = publicOrigin(c);
3268
+ const current = await newsletterSettings();
3269
+ if (current.origin !== origin)
3270
+ await settings.set('newsletter', { ...current, origin });
3271
+ return origin;
3272
+ }
3273
+ const site = ctx.config().site.url;
3274
+ try {
3275
+ if (site)
3276
+ return new URL(site).origin;
3277
+ }
3278
+ catch { }
3279
+ return (await newsletterSettings()).origin ?? 'http://localhost:4321';
3280
+ };
3281
+ const accentColor = async () => normalizeTheme(await settings.get('theme')).colors.light.accent;
3282
+ const allowSignup = createLimiter(5, 60_000);
3283
+ /** A signup from a form: the email field, a name if there is one, and the confirmation email. */
3284
+ const signUp = async (c, def, data, page) => {
3285
+ const field = def.fields.find((f) => f.kind === 'email') ?? def.fields.find((f) => /mail/i.test(f.name));
3286
+ const email = (field ? data[field.name] : '')?.trim().toLowerCase() ?? '';
3287
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
3288
+ return;
3289
+ if (!allowSignup(`${clientIp(c)}:newsletter`))
3290
+ return;
3291
+ const nameField = def.fields.find((f) => f.kind !== 'email' && /^(nome|name|first.?name)$/i.test(f.name));
3292
+ const { subscriber, confirm } = await newsletter.subscribe({
3293
+ email,
3294
+ name: nameField ? data[nameField.name] : '',
3295
+ locale: langOf(c.req.header('accept-language')),
3296
+ source: page || def.name || def.id,
3297
+ });
3298
+ if (!confirm)
3299
+ return;
3300
+ const mail = await settings.get('mail');
3301
+ if (!isConfigured(mail)) {
3302
+ console.warn('Materialize: a newsletter signup waits for confirmation, but email is not set up');
3303
+ return;
3304
+ }
3305
+ const url = `${await newsletterOrigin()}/api/newsletter/confirm/${subscriber.token}`;
3306
+ const message = confirmEmail({
3307
+ site: ctx.config().site.name,
3308
+ url,
3309
+ accent: await accentColor(),
3310
+ locale: subscriber.locale,
3311
+ });
3312
+ await sendMail(mail, { to: email, ...message }).catch((error) => console.error('Materialize: confirmation email not sent', error));
3313
+ };
3314
+ /** A small page for people coming from an email link: no panel, the site's name and a sentence. */
3315
+ const notice = (c, lang, title, text, form) => c.html(`<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${escapeHtml(title)}</title>
3316
+ <style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#18181b}main{max-width:420px;margin:24px;padding:32px;background:#fff;border-radius:14px;box-shadow:0 1px 3px rgba(0,0,0,.08)}h1{margin:0 0 10px;font-size:22px}p{margin:0 0 16px;line-height:1.6;color:#52525b}button{padding:11px 18px;border:0;border-radius:8px;background:#18181b;color:#fff;font:inherit;font-weight:600;cursor:pointer}a{color:inherit}</style></head>
3317
+ <body><main><p style="font-weight:700;color:#18181b">${escapeHtml(ctx.config().site.name)}</p><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>${form ?? ''}<p><a href="/">${lang === 'it' ? 'Vai al sito' : 'Go to the site'}</a></p></main></body></html>`);
3318
+ app.get('/api/newsletter/confirm/:token', async (c) => {
3319
+ const lang = langOf(c.req.header('accept-language'));
3320
+ const found = await newsletter.confirm(c.req.param('token'));
3321
+ if (!found)
3322
+ return notice(c, lang, lang === 'it' ? 'Link non valido' : 'Link not valid', lang === 'it'
3323
+ ? 'Questo link non funziona più. Iscriviti di nuovo dal sito, se vuoi ricevere la newsletter.'
3324
+ : 'This link no longer works. Sign up again on the site if you want the newsletter.');
3325
+ return notice(c, langOf(found.locale), langOf(found.locale) === 'it' ? 'Iscrizione confermata' : 'Subscription confirmed', langOf(found.locale) === 'it'
3326
+ ? `Da ora riceverai la newsletter di ${ctx.config().site.name} a ${found.email}. Puoi disiscriverti quando vuoi, dal link in fondo a ogni email.`
3327
+ : `From now on you will get the ${ctx.config().site.name} newsletter at ${found.email}. You can unsubscribe whenever you like, from the link at the bottom of every email.`);
3328
+ });
3329
+ // A page with a button, so that link scanners opening the address do not unsubscribe anyone;
3330
+ // email apps' one-click unsubscribe posts to the same address (RFC 8058).
3331
+ app.get('/api/newsletter/unsubscribe/:token', async (c) => {
3332
+ const found = await newsletter.byToken(c.req.param('token'));
3333
+ const lang = langOf(found?.locale ?? c.req.header('accept-language'));
3334
+ if (!found)
3335
+ return notice(c, lang, lang === 'it' ? 'Link non valido' : 'Link not valid', lang === 'it' ? 'Questo link non funziona.' : 'This link does not work.');
3336
+ if (found.status === 'unsubscribed')
3337
+ return notice(c, lang, lang === 'it' ? 'Sei già disiscritto' : 'You are already unsubscribed', lang === 'it'
3338
+ ? `${found.email} non riceve più la newsletter.`
3339
+ : `${found.email} no longer gets the newsletter.`);
3340
+ const send = c.req.query('s') ?? '';
3341
+ return notice(c, lang, lang === 'it' ? 'Disiscriviti dalla newsletter' : 'Unsubscribe from the newsletter', lang === 'it'
3342
+ ? `${found.email} non riceverà più la newsletter di ${ctx.config().site.name}.`
3343
+ : `${found.email} will no longer get the ${ctx.config().site.name} newsletter.`, `<form method="post"><input type="hidden" name="s" value="${escapeHtml(send)}"><p><button type="submit">${lang === 'it' ? 'Disiscrivimi' : 'Unsubscribe me'}</button></p></form>`);
3344
+ });
3345
+ app.post('/api/newsletter/unsubscribe/:token', async (c) => {
3346
+ const found = await newsletter.byToken(c.req.param('token'));
3347
+ const lang = langOf(found?.locale ?? c.req.header('accept-language'));
3348
+ if (!found)
3349
+ return notice(c, lang, lang === 'it' ? 'Link non valido' : 'Link not valid', '');
3350
+ const form = (await c.req.parseBody().catch(() => ({})));
3351
+ const send = typeof form.s === 'string' ? form.s : (c.req.query('s') ?? '');
3352
+ if (found.status !== 'unsubscribed') {
3353
+ await newsletter.unsubscribe(found.id);
3354
+ const sent = send ? await newsletter.send(send) : null;
3355
+ if (sent && sent.subscriberId === found.id)
3356
+ await newsletter.track(sent.id, 'unsubscribe');
3357
+ }
3358
+ return notice(c, lang, lang === 'it' ? 'Fatto: sei disiscritto' : 'Done: you are unsubscribed', lang === 'it'
3359
+ ? 'Non riceverai più la newsletter. Se cambi idea, puoi iscriverti di nuovo dal sito.'
3360
+ : 'You will not get the newsletter any more. If you change your mind, you can sign up again on the site.');
3361
+ });
3362
+ /** Statistics: the picture that tells the email was opened. */
3363
+ app.get('/api/newsletter/o/:send', async (c) => {
3364
+ const id = c.req.param('send').replace(/\.gif$/, '');
3365
+ if ((await newsletterSettings()).tracking !== false && (await newsletter.send(id)))
3366
+ await newsletter.track(id, 'open');
3367
+ return c.body(new Uint8Array(PIXEL), 200, { 'content-type': 'image/gif', 'cache-control': 'no-store' });
3368
+ });
3369
+ /** Statistics: a link followed, then on to where it points (only addresses this site signed). */
3370
+ app.get('/api/newsletter/c/:send', async (c) => {
3371
+ const id = c.req.param('send');
3372
+ const url = c.req.query('u') ?? '';
3373
+ if (!/^https?:\/\//i.test(url) || !validLink(id, url, c.req.query('s') ?? '', await newsletterSecret()))
3374
+ return c.text('Link not valid', 400);
3375
+ if (await newsletter.send(id))
3376
+ await newsletter.track(id, 'click');
3377
+ return c.redirect(url, 302);
3378
+ });
3379
+ // ----- panel: subscribers -----
3380
+ app.get('/api/subscribers', requireUser, async (c) => {
3381
+ const status = c.req.query('status');
3382
+ return c.json(await newsletter.subscribers({
3383
+ status: status === 'pending' || status === 'active' || status === 'unsubscribed' ? status : undefined,
3384
+ search: c.req.query('search') || undefined,
3385
+ limit: Math.min(200, Number(c.req.query('limit') ?? 50)),
3386
+ offset: Number(c.req.query('offset') ?? 0),
3387
+ }));
3388
+ });
3389
+ /** Someone added by hand gets the confirmation email too: consent is theirs to give. */
3390
+ app.post('/api/subscribers', requireUser, requireAdmin, async (c) => {
3391
+ const input = await body(c, z.object({ email: z.email(), name: z.string().trim().max(120).optional() }));
3392
+ const { subscriber, confirm } = await newsletter.subscribe({ ...input, source: 'panel', locale: null });
3393
+ let mailed = false;
3394
+ if (confirm) {
3395
+ const mail = await settings.get('mail');
3396
+ if (isConfigured(mail)) {
3397
+ const url = `${await newsletterOrigin(c)}/api/newsletter/confirm/${subscriber.token}`;
3398
+ await sendMail(mail, {
3399
+ to: subscriber.email,
3400
+ ...confirmEmail({
3401
+ site: ctx.config().site.name,
3402
+ url,
3403
+ accent: await accentColor(),
3404
+ locale: ctx.config().locales.default,
3405
+ }),
3406
+ });
3407
+ mailed = true;
3408
+ }
3409
+ }
3410
+ await record(c, 'newsletter.subscriber_added', subscriber.email);
3411
+ return c.json({ subscriber, mailed }, 201);
3412
+ });
3413
+ app.delete('/api/subscribers/:id', requireUser, requireAdmin, async (c) => {
3414
+ const found = await newsletter.subscriber(c.req.param('id'));
3415
+ if (!found)
3416
+ throw new ApiError(404, 'not_found', 'Questo iscritto non esiste');
3417
+ await newsletter.removeSubscriber(found.id);
3418
+ await record(c, 'newsletter.subscriber_removed', found.email);
3419
+ return c.body(null, 204);
3420
+ });
3421
+ app.post('/api/subscribers/import', requireUser, requireAdmin, async (c) => {
3422
+ const input = await body(c, z.object({ csv: z.string().max(5_000_000) }));
3423
+ const rows = parseSubscribersCsv(input.csv);
3424
+ if (!rows.length)
3425
+ throw new ApiError(422, 'no_addresses', 'Nel file non ho trovato indirizzi email');
3426
+ const result = await newsletter.importMany(rows);
3427
+ await record(c, 'newsletter.imported', '', result);
3428
+ return c.json({ found: rows.length, ...result });
3429
+ });
3430
+ app.get('/api/subscribers/export.csv', requireUser, requireAdmin, async (c) => {
3431
+ const cell = (v) => {
3432
+ const safe = /^[=+\-@\t\r]/.test(v) ? `'${v}` : v;
3433
+ return /[",;\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
3434
+ };
3435
+ const rows = (await newsletter.allSubscribers()).map((s) => [
3436
+ s.email,
3437
+ s.name,
3438
+ s.status,
3439
+ s.source,
3440
+ s.createdAt.toISOString(),
3441
+ s.confirmedAt?.toISOString() ?? '',
3442
+ s.unsubscribedAt?.toISOString() ?? '',
3443
+ ]
3444
+ .map(cell)
3445
+ .join(','));
3446
+ const csv = `email,name,status,source,created,confirmed,unsubscribed\n${rows.join('\n')}\n`;
3447
+ await record(c, 'newsletter.exported', '', { subscribers: rows.length });
3448
+ return c.body(csv, 200, {
3449
+ 'content-type': 'text/csv; charset=utf-8',
3450
+ 'content-disposition': 'attachment; filename="iscritti.csv"',
3451
+ });
3452
+ });
3453
+ app.get('/api/newsletter/settings', requireUser, async (c) => {
3454
+ const s = await newsletterSettings();
3455
+ return c.json({ footer: s.footer ?? '', tracking: s.tracking !== false });
3456
+ });
3457
+ app.put('/api/newsletter/settings', requireUser, requireAdmin, async (c) => {
3458
+ const input = await body(c, z.object({ footer: z.string().max(1000), tracking: z.boolean() }));
3459
+ await settings.set('newsletter', { ...(await newsletterSettings()), ...input });
3460
+ await record(c, 'settings.newsletter');
3461
+ return c.json(input);
3462
+ });
3463
+ // ----- panel: newsletters -----
3464
+ const newsletterJson = async (n) => ({ ...n, stats: await newsletter.stats(n.id) });
3465
+ const loadNewsletter = async (id) => {
3466
+ const found = await newsletter.get(id);
3467
+ if (!found)
3468
+ throw new ApiError(404, 'not_found', 'Questa newsletter non esiste');
3469
+ return found;
3470
+ };
3471
+ const editable = (n) => {
3472
+ if (n.status === 'sending' || n.status === 'sent')
3473
+ throw new ApiError(409, 'newsletter_sent', 'Questa newsletter è già partita: non si può più cambiare');
3474
+ };
3475
+ const newsletterBody = z.object({
3476
+ subject: z.string().trim().min(1).max(200),
3477
+ preheader: z.string().max(200).default(''),
3478
+ body: z.array(z.record(z.string(), z.unknown())).max(2000).default([]),
3479
+ });
3480
+ app.get('/api/newsletters', requireUser, async (c) => c.json({ newsletters: await Promise.all((await newsletter.list()).map(newsletterJson)) }));
3481
+ app.post('/api/newsletters', requireUser, async (c) => {
3482
+ const input = await body(c, newsletterBody);
3483
+ const created = await newsletter.create({ ...input, user: c.get('user') });
3484
+ await record(c, 'newsletter.created', created.subject);
3485
+ return c.json(await newsletterJson(created), 201);
3486
+ });
3487
+ app.get('/api/newsletters/:id', requireUser, async (c) => c.json(await newsletterJson(await loadNewsletter(c.req.param('id')))));
3488
+ app.put('/api/newsletters/:id', requireUser, async (c) => {
3489
+ const found = await loadNewsletter(c.req.param('id'));
3490
+ editable(found);
3491
+ const input = await body(c, newsletterBody);
3492
+ await newsletter.update(found.id, input);
3493
+ return c.json(await newsletterJson(await loadNewsletter(found.id)));
3494
+ });
3495
+ app.delete('/api/newsletters/:id', requireUser, async (c) => {
3496
+ const found = await loadNewsletter(c.req.param('id'));
3497
+ if (found.status === 'sending')
3498
+ throw new ApiError(409, 'newsletter_sending', 'La newsletter sta partendo: aspetta che finisca');
3499
+ await newsletter.remove(found.id);
3500
+ await record(c, 'newsletter.deleted', found.subject);
3501
+ return c.body(null, 204);
3502
+ });
3503
+ /** The email for one send: links, picture and unsubscribe address made for that person. */
3504
+ const composeNewsletter = async (n, send) => {
3505
+ const origin = await newsletterOrigin();
3506
+ const s = await newsletterSettings();
3507
+ const tracking = !!send && s.tracking !== false;
3508
+ const secret = tracking ? await newsletterSecret() : '';
3509
+ const unsubscribeUrl = send
3510
+ ? `${origin}/api/newsletter/unsubscribe/${send.token}?s=${encodeURIComponent(send.id)}`
3511
+ : `${origin}/api/newsletter/unsubscribe/prova`;
3512
+ const content = {
3513
+ site: ctx.config().site.name,
3514
+ siteUrl: origin,
3515
+ subject: n.subject,
3516
+ preheader: n.preheader,
3517
+ body: n.body,
3518
+ accent: await accentColor(),
3519
+ footer: s.footer ?? '',
3520
+ unsubscribeUrl,
3521
+ locale: send?.locale ?? ctx.config().locales.default,
3522
+ ...(tracking && send
3523
+ ? {
3524
+ openUrl: `${origin}/api/newsletter/o/${send.id}.gif`,
3525
+ link: (href) => `${origin}/api/newsletter/c/${send.id}?u=${encodeURIComponent(href)}&s=${linkSignature(send.id, href, secret)}`,
3526
+ }
3527
+ : {}),
3528
+ };
3529
+ return {
3530
+ subject: n.subject,
3531
+ html: newsletterHtml(content),
3532
+ text: newsletterText(content),
3533
+ headers: (send
3534
+ ? { 'List-Unsubscribe': `<${unsubscribeUrl}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' }
3535
+ : {}),
3536
+ };
3537
+ };
3538
+ const readyMail = async () => {
3539
+ const mail = await settings.get('mail');
3540
+ if (!isConfigured(mail))
3541
+ throw new ApiError(400, 'mail_not_configured', 'Per inviare la newsletter serve l’email: Impostazioni → Email');
3542
+ return mail;
3543
+ };
3544
+ /** A try, to the person writing it (or another address): nothing is counted. */
3545
+ app.post('/api/newsletters/:id/test', requireUser, async (c) => {
3546
+ const found = await loadNewsletter(c.req.param('id'));
3547
+ const input = await body(c, z.object({ to: z.email().optional() }));
3548
+ const mail = await readyMail();
3549
+ await newsletterOrigin(c);
3550
+ const message = await composeNewsletter(found, null);
3551
+ await sendMail(mail, { to: input.to ?? c.get('user').email, ...message, subject: `[Prova] ${message.subject}` });
3552
+ return c.json({ ok: true });
3553
+ });
3554
+ app.post('/api/newsletters/:id/send', requireUser, async (c) => {
3555
+ const found = await loadNewsletter(c.req.param('id'));
3556
+ editable(found);
3557
+ await readyMail();
3558
+ await newsletterOrigin(c);
3559
+ const { counts } = await newsletter.subscribers({ limit: 1 });
3560
+ if (!counts.active)
3561
+ throw new ApiError(400, 'no_subscribers', 'Non ci sono ancora iscritti attivi');
3562
+ await newsletter.setStatus(found.id, 'sending', { runAt: new Date(), error: null });
3563
+ await record(c, 'newsletter.sent', found.subject, { subscribers: counts.active });
3564
+ void runNewsletters();
3565
+ return c.json(await newsletterJson(await loadNewsletter(found.id)));
3566
+ });
3567
+ app.put('/api/newsletters/:id/schedule', requireUser, async (c) => {
3568
+ const found = await loadNewsletter(c.req.param('id'));
3569
+ editable(found);
3570
+ const input = await body(c, z.object({ at: z.iso.datetime({ offset: true }).nullable() }));
3571
+ if (input.at === null) {
3572
+ await newsletter.setStatus(found.id, 'draft', { runAt: null });
3573
+ return c.json(await newsletterJson(await loadNewsletter(found.id)));
3574
+ }
3575
+ const at = new Date(input.at);
3576
+ if (at.getTime() <= Date.now() + 30_000)
3577
+ throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
3578
+ await readyMail();
3579
+ await newsletterOrigin(c);
3580
+ await newsletter.setStatus(found.id, 'scheduled', { runAt: at, error: null });
3581
+ await record(c, 'newsletter.scheduled', found.subject, { at: at.toISOString() });
3582
+ return c.json(await newsletterJson(await loadNewsletter(found.id)));
3583
+ });
3584
+ /** An article becomes a newsletter: its title, its summary, its text and a link to read it on the site. */
3585
+ app.post('/api/newsletters/from-document', requireUser, async (c) => {
3586
+ const input = await body(c, z.object({ id: z.string() }));
3587
+ const doc = await loadDocument(input.id);
3588
+ const def = collection(doc.collection);
3589
+ const data = (doc.publishedData ?? doc.data);
3590
+ const fieldOf = (kind) => Object.entries(def.fields).find(([, f]) => f.kind === kind)?.[0];
3591
+ const rich = fieldOf('richText');
3592
+ const summary = Object.entries(def.fields).find(([n, f]) => f.kind === 'text' && /excerpt|summary|sommario|riassunto|descr/i.test(n))?.[0];
3593
+ const seo = (fieldOf('seo') ? data[fieldOf('seo')] : null);
3594
+ const preheader = (summary ? String(data[summary] ?? '') : '') || seo?.description || '';
3595
+ const blocks = rich && Array.isArray(data[rich]) ? [...data[rich]] : [];
3596
+ const url = (() => {
3597
+ try {
3598
+ return publicUrl(def, doc.locale, data);
3599
+ }
3600
+ catch {
3601
+ return null;
3602
+ }
3603
+ })();
3604
+ if (!blocks.length && preheader)
3605
+ blocks.push({ type: 'paragraph', children: [{ text: preheader }] });
3606
+ if (url)
3607
+ blocks.push({
3608
+ type: 'paragraph',
3609
+ children: [
3610
+ { text: langOf(doc.locale) === 'it' ? 'Leggi sul sito →' : 'Read it on the site →', href: url, bold: true },
3611
+ ],
3612
+ });
3613
+ const created = await newsletter.create({
3614
+ subject: doc.title,
3615
+ preheader: preheader.slice(0, 200),
3616
+ body: blocks,
3617
+ user: c.get('user'),
3618
+ });
3619
+ await record(c, 'newsletter.created', created.subject, { from: doc.id });
3620
+ return c.json(await newsletterJson(created), 201);
3621
+ });
3622
+ /** A draft written by the AI from the latest articles, in the brand voice, with a link to each. */
3623
+ app.post('/api/ai/newsletter', requireUser, async (c) => {
3624
+ const input = await body(c, z.object({ count: z.number().int().min(1).max(10).default(3), note: z.string().max(1000).optional() }));
3625
+ const user = c.get('user');
3626
+ if (!allowAi(user.id))
3627
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
3628
+ const ai = await readyAi();
3629
+ const config = ctx.config();
3630
+ const lang = config.locales.default;
3631
+ const items = [];
3632
+ for (const name of feedCollections(config.collections)) {
3633
+ const def = collection(name);
3634
+ for (const doc of await content.published(name)) {
3635
+ if (doc.locale !== lang)
3636
+ continue;
3637
+ const data = (doc.publishedData ?? {});
3638
+ let url = null;
3639
+ try {
3640
+ url = publicUrl(def, lang, data);
3641
+ }
3642
+ catch { }
3643
+ if (!url)
3644
+ continue;
3645
+ const text = Object.entries(def.fields)
3646
+ .filter(([, f]) => f.kind === 'text' || f.kind === 'richText')
3647
+ .map(([n]) => ((f) => (typeof f === 'string' ? f : Array.isArray(f) ? richTextHtml(f).replace(/<[^>]+>/g, ' ') : ''))(data[n]))
3648
+ .join('\n')
3649
+ .replace(/\s+/g, ' ')
3650
+ .slice(0, 2500);
3651
+ items.push({ title: doc.title, url, text, date: (doc.publishedAt ?? doc.updatedAt).getTime() });
3652
+ }
3653
+ }
3654
+ items.sort((a, b) => b.date - a.date);
3655
+ const latest = items.slice(0, input.count);
3656
+ if (!latest.length)
3657
+ throw new ApiError(400, 'no_articles', 'Non ci sono ancora articoli pubblicati da raccontare');
3658
+ const voice = await voiceText();
3659
+ const system = `${voice ? `${voice}\n\n` : ''}You write the email newsletter of the website "${config.site.name}", in ${languageName(lang)}. You get its latest articles; write a short newsletter that makes people want to read them: a subject (at most 70 characters), a preheader (one sentence, at most 120 characters), an opening of one or two sentences, then for each article a heading with its title and two or three sentences on why it is worth reading, ending with a link to it. Use only facts from the articles. Reply with JSON only: {"subject": "...", "preheader": "...", "blocks": [{"type": "paragraph"|"heading", "text": "...", "link": {"text": "...", "href": "..."}?}]}.`;
3660
+ const prompt = `${input.note ? `<note>${input.note}</note>\n` : ''}${latest.map((a) => `<article title="${a.title.replace(/"/g, "'")}" url="${a.url}">\n${a.text}\n</article>`).join('\n')}`;
3661
+ const reply = await complete(ai, { system, prompt }).catch(aiFailed);
3662
+ let parsed;
3663
+ try {
3664
+ parsed = JSON.parse(reply.replace(/^```(?:json)?\s*|\s*```$/g, ''));
3665
+ }
3666
+ catch {
3667
+ throw new ApiError(422, 'ai_failed', 'La risposta dell’AI non era leggibile: riprova');
3668
+ }
3669
+ const known = new Set(latest.map((a) => a.url));
3670
+ const blocks = [];
3671
+ for (const b of parsed.blocks ?? []) {
3672
+ const text = (b.text ?? '').trim();
3673
+ const children = text ? [{ text }] : [];
3674
+ if (b.link?.href && known.has(b.link.href) && b.link.text)
3675
+ children.push({ text: `${text ? ' ' : ''}${b.link.text.trim()}`, href: b.link.href, bold: true });
3676
+ if (!children.length)
3677
+ continue;
3678
+ blocks.push(b.type === 'heading' ? { type: 'heading', level: 2, children } : { type: 'paragraph', children });
3679
+ }
3680
+ const created = await newsletter.create({
3681
+ subject: (parsed.subject ?? '').trim().slice(0, 200) || latest[0]?.title || config.site.name,
3682
+ preheader: (parsed.preheader ?? '').trim().slice(0, 200),
3683
+ body: blocks,
3684
+ user,
3685
+ });
3686
+ await record(c, 'newsletter.created', created.subject, { ai: true });
3687
+ return c.json(await newsletterJson(created), 201);
3688
+ });
3689
+ /**
3690
+ * Sends what is due: scheduled newsletters whose time has come and those a stopped server left
3691
+ * halfway. One email at a time, a short pause between them, so the email service keeps up.
3692
+ */
3693
+ let newslettersRunning = false;
3694
+ const runNewsletters = async (now = new Date(), pause = 120) => {
3695
+ if (newslettersRunning)
3696
+ return 0;
3697
+ newslettersRunning = true;
3698
+ let sent = 0;
3699
+ try {
3700
+ for (const n of await newsletter.due(now)) {
3701
+ const mail = await settings.get('mail');
3702
+ if (!isConfigured(mail)) {
3703
+ await newsletter.setStatus(n.id, 'failed', { error: 'L’email non è configurata' });
3704
+ continue;
3705
+ }
3706
+ if (n.status === 'scheduled')
3707
+ await newsletter.setStatus(n.id, 'sending');
3708
+ await newsletter.prepare(n.id);
3709
+ for (;;) {
3710
+ const batch = await newsletter.pending(n.id, 50);
3711
+ if (!batch.length)
3712
+ break;
3713
+ for (const item of batch) {
3714
+ const who = await newsletter.subscriber(item.subscriberId);
3715
+ if (!who || who.status !== 'active') {
3716
+ await newsletter.markSend(item.id, 'failed', 'Non più iscritto');
3717
+ continue;
3718
+ }
3719
+ try {
3720
+ const message = await composeNewsletter(n, { id: item.id, token: who.token, locale: who.locale });
3721
+ await sendMail(mail, { to: who.email, ...message });
3722
+ await newsletter.markSend(item.id, 'sent');
3723
+ sent++;
3724
+ }
3725
+ catch (error) {
3726
+ await newsletter.markSend(item.id, 'failed', error instanceof Error ? error.message.slice(0, 300) : 'Errore');
3727
+ }
3728
+ if (pause)
3729
+ await new Promise((r) => setTimeout(r, pause));
3730
+ }
3731
+ }
3732
+ const stats = await newsletter.stats(n.id);
3733
+ const allFailed = stats.total > 0 && stats.sent === 0;
3734
+ await newsletter.setStatus(n.id, allFailed ? 'failed' : 'sent', {
3735
+ sentAt: new Date(),
3736
+ error: allFailed ? 'Nessun invio riuscito: controlla Impostazioni → Email' : null,
3737
+ });
3738
+ }
3739
+ // Signups never confirmed are dropped after a week.
3740
+ await newsletter.dropUnconfirmed(new Date(now.getTime() - 7 * 86_400_000));
3741
+ }
3742
+ catch (error) {
3743
+ console.error('Materialize: newsletter sending failed', error);
3744
+ }
3745
+ finally {
3746
+ newslettersRunning = false;
3747
+ }
3748
+ return sent;
3749
+ };
3750
+ setInterval(() => void runNewsletters(), 30_000).unref?.();
2651
3751
  // ---------- shared preview links ----------
2652
3752
  const previewJson = (c, link) => {
2653
3753
  const path = `/_materialize/share/${link.token}`;
@@ -2692,6 +3792,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2692
3792
  const existing = await loadDocument(c.req.param('id'));
2693
3793
  await content.remove(existing.id);
2694
3794
  await schedules.removeForDocument(existing.id);
3795
+ await releases.removeDocument(existing.id);
2695
3796
  await previewLinks.removeForDocument(existing.id);
2696
3797
  if (existing.publishedAt)
2697
3798
  sitePublished();
@@ -2761,7 +3862,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2761
3862
  slots = only(slots);
2762
3863
  if (!slots.length)
2763
3864
  return;
2764
- const translated = await translateTexts(ai, slots.map((s) => s.text), from, to).catch(aiFailed);
3865
+ const translated = await translateTexts(ai, slots.map((s) => s.text), from, to, undefined, await voiceText('style')).catch(aiFailed);
2765
3866
  for (const [i, slot] of slots.entries())
2766
3867
  slot.set(translated[i]);
2767
3868
  };
@@ -2838,7 +3939,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2838
3939
  const own = nodeSlots(walk(data[name]));
2839
3940
  if (!own.length)
2840
3941
  continue;
2841
- const translated = await translateTexts(ai, own.map((s) => s.text), original.locale, doc.locale).catch(aiFailed);
3942
+ const translated = await translateTexts(ai, own.map((s) => s.text), original.locale, doc.locale, undefined, await voiceText('style')).catch(aiFailed);
2842
3943
  for (const [i, slot] of own.entries())
2843
3944
  slot.set(translated[i]);
2844
3945
  }
@@ -2944,6 +4045,60 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2944
4045
  ])),
2945
4046
  });
2946
4047
  });
4048
+ /** The caller's credentials, for tools that go through the API as them. */
4049
+ const credentialsOf = (c) => {
4050
+ const authorization = c.req.header('authorization');
4051
+ const cookie = c.req.header('cookie');
4052
+ return authorization ? { authorization } : cookie ? { cookie } : {};
4053
+ };
4054
+ /**
4055
+ * An imported page laid out again by the AI with the section library, in its draft. The texts
4056
+ * and images must stay the same (checked after): otherwise the page is left as it was.
4057
+ */
4058
+ const relayoutPage = async (ai, id, user, credentials) => {
4059
+ const doc = await content.get(id);
4060
+ if (!doc)
4061
+ return 'la pagina non c’è più';
4062
+ const def = collection(doc.collection);
4063
+ const field = Object.entries(def.fields).find(([, f]) => f.kind === 'page')?.[0];
4064
+ const before = field && Array.isArray(doc.data[field]) ? doc.data[field] : null;
4065
+ if (!field || !before?.length)
4066
+ return 'la pagina è vuota';
4067
+ const page = { nodes: before };
4068
+ const call = apiCaller(app.fetch, credentials);
4069
+ const getSchema = () => call('GET', '/api/schema');
4070
+ const tools = [
4071
+ ...contentTools(call, getSchema).filter((t) => ['list_library', 'get_theme'].includes(t.name)),
4072
+ ...pageTools({
4073
+ withId: false,
4074
+ brand: async () => ctx.config().site.name,
4075
+ source: async () => ({
4076
+ load: async () => page.nodes,
4077
+ save: async (nodes) => {
4078
+ page.nodes = nodes;
4079
+ },
4080
+ }),
4081
+ }),
4082
+ ];
4083
+ const system = `You redesign one page of the website "${ctx.config().site.name}", just imported from WordPress into Materialize. Its content arrived as a plain column of texts, images and buttons, one section per heading. Rebuild it so it looks designed, with the section library.
4084
+
4085
+ How: read the page (get_page) and the library (list_library); replace the plain sections with library sections (edit_page: add_section, then fill them; remove what you replace and the library sample nodes you do not use); read the page again at the end and fix what is left.
4086
+
4087
+ Rules:
4088
+ - Keep every text word for word: you may split it across sections, change its style (title, body, eyebrow) or turn a list into cards, but do not rewrite, shorten or translate it.
4089
+ - Keep every image (same src and alt) and every button with its link.
4090
+ - Add no text of your own: no new headings, slogans, labels or facts. Library sample texts and pictures you do not replace with the page's own content must be removed.
4091
+ - Do not add a menu or a footer: the site has its own.
4092
+ - Keep it simple: a clear opening, then sections that follow the page's order.
4093
+ When done, reply with one short sentence.`;
4094
+ await runTurn(ai, { provider: ai.provider, messages: [] }, { system, text: `Lay out the page "${doc.title}".`, tools, onEvent: () => { }, maxSteps: 30 });
4095
+ const problem = relayoutProblem(before, page.nodes);
4096
+ if (problem)
4097
+ return problem;
4098
+ const draft = draftData(def, { ...doc.data, [field]: restoreAlts(before, page.nodes).map(plain) });
4099
+ await content.update(doc.id, { ...draft, userId: user.id });
4100
+ return null;
4101
+ };
2947
4102
  app.post('/api/import/wordpress/run', requireUser, requireSession, requireAdmin, async (c) => {
2948
4103
  const target = z.object({ collection: z.string(), fields: z.record(z.string(), z.string()) }).nullable();
2949
4104
  const input = await body(c, z.object({
@@ -2956,9 +4111,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2956
4111
  locale: z.string().optional(),
2957
4112
  categories: z.record(z.string(), z.string()).optional(),
2958
4113
  fallbackCategory: z.string().optional(),
4114
+ aiLayout: z.boolean().optional(),
2959
4115
  }),
2960
4116
  }));
2961
4117
  const held = wpSites.get(input.token);
4118
+ const ai = input.plan.aiLayout ? await readyAi() : null;
2962
4119
  if (!held)
2963
4120
  throw new ApiError(409, 'import_expired', 'Read the site again');
2964
4121
  const lang = locale(input.plan.locale);
@@ -2977,6 +4134,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2977
4134
  problems: [],
2978
4135
  media: { downloaded: 0, failed: 0, reasons: {} },
2979
4136
  redirects: 0,
4137
+ relaid: 0,
2980
4138
  at: Date.now(),
2981
4139
  };
2982
4140
  wpJobs.set(id, job);
@@ -3067,6 +4225,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3067
4225
  return list.length - before;
3068
4226
  },
3069
4227
  onProgress: (p) => Object.assign(job, p),
4228
+ relayout: ai ? (doc) => relayoutPage(ai, doc.id, user, credentialsOf(c)) : undefined,
3070
4229
  };
3071
4230
  void runImport(held.site, plan, deps)
3072
4231
  .then(async (result) => {
@@ -3078,7 +4237,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3078
4237
  userName: user.name,
3079
4238
  action: 'import.wordpress',
3080
4239
  target: held.site.name,
3081
- detail: { created: result.created.length, media: result.media.downloaded, redirects: result.redirects },
4240
+ detail: {
4241
+ created: result.created.length,
4242
+ media: result.media.downloaded,
4243
+ redirects: result.redirects,
4244
+ ...(result.relaid ? { relaid: result.relaid } : {}),
4245
+ },
3082
4246
  ip: null,
3083
4247
  });
3084
4248
  })
@@ -3260,5 +4424,5 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3260
4424
  return c.json(publicDocument(doc));
3261
4425
  });
3262
4426
  // runSchedules: for the tests, which cannot wait for the clock.
3263
- return Object.assign(app, { runSchedules });
4427
+ return Object.assign(app, { runSchedules, runNewsletters });
3264
4428
  }