@materializecms/server 0.6.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/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ Ogni versione, dalla più recente. Le novità in arrivo si scrivono sotto «Pros
4
4
 
5
5
  ## Prossima versione
6
6
 
7
+ ## 0.7.0 — 2026-11-06
8
+
9
+ - **Tour del pannello**: la prima volta che entri, un tour mostra il pannello una zona alla volta, come quello dell'editor. Si rifà dal menu del profilo (**Tour del pannello**) o cercando «tour» con ⌘K.
10
+ - **Newsletter**: raccogli iscritti dal sito con doppia conferma via email, importali da Mailchimp o Brevo, scrivi la newsletter da zero, da un articolo o con l'AI dagli ultimi articoli, provala su di te e mandala subito o a un'ora stabilita. Ogni email ha il link per disiscriversi; per ogni invio vedi aperture e clic. Nuova voce **Newsletter** nella barra laterale.
11
+ - Le sezioni «Invito → Newsletter» e «Footer → Con iscrizione» della libreria hanno un modulo vero, collegato alla newsletter.
12
+ - **Pannello in inglese, prima parte**: ognuno può scegliere l'inglese nel profilo (o nella pagina di accesso); per ora sono tradotti accesso, barra laterale, panoramica e profilo. Finché la traduzione non è completa il pannello parte in italiano per tutti.
7
13
  ## 0.6.0 — 2026-11-06
8
14
 
9
15
  - **Panoramica rinnovata**: si apre su quello che c'è da fare (documenti da pubblicare, il rilascio in preparazione, la prossima uscita programmata, i messaggi nuovi), con un riepilogo in una frase, le ultime modifiche e il sito a colpo d'occhio.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ <img src="https://raw.githubusercontent.com/materializecms/materialize/main/docs/assets/logo-icon.svg" alt="Materialize" width="48" height="48">
2
+
1
3
  # @materializecms/server
2
4
 
3
5
  Il server di Materialize: API, accessi, media, backup, aggiornamenti dal pannello e server MCP.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@materializecms/server",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Il server di Materialize: API, accessi, media, backup, aggiornamenti dal pannello e server MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,9 +20,9 @@
20
20
  "dependencies": {
21
21
  "@anthropic-ai/sdk": "1.3.0",
22
22
  "@hono/node-server": "2.1.5",
23
- "@materializecms/admin": "0.6.0",
24
- "@materializecms/core": "0.6.0",
25
- "@materializecms/db": "0.6.0",
23
+ "@materializecms/admin": "0.7.0",
24
+ "@materializecms/core": "0.7.0",
25
+ "@materializecms/db": "0.7.0",
26
26
  "@modelcontextprotocol/sdk": "1.32.2",
27
27
  "hono": "4.13.14",
28
28
  "htmlparser2": "12.0.0",
package/src/app.d.ts CHANGED
@@ -31,5 +31,6 @@ export interface ServerContext {
31
31
  }
32
32
  export declare function createApp(base: ServerContext): Hono<Env, import("hono/types").BlankSchema, "/"> & {
33
33
  runSchedules: (now?: Date) => Promise<number>;
34
+ runNewsletters: (now?: Date, pause?: number) => Promise<number>;
34
35
  };
35
36
  export {};
package/src/app.js CHANGED
@@ -9,12 +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
16
  import { VOICE_TONES, voicePrompt } from '@materializecms/core/voice';
17
- import { createActivityStore, createAuthStore, createCommentStore, createContentStore, createMediaStore, createPreviewLinkStore, createReleaseStore, createScheduleStore, createSettingsStore, createSubmissionStore, EmailTakenError, SlugTakenError, } from '@materializecms/db';
17
+ import { createActivityStore, createAuthStore, createCommentStore, createContentStore, createMediaStore, createNewsletterStore, createPreviewLinkStore, createReleaseStore, createScheduleStore, createSettingsStore, createSubmissionStore, EmailTakenError, SlugTakenError, } from '@materializecms/db';
18
18
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
19
19
  import { Hono } from 'hono';
20
20
  import { bodyLimit } from 'hono/body-limit';
@@ -34,6 +34,7 @@ import { fingerprintOf, readSubmission, removeFiles, saveFiles, sendWebhook, spa
34
34
  import { checkSenderDomain, explainMailError, isConfigured, publicMail, sendMail, submissionText, } from './mail.js';
35
35
  import { createMcpServer } from './mcp.js';
36
36
  import { createMediaFiles, fileBody, MAX_UPLOAD, MediaError, mediaUrl } from './media.js';
37
+ import { confirmEmail, langOf, linkSignature, newsletterHtml, newsletterText, PIXEL, parseSubscribersCsv, validLink, } from './newsletter.js';
37
38
  import { DEFAULT_OFFSITE, newRecoveryKey, runOffsite, testOffsite, } from './offsite.js';
38
39
  import { hashPassword, verifyPassword } from './password.js';
39
40
  import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
@@ -194,6 +195,7 @@ export function createApp(base) {
194
195
  const activityLog = createActivityStore(ctx.database.db);
195
196
  const schedules = createScheduleStore(ctx.database.db);
196
197
  const releases = createReleaseStore(ctx.database.db);
198
+ const newsletter = createNewsletterStore(ctx.database.db);
197
199
  const previewLinks = createPreviewLinkStore(ctx.database.db);
198
200
  const backups = createBackups(ctx.database, () => ctx.config().media.dir);
199
201
  const backupSettings = async () => ({
@@ -504,7 +506,9 @@ export function createApp(base) {
504
506
  app.use('*', secureHeaders());
505
507
  // Requests with a bearer token carry no cookie, so a forged form cannot ride on them.
506
508
  const checkOrigin = csrf();
507
- 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));
508
512
  // Answers change with every edit: browsers must not reuse them.
509
513
  app.use('/api/*', async (c, next) => {
510
514
  await next();
@@ -898,7 +902,7 @@ export function createApp(base) {
898
902
  const ACTIVITY_GROUPS = {
899
903
  access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.', 'password.'],
900
904
  people: ['user.'],
901
- content: ['document.', 'release.', 'site.', 'media.', 'message.'],
905
+ content: ['document.', 'release.', 'site.', 'newsletter.', 'import.', 'media.', 'message.'],
902
906
  settings: ['settings.', 'backup.', 'update.'],
903
907
  };
904
908
  app.get('/api/activity', requireUser, requireAdmin, async (c) => {
@@ -1357,7 +1361,11 @@ export function createApp(base) {
1357
1361
  return c.json({ ok: true });
1358
1362
  });
1359
1363
  app.patch('/api/auth/me', requireUser, async (c) => {
1360
- 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
+ }));
1361
1369
  return c.json({ user: await auth.updateUser(c.get('user').id, input) });
1362
1370
  });
1363
1371
  /** A changed sign-in address: the old one is told, in case it was not its owner who changed it. */
@@ -2485,6 +2493,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2485
2493
  const fingerprint = fingerprintOf(form, data);
2486
2494
  const spam = spamReason(data, elapsed) ??
2487
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
+ }
2488
2502
  const formName = def?.name?.trim() || def?.subject?.trim() || '';
2489
2503
  const saved = await submissions.add({ form, formName, page, data, spam, fingerprint });
2490
2504
  if (files.length)
@@ -3235,6 +3249,505 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3235
3249
  await record(c, 'release.scheduled', release.name, { id: release.id, at: at.toISOString() });
3236
3250
  return c.json(await releaseJson(await loadRelease(release.id)));
3237
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?.();
3238
3751
  // ---------- shared preview links ----------
3239
3752
  const previewJson = (c, link) => {
3240
3753
  const path = `/_materialize/share/${link.token}`;
@@ -3911,5 +4424,5 @@ When done, reply with one short sentence.`;
3911
4424
  return c.json(publicDocument(doc));
3912
4425
  });
3913
4426
  // runSchedules: for the tests, which cannot wait for the clock.
3914
- return Object.assign(app, { runSchedules });
4427
+ return Object.assign(app, { runSchedules, runNewsletters });
3915
4428
  }
package/src/mail.d.ts CHANGED
@@ -30,7 +30,9 @@ export declare function sendMail(m: MailSettings, message: {
30
30
  to: string;
31
31
  subject: string;
32
32
  text: string;
33
+ html?: string;
33
34
  replyTo?: string;
35
+ headers?: Record<string, string>;
34
36
  }): Promise<void>;
35
37
  /** A message from a site form as plain text: one "Label: value" per field. */
36
38
  export declare function submissionText(page: string, data: Record<string, string>): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The newsletter as an email: an HTML version that reads well in every email app (one column,
3
+ * inline styles, absolute addresses) and a plain text one. With statistics on, a tiny picture
4
+ * tells when it is opened and links pass through the site, signed so they cannot be bent to
5
+ * send people elsewhere.
6
+ */
7
+ type Lang = 'it' | 'en';
8
+ export declare const langOf: (locale: string | null | undefined) => Lang;
9
+ export interface NewsletterContent {
10
+ site: string;
11
+ /** The site's public address, for links and pictures. */
12
+ siteUrl: string;
13
+ subject: string;
14
+ preheader: string;
15
+ body: unknown[];
16
+ /** The site's accent color, for links and buttons. */
17
+ accent: string;
18
+ /** The sender's details, shown at the bottom (often required by law: a postal address). */
19
+ footer: string;
20
+ unsubscribeUrl: string;
21
+ /** Statistics: the picture that tells the email was opened, and each link through the site. */
22
+ openUrl?: string;
23
+ link?: (href: string) => string;
24
+ locale?: string | null;
25
+ }
26
+ export declare function newsletterHtml(c: NewsletterContent): string;
27
+ /** The same newsletter as plain text, for email apps that show no HTML. */
28
+ export declare function newsletterText(c: NewsletterContent): string;
29
+ /** The email that asks a new subscriber to confirm. */
30
+ export declare function confirmEmail(input: {
31
+ site: string;
32
+ url: string;
33
+ accent: string;
34
+ locale?: string | null;
35
+ }): {
36
+ subject: string;
37
+ text: string;
38
+ html: string;
39
+ };
40
+ /** The signature of a tracked link: without it, the address would redirect anywhere. */
41
+ export declare function linkSignature(sendId: string, url: string, secret: string): string;
42
+ export declare function validLink(sendId: string, url: string, signature: string, secret: string): boolean;
43
+ /** A 1×1 transparent GIF: the "opened" picture. */
44
+ export declare const PIXEL: Buffer<ArrayBuffer>;
45
+ /** Addresses out of a CSV export (Mailchimp, Brevo, a spreadsheet): the email column, and a name if there is one. */
46
+ export declare function parseSubscribersCsv(text: string): {
47
+ email: string;
48
+ name?: string;
49
+ }[];
50
+ export {};
@@ -0,0 +1,192 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { richTextHtml } from '@materializecms/core/page';
3
+ const WORDS = {
4
+ it: {
5
+ unsubscribe: 'Non vuoi più ricevere queste email? Disiscriviti',
6
+ why: 'Ricevi questa email perché ti sei iscritto alla newsletter di {site}.',
7
+ confirmSubject: 'Conferma l’iscrizione alla newsletter di {site}',
8
+ confirmIntro: 'Ciao! Per ricevere la newsletter di {site} conferma il tuo indirizzo:',
9
+ confirmButton: 'Confermo l’iscrizione',
10
+ confirmIgnore: 'Se non ti sei iscritto tu, ignora questa email: senza conferma non riceverai niente.',
11
+ view: 'Apri nel browser',
12
+ },
13
+ en: {
14
+ unsubscribe: 'Don’t want these emails any more? Unsubscribe',
15
+ why: 'You get this email because you signed up for the {site} newsletter.',
16
+ confirmSubject: 'Confirm your subscription to the {site} newsletter',
17
+ confirmIntro: 'Hi! To get the {site} newsletter, confirm your address:',
18
+ confirmButton: 'Confirm my subscription',
19
+ confirmIgnore: 'If you did not sign up, ignore this email: without confirming you will get nothing.',
20
+ view: 'Open in your browser',
21
+ },
22
+ };
23
+ export const langOf = (locale) => locale?.toLowerCase().startsWith('it') ? 'it' : 'en';
24
+ const say = (lang, key, site) => (WORDS[lang][key] ?? key).replace('{site}', site);
25
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
26
+ /** An address on the site made absolute; others stay as they are. */
27
+ const absolute = (href, base) => {
28
+ try {
29
+ return new URL(href, `${base.replace(/\/$/, '')}/`).toString();
30
+ }
31
+ catch {
32
+ return href;
33
+ }
34
+ };
35
+ /** Inline styles: email apps drop style sheets. */
36
+ const STYLE = {
37
+ p: 'margin:0 0 16px;font-size:16px;line-height:1.6;',
38
+ h2: 'margin:28px 0 12px;font-size:22px;line-height:1.3;font-weight:700;',
39
+ h3: 'margin:24px 0 10px;font-size:18px;line-height:1.35;font-weight:700;',
40
+ h4: 'margin:20px 0 8px;font-size:16px;line-height:1.4;font-weight:700;',
41
+ ul: 'margin:0 0 16px;padding-left:22px;font-size:16px;line-height:1.6;',
42
+ li: 'margin:0 0 6px;',
43
+ blockquote: 'margin:0 0 16px;padding:4px 0 4px 16px;border-left:3px solid #d4d4d8;color:#52525b;font-style:italic;',
44
+ figure: 'margin:0 0 16px;',
45
+ img: 'display:block;max-width:100%;height:auto;border:0;border-radius:8px;',
46
+ };
47
+ export function newsletterHtml(c) {
48
+ const lang = langOf(c.locale);
49
+ let body = richTextHtml(c.body);
50
+ body = body.replace(/(src|href)="([^"]*)"/g, (_, attr, value) => {
51
+ const raw = value.replace(/&amp;/g, '&');
52
+ const abs = absolute(raw, c.siteUrl);
53
+ const out = attr === 'href' && c.link && /^https?:/i.test(abs) ? c.link(abs) : abs;
54
+ return `${attr}="${esc(out)}"`;
55
+ });
56
+ body = body.replace(/<(p|h2|h3|h4|ul|li|blockquote|figure|img)(\s|>)/g, (_, tag, after) => {
57
+ return `<${tag} style="${STYLE[tag]}"${after}`;
58
+ });
59
+ body = body.replace(/<a /g, `<a style="color:${esc(c.accent)};text-decoration:underline;" `);
60
+ body = body.replace(/ loading="lazy" decoding="async"/g, '');
61
+ const footer = c.footer.trim() ? `<p style="margin:0 0 8px;">${esc(c.footer.trim()).replace(/\n/g, '<br>')}</p>` : '';
62
+ return `<!doctype html>
63
+ <html lang="${lang}">
64
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(c.subject)}</title></head>
65
+ <body style="margin:0;padding:0;background:#f4f4f5;">
66
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;">${esc(c.preheader)}</div>
67
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;">
68
+ <tr><td align="center" style="padding:32px 16px;">
69
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:12px;">
70
+ <tr><td style="padding:28px 32px 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;font-weight:700;color:#18181b;">${esc(c.site)}</td></tr>
71
+ <tr><td style="padding:8px 32px 24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#18181b;">
72
+ <h1 style="margin:0 0 20px;font-size:26px;line-height:1.25;font-weight:700;">${esc(c.subject)}</h1>
73
+ ${body}
74
+ </td></tr>
75
+ </table>
76
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;">
77
+ <tr><td style="padding:20px 32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:12px;line-height:1.6;color:#71717a;text-align:center;">
78
+ ${footer}<p style="margin:0 0 8px;">${esc(say(lang, 'why', c.site))}</p>
79
+ <p style="margin:0;"><a href="${esc(c.unsubscribeUrl)}" style="color:#71717a;text-decoration:underline;">${esc(say(lang, 'unsubscribe', c.site))}</a></p>
80
+ </td></tr>
81
+ </table>
82
+ </td></tr>
83
+ </table>
84
+ ${c.openUrl ? `<img src="${esc(c.openUrl)}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px;">` : ''}
85
+ </body>
86
+ </html>`;
87
+ }
88
+ /** The same newsletter as plain text, for email apps that show no HTML. */
89
+ export function newsletterText(c) {
90
+ const lang = langOf(c.locale);
91
+ const lines = [c.subject, ''];
92
+ const runs = (b) => (b.children ?? [])
93
+ .map((r) => (r.href ? `${r.text ?? ''} (${absolute(r.href, c.siteUrl)})` : (r.text ?? '')))
94
+ .join('');
95
+ for (const b of c.body) {
96
+ if (b.type === 'image')
97
+ continue;
98
+ const text = runs(b).trim();
99
+ if (!text)
100
+ continue;
101
+ if (b.type === 'listItem')
102
+ lines.push(`- ${text}`);
103
+ else if (b.type === 'heading')
104
+ lines.push('', text.toUpperCase(), '');
105
+ else
106
+ lines.push(text, '');
107
+ }
108
+ lines.push('—', ...(c.footer.trim() ? [c.footer.trim()] : []), say(lang, 'why', c.site));
109
+ lines.push(`${say(lang, 'unsubscribe', c.site)}: ${c.unsubscribeUrl}`);
110
+ return lines.join('\n');
111
+ }
112
+ /** The email that asks a new subscriber to confirm. */
113
+ export function confirmEmail(input) {
114
+ const lang = langOf(input.locale);
115
+ const subject = say(lang, 'confirmSubject', input.site);
116
+ const text = [say(lang, 'confirmIntro', input.site), '', input.url, '', say(lang, 'confirmIgnore', input.site)].join('\n');
117
+ const html = `<!doctype html><html lang="${lang}"><body style="margin:0;padding:32px 16px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#18181b;">
118
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:12px;"><tr><td style="padding:28px 32px;">
119
+ <p style="margin:0 0 20px;font-size:16px;line-height:1.6;">${esc(say(lang, 'confirmIntro', input.site))}</p>
120
+ <p style="margin:0 0 24px;"><a href="${esc(input.url)}" style="display:inline-block;padding:12px 20px;border-radius:8px;background:${esc(input.accent)};color:#ffffff;font-weight:600;text-decoration:none;">${esc(say(lang, 'confirmButton', input.site))}</a></p>
121
+ <p style="margin:0;font-size:13px;line-height:1.6;color:#71717a;">${esc(say(lang, 'confirmIgnore', input.site))}</p>
122
+ </td></tr></table></body></html>`;
123
+ return { subject, text, html };
124
+ }
125
+ /** The signature of a tracked link: without it, the address would redirect anywhere. */
126
+ export function linkSignature(sendId, url, secret) {
127
+ return createHmac('sha256', secret).update(`${sendId}\n${url}`).digest('base64url').slice(0, 22);
128
+ }
129
+ export function validLink(sendId, url, signature, secret) {
130
+ const expected = Buffer.from(linkSignature(sendId, url, secret));
131
+ const given = Buffer.from(signature);
132
+ return expected.length === given.length && timingSafeEqual(expected, given);
133
+ }
134
+ /** A 1×1 transparent GIF: the "opened" picture. */
135
+ export const PIXEL = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
136
+ /** Addresses out of a CSV export (Mailchimp, Brevo, a spreadsheet): the email column, and a name if there is one. */
137
+ export function parseSubscribersCsv(text) {
138
+ const lines = text
139
+ .replace(/^/, '')
140
+ .split(/\r?\n/)
141
+ .filter((l) => l.trim());
142
+ if (!lines.length)
143
+ return [];
144
+ const sep = [';', ',', '\t'].sort((a, b) => (lines[0]?.split(b).length ?? 0) - (lines[0]?.split(a).length ?? 0))[0] ?? ',';
145
+ const cells = (l) => {
146
+ const out = [];
147
+ let cur = '';
148
+ let quoted = false;
149
+ for (let i = 0; i < l.length; i++) {
150
+ const ch = l[i];
151
+ if (ch === '"') {
152
+ if (quoted && l[i + 1] === '"') {
153
+ cur += '"';
154
+ i++;
155
+ }
156
+ else
157
+ quoted = !quoted;
158
+ }
159
+ else if (ch === sep && !quoted) {
160
+ out.push(cur);
161
+ cur = '';
162
+ }
163
+ else
164
+ cur += ch;
165
+ }
166
+ out.push(cur);
167
+ return out.map((c) => c.trim());
168
+ };
169
+ const head = cells(lines[0] ?? '').map((h) => h.toLowerCase());
170
+ const isEmail = (v) => /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(v);
171
+ let emailCol = head.findIndex((h) => /e-?mail/.test(h));
172
+ const nameCol = head.findIndex((h) => /^(name|nome|first name|nome e cognome|full name)$/.test(h));
173
+ let rows = lines.slice(1);
174
+ // No header: the column holding addresses, from the first line on.
175
+ if (emailCol < 0) {
176
+ emailCol = cells(lines[0] ?? '').findIndex(isEmail);
177
+ rows = lines;
178
+ }
179
+ if (emailCol < 0)
180
+ return [];
181
+ const seen = new Set();
182
+ const out = [];
183
+ for (const l of rows) {
184
+ const c = cells(l);
185
+ const email = (c[emailCol] ?? '').toLowerCase();
186
+ if (!isEmail(email) || seen.has(email))
187
+ continue;
188
+ seen.add(email);
189
+ out.push({ email, ...(nameCol >= 0 && c[nameCol] ? { name: c[nameCol] } : {}) });
190
+ }
191
+ return out;
192
+ }