firstly 0.4.0 → 0.4.2

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/esm/changeLog/index.d.ts +2 -2
  3. package/esm/changeLog/index.js +1 -1
  4. package/esm/core/FF_Allow.d.ts +55 -0
  5. package/esm/core/FF_Allow.js +54 -0
  6. package/esm/core/FF_Filter.d.ts +55 -0
  7. package/esm/core/FF_Filter.js +57 -0
  8. package/esm/core/FF_Validators.d.ts +63 -0
  9. package/esm/core/FF_Validators.js +97 -0
  10. package/esm/core/helper.d.ts +17 -0
  11. package/esm/core/helper.js +40 -0
  12. package/esm/index.d.ts +5 -1
  13. package/esm/index.js +4 -1
  14. package/esm/mail/MailController.d.ts +22 -0
  15. package/esm/mail/MailController.js +68 -0
  16. package/esm/mail/index.d.ts +5 -0
  17. package/esm/mail/index.js +4 -0
  18. package/esm/mail/server/formatMailHelper.d.ts +2 -7
  19. package/esm/mail/server/index.d.ts +12 -7
  20. package/esm/mail/server/index.js +40 -18
  21. package/esm/mail/types.d.ts +11 -0
  22. package/esm/mail/types.js +1 -0
  23. package/esm/mail/ui/LastMails.svelte +184 -0
  24. package/esm/mail/ui/LastMails.svelte.d.ts +12 -0
  25. package/esm/mail/ui/WriteMail.svelte +183 -0
  26. package/esm/mail/ui/WriteMail.svelte.d.ts +3 -0
  27. package/esm/sqlAdmin/Roles_SqlAdmin.d.ts +3 -0
  28. package/esm/sqlAdmin/Roles_SqlAdmin.js +3 -0
  29. package/esm/sqlAdmin/SqlAdminController.d.ts +9 -0
  30. package/esm/sqlAdmin/SqlAdminController.js +23 -0
  31. package/esm/sqlAdmin/index.d.ts +6 -0
  32. package/esm/sqlAdmin/index.js +6 -0
  33. package/esm/sqlAdmin/server/index.d.ts +40 -0
  34. package/esm/sqlAdmin/server/index.js +40 -0
  35. package/esm/sqlAdmin/ui/SqlAdmin.svelte +197 -0
  36. package/esm/sqlAdmin/ui/SqlAdmin.svelte.d.ts +3 -0
  37. package/package.json +13 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # firstly
2
2
 
3
+ ## 0.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#258](https://github.com/jycouet/firstly/pull/258) [`43f3f77`](https://github.com/jycouet/firstly/commit/43f3f77aa34f064720c90cdd240ac050a360ecf6) Thanks [@jycouet](https://github.com/jycouet)! - `firstly/changeLog`: export the `ChangeLog` entity class so apps can declare `@Relations` to it (e.g. linking changelog rows back to a `User`).
8
+
9
+ - [#253](https://github.com/jycouet/firstly/pull/253) [`6e203da`](https://github.com/jycouet/firstly/commit/6e203dab7bcbfe043c087703e8c199d42916015f) Thanks [@jycouet](https://github.com/jycouet)! - `firstly/mail`: export `MailSection`, persist nodemailer response into `Mail.metadata.transport` (so provider IDs like Resend's `re_...` are recoverable from the DB), ship drop-in `<WriteMail />` + `<LastMails />` admin components and an opt-in `MailController.sendTest` BackendMethod (`mail({ enableTest: true })`), plus a Resend docs section. Root: new `errorMessage(err)` helper that handles native `Error`, remult `ErrorInfo` rejections, and falls back to JSON instead of `[object Object]`.
10
+
11
+ - [#252](https://github.com/jycouet/firstly/pull/252) [`a4ee628`](https://github.com/jycouet/firstly/commit/a4ee628262ac290901039eba77cdf508dde48a2e) Thanks [@jycouet](https://github.com/jycouet)! - `firstly/sqlAdmin`: drop daisyUI dep, style with raw Tailwind, and add docs page.
12
+
13
+ ## 0.4.1
14
+
15
+ ### Patch Changes
16
+
17
+ - [#250](https://github.com/jycouet/firstly/pull/250) [`53916ad`](https://github.com/jycouet/firstly/commit/53916ad519835a47e3598afb68ce6d84356af944) Thanks [@jycouet](https://github.com/jycouet)! - Add `firstly/sqlAdmin` module: a drop-in `<SqlAdmin />` Svelte component plus a `BackendMethod` controller gated by `Roles_SqlAdmin.SqlAdmin_Admin` (or `FF_Role.FF_Role_Admin`). The component ships with prefilled queries (DB size, table sizes, indexes) and logs results as `for AI: <rows>` to the browser console for chrome-devtools / AI-agent inspection. `sqlAdmin({ path })` logs an AI hint on server start pointing to the page (default `/sql/admin`).
18
+
19
+ Add `FF_Allow` and `FF_Filter` helpers (exported from `firstly`) for owner-only / admin-or-owner row checks and prefilters - usable in `allowApi*` and `apiPrefilter`. Both accept an entity generic (`FF_Allow.owner<Task>('userId')`) for type-safe column names; `col` defaults to `'userId'` if omitted. The `ownerOr<T>({ col?, roles })` variants are shortcuts for the "admin (or any role) OR owner" pattern.
20
+
3
21
  ## 0.4.0
4
22
 
5
23
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  import { type EntityOptions, type FieldRef, type FieldsRef, type LifecycleEvent } from 'remult';
2
- import { Roles_ChangeLog, type change } from './changeLogEntities';
3
- export { Roles_ChangeLog };
2
+ import { ChangeLog, Roles_ChangeLog, type change } from './changeLogEntities';
3
+ export { ChangeLog, Roles_ChangeLog };
4
4
  export type { change };
5
5
  export interface changeEvent {
6
6
  date: Date;
@@ -1,6 +1,6 @@
1
1
  import { getEntityRef, IdEntity, isBackend, remult, repo, } from 'remult';
2
2
  import { ChangeLog, Roles_ChangeLog } from './changeLogEntities';
3
- export { Roles_ChangeLog };
3
+ export { ChangeLog, Roles_ChangeLog };
4
4
  export async function recordSaved(entity, e, options) {
5
5
  if (isBackend()) {
6
6
  const changes = [];
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Row-level allow helpers (for `allowApiUpdate`, `allowApiDelete`, ...).
3
+ *
4
+ * Pair with `FF_Filter` (the equivalent for `apiPrefilter`).
5
+ *
6
+ * Pass the entity type as a generic (`FF_Allow.owner<Task>('userId')`) to get
7
+ * autocompletion and type-safety on the column name. Without a generic the
8
+ * column is just a `string`.
9
+ */
10
+ export declare const FF_Allow: {
11
+ /**
12
+ * Allow only when the row's `col` equals the current user id.
13
+ *
14
+ * `col` defaults to `'userId'` if omitted.
15
+ *
16
+ * @example
17
+ * Owner-only update / delete (typed):
18
+ * ```ts
19
+ * import { FF_Entity, FF_Allow } from '..'
20
+ *
21
+ * \@FF_Entity<Task>('tasks', {
22
+ * allowApiUpdate: FF_Allow.owner<Task>('userId'), // typed: 'userId' must be a key of Task
23
+ * allowApiDelete: FF_Allow.owner<Task>(), // defaults to 'userId'
24
+ * })
25
+ * export class Task { ... }
26
+ * ```
27
+ *
28
+ * For "admin OR owner", prefer `FF_Allow.ownerOr` instead of inlining the
29
+ * combination yourself.
30
+ */
31
+ owner: <T>(col?: keyof T & string) => (entity?: T) => boolean;
32
+ /**
33
+ * Allow when the current user has any of the given `roles`, OR when the
34
+ * row's `col` equals the current user id.
35
+ *
36
+ * `col` defaults to `'userId'` if omitted.
37
+ *
38
+ * @example
39
+ * Admin OR owner (typed):
40
+ * ```ts
41
+ * import { FF_Entity, FF_Allow } from '..'
42
+ * import { Roles } from '../roles'
43
+ *
44
+ * \@FF_Entity<Task>('tasks', {
45
+ * allowApiUpdate: FF_Allow.ownerOr<Task>({ roles: [Roles.Admin] }),
46
+ * allowApiDelete: FF_Allow.ownerOr<Task>({ col: 'createdBy', roles: [Roles.Admin] }),
47
+ * })
48
+ * export class Task { ... }
49
+ * ```
50
+ */
51
+ ownerOr: <T>({ col, roles }: {
52
+ col?: keyof T & string;
53
+ roles: string[];
54
+ }) => (entity?: T) => boolean;
55
+ };
@@ -0,0 +1,54 @@
1
+ import { remult } from 'remult';
2
+ /**
3
+ * Row-level allow helpers (for `allowApiUpdate`, `allowApiDelete`, ...).
4
+ *
5
+ * Pair with `FF_Filter` (the equivalent for `apiPrefilter`).
6
+ *
7
+ * Pass the entity type as a generic (`FF_Allow.owner<Task>('userId')`) to get
8
+ * autocompletion and type-safety on the column name. Without a generic the
9
+ * column is just a `string`.
10
+ */
11
+ export const FF_Allow = {
12
+ /**
13
+ * Allow only when the row's `col` equals the current user id.
14
+ *
15
+ * `col` defaults to `'userId'` if omitted.
16
+ *
17
+ * @example
18
+ * Owner-only update / delete (typed):
19
+ * ```ts
20
+ * import { FF_Entity, FF_Allow } from '..'
21
+ *
22
+ * \@FF_Entity<Task>('tasks', {
23
+ * allowApiUpdate: FF_Allow.owner<Task>('userId'), // typed: 'userId' must be a key of Task
24
+ * allowApiDelete: FF_Allow.owner<Task>(), // defaults to 'userId'
25
+ * })
26
+ * export class Task { ... }
27
+ * ```
28
+ *
29
+ * For "admin OR owner", prefer `FF_Allow.ownerOr` instead of inlining the
30
+ * combination yourself.
31
+ */
32
+ owner: (col = 'userId') => (entity) => !!remult.user?.id && entity?.[col] === remult.user.id,
33
+ /**
34
+ * Allow when the current user has any of the given `roles`, OR when the
35
+ * row's `col` equals the current user id.
36
+ *
37
+ * `col` defaults to `'userId'` if omitted.
38
+ *
39
+ * @example
40
+ * Admin OR owner (typed):
41
+ * ```ts
42
+ * import { FF_Entity, FF_Allow } from '..'
43
+ * import { Roles } from '../roles'
44
+ *
45
+ * \@FF_Entity<Task>('tasks', {
46
+ * allowApiUpdate: FF_Allow.ownerOr<Task>({ roles: [Roles.Admin] }),
47
+ * allowApiDelete: FF_Allow.ownerOr<Task>({ col: 'createdBy', roles: [Roles.Admin] }),
48
+ * })
49
+ * export class Task { ... }
50
+ * ```
51
+ */
52
+ ownerOr: ({ col = 'userId', roles }) => (entity) => roles.some((r) => remult.isAllowed(r)) ||
53
+ (!!remult.user?.id && entity?.[col] === remult.user.id),
54
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Prefilter helpers (for `apiPrefilter`, `backendPrefilter`).
3
+ *
4
+ * Pair with `FF_Allow` (the equivalent for `allowApi*` row checks).
5
+ *
6
+ * Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
7
+ * autocompletion and type-safety on the column name. Without a generic the
8
+ * column is just a `string`.
9
+ */
10
+ export declare const FF_Filter: {
11
+ /**
12
+ * Prefilter rows where `col` equals the current user id.
13
+ *
14
+ * `col` defaults to `'userId'` if omitted. When anonymous, yields
15
+ * `IN (NULL)` which matches nothing.
16
+ *
17
+ * @example
18
+ * Owner-only prefilter (typed):
19
+ * ```ts
20
+ * import { FF_Entity, FF_Filter } from '..'
21
+ *
22
+ * \@FF_Entity<Task>('tasks', {
23
+ * apiPrefilter: () => FF_Filter.owner<Task>('userId'),
24
+ * })
25
+ * export class Task { ... }
26
+ * ```
27
+ *
28
+ * For "admin sees all, others see only their own", prefer
29
+ * `FF_Filter.ownerOr` instead of inlining the combination yourself.
30
+ */
31
+ owner: <T>(col?: keyof T & string) => any;
32
+ /**
33
+ * Prefilter that returns `{}` (no filter) when the current user has any of
34
+ * the given `roles`, otherwise restricts to rows where `col` equals the
35
+ * current user id.
36
+ *
37
+ * `col` defaults to `'userId'` if omitted.
38
+ *
39
+ * @example
40
+ * Admin sees all, others only their own (typed):
41
+ * ```ts
42
+ * import { FF_Entity, FF_Filter } from '..'
43
+ * import { Roles } from '../roles'
44
+ *
45
+ * \@FF_Entity<Task>('tasks', {
46
+ * apiPrefilter: () => FF_Filter.ownerOr<Task>({ roles: [Roles.Admin] }),
47
+ * })
48
+ * export class Task { ... }
49
+ * ```
50
+ */
51
+ ownerOr: <T>({ col, roles, }: {
52
+ col?: keyof T & string;
53
+ roles: string[];
54
+ }) => any;
55
+ };
@@ -0,0 +1,57 @@
1
+ import { remult } from 'remult';
2
+ /**
3
+ * Prefilter helpers (for `apiPrefilter`, `backendPrefilter`).
4
+ *
5
+ * Pair with `FF_Allow` (the equivalent for `allowApi*` row checks).
6
+ *
7
+ * Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
8
+ * autocompletion and type-safety on the column name. Without a generic the
9
+ * column is just a `string`.
10
+ */
11
+ export const FF_Filter = {
12
+ /**
13
+ * Prefilter rows where `col` equals the current user id.
14
+ *
15
+ * `col` defaults to `'userId'` if omitted. When anonymous, yields
16
+ * `IN (NULL)` which matches nothing.
17
+ *
18
+ * @example
19
+ * Owner-only prefilter (typed):
20
+ * ```ts
21
+ * import { FF_Entity, FF_Filter } from '..'
22
+ *
23
+ * \@FF_Entity<Task>('tasks', {
24
+ * apiPrefilter: () => FF_Filter.owner<Task>('userId'),
25
+ * })
26
+ * export class Task { ... }
27
+ * ```
28
+ *
29
+ * For "admin sees all, others see only their own", prefer
30
+ * `FF_Filter.ownerOr` instead of inlining the combination yourself.
31
+ */
32
+ owner: (col = 'userId') => ({ [col]: [remult.user?.id] }),
33
+ /**
34
+ * Prefilter that returns `{}` (no filter) when the current user has any of
35
+ * the given `roles`, otherwise restricts to rows where `col` equals the
36
+ * current user id.
37
+ *
38
+ * `col` defaults to `'userId'` if omitted.
39
+ *
40
+ * @example
41
+ * Admin sees all, others only their own (typed):
42
+ * ```ts
43
+ * import { FF_Entity, FF_Filter } from '..'
44
+ * import { Roles } from '../roles'
45
+ *
46
+ * \@FF_Entity<Task>('tasks', {
47
+ * apiPrefilter: () => FF_Filter.ownerOr<Task>({ roles: [Roles.Admin] }),
48
+ * })
49
+ * export class Task { ... }
50
+ * ```
51
+ */
52
+ ownerOr: ({ col = 'userId', roles, }) => {
53
+ if (roles.some((r) => remult.isAllowed(r)))
54
+ return {};
55
+ return { [col]: [remult.user?.id] };
56
+ },
57
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A localized message. Either a literal string (single-locale projects) or a
3
+ * function called at validation time (multi-locale projects - typically a
4
+ * paraglide / i18next / lingui message function that resolves against the
5
+ * current request's locale).
6
+ */
7
+ export type LocalizedMessage = string | (() => string);
8
+ export type EmailMessages = {
9
+ /** Returned when the value doesn't match the basic email shape regex. */
10
+ invalid?: LocalizedMessage;
11
+ /** Returned when the domain is malformed (`..`, leading/trailing dot, missing TLD). */
12
+ invalidDomain?: LocalizedMessage;
13
+ /** Returned when the domain is on the blocked list (`example.com`, `test.com`, ...). */
14
+ blockedDomain?: LocalizedMessage;
15
+ /** Returned when the TLD is on the blocked list (`.test`, `.example`, `.invalid`, ...). */
16
+ blockedTld?: LocalizedMessage;
17
+ };
18
+ export type ValidatorMessages = {
19
+ email?: EmailMessages;
20
+ };
21
+ /**
22
+ * Build a project-localized set of validators. Override any subset of
23
+ * messages; defaults are English.
24
+ *
25
+ * Each message can be a literal string OR a function returning a string.
26
+ * Function form is called at validation time (NOT at factory call time), so
27
+ * pass paraglide / i18next / lingui message functions to get per-request
28
+ * locale resolution:
29
+ *
30
+ * ```ts
31
+ * import { createValidators } from '..'
32
+ * import * as m from '../paraglide/messages'
33
+ *
34
+ * export const App_Validators = createValidators({
35
+ * email: {
36
+ * invalid: () => m.email_invalid(),
37
+ * invalidDomain: () => m.email_invalid_domain(),
38
+ * blockedDomain: () => m.email_blocked_domain(),
39
+ * blockedTld: () => m.email_blocked_tld(),
40
+ * },
41
+ * })
42
+ * ```
43
+ *
44
+ * Each validator group exposes two members:
45
+ * - `checkXxx(value)` - pure function, returns `true` on valid or a
46
+ * localized error message string. Use for live UI feedback.
47
+ * - `xxx` - a Remult `Validator` wrapping the same `checkXxx`. Use as
48
+ * `@Fields.string({ validate: app.email })`.
49
+ */
50
+ export declare function createValidators(messages?: ValidatorMessages): {
51
+ checkEmail: (value: string) => true | string;
52
+ email: import("remult").Validator<string>;
53
+ };
54
+ /**
55
+ * Default English validators. For localized messages, build your own with
56
+ * `createValidators(messages)` and import that one in your project instead.
57
+ */
58
+ export declare const FF_Validators: {
59
+ checkEmail: (value: string) => true | string;
60
+ email: import("remult").Validator<string>;
61
+ };
62
+ /** Convenience direct export of `FF_Validators.checkEmail` for ad-hoc use. */
63
+ export declare const checkEmail: (value: string) => true | string;
@@ -0,0 +1,97 @@
1
+ import { createValueValidator } from 'remult';
2
+ // RFC 2606 reserved test/example domains and obvious placeholders.
3
+ const BLOCKED_EMAIL_DOMAINS = new Set([
4
+ 'example.com',
5
+ 'example.net',
6
+ 'example.org',
7
+ 'test.com',
8
+ 'test.net',
9
+ 'test.org',
10
+ 'foo.com',
11
+ 'foo.bar',
12
+ 'bar.com',
13
+ 'baz.com',
14
+ 'domain.com',
15
+ 'mail.com',
16
+ 'localhost',
17
+ 'invalid',
18
+ ]);
19
+ const BLOCKED_EMAIL_TLDS = new Set(['test', 'example', 'invalid', 'localhost', 'local']);
20
+ const EMAIL_SHAPE_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21
+ const DEFAULT_EMAIL_MESSAGES = {
22
+ invalid: 'Invalid email',
23
+ invalidDomain: 'Invalid domain',
24
+ blockedDomain: 'Test/example email not accepted',
25
+ blockedTld: 'Test/example TLD not accepted',
26
+ };
27
+ /** Resolve a `LocalizedMessage` to its current string value. */
28
+ function resolve(m) {
29
+ return typeof m === 'function' ? m() : m;
30
+ }
31
+ /**
32
+ * Build a project-localized set of validators. Override any subset of
33
+ * messages; defaults are English.
34
+ *
35
+ * Each message can be a literal string OR a function returning a string.
36
+ * Function form is called at validation time (NOT at factory call time), so
37
+ * pass paraglide / i18next / lingui message functions to get per-request
38
+ * locale resolution:
39
+ *
40
+ * ```ts
41
+ * import { createValidators } from '..'
42
+ * import * as m from '../paraglide/messages'
43
+ *
44
+ * export const App_Validators = createValidators({
45
+ * email: {
46
+ * invalid: () => m.email_invalid(),
47
+ * invalidDomain: () => m.email_invalid_domain(),
48
+ * blockedDomain: () => m.email_blocked_domain(),
49
+ * blockedTld: () => m.email_blocked_tld(),
50
+ * },
51
+ * })
52
+ * ```
53
+ *
54
+ * Each validator group exposes two members:
55
+ * - `checkXxx(value)` - pure function, returns `true` on valid or a
56
+ * localized error message string. Use for live UI feedback.
57
+ * - `xxx` - a Remult `Validator` wrapping the same `checkXxx`. Use as
58
+ * `@Fields.string({ validate: app.email })`.
59
+ */
60
+ export function createValidators(messages = {}) {
61
+ const m = {
62
+ ...DEFAULT_EMAIL_MESSAGES,
63
+ ...messages.email,
64
+ };
65
+ function checkEmail(value) {
66
+ if (value === '')
67
+ return true;
68
+ if (!EMAIL_SHAPE_RE.test(value))
69
+ return resolve(m.invalid);
70
+ const at = value.lastIndexOf('@');
71
+ const domain = value.slice(at + 1).toLowerCase();
72
+ if (!domain || domain.includes('..') || domain.startsWith('.') || domain.endsWith('.')) {
73
+ return resolve(m.invalidDomain);
74
+ }
75
+ if (BLOCKED_EMAIL_DOMAINS.has(domain))
76
+ return resolve(m.blockedDomain);
77
+ const tld = domain.split('.').pop() ?? '';
78
+ if (BLOCKED_EMAIL_TLDS.has(tld))
79
+ return resolve(m.blockedTld);
80
+ if (!domain.includes('.') || tld.length < 2)
81
+ return resolve(m.invalidDomain);
82
+ return true;
83
+ }
84
+ return {
85
+ checkEmail,
86
+ // Pass a function so the per-request locale is resolved when the
87
+ // validator actually fires, not when the validator is built.
88
+ email: createValueValidator(checkEmail, () => resolve(m.invalid)),
89
+ };
90
+ }
91
+ /**
92
+ * Default English validators. For localized messages, build your own with
93
+ * `createValidators(messages)` and import that one in your project instead.
94
+ */
95
+ export const FF_Validators = createValidators();
96
+ /** Convenience direct export of `FF_Validators.checkEmail` for ad-hoc use. */
97
+ export const checkEmail = FF_Validators.checkEmail;
@@ -1,2 +1,19 @@
1
1
  import type { ErrorInfo } from 'remult';
2
2
  export declare function isError<T>(object: any): object is ErrorInfo<T>;
3
+ /**
4
+ * Extract a user-readable message from any thrown / rejected value.
5
+ *
6
+ * Why a helper instead of `String(err)`: when a remult `BackendMethod`
7
+ * rejects, the client receives a plain object matching `ErrorInfo`
8
+ * (`{ message?, modelState? }`) - **not** an `Error` instance. The naive
9
+ * `err instanceof Error ? err.message : String(err)` fallback prints
10
+ * `[object Object]` for those rejections and surfaces nothing useful.
11
+ *
12
+ * Order of resolution:
13
+ * 1. plain string -> as-is
14
+ * 2. native `Error` (incl. `EntityError`, `ForbiddenError`) -> `.message`
15
+ * 3. `ErrorInfo`-shaped object with `.message` -> that message
16
+ * 4. `ErrorInfo`-shaped object with `.modelState` -> first non-empty value
17
+ * 5. fallback to `JSON.stringify(err)` (so the shape is at least readable)
18
+ */
19
+ export declare function errorMessage(err: unknown, fallback?: string): string;
@@ -1,3 +1,43 @@
1
1
  export function isError(object) {
2
2
  return object;
3
3
  }
4
+ /**
5
+ * Extract a user-readable message from any thrown / rejected value.
6
+ *
7
+ * Why a helper instead of `String(err)`: when a remult `BackendMethod`
8
+ * rejects, the client receives a plain object matching `ErrorInfo`
9
+ * (`{ message?, modelState? }`) - **not** an `Error` instance. The naive
10
+ * `err instanceof Error ? err.message : String(err)` fallback prints
11
+ * `[object Object]` for those rejections and surfaces nothing useful.
12
+ *
13
+ * Order of resolution:
14
+ * 1. plain string -> as-is
15
+ * 2. native `Error` (incl. `EntityError`, `ForbiddenError`) -> `.message`
16
+ * 3. `ErrorInfo`-shaped object with `.message` -> that message
17
+ * 4. `ErrorInfo`-shaped object with `.modelState` -> first non-empty value
18
+ * 5. fallback to `JSON.stringify(err)` (so the shape is at least readable)
19
+ */
20
+ export function errorMessage(err, fallback = 'Unknown error') {
21
+ if (err == null)
22
+ return fallback;
23
+ if (typeof err === 'string')
24
+ return err || fallback;
25
+ if (err instanceof Error && err.message)
26
+ return err.message;
27
+ if (typeof err === 'object') {
28
+ const e = err;
29
+ if (typeof e.message === 'string' && e.message)
30
+ return e.message;
31
+ if (e.modelState && typeof e.modelState === 'object') {
32
+ const first = Object.values(e.modelState).find((v) => typeof v === 'string' && v);
33
+ if (typeof first === 'string')
34
+ return first;
35
+ }
36
+ }
37
+ try {
38
+ return JSON.stringify(err);
39
+ }
40
+ catch {
41
+ return String(err);
42
+ }
43
+ }
package/esm/index.d.ts CHANGED
@@ -7,7 +7,11 @@ export { BaseEnum } from './core/BaseEnum.js';
7
7
  export type { BaseEnumOptions, BaseItem, BaseItemLight, FF_Icon } from './core/BaseEnum.js';
8
8
  export { FF_Entity } from './core/FF_Entity.js';
9
9
  export { FF_Role } from './core/common.js';
10
- export { isError } from './core/helper.js';
10
+ export { FF_Allow } from './core/FF_Allow.js';
11
+ export { FF_Filter } from './core/FF_Filter.js';
12
+ export { FF_Validators, createValidators } from './core/FF_Validators.js';
13
+ export type { EmailMessages, ValidatorMessages } from './core/FF_Validators.js';
14
+ export { errorMessage, isError } from './core/helper.js';
11
15
  export { tryCatch, tryCatchSync } from './core/tryCatch.js';
12
16
  export type { ResolvedType, UnArray, RecursivePartial } from './core/types.js';
13
17
  export { tw } from './core/tailwind.js';
package/esm/index.js CHANGED
@@ -9,7 +9,10 @@ export const ff_Log = new h.Log('firstly');
9
9
  export { BaseEnum } from './core/BaseEnum.js';
10
10
  export { FF_Entity } from './core/FF_Entity.js';
11
11
  export { FF_Role } from './core/common.js';
12
- export { isError } from './core/helper.js';
12
+ export { FF_Allow } from './core/FF_Allow.js';
13
+ export { FF_Filter } from './core/FF_Filter.js';
14
+ export { FF_Validators, createValidators } from './core/FF_Validators.js';
15
+ export { errorMessage, isError } from './core/helper.js';
13
16
  export { tryCatch, tryCatchSync } from './core/tryCatch.js';
14
17
  export { tw } from './core/tailwind.js';
15
18
  // Misc primitives still exposed from the root.
@@ -0,0 +1,22 @@
1
+ export declare class MailController {
2
+ /**
3
+ * Drives the bundled `<WriteMail />` component. Sends a single-section
4
+ * mail through `remult.context.sendMail` (set by the `mail()` module) and
5
+ * returns the provider's `messageId` when available.
6
+ *
7
+ * Each of `to`, `cc`, `bcc` is a comma-separated string. Splitting,
8
+ * trimming, lowercasing, and per-entry validation happen here on the
9
+ * server - the client just hands over what the user typed.
10
+ */
11
+ static sendTest(input: {
12
+ to: string;
13
+ cc?: string;
14
+ bcc?: string;
15
+ subject: string;
16
+ body: string;
17
+ }): Promise<{
18
+ ok: boolean;
19
+ messageId: string | null;
20
+ error: string | null;
21
+ }>;
22
+ }
@@ -0,0 +1,68 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { BackendMethod, remult } from 'remult';
8
+ import { checkEmail } from '../core/FF_Validators';
9
+ import { Roles_Mail } from './Roles_Mail';
10
+ /** Split on commas, trim, lowercase, drop empties. */
11
+ function parseRecipients(raw) {
12
+ if (!raw)
13
+ return [];
14
+ return raw
15
+ .split(',')
16
+ .map((s) => s.trim().toLowerCase())
17
+ .filter(Boolean);
18
+ }
19
+ export class MailController {
20
+ /**
21
+ * Drives the bundled `<WriteMail />` component. Sends a single-section
22
+ * mail through `remult.context.sendMail` (set by the `mail()` module) and
23
+ * returns the provider's `messageId` when available.
24
+ *
25
+ * Each of `to`, `cc`, `bcc` is a comma-separated string. Splitting,
26
+ * trimming, lowercasing, and per-entry validation happen here on the
27
+ * server - the client just hands over what the user typed.
28
+ */
29
+ static async sendTest(input) {
30
+ if (import.meta.env.SSR) {
31
+ if (!remult.context.sendMail) {
32
+ throw new Error('mail module not registered (call mail() in remultApi.modules)');
33
+ }
34
+ const tos = parseRecipients(input.to);
35
+ const ccs = parseRecipients(input.cc);
36
+ const bccs = parseRecipients(input.bcc);
37
+ if (tos.length === 0 && ccs.length === 0 && bccs.length === 0) {
38
+ throw new Error('At least one recipient is required (to, cc, or bcc).');
39
+ }
40
+ for (const entry of [...tos, ...ccs, ...bccs]) {
41
+ const verdict = checkEmail(entry);
42
+ if (verdict !== true)
43
+ throw new Error(`${verdict}: "${entry}"`);
44
+ }
45
+ const safe = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
46
+ const r = await remult.context.sendMail('test', {
47
+ to: tos.length === 1 ? tos[0] : tos,
48
+ cc: ccs.length ? (ccs.length === 1 ? ccs[0] : ccs) : undefined,
49
+ bcc: bccs.length ? (bccs.length === 1 ? bccs[0] : bccs) : undefined,
50
+ subject: input.subject,
51
+ sections: [
52
+ {
53
+ html: `<p style="margin:0;white-space:pre-wrap;">${safe(input.body) || '<em>(no body)</em>'}</p>`,
54
+ },
55
+ ],
56
+ });
57
+ return {
58
+ ok: !!r.data,
59
+ messageId: r.data?.messageId ?? null,
60
+ error: r.error ? String(r.error.message ?? r.error) : null,
61
+ };
62
+ }
63
+ throw new Error('sendTest: server-only');
64
+ }
65
+ }
66
+ __decorate([
67
+ BackendMethod({ allowed: Roles_Mail.Mail_Admin })
68
+ ], MailController, "sendTest", null);
@@ -1,6 +1,11 @@
1
1
  import { Log } from '@kitql/helpers';
2
2
  import { Mail } from './Mail';
3
+ export { Mail } from './Mail';
4
+ export { MailController } from './MailController';
3
5
  export { Roles_Mail } from './Roles_Mail';
6
+ export type { MailSection } from './types';
7
+ export { default as WriteMail } from './ui/WriteMail.svelte';
8
+ export { default as LastMails } from './ui/LastMails.svelte';
4
9
  export declare const key = "mail";
5
10
  export declare const log: Log;
6
11
  export declare const mailEntities: {
package/esm/mail/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { Log } from '@kitql/helpers';
2
2
  import { Mail } from './Mail';
3
+ export { Mail } from './Mail';
4
+ export { MailController } from './MailController';
3
5
  export { Roles_Mail } from './Roles_Mail';
6
+ export { default as WriteMail } from './ui/WriteMail.svelte';
7
+ export { default as LastMails } from './ui/LastMails.svelte';
4
8
  export const key = 'mail';
5
9
  export const log = new Log(key);
6
10
  export const mailEntities = {
@@ -1,16 +1,11 @@
1
+ import type { MailSection } from '../types';
1
2
  export type MailStyle = {
2
3
  service: string;
3
4
  primaryColor: string;
4
5
  secondaryColor: string;
5
6
  subject: string;
6
7
  title: string;
7
- sections: {
8
- html: string;
9
- cta?: {
10
- html: string;
11
- link: string;
12
- } | undefined;
13
- }[];
8
+ sections: MailSection[];
14
9
  footer: string;
15
10
  };
16
11
  export declare const toHtml: (args: MailStyle) => string;