firstly 0.4.1 → 0.4.3
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 +16 -0
- package/esm/changeLog/index.d.ts +2 -2
- package/esm/changeLog/index.js +1 -1
- 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 +3 -1
- package/esm/index.js +2 -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 +43 -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/ui/SqlAdmin.svelte +112 -61
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# firstly
|
|
2
2
|
|
|
3
|
+
## 0.4.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#265](https://github.com/jycouet/firstly/pull/265) [`9105a8b`](https://github.com/jycouet/firstly/commit/9105a8b4ee47121fdfd8c33419c43bfa75f75c5c) Thanks [@jycouet](https://github.com/jycouet)! - mail: store `attachments` count in `Mail.metadata` so the admin UI can show how many files were sent.
|
|
8
|
+
|
|
9
|
+
## 0.4.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#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`).
|
|
14
|
+
|
|
15
|
+
- [#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]`.
|
|
16
|
+
|
|
17
|
+
- [#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.
|
|
18
|
+
|
|
3
19
|
## 0.4.1
|
|
4
20
|
|
|
5
21
|
### Patch 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,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
|
@@ -9,7 +9,9 @@ export { FF_Entity } from './core/FF_Entity.js';
|
|
|
9
9
|
export { FF_Role } from './core/common.js';
|
|
10
10
|
export { FF_Allow } from './core/FF_Allow.js';
|
|
11
11
|
export { FF_Filter } from './core/FF_Filter.js';
|
|
12
|
-
export {
|
|
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';
|
|
13
15
|
export { tryCatch, tryCatchSync } from './core/tryCatch.js';
|
|
14
16
|
export type { ResolvedType, UnArray, RecursivePartial } from './core/types.js';
|
|
15
17
|
export { tw } from './core/tailwind.js';
|
package/esm/index.js
CHANGED
|
@@ -11,7 +11,8 @@ export { FF_Entity } from './core/FF_Entity.js';
|
|
|
11
11
|
export { FF_Role } from './core/common.js';
|
|
12
12
|
export { FF_Allow } from './core/FF_Allow.js';
|
|
13
13
|
export { FF_Filter } from './core/FF_Filter.js';
|
|
14
|
-
export {
|
|
14
|
+
export { FF_Validators, createValidators } from './core/FF_Validators.js';
|
|
15
|
+
export { errorMessage, isError } from './core/helper.js';
|
|
15
16
|
export { tryCatch, tryCatchSync } from './core/tryCatch.js';
|
|
16
17
|
export { tw } from './core/tailwind.js';
|
|
17
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;
|
|
@@ -6,6 +6,7 @@ import type SMTPPool from 'nodemailer/lib/smtp-pool';
|
|
|
6
6
|
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
|
7
7
|
import type StreamTransport from 'nodemailer/lib/stream-transport';
|
|
8
8
|
import { Module } from 'remult/server';
|
|
9
|
+
import type { MailSection } from '../types';
|
|
9
10
|
import { type MailStyle } from './formatMailHelper';
|
|
10
11
|
declare module 'remult' {
|
|
11
12
|
interface RemultContext {
|
|
@@ -28,6 +29,14 @@ export type MailOptions = GlobalEasyOptions & {
|
|
|
28
29
|
transport?: TransportTypes;
|
|
29
30
|
defaults?: DefaultOptions;
|
|
30
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Register `MailController.sendTest` (the BackendMethod that drives the
|
|
34
|
+
* bundled `<WriteMail />` component). Off by default - exposing a
|
|
35
|
+
* "send any mail to anyone" endpoint should be an explicit opt-in.
|
|
36
|
+
*
|
|
37
|
+
* Gated by `Roles_Mail.Mail_Admin` regardless.
|
|
38
|
+
*/
|
|
39
|
+
enableTest?: boolean;
|
|
31
40
|
};
|
|
32
41
|
export type SendMail = typeof sendMail;
|
|
33
42
|
export type SendMailResult = {
|
|
@@ -41,15 +50,11 @@ export declare const sendMail: (
|
|
|
41
50
|
/** usefull for logs, it has NO impact on the mail itself */
|
|
42
51
|
topic: string, easyOptions: GlobalEasyOptions & {
|
|
43
52
|
to: Required<DefaultOptions>['to'];
|
|
53
|
+
cc?: DefaultOptions['cc'];
|
|
54
|
+
bcc?: DefaultOptions['bcc'];
|
|
44
55
|
subject: Required<DefaultOptions>['subject'];
|
|
45
56
|
title?: string;
|
|
46
|
-
sections:
|
|
47
|
-
html: string;
|
|
48
|
-
cta?: {
|
|
49
|
-
html: string;
|
|
50
|
-
link: string;
|
|
51
|
-
} | undefined;
|
|
52
|
-
}[];
|
|
57
|
+
sections: MailSection[];
|
|
53
58
|
}, options?: {
|
|
54
59
|
nodemailer?: MailOptions['nodemailer'];
|
|
55
60
|
}) => Promise<SendMailResult>;
|
package/esm/mail/server/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { remult, repo } from 'remult';
|
|
|
3
3
|
import { Module } from 'remult/server';
|
|
4
4
|
import { cyan, green, magenta, red, sleep, white } from '@kitql/helpers';
|
|
5
5
|
import { log, mailEntities } from '../index';
|
|
6
|
+
import { MailController } from '../MailController';
|
|
6
7
|
import { toHtml } from './formatMailHelper';
|
|
7
8
|
let transporter;
|
|
8
9
|
let globalOptions;
|
|
@@ -64,12 +65,14 @@ export const sendMail = async (topic, easyOptions, options) => {
|
|
|
64
65
|
from: easyOptions.from ?? globalOptions?.from,
|
|
65
66
|
};
|
|
66
67
|
let { primaryColor, secondaryColor, title, footer, service } = easyOptionsToUse;
|
|
67
|
-
const { subject, sections, to } = easyOptionsToUse;
|
|
68
|
+
const { subject, sections, to, cc, bcc } = easyOptionsToUse;
|
|
68
69
|
service = service ?? 'service';
|
|
69
70
|
primaryColor = primaryColor ?? '#0d0f70';
|
|
70
71
|
secondaryColor = secondaryColor ?? '#653eae';
|
|
71
72
|
title = title ?? subject ?? 'subject';
|
|
72
73
|
footer = footer ?? 'The team wishes you a great day 🚀';
|
|
74
|
+
const mergedAttachments = options?.nodemailer?.defaults?.attachments ?? globalOptions?.nodemailer?.defaults?.attachments;
|
|
75
|
+
const attachments = Array.isArray(mergedAttachments) ? mergedAttachments.length : 0;
|
|
73
76
|
const metadata = {
|
|
74
77
|
service,
|
|
75
78
|
primaryColor,
|
|
@@ -78,12 +81,17 @@ export const sendMail = async (topic, easyOptions, options) => {
|
|
|
78
81
|
title,
|
|
79
82
|
footer,
|
|
80
83
|
sections,
|
|
84
|
+
cc,
|
|
85
|
+
bcc,
|
|
86
|
+
attachments,
|
|
81
87
|
};
|
|
82
88
|
const html = easyOptionsToUse.toHtml ? easyOptionsToUse.toHtml(metadata) : toHtml(metadata);
|
|
83
89
|
nodemailerOptions = {
|
|
84
90
|
defaults: {
|
|
85
91
|
...globalOptions?.nodemailer?.defaults,
|
|
86
92
|
to,
|
|
93
|
+
cc,
|
|
94
|
+
bcc,
|
|
87
95
|
subject,
|
|
88
96
|
html,
|
|
89
97
|
...nodemailerOptions?.defaults,
|
|
@@ -100,18 +108,19 @@ export const sendMail = async (topic, easyOptions, options) => {
|
|
|
100
108
|
try {
|
|
101
109
|
if (!globalOptions?.nodemailer?.transport) {
|
|
102
110
|
const data = await transporter.sendMail({ ...nodemailerOptions.defaults });
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
const previewUrl = nodemailer.getTestMessageUrl(data) || undefined;
|
|
112
|
+
log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not configured`)} ⚠️
|
|
113
|
+
We are still nice and generated you an email preview link (the mail was NOT really sent):
|
|
114
|
+
👉 ${cyan(String(previewUrl))}
|
|
115
|
+
|
|
116
|
+
To really send mails (likely a missing provider API key), see ${white(`https://firstly.fun/docs/modules/mail`)}.
|
|
108
117
|
`);
|
|
109
118
|
await repo(mailEntities.Mail).insert({
|
|
110
119
|
status: 'transport_not_configured',
|
|
111
120
|
to: JSON.stringify(to),
|
|
112
121
|
html: easyOptionsToUse.saveHtml ? html : '',
|
|
113
122
|
topic,
|
|
114
|
-
metadata,
|
|
123
|
+
metadata: { ...metadata, transport: extractTransportInfo(data, previewUrl) },
|
|
115
124
|
});
|
|
116
125
|
return { data };
|
|
117
126
|
}
|
|
@@ -123,16 +132,18 @@ export const sendMail = async (topic, easyOptions, options) => {
|
|
|
123
132
|
to: JSON.stringify(to),
|
|
124
133
|
html: easyOptionsToUse.saveHtml ? html : '',
|
|
125
134
|
topic,
|
|
126
|
-
metadata,
|
|
135
|
+
metadata: { ...metadata, transport: extractTransportInfo(data) },
|
|
127
136
|
});
|
|
128
137
|
return { data };
|
|
129
138
|
}
|
|
130
139
|
}
|
|
131
140
|
catch (error) {
|
|
132
141
|
if (error instanceof Error && error.message.includes('Missing credentials for "PLAIN"')) {
|
|
133
|
-
log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not well configured`)} ⚠️
|
|
142
|
+
log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not well configured`)} ⚠️
|
|
134
143
|
👉 transport used:
|
|
135
144
|
${cyan(JSON.stringify(globalOptions?.nodemailer?.transport, null, 2))}
|
|
145
|
+
|
|
146
|
+
Auth was refused - check your provider's API key. Docs: ${white(`https://firstly.fun/docs/modules/mail`)}.
|
|
136
147
|
`);
|
|
137
148
|
}
|
|
138
149
|
else {
|
|
@@ -177,20 +188,34 @@ ${cyan(JSON.stringify(globalOptions?.nodemailer?.transport, null, 2))}
|
|
|
177
188
|
return { error };
|
|
178
189
|
}
|
|
179
190
|
};
|
|
180
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Captured nodemailer-side metadata persisted on every send. This makes
|
|
193
|
+
* provider-side IDs (e.g. Resend's `re_...` returned via SMTP `messageId`)
|
|
194
|
+
* recoverable from the DB without an extra round-trip to the provider.
|
|
195
|
+
*/
|
|
196
|
+
function extractTransportInfo(data, preview) {
|
|
197
|
+
return {
|
|
198
|
+
messageId: data.messageId,
|
|
199
|
+
response: data.response,
|
|
200
|
+
accepted: data.accepted,
|
|
201
|
+
rejected: data.rejected,
|
|
202
|
+
envelope: data.envelope,
|
|
203
|
+
preview,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export const mail = (o) => new Module({
|
|
181
207
|
key: 'mail',
|
|
182
208
|
priority: -888,
|
|
183
209
|
entities: Object.values(mailEntities),
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
210
|
+
// Opt-in: only register the test endpoint when the consumer asks for it.
|
|
211
|
+
controllers: o?.enableTest ? [MailController] : [],
|
|
212
|
+
initApi: () => {
|
|
187
213
|
initMail(o);
|
|
188
214
|
// Need to init in the 2 places!
|
|
189
215
|
remult.context.sendMail = sendMail;
|
|
190
|
-
}
|
|
191
|
-
|
|
216
|
+
},
|
|
217
|
+
initRequest: async () => {
|
|
192
218
|
// Need to init in the 2 places!
|
|
193
219
|
remult.context.sendMail = sendMail;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
};
|
|
220
|
+
},
|
|
221
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, onMount } from 'svelte'
|
|
3
|
+
|
|
4
|
+
import { remult, repo } from 'remult'
|
|
5
|
+
|
|
6
|
+
import { Mail } from '../Mail'
|
|
7
|
+
import { Roles_Mail } from '../Roles_Mail'
|
|
8
|
+
|
|
9
|
+
const hasAccess = $derived(remult.user?.roles?.includes(Roles_Mail.Mail_Admin) ?? false)
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
limit?: number
|
|
13
|
+
/** Subscribe to a remult `liveQuery` so the list updates over SSE.
|
|
14
|
+
* Default `true`. Set to `false` to fall back to a one-shot fetch on
|
|
15
|
+
* mount (the Refresh button still works either way). */
|
|
16
|
+
live?: boolean
|
|
17
|
+
}
|
|
18
|
+
let { limit = 30, live = true }: Props = $props()
|
|
19
|
+
|
|
20
|
+
let mails: Mail[] = $state([])
|
|
21
|
+
let isLoading = $state(false)
|
|
22
|
+
let error = $state('')
|
|
23
|
+
let unsubscribe: (() => void) | null = null
|
|
24
|
+
|
|
25
|
+
export async function refresh() {
|
|
26
|
+
isLoading = true
|
|
27
|
+
error = ''
|
|
28
|
+
try {
|
|
29
|
+
mails = await repo(Mail).find({ limit })
|
|
30
|
+
} catch (e) {
|
|
31
|
+
error = e instanceof Error ? e.message : String(e)
|
|
32
|
+
} finally {
|
|
33
|
+
isLoading = false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
onMount(() => {
|
|
38
|
+
if (live) {
|
|
39
|
+
// Pass `orderBy` explicitly: remult keeps the live state sorted on
|
|
40
|
+
// incremental adds/replaces only when `query.options.orderBy` is
|
|
41
|
+
// set (the entity's `defaultOrderBy` only applies to the initial
|
|
42
|
+
// fetch). Without this, new SSE rows would land at the bottom.
|
|
43
|
+
unsubscribe = repo(Mail)
|
|
44
|
+
.liveQuery({ limit, orderBy: { createdAt: 'desc' } })
|
|
45
|
+
.subscribe((res) => {
|
|
46
|
+
mails = res.items
|
|
47
|
+
error = ''
|
|
48
|
+
})
|
|
49
|
+
} else {
|
|
50
|
+
refresh()
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
onDestroy(() => unsubscribe?.())
|
|
55
|
+
|
|
56
|
+
function formatList(v: unknown): string {
|
|
57
|
+
if (v == null) return ''
|
|
58
|
+
if (Array.isArray(v)) return v.join(', ')
|
|
59
|
+
return String(v)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseTo(raw: string): string {
|
|
63
|
+
try {
|
|
64
|
+
return formatList(JSON.parse(raw))
|
|
65
|
+
} catch {
|
|
66
|
+
return raw
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatDate(d: unknown): string {
|
|
71
|
+
if (!d) return ''
|
|
72
|
+
const date = d instanceof Date ? d : new Date(d as string)
|
|
73
|
+
if (isNaN(date.getTime())) return String(d)
|
|
74
|
+
return date.toLocaleString()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function badgeClass(status: Mail['status']): string {
|
|
78
|
+
if (status === 'sent') return 'bg-emerald-500/10 border-emerald-500/40 text-emerald-300'
|
|
79
|
+
if (status === 'transport_not_configured')
|
|
80
|
+
return 'bg-amber-500/10 border-amber-500/40 text-amber-300'
|
|
81
|
+
return 'bg-red-500/10 border-red-500/40 text-red-300'
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<div class="border border-slate-700 bg-slate-800 text-slate-200">
|
|
86
|
+
<header class="flex flex-wrap items-center gap-3 border-b border-slate-700 px-5 py-4">
|
|
87
|
+
<div class="flex flex-col">
|
|
88
|
+
<h2 class="text-lg font-semibold text-slate-100">Last mails</h2>
|
|
89
|
+
<p class="text-sm text-slate-400">Recent mails sent through this app.</p>
|
|
90
|
+
</div>
|
|
91
|
+
{#if hasAccess}
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onclick={refresh}
|
|
95
|
+
disabled={isLoading}
|
|
96
|
+
class="ml-auto inline-flex items-center gap-2 border border-slate-600 bg-slate-700 px-3 py-1.5 text-sm font-medium text-slate-100 hover:bg-slate-600 disabled:opacity-50"
|
|
97
|
+
>
|
|
98
|
+
{#if isLoading}
|
|
99
|
+
<svg
|
|
100
|
+
class="h-4 w-4 animate-spin"
|
|
101
|
+
viewBox="0 0 24 24"
|
|
102
|
+
fill="none"
|
|
103
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
>
|
|
106
|
+
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.25" stroke-width="4"
|
|
107
|
+
></circle>
|
|
108
|
+
<path d="M4 12a8 8 0 0 1 8-8" stroke="currentColor" stroke-width="4" stroke-linecap="round"
|
|
109
|
+
></path>
|
|
110
|
+
</svg>
|
|
111
|
+
{/if}
|
|
112
|
+
Refresh
|
|
113
|
+
</button>
|
|
114
|
+
<span class="text-xs text-slate-500">
|
|
115
|
+
{mails.length} mail{mails.length === 1 ? '' : 's'}
|
|
116
|
+
{#if live}<span class="text-indigo-400">· live</span>{/if}
|
|
117
|
+
</span>
|
|
118
|
+
{/if}
|
|
119
|
+
</header>
|
|
120
|
+
|
|
121
|
+
<div class="p-5">
|
|
122
|
+
{#if !hasAccess}
|
|
123
|
+
<div class="border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
|
|
124
|
+
You need the
|
|
125
|
+
<code class="bg-amber-500/20 px-1 py-0.5 text-xs text-amber-100">Mail.Admin</code>
|
|
126
|
+
role to use this.
|
|
127
|
+
</div>
|
|
128
|
+
{:else if error}
|
|
129
|
+
<div class="border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{error}</div>
|
|
130
|
+
{:else if mails.length === 0}
|
|
131
|
+
<div class="border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">No mails yet.</div>
|
|
132
|
+
{:else}
|
|
133
|
+
<div class="flex flex-col gap-3">
|
|
134
|
+
{#each mails as m (m.id)}
|
|
135
|
+
{@const subject = m.metadata?.subject as string | undefined}
|
|
136
|
+
{@const cc = formatList(m.metadata?.cc)}
|
|
137
|
+
{@const bcc = formatList(m.metadata?.bcc)}
|
|
138
|
+
{@const messageId = m.metadata?.transport?.messageId as string | undefined}
|
|
139
|
+
{@const preview = m.metadata?.transport?.preview as string | undefined}
|
|
140
|
+
<article class="flex flex-col gap-2 border border-slate-700 bg-slate-900 p-4">
|
|
141
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
142
|
+
<span class="border px-2 py-0.5 text-xs font-medium {badgeClass(m.status)}">{m.status}</span>
|
|
143
|
+
<span
|
|
144
|
+
class="border border-slate-600 bg-slate-700/50 px-2 py-0.5 text-xs font-medium text-slate-200"
|
|
145
|
+
>{m.topic}</span
|
|
146
|
+
>
|
|
147
|
+
<span class="text-xs text-slate-400">{parseTo(m.to)}</span>
|
|
148
|
+
<span class="ml-auto text-xs text-slate-500">{formatDate(m.createdAt)}</span>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div class="text-base font-medium text-slate-100">{subject || '(no subject)'}</div>
|
|
152
|
+
|
|
153
|
+
{#if cc}
|
|
154
|
+
<div class="text-xs text-slate-500">cc: {cc}</div>
|
|
155
|
+
{/if}
|
|
156
|
+
{#if bcc}
|
|
157
|
+
<div class="text-xs text-slate-500">bcc: {bcc}</div>
|
|
158
|
+
{/if}
|
|
159
|
+
|
|
160
|
+
{#if preview}
|
|
161
|
+
<div class="text-xs">
|
|
162
|
+
<a
|
|
163
|
+
href={preview}
|
|
164
|
+
target="_blank"
|
|
165
|
+
rel="noopener noreferrer"
|
|
166
|
+
class="text-indigo-400 underline hover:text-indigo-300">preview (mail not really sent)</a
|
|
167
|
+
>
|
|
168
|
+
</div>
|
|
169
|
+
{/if}
|
|
170
|
+
|
|
171
|
+
{#if messageId}
|
|
172
|
+
<div class="text-xs break-all text-slate-500">id: <code>{messageId}</code></div>
|
|
173
|
+
{/if}
|
|
174
|
+
|
|
175
|
+
{#if m.status === 'error' && m.errorInfo}
|
|
176
|
+
<pre
|
|
177
|
+
class="border border-red-500/40 bg-red-500/10 p-2 text-xs whitespace-pre-wrap text-red-200">{m.errorInfo}</pre>
|
|
178
|
+
{/if}
|
|
179
|
+
</article>
|
|
180
|
+
{/each}
|
|
181
|
+
</div>
|
|
182
|
+
{/if}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
limit?: number;
|
|
3
|
+
/** Subscribe to a remult `liveQuery` so the list updates over SSE.
|
|
4
|
+
* Default `true`. Set to `false` to fall back to a one-shot fetch on
|
|
5
|
+
* mount (the Refresh button still works either way). */
|
|
6
|
+
live?: boolean;
|
|
7
|
+
};
|
|
8
|
+
declare const LastMails: import("svelte").Component<Props, {
|
|
9
|
+
refresh: () => Promise<void>;
|
|
10
|
+
}, "">;
|
|
11
|
+
type LastMails = ReturnType<typeof LastMails>;
|
|
12
|
+
export default LastMails;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { remult } from 'remult'
|
|
3
|
+
import { errorMessage } from '../..'
|
|
4
|
+
|
|
5
|
+
import { MailController } from '../MailController'
|
|
6
|
+
import { Roles_Mail } from '../Roles_Mail'
|
|
7
|
+
|
|
8
|
+
const hasAccess = $derived(remult.user?.roles?.includes(Roles_Mail.Mail_Admin) ?? false)
|
|
9
|
+
|
|
10
|
+
let to = $state('')
|
|
11
|
+
let cc = $state('')
|
|
12
|
+
let bcc = $state('')
|
|
13
|
+
let subject = $state('')
|
|
14
|
+
let body = $state('')
|
|
15
|
+
let isLoading = $state(false)
|
|
16
|
+
|
|
17
|
+
let result: { ok: boolean; messageId: string | null } | null = $state(null)
|
|
18
|
+
let error = $state('')
|
|
19
|
+
|
|
20
|
+
// Bare-minimum client gate: subject + at least one recipient slot has
|
|
21
|
+
// content. Server splits, trims, lowercases, and validates each entry.
|
|
22
|
+
const canSend = $derived(
|
|
23
|
+
subject.trim().length > 0 && (to.trim() || cc.trim() || bcc.trim()).length > 0,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
async function handleSubmit(e: Event) {
|
|
27
|
+
e.preventDefault()
|
|
28
|
+
if (!canSend) return
|
|
29
|
+
result = null
|
|
30
|
+
error = ''
|
|
31
|
+
isLoading = true
|
|
32
|
+
// We don't gate the request on `hasAccess` (it's a client-only signal):
|
|
33
|
+
// the server cookie-auths via the BackendMethod's `allowed`. The amber
|
|
34
|
+
// notice in the template is for UX only.
|
|
35
|
+
try {
|
|
36
|
+
const r = await MailController.sendTest({ to, cc, bcc, subject, body })
|
|
37
|
+
if (r.ok) {
|
|
38
|
+
result = { ok: true, messageId: r.messageId }
|
|
39
|
+
} else {
|
|
40
|
+
error = r.error ?? 'Unknown error'
|
|
41
|
+
}
|
|
42
|
+
} catch (e) {
|
|
43
|
+
error = errorMessage(e)
|
|
44
|
+
} finally {
|
|
45
|
+
isLoading = false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<div class="border border-slate-700 bg-slate-800 text-slate-200">
|
|
51
|
+
<header class="border-b border-slate-700 px-5 py-4">
|
|
52
|
+
<h2 class="text-lg font-semibold text-slate-100">Write mail</h2>
|
|
53
|
+
<p class="mt-1 text-sm text-slate-400">Send a test mail through the configured transport.</p>
|
|
54
|
+
</header>
|
|
55
|
+
|
|
56
|
+
<div class="p-5">
|
|
57
|
+
{#if !hasAccess}
|
|
58
|
+
<div class="border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
|
|
59
|
+
You need the
|
|
60
|
+
<code class="bg-amber-500/20 px-1 py-0.5 text-xs text-amber-100">Mail.Admin</code>
|
|
61
|
+
role to use this.
|
|
62
|
+
</div>
|
|
63
|
+
{:else}
|
|
64
|
+
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
|
|
65
|
+
<div class="flex flex-col gap-1">
|
|
66
|
+
<label for="write-mail-to" class="text-xs font-medium tracking-wide text-slate-400 uppercase"
|
|
67
|
+
>To</label
|
|
68
|
+
>
|
|
69
|
+
<input
|
|
70
|
+
id="write-mail-to"
|
|
71
|
+
type="text"
|
|
72
|
+
bind:value={to}
|
|
73
|
+
disabled={isLoading}
|
|
74
|
+
placeholder="someone@example.com, other@example.com"
|
|
75
|
+
class="border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="grid grid-cols-2 gap-4 max-md:grid-cols-1">
|
|
80
|
+
<div class="flex flex-col gap-1">
|
|
81
|
+
<label for="write-mail-cc" class="text-xs font-medium tracking-wide text-slate-400 uppercase"
|
|
82
|
+
>Cc</label
|
|
83
|
+
>
|
|
84
|
+
<input
|
|
85
|
+
id="write-mail-cc"
|
|
86
|
+
type="text"
|
|
87
|
+
bind:value={cc}
|
|
88
|
+
disabled={isLoading}
|
|
89
|
+
placeholder="optional, comma-separated"
|
|
90
|
+
class="border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="flex flex-col gap-1">
|
|
94
|
+
<label for="write-mail-bcc" class="text-xs font-medium tracking-wide text-slate-400 uppercase"
|
|
95
|
+
>Bcc</label
|
|
96
|
+
>
|
|
97
|
+
<input
|
|
98
|
+
id="write-mail-bcc"
|
|
99
|
+
type="text"
|
|
100
|
+
bind:value={bcc}
|
|
101
|
+
disabled={isLoading}
|
|
102
|
+
placeholder="optional, comma-separated"
|
|
103
|
+
class="border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div class="flex flex-col gap-1">
|
|
109
|
+
<label
|
|
110
|
+
for="write-mail-subject"
|
|
111
|
+
class="text-xs font-medium tracking-wide text-slate-400 uppercase">Subject</label
|
|
112
|
+
>
|
|
113
|
+
<input
|
|
114
|
+
id="write-mail-subject"
|
|
115
|
+
type="text"
|
|
116
|
+
bind:value={subject}
|
|
117
|
+
disabled={isLoading}
|
|
118
|
+
required
|
|
119
|
+
placeholder="Subject"
|
|
120
|
+
class="border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div class="flex flex-col gap-1">
|
|
125
|
+
<label for="write-mail-body" class="text-xs font-medium tracking-wide text-slate-400 uppercase"
|
|
126
|
+
>Body</label
|
|
127
|
+
>
|
|
128
|
+
<textarea
|
|
129
|
+
id="write-mail-body"
|
|
130
|
+
bind:value={body}
|
|
131
|
+
disabled={isLoading}
|
|
132
|
+
placeholder="Write your message..."
|
|
133
|
+
class="h-40 w-full border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
|
|
134
|
+
></textarea>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div class="flex items-center gap-4 border-t border-slate-700 pt-4">
|
|
138
|
+
<button
|
|
139
|
+
type="submit"
|
|
140
|
+
disabled={isLoading || !canSend}
|
|
141
|
+
class="inline-flex items-center gap-2 bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 disabled:opacity-50"
|
|
142
|
+
>
|
|
143
|
+
{#if isLoading}
|
|
144
|
+
<svg
|
|
145
|
+
class="h-4 w-4 animate-spin"
|
|
146
|
+
viewBox="0 0 24 24"
|
|
147
|
+
fill="none"
|
|
148
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
149
|
+
aria-hidden="true"
|
|
150
|
+
>
|
|
151
|
+
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.25" stroke-width="4"
|
|
152
|
+
></circle>
|
|
153
|
+
<path d="M4 12a8 8 0 0 1 8-8" stroke="currentColor" stroke-width="4" stroke-linecap="round"
|
|
154
|
+
></path>
|
|
155
|
+
</svg>
|
|
156
|
+
{/if}
|
|
157
|
+
Send
|
|
158
|
+
</button>
|
|
159
|
+
|
|
160
|
+
{#if !canSend && !result && !error}
|
|
161
|
+
<span class="text-xs text-slate-500"> Add a subject and at least one recipient. </span>
|
|
162
|
+
{/if}
|
|
163
|
+
|
|
164
|
+
{#if result}
|
|
165
|
+
<div
|
|
166
|
+
class="flex flex-1 items-center gap-2 border border-emerald-500/40 bg-emerald-500/10 px-3 py-1.5 text-sm text-emerald-200"
|
|
167
|
+
>
|
|
168
|
+
<span class="font-medium">Sent</span>
|
|
169
|
+
{#if result.messageId}
|
|
170
|
+
<code class="ml-auto text-xs break-all">{result.messageId}</code>
|
|
171
|
+
{/if}
|
|
172
|
+
</div>
|
|
173
|
+
{/if}
|
|
174
|
+
|
|
175
|
+
{#if error}
|
|
176
|
+
<pre
|
|
177
|
+
class="flex-1 overflow-auto border border-red-500/40 bg-red-500/10 px-3 py-1.5 text-xs whitespace-pre-wrap text-red-200">{error}</pre>
|
|
178
|
+
{/if}
|
|
179
|
+
</div>
|
|
180
|
+
</form>
|
|
181
|
+
{/if}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* SQL Admin UI.
|
|
4
4
|
*
|
|
5
|
+
* Dark theme (zinc + indigo accent), styled with raw Tailwind utilities only -
|
|
6
|
+
* no plugin (daisyUI, shadcn, etc.) required. Drop into any Tailwind-powered
|
|
7
|
+
* project and it just works.
|
|
8
|
+
*
|
|
5
9
|
* Results are logged to the browser console as `for AI: <json rows>` after
|
|
6
10
|
* each successful query - chrome-devtools / AI agents inspecting the page
|
|
7
11
|
* can read them with `list_console_messages`.
|
|
8
12
|
*/
|
|
13
|
+
import { log } from '../index'
|
|
9
14
|
import { SqlAdminController } from '../SqlAdminController'
|
|
10
15
|
|
|
11
16
|
const defaultQuery = `SELECT *
|
|
@@ -46,9 +51,7 @@ ORDER BY tablename, indexname`,
|
|
|
46
51
|
},
|
|
47
52
|
} as const
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
'[firstly/SqlAdmin] AI Hint: results are also logged as "for AI:" JSON after each query.',
|
|
51
|
-
)
|
|
54
|
+
log.info('AI Hint: results are also logged as "for AI:" JSON after each query.')
|
|
52
55
|
|
|
53
56
|
function setPresetQuery(queryId: keyof typeof queries) {
|
|
54
57
|
sqlInput = queries[queryId].sql
|
|
@@ -60,8 +63,8 @@ ORDER BY tablename, indexname`,
|
|
|
60
63
|
error = ''
|
|
61
64
|
isLoading = true
|
|
62
65
|
result = { ...(await SqlAdminController.exec(sqlInput)) }
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
log.info('for AI:', JSON.stringify(result.r.rows))
|
|
67
|
+
log.info('for humans:', result)
|
|
65
68
|
} catch (e) {
|
|
66
69
|
error = JSON.stringify(e, null, 2)
|
|
67
70
|
} finally {
|
|
@@ -75,72 +78,120 @@ ORDER BY tablename, indexname`,
|
|
|
75
78
|
}
|
|
76
79
|
</script>
|
|
77
80
|
|
|
78
|
-
<div class="
|
|
79
|
-
<
|
|
80
|
-
<h2 class="text-
|
|
81
|
-
<p class="text-sm text-
|
|
81
|
+
<div class="border border-slate-700 bg-slate-800 text-slate-200">
|
|
82
|
+
<header class="border-b border-slate-700 px-5 py-4">
|
|
83
|
+
<h2 class="text-lg font-semibold text-slate-100">SQL Admin</h2>
|
|
84
|
+
<p class="mt-1 text-sm text-slate-400">
|
|
82
85
|
Execute SQL queries directly on the database. Results are displayed below and also logged to the
|
|
83
86
|
browser console.
|
|
84
87
|
</p>
|
|
85
|
-
</
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
</header>
|
|
89
|
+
|
|
90
|
+
<div class="flex flex-col gap-4 p-5">
|
|
91
|
+
<div class="flex flex-wrap gap-2">
|
|
92
|
+
{#each Object.entries(queries) as [id, query] (id)}
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
class="border border-slate-600 bg-slate-700 px-3 py-1.5 text-sm font-medium text-slate-100 hover:bg-slate-600 disabled:opacity-50"
|
|
96
|
+
onclick={() => setPresetQuery(id as keyof typeof queries)}>{query.label}</button
|
|
97
|
+
>
|
|
98
|
+
{/each}
|
|
99
|
+
</div>
|
|
100
|
+
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
|
|
95
101
|
<textarea
|
|
96
102
|
bind:value={sqlInput}
|
|
97
|
-
class="
|
|
103
|
+
class="h-52 w-full border border-slate-700 bg-slate-900 p-3 font-mono text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
|
|
98
104
|
placeholder="Enter SQL command..."
|
|
99
105
|
disabled={isLoading}
|
|
100
106
|
></textarea>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
<span class="text-success-content">{result.r.rowCount} rows</span>
|
|
115
|
-
</div>
|
|
116
|
-
{/if}
|
|
117
|
-
</div>
|
|
118
|
-
</form>
|
|
119
|
-
{#if result}
|
|
120
|
-
<div class="max-h-[600px] overflow-auto rounded-lg border border-base-300">
|
|
121
|
-
{#if result.r.rows && result.r.rows.length > 0}
|
|
122
|
-
<table class="table w-full table-zebra bg-base-200">
|
|
123
|
-
<thead class="sticky top-0 z-10 bg-base-300">
|
|
124
|
-
<tr
|
|
125
|
-
>{#each getHeaders(result.r.rows) as header, i (i)}<th>{header}</th>{/each}</tr
|
|
107
|
+
<div class="flex flex-wrap items-center gap-4">
|
|
108
|
+
<button
|
|
109
|
+
type="submit"
|
|
110
|
+
class="inline-flex items-center gap-2 bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 disabled:opacity-50"
|
|
111
|
+
disabled={isLoading}
|
|
112
|
+
>
|
|
113
|
+
{#if isLoading}
|
|
114
|
+
<svg
|
|
115
|
+
class="h-4 w-4 animate-spin"
|
|
116
|
+
viewBox="0 0 24 24"
|
|
117
|
+
fill="none"
|
|
118
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
119
|
+
aria-hidden="true"
|
|
126
120
|
>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
121
|
+
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.25" stroke-width="4"
|
|
122
|
+
></circle>
|
|
123
|
+
<path d="M4 12a8 8 0 0 1 8-8" stroke="currentColor" stroke-width="4" stroke-linecap="round"
|
|
124
|
+
></path>
|
|
125
|
+
</svg>
|
|
126
|
+
{/if}
|
|
127
|
+
Execute SQL
|
|
128
|
+
</button>
|
|
129
|
+
{#if error}
|
|
130
|
+
<pre
|
|
131
|
+
class="flex-1 overflow-auto border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{error.replaceAll(
|
|
132
|
+
'\\n',
|
|
133
|
+
'\n',
|
|
134
|
+
)}</pre>
|
|
135
|
+
{/if}
|
|
136
|
+
{#if result}
|
|
137
|
+
<div
|
|
138
|
+
class="flex flex-1 justify-between border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm text-emerald-200"
|
|
139
|
+
>
|
|
140
|
+
<span>{result.took.toFixed(0)} ms</span>
|
|
141
|
+
<span>{result.r.rowCount} rows</span>
|
|
142
|
+
</div>
|
|
143
|
+
{/if}
|
|
144
|
+
</div>
|
|
145
|
+
</form>
|
|
146
|
+
{#if result}
|
|
147
|
+
<!-- contain: paint isolates the scroll container's repaint area; without
|
|
148
|
+
it, scrolling a wide result table forces the whole page to repaint
|
|
149
|
+
every frame, which is what made horizontal scroll feel laggy. -->
|
|
150
|
+
<div class="max-h-[600px] overflow-auto border border-slate-700 [contain:paint]">
|
|
151
|
+
{#if result.r.rows && result.r.rows.length > 0}
|
|
152
|
+
<table class="w-full border-collapse text-sm">
|
|
153
|
+
<thead class="sticky top-0 z-10 bg-slate-700">
|
|
130
154
|
<tr>
|
|
131
|
-
{#each
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
</td>
|
|
155
|
+
{#each getHeaders(result.r.rows) as header, i (i)}
|
|
156
|
+
<th class="border-b border-slate-600 px-3 py-2 text-left font-semibold text-slate-100"
|
|
157
|
+
>{header}</th
|
|
158
|
+
>
|
|
136
159
|
{/each}
|
|
137
160
|
</tr>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
161
|
+
</thead>
|
|
162
|
+
<tbody>
|
|
163
|
+
{#each result.r.rows as row, r (r)}
|
|
164
|
+
<!-- Solid stripe (no /50 alpha) so the GPU doesn't have to alpha-
|
|
165
|
+
composite every cell on every scroll frame. -->
|
|
166
|
+
<tr class="even:bg-slate-900">
|
|
167
|
+
{#each Object.values(row) as cell, c (c)}
|
|
168
|
+
<!-- min-w to keep short cells readable, max-w-xs to cap
|
|
169
|
+
wide ones, break-all so long unbroken strings (URLs,
|
|
170
|
+
DIDs) wrap inside their cell instead of forcing the
|
|
171
|
+
column to ~940px and the table to 2.5kpx (which is
|
|
172
|
+
what made horizontal scroll laggy). -->
|
|
173
|
+
<td
|
|
174
|
+
class="max-w-xs min-w-[8rem] border-b border-slate-700 px-3 py-2 align-top text-sm break-all text-slate-200"
|
|
175
|
+
>
|
|
176
|
+
{#if typeof cell === 'object'}<pre
|
|
177
|
+
class="text-xs whitespace-pre-wrap text-slate-400">{JSON.stringify(
|
|
178
|
+
cell,
|
|
179
|
+
null,
|
|
180
|
+
2,
|
|
181
|
+
)}</pre>
|
|
182
|
+
{:else}{cell === null ? 'null' : cell}{/if}
|
|
183
|
+
</td>
|
|
184
|
+
{/each}
|
|
185
|
+
</tr>
|
|
186
|
+
{/each}
|
|
187
|
+
</tbody>
|
|
188
|
+
</table>
|
|
189
|
+
{:else}
|
|
190
|
+
<div class="border border-slate-700 bg-slate-800 p-3 text-sm text-slate-300">
|
|
191
|
+
No rows returned
|
|
192
|
+
</div>
|
|
193
|
+
{/if}
|
|
194
|
+
</div>
|
|
195
|
+
{/if}
|
|
196
|
+
</div>
|
|
146
197
|
</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firstly",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Firstly, an opinionated Remult setup!",
|
|
6
6
|
"funding": "https://github.com/sponsors/jycouet",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"nodemailer": "8.0.5",
|
|
41
41
|
"tailwind-merge": "3.5.0",
|
|
42
42
|
"tailwindcss": "4.2.2",
|
|
43
|
-
"vite-plugin-kit-routes": "1.0.
|
|
44
|
-
"vite-plugin-stripper": "0.10.
|
|
43
|
+
"vite-plugin-kit-routes": "1.0.5",
|
|
44
|
+
"vite-plugin-stripper": "0.10.3"
|
|
45
45
|
},
|
|
46
46
|
"sideEffects": false,
|
|
47
47
|
"exports": {
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
},
|
|
86
86
|
"./mail": {
|
|
87
87
|
"types": "./esm/mail/index.d.ts",
|
|
88
|
+
"svelte": "./esm/mail/index.js",
|
|
88
89
|
"default": "./esm/mail/index.js"
|
|
89
90
|
},
|
|
90
91
|
"./mail/server": {
|