@easy-cms/core 0.19.0 → 0.20.1

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/dist/index.d.ts CHANGED
@@ -196,6 +196,40 @@ interface BundleHints {
196
196
  readonly traceInclude: readonly string[];
197
197
  }
198
198
 
199
+ /** An email to send. Addresses may be `name <address>`. */
200
+ interface EmailMessage {
201
+ readonly to: string | readonly string[];
202
+ readonly cc?: string | readonly string[];
203
+ readonly bcc?: string | readonly string[];
204
+ /** Default: the adapter's `from`. */
205
+ readonly from?: string;
206
+ readonly replyTo?: string;
207
+ readonly subject: string;
208
+ readonly html?: string;
209
+ readonly text?: string;
210
+ }
211
+ /**
212
+ * Sends email for Easy CMS and its plugins (form notifications, and more later). Set one in
213
+ * the config as `email`, e.g. `smtp()` from `@easy-cms/email-smtp`, or `consoleEmail()` in
214
+ * development. Throw to report a failure: the message is retried later.
215
+ */
216
+ interface EmailAdapter {
217
+ /** The sender when a message has none, e.g. `Easy CMS <no-reply@example.com>`. */
218
+ readonly from?: string | undefined;
219
+ send(message: EmailMessage & {
220
+ readonly from: string;
221
+ }): Promise<void>;
222
+ }
223
+ /** Prints emails to the log instead of sending them: for development and tests. */
224
+ declare function consoleEmail(options?: {
225
+ from?: string;
226
+ log?: (text: string) => void;
227
+ }): EmailAdapter & {
228
+ readonly sent: (EmailMessage & {
229
+ from: string;
230
+ })[];
231
+ };
232
+
199
233
  /** A label shown in the admin UI. Either one string or one string per admin locale. */
200
234
  type Label = string | {
201
235
  readonly [locale: string]: string;
@@ -779,6 +813,7 @@ declare class EasyCMS<C extends Config = Config> {
779
813
  readonly cwd: string;
780
814
  private readonly versions;
781
815
  private readonly webhooks;
816
+ private readonly mailer;
782
817
  constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter, cwd?: string);
783
818
  find<S extends Slug<C>>(collection: S, options?: FindOptions): Promise<PaginatedDocs<Doc<C, S>>>;
784
819
  findById<S extends Slug<C>>(collection: S, id: ID, options?: ReadOptions): Promise<Doc<C, S> | null>;
@@ -895,6 +930,16 @@ declare class EasyCMS<C extends Config = Config> {
895
930
  * returns, which may otherwise stop them.
896
931
  */
897
932
  flushWebhooks(): Promise<void>;
933
+ /**
934
+ * Sends an email with the config's `email` adapter, e.g. from a hook or plugin. Resolves once
935
+ * the email is queued; it is sent in the background and retried from the queue if sending
936
+ * fails. Without `email` in the config, the email is skipped with a warning.
937
+ */
938
+ sendEmail(message: EmailMessage): Promise<void>;
939
+ /** Waits for emails being sent, e.g. before a serverless function returns. */
940
+ flushEmails(): Promise<void>;
941
+ /** Emails queued until sent, stored in `email-deliveries`. */
942
+ private emailQueue;
898
943
  destroy(): Promise<void>;
899
944
  private schedulerTimer;
900
945
  private running;
@@ -911,6 +956,10 @@ declare class EasyCMS<C extends Config = Config> {
911
956
  sent: number;
912
957
  failed: number;
913
958
  };
959
+ emails: {
960
+ sent: number;
961
+ failed: number;
962
+ };
914
963
  }>;
915
964
  /**
916
965
  * Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
@@ -1270,6 +1319,8 @@ interface EndpointRequest {
1270
1319
  readonly params: Readonly<Record<string, string>>;
1271
1320
  /** The logged-in user (session cookie or Bearer token), or `null`. */
1272
1321
  readonly user: AuthUser | null;
1322
+ /** The client's IP address, when the adapter knows it (e.g. to rate-limit a public form). */
1323
+ readonly ip: string | undefined;
1273
1324
  /** The Local API. Pass `{ user, overrideAccess: false }` to apply the user's access rules. */
1274
1325
  readonly cms: EasyCMS;
1275
1326
  /** The request's JSON body, which must be an object (at most 1 MB). */
@@ -1343,6 +1394,12 @@ interface Config {
1343
1394
  * under Settings. A key acts as its owner, limited to the collections and operations it lists.
1344
1395
  */
1345
1396
  readonly apiKeys?: boolean;
1397
+ /**
1398
+ * Sends email for plugins such as the form builder, e.g. `smtp()` from
1399
+ * `@easy-cms/email-smtp`, or `consoleEmail()` in development. Emails are queued in the
1400
+ * database and retried until sent.
1401
+ */
1402
+ readonly email?: EmailAdapter;
1346
1403
  readonly plugins?: readonly Plugin[];
1347
1404
  }
1348
1405
  /** The config after plugins ran, validation passed and defaults were applied. */
@@ -1738,4 +1795,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1738
1795
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1739
1796
  declare function validateConfig(config: Config): ConfigIssue[];
1740
1797
 
1741
- export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
1798
+ export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailField, type EmailMessage, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
package/dist/index.js CHANGED
@@ -316,12 +316,14 @@ var LOGIN_ATTEMPTS = "login-attempts";
316
316
  var VERSIONS = "document-versions";
317
317
  var SCHEDULED_JOBS = "scheduled-jobs";
318
318
  var WEBHOOK_DELIVERIES = "webhook-deliveries";
319
+ var EMAIL_DELIVERIES = "email-deliveries";
319
320
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
320
321
  SESSIONS,
321
322
  LOGIN_ATTEMPTS,
322
323
  VERSIONS,
323
324
  SCHEDULED_JOBS,
324
- WEBHOOK_DELIVERIES
325
+ WEBHOOK_DELIVERIES,
326
+ EMAIL_DELIVERIES
325
327
  ]);
326
328
  var DEFAULT_ROLES = ["admin", "editor"];
327
329
  var adminOrSelf = ({ user }) => {
@@ -536,6 +538,19 @@ var webhookDeliveriesCollection = {
536
538
  { name: "error", type: "text" }
537
539
  ]
538
540
  };
541
+ var emailDeliveriesCollection = {
542
+ slug: EMAIL_DELIVERIES,
543
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
544
+ fields: [
545
+ // The message as JSON.
546
+ { name: "message", type: "textarea", required: true },
547
+ { name: "attempts", type: "number", required: true },
548
+ { name: "nextAttemptAt", type: "text", required: true, index: true },
549
+ // pending | failed
550
+ { name: "state", type: "text", required: true, index: true },
551
+ { name: "error", type: "text" }
552
+ ]
553
+ };
539
554
 
540
555
  // src/auth/password.ts
541
556
  import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
@@ -1290,6 +1305,140 @@ function normalizeSelect(field, raw, fail) {
1290
1305
  return field.hasMany ? [...new Set(values)] : values[0];
1291
1306
  }
1292
1307
 
1308
+ // src/email.ts
1309
+ function consoleEmail(options = {}) {
1310
+ const sent = [];
1311
+ const adapter = {
1312
+ from: options.from ?? "Easy CMS <no-reply@localhost>",
1313
+ sent,
1314
+ async send(message) {
1315
+ sent.push(message);
1316
+ const list = (value) => value === void 0 ? void 0 : typeof value === "string" ? value : value.join(", ");
1317
+ const lines = [
1318
+ `Email to ${list(message.to)}${message.cc ? `, cc ${list(message.cc)}` : ""}`,
1319
+ `From: ${message.from}${message.replyTo ? ` (reply to ${message.replyTo})` : ""}`,
1320
+ `Subject: ${message.subject}`,
1321
+ "",
1322
+ message.text ?? message.html ?? ""
1323
+ ];
1324
+ (options.log ?? console.log)(lines.join("\n"));
1325
+ }
1326
+ };
1327
+ return adapter;
1328
+ }
1329
+ var QUEUE_DELAYS = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 6 * 36e5, 12 * 36e5];
1330
+ var CLAIM = 5 * 6e4;
1331
+ var later = (from, ms) => new Date(from.getTime() + ms).toISOString();
1332
+ var Mailer = class {
1333
+ constructor(adapter, logger, queue) {
1334
+ this.adapter = adapter;
1335
+ this.logger = logger;
1336
+ this.queue = queue;
1337
+ }
1338
+ adapter;
1339
+ logger;
1340
+ queue;
1341
+ pending = /* @__PURE__ */ new Set();
1342
+ get configured() {
1343
+ return this.adapter !== void 0;
1344
+ }
1345
+ /**
1346
+ * Queues an email and sends it without waiting. Resolves once it is saved; `flush()` waits
1347
+ * for the sending too. Without an `email` adapter in the config, the email is skipped with a
1348
+ * warning.
1349
+ */
1350
+ async send(message) {
1351
+ if (!this.adapter) {
1352
+ this.logger.warn(
1353
+ `Email "${message.subject}" not sent: set \`email\` in the config (e.g. consoleEmail() in development)`
1354
+ );
1355
+ return;
1356
+ }
1357
+ const body = JSON.stringify(message);
1358
+ let id;
1359
+ try {
1360
+ id = await this.queue?.add({
1361
+ message: body,
1362
+ attempts: 0,
1363
+ nextAttemptAt: later(/* @__PURE__ */ new Date(), CLAIM),
1364
+ state: "pending",
1365
+ error: null
1366
+ });
1367
+ } catch (error) {
1368
+ this.logger.error(`Could not save email: ${error.message}`);
1369
+ }
1370
+ this.track(this.deliver(message, body, id));
1371
+ }
1372
+ /** Waits for emails being sent, e.g. before a serverless function returns. */
1373
+ async flush() {
1374
+ while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
1375
+ }
1376
+ /** Sends queued emails that are due for another attempt. Runs with scheduled jobs. */
1377
+ async retry(now = /* @__PURE__ */ new Date()) {
1378
+ if (!this.queue || !this.adapter) return { sent: 0, failed: 0 };
1379
+ let sent = 0;
1380
+ let failed = 0;
1381
+ for (const { id, ...queued } of await this.queue.due(now, 50)) {
1382
+ const attempts = queued.attempts + 1;
1383
+ await this.queue.update(id, { ...queued, attempts, nextAttemptAt: later(now, CLAIM) });
1384
+ const error = await this.attempt(JSON.parse(queued.message));
1385
+ if (!error) {
1386
+ await this.queue.remove(id);
1387
+ sent++;
1388
+ continue;
1389
+ }
1390
+ const delay = QUEUE_DELAYS[attempts - 1];
1391
+ if (delay === void 0) {
1392
+ failed++;
1393
+ this.logger.error(`Email gave up after ${attempts} attempts: ${error}`);
1394
+ }
1395
+ await this.queue.update(id, {
1396
+ ...queued,
1397
+ attempts,
1398
+ nextAttemptAt: later(now, delay ?? 0),
1399
+ state: delay === void 0 ? "failed" : "pending",
1400
+ error
1401
+ });
1402
+ }
1403
+ return { sent, failed };
1404
+ }
1405
+ track(delivery) {
1406
+ const tracked = delivery.catch((error) => this.logger.error(`Email queue failed: ${error.message}`)).finally(() => this.pending.delete(tracked));
1407
+ this.pending.add(tracked);
1408
+ }
1409
+ async deliver(message, body, id) {
1410
+ const error = await this.attempt(message);
1411
+ if (!error) {
1412
+ if (id !== void 0) await this.queue?.remove(id);
1413
+ return;
1414
+ }
1415
+ if (id === void 0 || !this.queue) {
1416
+ this.logger.error(`Email "${message.subject}" failed: ${error}`);
1417
+ return;
1418
+ }
1419
+ this.logger.warn(`Email "${message.subject}" failed, retrying later: ${error}`);
1420
+ await this.queue.update(id, {
1421
+ message: body,
1422
+ attempts: 1,
1423
+ nextAttemptAt: later(/* @__PURE__ */ new Date(), QUEUE_DELAYS[0]),
1424
+ state: "pending",
1425
+ error
1426
+ });
1427
+ }
1428
+ /** `undefined` when sent, else the error. */
1429
+ async attempt(message) {
1430
+ const adapter = this.adapter;
1431
+ const from = message.from ?? adapter.from;
1432
+ if (!from) return "no sender: set `from` on the message or the email adapter";
1433
+ try {
1434
+ await adapter.send({ ...message, from });
1435
+ return void 0;
1436
+ } catch (error) {
1437
+ return error.message || String(error);
1438
+ }
1439
+ }
1440
+ };
1441
+
1293
1442
  // src/load-config.ts
1294
1443
  import { existsSync } from "fs";
1295
1444
  import { resolve } from "path";
@@ -1306,8 +1455,8 @@ var WEBHOOK_EVENTS = [
1306
1455
  "draft"
1307
1456
  ];
1308
1457
  var RETRY_DELAYS = [1e3, 5e3];
1309
- var QUEUE_DELAYS = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 6 * 36e5, 12 * 36e5];
1310
- var CLAIM = 5 * 6e4;
1458
+ var QUEUE_DELAYS2 = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 6 * 36e5, 12 * 36e5];
1459
+ var CLAIM2 = 5 * 6e4;
1311
1460
  var TIMEOUT = 1e4;
1312
1461
  var Webhooks = class {
1313
1462
  constructor(hooks, logger, queue, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
@@ -1353,19 +1502,19 @@ var Webhooks = class {
1353
1502
  continue;
1354
1503
  }
1355
1504
  const attempts = queued.attempts + 1;
1356
- await this.queue.update(id, { ...queued, attempts, nextAttemptAt: later(now, CLAIM) });
1505
+ await this.queue.update(id, { ...queued, attempts, nextAttemptAt: later2(now, CLAIM2) });
1357
1506
  const result = await this.attempt(hook, queued.event, queued.body, queued.delivery);
1358
1507
  if (result.ok) {
1359
1508
  await this.queue.remove(id);
1360
1509
  sent++;
1361
1510
  continue;
1362
1511
  }
1363
- const delay = QUEUE_DELAYS[Math.max(0, attempts - this.retryDelays.length - 1)];
1512
+ const delay = QUEUE_DELAYS2[Math.max(0, attempts - this.retryDelays.length - 1)];
1364
1513
  if (delay === void 0) failed++;
1365
1514
  await this.queue.update(id, {
1366
1515
  ...queued,
1367
1516
  attempts,
1368
- nextAttemptAt: later(now, delay ?? 0),
1517
+ nextAttemptAt: later2(now, delay ?? 0),
1369
1518
  state: delay === void 0 ? "failed" : "pending",
1370
1519
  error: result.error
1371
1520
  });
@@ -1392,7 +1541,7 @@ var Webhooks = class {
1392
1541
  error
1393
1542
  });
1394
1543
  const { queue } = this;
1395
- const saved = queue ? this.saving.then(() => queue.add(entry(0, later(/* @__PURE__ */ new Date(), CLAIM), null))) : Promise.resolve(void 0);
1544
+ const saved = queue ? this.saving.then(() => queue.add(entry(0, later2(/* @__PURE__ */ new Date(), CLAIM2), null))) : Promise.resolve(void 0);
1396
1545
  this.saving = saved.catch(() => {
1397
1546
  });
1398
1547
  let queued;
@@ -1411,7 +1560,7 @@ var Webhooks = class {
1411
1560
  if (delay === void 0) {
1412
1561
  if (queued === void 0 || !this.queue)
1413
1562
  return this.giveUp(hook, event, result.error, attempt + 1);
1414
- const next = later(/* @__PURE__ */ new Date(), QUEUE_DELAYS[0]);
1563
+ const next = later2(/* @__PURE__ */ new Date(), QUEUE_DELAYS2[0]);
1415
1564
  await this.queue.update(queued, entry(attempt + 1, next, result.error));
1416
1565
  this.logger.warn(
1417
1566
  `Webhook ${hook.url} failed for ${event} (${result.error}); will retry later`
@@ -1456,7 +1605,7 @@ var Webhooks = class {
1456
1605
  );
1457
1606
  }
1458
1607
  };
1459
- var later = (from, ms) => new Date(from.getTime() + ms).toISOString();
1608
+ var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
1460
1609
  function matches(hook, payload) {
1461
1610
  if (hook.events && !hook.events.includes(payload.event)) return false;
1462
1611
  if (payload.collection !== void 0)
@@ -1727,6 +1876,13 @@ function validateLocalization(config, add) {
1727
1876
  for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
1728
1877
  }
1729
1878
  function validateWebhooks(config, add) {
1879
+ const email = config.email;
1880
+ if (email !== void 0 && (typeof email !== "object" || email === null || typeof email.send !== "function"))
1881
+ add(
1882
+ "email",
1883
+ "must be an email adapter with a send() function",
1884
+ "e.g. email: consoleEmail() or smtp({ \u2026 }) from @easy-cms/email-smtp"
1885
+ );
1730
1886
  const hooks = config.webhooks;
1731
1887
  if (hooks === void 0) return;
1732
1888
  if (!Array.isArray(hooks)) {
@@ -2163,7 +2319,8 @@ async function resolveConfig(input) {
2163
2319
  ...usesVersions(config) ? [versionsCollection] : [],
2164
2320
  ...usesSchedule(config) ? [scheduledJobsCollection] : [],
2165
2321
  ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : [],
2166
- ...config.apiKeys ? [apiKeysCollection] : []
2322
+ ...config.apiKeys ? [apiKeysCollection] : [],
2323
+ ...config.email ? [emailDeliveriesCollection] : []
2167
2324
  ],
2168
2325
  globals: config.globals ?? [],
2169
2326
  endpoints: config.endpoints ?? []
@@ -2687,7 +2844,7 @@ async function createEasyCMS(config, options = {}) {
2687
2844
  interactive: options.interactive ?? false
2688
2845
  });
2689
2846
  const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
2690
- const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0;
2847
+ const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0;
2691
2848
  if (scheduling && options.scheduler !== false) cms.startScheduler();
2692
2849
  return cms;
2693
2850
  }
@@ -2711,6 +2868,7 @@ var EasyCMS = class {
2711
2868
  cwd;
2712
2869
  versions;
2713
2870
  webhooks;
2871
+ mailer;
2714
2872
  constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd()) {
2715
2873
  this.config = config;
2716
2874
  this.db = db;
@@ -2723,6 +2881,11 @@ var EasyCMS = class {
2723
2881
  logger,
2724
2882
  config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES) ? this.webhookQueue() : void 0
2725
2883
  );
2884
+ this.mailer = new Mailer(
2885
+ config.email,
2886
+ logger,
2887
+ config.collections.some((c) => c.slug === EMAIL_DELIVERIES) ? this.emailQueue() : void 0
2888
+ );
2726
2889
  this.auth = new Auth(this);
2727
2890
  }
2728
2891
  async find(collection2, options = {}) {
@@ -3370,10 +3533,57 @@ var EasyCMS = class {
3370
3533
  flushWebhooks() {
3371
3534
  return this.webhooks.flush();
3372
3535
  }
3536
+ /**
3537
+ * Sends an email with the config's `email` adapter, e.g. from a hook or plugin. Resolves once
3538
+ * the email is queued; it is sent in the background and retried from the queue if sending
3539
+ * fails. Without `email` in the config, the email is skipped with a warning.
3540
+ */
3541
+ sendEmail(message) {
3542
+ return this.mailer.send(message);
3543
+ }
3544
+ /** Waits for emails being sent, e.g. before a serverless function returns. */
3545
+ flushEmails() {
3546
+ return this.mailer.flush();
3547
+ }
3548
+ /** Emails queued until sent, stored in `email-deliveries`. */
3549
+ emailQueue() {
3550
+ const collection2 = EMAIL_DELIVERIES;
3551
+ const toRow = (email, createdAt) => {
3552
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3553
+ return { ...email, createdAt: createdAt ?? now, updatedAt: now };
3554
+ };
3555
+ return {
3556
+ add: async (email) => (await this.db.create({ collection: collection2, data: toRow(email) })).id,
3557
+ update: async (id, email) => {
3558
+ const current = await this.db.findById({ collection: collection2, id });
3559
+ await this.db.update({
3560
+ collection: collection2,
3561
+ id,
3562
+ data: toRow(email, current?.createdAt)
3563
+ });
3564
+ },
3565
+ remove: async (id) => {
3566
+ await this.db.delete({ collection: collection2, id });
3567
+ },
3568
+ due: async (now, limit) => {
3569
+ const { docs } = await this.db.find({
3570
+ collection: collection2,
3571
+ where: {
3572
+ and: [{ state: { equals: "pending" } }, { nextAttemptAt: { lte: now.toISOString() } }]
3573
+ },
3574
+ sort: ["nextAttemptAt"],
3575
+ limit,
3576
+ page: 1
3577
+ });
3578
+ return docs;
3579
+ }
3580
+ };
3581
+ }
3373
3582
  async destroy() {
3374
3583
  if (this.schedulerTimer) clearInterval(this.schedulerTimer);
3375
3584
  await this.running;
3376
3585
  await this.webhooks.flush();
3586
+ await this.mailer.flush();
3377
3587
  await this.db.destroy();
3378
3588
  }
3379
3589
  // --- Scheduled publishing --------------------------------------------------
@@ -3397,7 +3607,11 @@ var EasyCMS = class {
3397
3607
  */
3398
3608
  async runJobs(now = /* @__PURE__ */ new Date()) {
3399
3609
  const scheduled = await this.runScheduled(now);
3400
- return { ...scheduled, webhooks: await this.retryWebhooks(now) };
3610
+ return {
3611
+ ...scheduled,
3612
+ webhooks: await this.retryWebhooks(now),
3613
+ emails: await this.mailer.retry(now)
3614
+ };
3401
3615
  }
3402
3616
  /**
3403
3617
  * Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
@@ -4436,7 +4650,7 @@ async function respond(cms, request, url, path, root, options, production) {
4436
4650
  const ctx = { request, url, user, via: user ? via : null, token, headers };
4437
4651
  const method = request.method.toUpperCase();
4438
4652
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
4439
- const result = root ? await customEndpoint(cms, ctx, method, segments, true) : await customEndpoint(cms, ctx, method, segments, false) ?? await route(cms, ctx, method, segments, options);
4653
+ const result = root ? await customEndpoint(cms, ctx, method, segments, true, options) : await customEndpoint(cms, ctx, method, segments, false, options) ?? await route(cms, ctx, method, segments, options);
4440
4654
  if (!result) throw new HttpError("Not found", 404);
4441
4655
  if (result.body instanceof Response) return result.body;
4442
4656
  return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
@@ -4474,7 +4688,7 @@ function withHeaders(response, extra) {
4474
4688
  headers
4475
4689
  });
4476
4690
  }
4477
- async function customEndpoint(cms, ctx, method, segments, root) {
4691
+ async function customEndpoint(cms, ctx, method, segments, root, options) {
4478
4692
  let pathMatched = false;
4479
4693
  const own = cms.config.endpoints.filter((e) => !!e.root === root);
4480
4694
  const endpoints = [...own].sort((a, b) => fixedSegments(b) - fixedSegments(a));
@@ -4489,6 +4703,7 @@ async function customEndpoint(cms, ctx, method, segments, root) {
4489
4703
  url: ctx.url,
4490
4704
  params,
4491
4705
  user: ctx.user,
4706
+ ip: options.getClientIp?.(ctx.request),
4492
4707
  cms,
4493
4708
  json: () => readJson(ctx.request)
4494
4709
  });
@@ -4851,7 +5066,9 @@ function readCookie(request, name) {
4851
5066
  function checkCsrf(cms, ctx) {
4852
5067
  const origin = ctx.request.headers.get("origin");
4853
5068
  const fetchSite = ctx.request.headers.get("sec-fetch-site");
4854
- const trusted = origin !== null && (origin === ctx.url.origin || cms.config.auth.trustedOrigins.includes(origin));
5069
+ const cookie2 = ctx.via === "cookie" && ctx.token !== void 0;
5070
+ const { cors } = cms.config;
5071
+ const trusted = origin !== null && (origin === ctx.url.origin || cms.config.auth.trustedOrigins.includes(origin) || !cookie2 && (cors === "*" || cors.includes(origin)));
4855
5072
  if (origin !== null && !trusted) throw new ForbiddenError("CSRF check failed: untrusted origin");
4856
5073
  if (origin === null && fetchSite === "cross-site")
4857
5074
  throw new ForbiddenError("CSRF check failed: cross-site request");
@@ -5171,6 +5388,7 @@ export {
5171
5388
  applyDefaults,
5172
5389
  applyPlugins,
5173
5390
  configSignature,
5391
+ consoleEmail,
5174
5392
  consoleLogger,
5175
5393
  copyDatabase,
5176
5394
  createEasyCMS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",