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.
- package/CHANGELOG.md +18 -0
- package/esm/changeLog/index.d.ts +2 -2
- package/esm/changeLog/index.js +1 -1
- package/esm/core/FF_Allow.d.ts +55 -0
- package/esm/core/FF_Allow.js +54 -0
- package/esm/core/FF_Filter.d.ts +55 -0
- package/esm/core/FF_Filter.js +57 -0
- package/esm/core/FF_Validators.d.ts +63 -0
- package/esm/core/FF_Validators.js +97 -0
- package/esm/core/helper.d.ts +17 -0
- package/esm/core/helper.js +40 -0
- package/esm/index.d.ts +5 -1
- package/esm/index.js +4 -1
- package/esm/mail/MailController.d.ts +22 -0
- package/esm/mail/MailController.js +68 -0
- package/esm/mail/index.d.ts +5 -0
- package/esm/mail/index.js +4 -0
- package/esm/mail/server/formatMailHelper.d.ts +2 -7
- package/esm/mail/server/index.d.ts +12 -7
- package/esm/mail/server/index.js +40 -18
- package/esm/mail/types.d.ts +11 -0
- package/esm/mail/types.js +1 -0
- package/esm/mail/ui/LastMails.svelte +184 -0
- package/esm/mail/ui/LastMails.svelte.d.ts +12 -0
- package/esm/mail/ui/WriteMail.svelte +183 -0
- package/esm/mail/ui/WriteMail.svelte.d.ts +3 -0
- package/esm/sqlAdmin/Roles_SqlAdmin.d.ts +3 -0
- package/esm/sqlAdmin/Roles_SqlAdmin.js +3 -0
- package/esm/sqlAdmin/SqlAdminController.d.ts +9 -0
- package/esm/sqlAdmin/SqlAdminController.js +23 -0
- package/esm/sqlAdmin/index.d.ts +6 -0
- package/esm/sqlAdmin/index.js +6 -0
- package/esm/sqlAdmin/server/index.d.ts +40 -0
- package/esm/sqlAdmin/server/index.js +40 -0
- package/esm/sqlAdmin/ui/SqlAdmin.svelte +197 -0
- package/esm/sqlAdmin/ui/SqlAdmin.svelte.d.ts +3 -0
- 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
|
package/esm/changeLog/index.d.ts
CHANGED
|
@@ -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;
|
package/esm/changeLog/index.js
CHANGED
|
@@ -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;
|
package/esm/core/helper.d.ts
CHANGED
|
@@ -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;
|
package/esm/core/helper.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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) => ({ '&': '&', '<': '<', '>': '>' })[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);
|
package/esm/mail/index.d.ts
CHANGED
|
@@ -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;
|