firstly 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/esm/changeLog/index.d.ts +2 -2
  3. package/esm/changeLog/index.js +1 -1
  4. package/esm/core/FF_Allow.d.ts +55 -0
  5. package/esm/core/FF_Allow.js +54 -0
  6. package/esm/core/FF_Filter.d.ts +55 -0
  7. package/esm/core/FF_Filter.js +57 -0
  8. package/esm/core/FF_Validators.d.ts +63 -0
  9. package/esm/core/FF_Validators.js +97 -0
  10. package/esm/core/helper.d.ts +17 -0
  11. package/esm/core/helper.js +40 -0
  12. package/esm/index.d.ts +5 -1
  13. package/esm/index.js +4 -1
  14. package/esm/mail/MailController.d.ts +22 -0
  15. package/esm/mail/MailController.js +68 -0
  16. package/esm/mail/index.d.ts +5 -0
  17. package/esm/mail/index.js +4 -0
  18. package/esm/mail/server/formatMailHelper.d.ts +2 -7
  19. package/esm/mail/server/index.d.ts +12 -7
  20. package/esm/mail/server/index.js +40 -18
  21. package/esm/mail/types.d.ts +11 -0
  22. package/esm/mail/types.js +1 -0
  23. package/esm/mail/ui/LastMails.svelte +184 -0
  24. package/esm/mail/ui/LastMails.svelte.d.ts +12 -0
  25. package/esm/mail/ui/WriteMail.svelte +183 -0
  26. package/esm/mail/ui/WriteMail.svelte.d.ts +3 -0
  27. package/esm/sqlAdmin/Roles_SqlAdmin.d.ts +3 -0
  28. package/esm/sqlAdmin/Roles_SqlAdmin.js +3 -0
  29. package/esm/sqlAdmin/SqlAdminController.d.ts +9 -0
  30. package/esm/sqlAdmin/SqlAdminController.js +23 -0
  31. package/esm/sqlAdmin/index.d.ts +6 -0
  32. package/esm/sqlAdmin/index.js +6 -0
  33. package/esm/sqlAdmin/server/index.d.ts +40 -0
  34. package/esm/sqlAdmin/server/index.js +40 -0
  35. package/esm/sqlAdmin/ui/SqlAdmin.svelte +197 -0
  36. package/esm/sqlAdmin/ui/SqlAdmin.svelte.d.ts +3 -0
  37. package/package.json +13 -3
@@ -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>;
@@ -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
- log.error(`${magenta(`[${topic}]`)} - ⚠️ ${red(`mail not configured`)} ⚠️
104
- We are still nice and generated you an email preview link (the mail we not really sent):
105
- 👉 ${cyan(String(nodemailer.getTestMessageUrl(data)))}
106
-
107
- To really send mails, check out the doc ${white(`https://firstly.fun/modules/mail`)}.
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
- const mailModule = new Module({
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
- export const mail = (o) => {
186
- mailModule.initApi = () => {
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
- mailModule.initRequest = async () => {
213
+ },
214
+ initRequest: async () => {
192
215
  // Need to init in the 2 places!
193
216
  remult.context.sendMail = sendMail;
194
- };
195
- return mailModule;
196
- };
217
+ },
218
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * One section in a mail body. Pure structural type, safe to import on the
3
+ * client when building sections from a UI.
4
+ */
5
+ export type MailSection = {
6
+ html: string;
7
+ cta?: {
8
+ html: string;
9
+ link: string;
10
+ };
11
+ };
@@ -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,3 @@
1
+ declare const WriteMail: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type WriteMail = ReturnType<typeof WriteMail>;
3
+ export default WriteMail;
@@ -0,0 +1,3 @@
1
+ export declare const Roles_SqlAdmin: {
2
+ readonly SqlAdmin_Admin: "SqlAdmin.Admin";
3
+ };
@@ -0,0 +1,3 @@
1
+ export const Roles_SqlAdmin = {
2
+ SqlAdmin_Admin: 'SqlAdmin.Admin',
3
+ };
@@ -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>;