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
|
@@ -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,7 +65,7 @@ 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';
|
|
@@ -78,12 +79,16 @@ export const sendMail = async (topic, easyOptions, options) => {
|
|
|
78
79
|
title,
|
|
79
80
|
footer,
|
|
80
81
|
sections,
|
|
82
|
+
cc,
|
|
83
|
+
bcc,
|
|
81
84
|
};
|
|
82
85
|
const html = easyOptionsToUse.toHtml ? easyOptionsToUse.toHtml(metadata) : toHtml(metadata);
|
|
83
86
|
nodemailerOptions = {
|
|
84
87
|
defaults: {
|
|
85
88
|
...globalOptions?.nodemailer?.defaults,
|
|
86
89
|
to,
|
|
90
|
+
cc,
|
|
91
|
+
bcc,
|
|
87
92
|
subject,
|
|
88
93
|
html,
|
|
89
94
|
...nodemailerOptions?.defaults,
|
|
@@ -100,18 +105,19 @@ export const sendMail = async (topic, easyOptions, options) => {
|
|
|
100
105
|
try {
|
|
101
106
|
if (!globalOptions?.nodemailer?.transport) {
|
|
102
107
|
const data = await transporter.sendMail({ ...nodemailerOptions.defaults });
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
const previewUrl = nodemailer.getTestMessageUrl(data) || undefined;
|
|
109
|
+
log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not configured`)} ⚠️
|
|
110
|
+
We are still nice and generated you an email preview link (the mail was NOT really sent):
|
|
111
|
+
👉 ${cyan(String(previewUrl))}
|
|
112
|
+
|
|
113
|
+
To really send mails (likely a missing provider API key), see ${white(`https://firstly.fun/docs/modules/mail`)}.
|
|
108
114
|
`);
|
|
109
115
|
await repo(mailEntities.Mail).insert({
|
|
110
116
|
status: 'transport_not_configured',
|
|
111
117
|
to: JSON.stringify(to),
|
|
112
118
|
html: easyOptionsToUse.saveHtml ? html : '',
|
|
113
119
|
topic,
|
|
114
|
-
metadata,
|
|
120
|
+
metadata: { ...metadata, transport: extractTransportInfo(data, previewUrl) },
|
|
115
121
|
});
|
|
116
122
|
return { data };
|
|
117
123
|
}
|
|
@@ -123,16 +129,18 @@ export const sendMail = async (topic, easyOptions, options) => {
|
|
|
123
129
|
to: JSON.stringify(to),
|
|
124
130
|
html: easyOptionsToUse.saveHtml ? html : '',
|
|
125
131
|
topic,
|
|
126
|
-
metadata,
|
|
132
|
+
metadata: { ...metadata, transport: extractTransportInfo(data) },
|
|
127
133
|
});
|
|
128
134
|
return { data };
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
catch (error) {
|
|
132
138
|
if (error instanceof Error && error.message.includes('Missing credentials for "PLAIN"')) {
|
|
133
|
-
log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not well configured`)} ⚠️
|
|
139
|
+
log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not well configured`)} ⚠️
|
|
134
140
|
👉 transport used:
|
|
135
141
|
${cyan(JSON.stringify(globalOptions?.nodemailer?.transport, null, 2))}
|
|
142
|
+
|
|
143
|
+
Auth was refused - check your provider's API key. Docs: ${white(`https://firstly.fun/docs/modules/mail`)}.
|
|
136
144
|
`);
|
|
137
145
|
}
|
|
138
146
|
else {
|
|
@@ -177,20 +185,34 @@ ${cyan(JSON.stringify(globalOptions?.nodemailer?.transport, null, 2))}
|
|
|
177
185
|
return { error };
|
|
178
186
|
}
|
|
179
187
|
};
|
|
180
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Captured nodemailer-side metadata persisted on every send. This makes
|
|
190
|
+
* provider-side IDs (e.g. Resend's `re_...` returned via SMTP `messageId`)
|
|
191
|
+
* recoverable from the DB without an extra round-trip to the provider.
|
|
192
|
+
*/
|
|
193
|
+
function extractTransportInfo(data, preview) {
|
|
194
|
+
return {
|
|
195
|
+
messageId: data.messageId,
|
|
196
|
+
response: data.response,
|
|
197
|
+
accepted: data.accepted,
|
|
198
|
+
rejected: data.rejected,
|
|
199
|
+
envelope: data.envelope,
|
|
200
|
+
preview,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export const mail = (o) => new Module({
|
|
181
204
|
key: 'mail',
|
|
182
205
|
priority: -888,
|
|
183
206
|
entities: Object.values(mailEntities),
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
207
|
+
// Opt-in: only register the test endpoint when the consumer asks for it.
|
|
208
|
+
controllers: o?.enableTest ? [MailController] : [],
|
|
209
|
+
initApi: () => {
|
|
187
210
|
initMail(o);
|
|
188
211
|
// Need to init in the 2 places!
|
|
189
212
|
remult.context.sendMail = sendMail;
|
|
190
|
-
}
|
|
191
|
-
|
|
213
|
+
},
|
|
214
|
+
initRequest: async () => {
|
|
192
215
|
// Need to init in the 2 places!
|
|
193
216
|
remult.context.sendMail = sendMail;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
};
|
|
217
|
+
},
|
|
218
|
+
});
|
|
@@ -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>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SqlDatabase } from 'remult';
|
|
2
|
+
export declare class SqlAdminController {
|
|
3
|
+
/** Optional override set by the `sqlAdmin()` module's `initApi`. Falls back to `SqlDatabase.getDb()`. */
|
|
4
|
+
static dp?: SqlDatabase;
|
|
5
|
+
static exec(cmd: string): Promise<{
|
|
6
|
+
r: import("remult").SqlResult;
|
|
7
|
+
took: number;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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, SqlDatabase } from 'remult';
|
|
8
|
+
import { FF_Role } from '../core/common';
|
|
9
|
+
import { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
10
|
+
export class SqlAdminController {
|
|
11
|
+
/** Optional override set by the `sqlAdmin()` module's `initApi`. Falls back to `SqlDatabase.getDb()`. */
|
|
12
|
+
static dp;
|
|
13
|
+
static async exec(cmd) {
|
|
14
|
+
const db = SqlAdminController.dp ?? SqlDatabase.getDb();
|
|
15
|
+
const start = performance.now();
|
|
16
|
+
const r = await db.execute(cmd);
|
|
17
|
+
const took = performance.now() - start;
|
|
18
|
+
return { r, took };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
__decorate([
|
|
22
|
+
BackendMethod({ allowed: [Roles_SqlAdmin.SqlAdmin_Admin, FF_Role.FF_Role_Admin] })
|
|
23
|
+
], SqlAdminController, "exec", null);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Log } from '@kitql/helpers';
|
|
2
|
+
export { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
3
|
+
export { SqlAdminController } from './SqlAdminController';
|
|
4
|
+
export { default as SqlAdmin } from './ui/SqlAdmin.svelte';
|
|
5
|
+
export declare const key = "sqlAdmin";
|
|
6
|
+
export declare const log: Log;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Log } from '@kitql/helpers';
|
|
2
|
+
export { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
3
|
+
export { SqlAdminController } from './SqlAdminController';
|
|
4
|
+
export { default as SqlAdmin } from './ui/SqlAdmin.svelte';
|
|
5
|
+
export const key = 'sqlAdmin';
|
|
6
|
+
export const log = new Log(key);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { SqlDatabase } from 'remult';
|
|
2
|
+
import { Module } from 'remult/server';
|
|
3
|
+
export type SqlAdminOptions = {
|
|
4
|
+
/**
|
|
5
|
+
* Override the SqlDatabase used to execute queries.
|
|
6
|
+
* Defaults to `SqlDatabase.getDb()` (the active Remult data provider).
|
|
7
|
+
*/
|
|
8
|
+
dp?: () => SqlDatabase | Promise<SqlDatabase>;
|
|
9
|
+
/**
|
|
10
|
+
* The route where you mounted the `<SqlAdmin />` component.
|
|
11
|
+
* Used only for the AI hint logged on server start.
|
|
12
|
+
*
|
|
13
|
+
* @default '/sql/admin'
|
|
14
|
+
*/
|
|
15
|
+
path?: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Drop-in SQL admin endpoint + companion `<SqlAdmin />` component (`firstly/sqlAdmin`).
|
|
19
|
+
*
|
|
20
|
+
* Gated by `Roles_SqlAdmin.SqlAdmin_Admin` (or the global `FF_Role.FF_Role_Admin`).
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { remultApi } from 'remult/remult-sveltekit'
|
|
25
|
+
* import { sqlAdmin } from './'
|
|
26
|
+
*
|
|
27
|
+
* export const api = remultApi({
|
|
28
|
+
* modules: [sqlAdmin()],
|
|
29
|
+
* })
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Then on any admin route:
|
|
33
|
+
* ```svelte
|
|
34
|
+
* <script>
|
|
35
|
+
* import { SqlAdmin } from '..'
|
|
36
|
+
* </script>
|
|
37
|
+
* <SqlAdmin />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare const sqlAdmin: (opts?: SqlAdminOptions) => Module<unknown>;
|