@easy-cms/core 0.18.0 → 0.20.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/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
@@ -1124,6 +1173,13 @@ interface ContainerAdmin {
1124
1173
  /** Panels in the edit page's side column, below publishing (components from admin modules). */
1125
1174
  readonly sidebar?: readonly AdminComponent[];
1126
1175
  }
1176
+ interface CollectionAdmin extends ContainerAdmin {
1177
+ /**
1178
+ * `settings` lists the collection under Settings in the admin menu (with Users and API keys)
1179
+ * instead of with the content, e.g. for redirects or forms settings.
1180
+ */
1181
+ readonly group?: 'settings';
1182
+ }
1127
1183
  interface CollectionConfig {
1128
1184
  /** URL and table name. Lowercase letters, digits, `-` and `_`. */
1129
1185
  readonly slug: string;
@@ -1156,8 +1212,8 @@ interface CollectionConfig {
1156
1212
  readonly preview?: PreviewURL;
1157
1213
  readonly access?: CollectionAccess;
1158
1214
  readonly hooks?: CollectionHooks;
1159
- /** Custom admin components. */
1160
- readonly admin?: ContainerAdmin;
1215
+ /** Custom admin components, and where the collection is in the menu. */
1216
+ readonly admin?: CollectionAdmin;
1161
1217
  }
1162
1218
  interface GlobalConfig {
1163
1219
  readonly slug: string;
@@ -1263,6 +1319,8 @@ interface EndpointRequest {
1263
1319
  readonly params: Readonly<Record<string, string>>;
1264
1320
  /** The logged-in user (session cookie or Bearer token), or `null`. */
1265
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;
1266
1324
  /** The Local API. Pass `{ user, overrideAccess: false }` to apply the user's access rules. */
1267
1325
  readonly cms: EasyCMS;
1268
1326
  /** The request's JSON body, which must be an object (at most 1 MB). */
@@ -1336,6 +1394,12 @@ interface Config {
1336
1394
  * under Settings. A key acts as its owner, limited to the collections and operations it lists.
1337
1395
  */
1338
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;
1339
1403
  readonly plugins?: readonly Plugin[];
1340
1404
  }
1341
1405
  /** The config after plugins ran, validation passed and defaults were applied. */
@@ -1654,6 +1718,8 @@ interface AdminCollection {
1654
1718
  fields: AdminField[];
1655
1719
  /** Panels from admin modules in the edit page's side column. */
1656
1720
  sidebar?: AdminComponentRef[];
1721
+ /** Listed under Settings in the menu (users, API keys, `admin.group: 'settings'`). */
1722
+ group?: 'settings';
1657
1723
  permissions: {
1658
1724
  read: boolean;
1659
1725
  create: boolean;
@@ -1729,4 +1795,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1729
1795
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1730
1796
  declare function validateConfig(config: Config): ConfigIssue[];
1731
1797
 
1732
- 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 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)) {
@@ -1838,6 +1994,9 @@ function validateContainer(container, path, collectionSlugs, add) {
1838
1994
  } else {
1839
1995
  const sidebar = admin.sidebar;
1840
1996
  if (sidebar !== void 0) validateComponents(sidebar, `${path}.admin.sidebar`, add);
1997
+ const group = admin.group;
1998
+ if (group !== void 0 && group !== "settings")
1999
+ add(`${path}.admin.group`, `must be 'settings' (got ${JSON.stringify(group)})`);
1841
2000
  }
1842
2001
  }
1843
2002
  const reserved = new Set(SYSTEM_FIELD_NAMES);
@@ -2160,7 +2319,8 @@ async function resolveConfig(input) {
2160
2319
  ...usesVersions(config) ? [versionsCollection] : [],
2161
2320
  ...usesSchedule(config) ? [scheduledJobsCollection] : [],
2162
2321
  ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : [],
2163
- ...config.apiKeys ? [apiKeysCollection] : []
2322
+ ...config.apiKeys ? [apiKeysCollection] : [],
2323
+ ...config.email ? [emailDeliveriesCollection] : []
2164
2324
  ],
2165
2325
  globals: config.globals ?? [],
2166
2326
  endpoints: config.endpoints ?? []
@@ -2684,7 +2844,7 @@ async function createEasyCMS(config, options = {}) {
2684
2844
  interactive: options.interactive ?? false
2685
2845
  });
2686
2846
  const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
2687
- 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;
2688
2848
  if (scheduling && options.scheduler !== false) cms.startScheduler();
2689
2849
  return cms;
2690
2850
  }
@@ -2708,6 +2868,7 @@ var EasyCMS = class {
2708
2868
  cwd;
2709
2869
  versions;
2710
2870
  webhooks;
2871
+ mailer;
2711
2872
  constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd()) {
2712
2873
  this.config = config;
2713
2874
  this.db = db;
@@ -2720,6 +2881,11 @@ var EasyCMS = class {
2720
2881
  logger,
2721
2882
  config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES) ? this.webhookQueue() : void 0
2722
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
+ );
2723
2889
  this.auth = new Auth(this);
2724
2890
  }
2725
2891
  async find(collection2, options = {}) {
@@ -3367,10 +3533,57 @@ var EasyCMS = class {
3367
3533
  flushWebhooks() {
3368
3534
  return this.webhooks.flush();
3369
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
+ }
3370
3582
  async destroy() {
3371
3583
  if (this.schedulerTimer) clearInterval(this.schedulerTimer);
3372
3584
  await this.running;
3373
3585
  await this.webhooks.flush();
3586
+ await this.mailer.flush();
3374
3587
  await this.db.destroy();
3375
3588
  }
3376
3589
  // --- Scheduled publishing --------------------------------------------------
@@ -3394,7 +3607,11 @@ var EasyCMS = class {
3394
3607
  */
3395
3608
  async runJobs(now = /* @__PURE__ */ new Date()) {
3396
3609
  const scheduled = await this.runScheduled(now);
3397
- 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
+ };
3398
3615
  }
3399
3616
  /**
3400
3617
  * Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
@@ -4130,6 +4347,7 @@ async function readAdminModule(cms, index) {
4130
4347
  }
4131
4348
 
4132
4349
  // src/rest/admin-schema.ts
4350
+ var SETTINGS = /* @__PURE__ */ new Set([USERS, API_KEYS]);
4133
4351
  function componentRef(component) {
4134
4352
  if (typeof component === "string") return { tag: component };
4135
4353
  return component.props ? { tag: component.tag, props: JSON.parse(JSON.stringify(component.props)) } : { tag: component.tag };
@@ -4228,6 +4446,7 @@ async function collection(config, user, localized) {
4228
4446
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
4229
4447
  if (config.icon) result.icon = config.icon;
4230
4448
  if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
4449
+ if (config.admin?.group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
4231
4450
  if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
4232
4451
  result.editIn = "drawer";
4233
4452
  return result;
@@ -4431,7 +4650,7 @@ async function respond(cms, request, url, path, root, options, production) {
4431
4650
  const ctx = { request, url, user, via: user ? via : null, token, headers };
4432
4651
  const method = request.method.toUpperCase();
4433
4652
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
4434
- 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);
4435
4654
  if (!result) throw new HttpError("Not found", 404);
4436
4655
  if (result.body instanceof Response) return result.body;
4437
4656
  return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
@@ -4469,7 +4688,7 @@ function withHeaders(response, extra) {
4469
4688
  headers
4470
4689
  });
4471
4690
  }
4472
- async function customEndpoint(cms, ctx, method, segments, root) {
4691
+ async function customEndpoint(cms, ctx, method, segments, root, options) {
4473
4692
  let pathMatched = false;
4474
4693
  const own = cms.config.endpoints.filter((e) => !!e.root === root);
4475
4694
  const endpoints = [...own].sort((a, b) => fixedSegments(b) - fixedSegments(a));
@@ -4484,6 +4703,7 @@ async function customEndpoint(cms, ctx, method, segments, root) {
4484
4703
  url: ctx.url,
4485
4704
  params,
4486
4705
  user: ctx.user,
4706
+ ip: options.getClientIp?.(ctx.request),
4487
4707
  cms,
4488
4708
  json: () => readJson(ctx.request)
4489
4709
  });
@@ -4846,7 +5066,9 @@ function readCookie(request, name) {
4846
5066
  function checkCsrf(cms, ctx) {
4847
5067
  const origin = ctx.request.headers.get("origin");
4848
5068
  const fetchSite = ctx.request.headers.get("sec-fetch-site");
4849
- 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)));
4850
5072
  if (origin !== null && !trusted) throw new ForbiddenError("CSRF check failed: untrusted origin");
4851
5073
  if (origin === null && fetchSite === "cross-site")
4852
5074
  throw new ForbiddenError("CSRF check failed: cross-site request");
@@ -5166,6 +5388,7 @@ export {
5166
5388
  applyDefaults,
5167
5389
  applyPlugins,
5168
5390
  configSignature,
5391
+ consoleEmail,
5169
5392
  consoleLogger,
5170
5393
  copyDatabase,
5171
5394
  createEasyCMS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",