@kernhq/module-mail 0.1.1 → 0.2.0
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/package.json +4 -2
- package/src/client/api.ts +16 -0
- package/src/client/index.ts +7 -25
- package/src/contract.ts +142 -0
- package/src/client/MailSettings.svelte +0 -214
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernhq/module-mail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Kern mail module: outbound providers (SMTP/Mailgun/SES/Postmark/Resend), MJML templates, delivery queue, bounces & suppressions",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"dist",
|
|
17
17
|
"migrations",
|
|
18
18
|
"templates",
|
|
19
|
-
"src/client"
|
|
19
|
+
"src/client",
|
|
20
|
+
"src/contract.ts"
|
|
20
21
|
],
|
|
21
22
|
"exports": {
|
|
22
23
|
"./contract": {
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
"@aws-sdk/client-sesv2": "^3.1116.0",
|
|
47
48
|
"@kernhq/contracts": "^0.1.0",
|
|
48
49
|
"@kernhq/kernel": "^0.1.0",
|
|
50
|
+
"@kernhq/sdk": "^0.1.0",
|
|
49
51
|
"@orpc/contract": "^1.15.0",
|
|
50
52
|
"@orpc/server": "^1.15.0",
|
|
51
53
|
"drizzle-orm": "^0.45.0",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createModuleClient, type KernClientOptions } from '@kernhq/sdk'
|
|
2
|
+
import type { ContractRouterClient } from '@orpc/contract'
|
|
3
|
+
import type { MailContract } from '../contract.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Typed oRPC client for `/api/mail` (see `../contract`).
|
|
7
|
+
*
|
|
8
|
+
* Mail runs as its own service because it holds provider connections and a delivery queue, but its
|
|
9
|
+
* REST surface is reached like any other module's: same origin, same session cookie, the reverse
|
|
10
|
+
* proxy routes the prefix.
|
|
11
|
+
*/
|
|
12
|
+
export type MailApi = ContractRouterClient<MailContract>
|
|
13
|
+
|
|
14
|
+
export function createMailClient(opts: KernClientOptions): MailApi {
|
|
15
|
+
return createModuleClient<MailApi>(opts, 'mail')
|
|
16
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,29 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export { MODULE_ID, SECRET_PLACEHOLDER } from '../contract.js'
|
|
2
|
+
export { createMailClient, type MailApi } from './api.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Mail's client surface
|
|
6
|
-
* not a place people visit — so it contributes a workspace settings page where an admin chooses the
|
|
7
|
-
* provider and sends a test message.
|
|
5
|
+
* Mail's client surface is a typed API client and nothing else.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* There is no client module here: the app composes its own (`registry.ts` registers modules defined
|
|
8
|
+
* in `app/src/lib/modules/*/client.ts`), because navigation labels have to go through the app's
|
|
9
|
+
* message catalogue and its screens are app routes. A `defineClientModule` in this package would
|
|
10
|
+
* never be registered and its `component` never rendered.
|
|
11
11
|
*/
|
|
12
|
-
export const mailClient = defineClientModule({
|
|
13
|
-
id: MODULE_ID,
|
|
14
|
-
name: 'Mail',
|
|
15
|
-
icon: 'mail',
|
|
16
|
-
settingsPages: [
|
|
17
|
-
{
|
|
18
|
-
id: 'mail',
|
|
19
|
-
label: 'Email',
|
|
20
|
-
icon: 'mail',
|
|
21
|
-
scope: 'workspace',
|
|
22
|
-
permission: 'mail.settings.manage',
|
|
23
|
-
order: 40,
|
|
24
|
-
component: () => import('./MailSettings.svelte'),
|
|
25
|
-
},
|
|
26
|
-
],
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
export default mailClient
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
baseContract,
|
|
3
|
+
defineEvent,
|
|
4
|
+
definePermissions,
|
|
5
|
+
Email,
|
|
6
|
+
PageInput,
|
|
7
|
+
page,
|
|
8
|
+
Timestamp,
|
|
9
|
+
WorkspaceId,
|
|
10
|
+
} from '@kernhq/contracts'
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
|
|
13
|
+
export const MODULE_ID = 'mail'
|
|
14
|
+
|
|
15
|
+
/** Value returned in place of a secret field when reading settings; sending it back keeps the stored value. */
|
|
16
|
+
export const SECRET_PLACEHOLDER = '__kern_secret__'
|
|
17
|
+
|
|
18
|
+
export const MailProviderKind = z.enum(['platform', 'smtp', 'mailgun', 'ses', 'postmark', 'resend'])
|
|
19
|
+
export type MailProviderKind = z.infer<typeof MailProviderKind>
|
|
20
|
+
|
|
21
|
+
export const MailAttachment = z
|
|
22
|
+
.object({
|
|
23
|
+
filename: z.string().min(1).max(255),
|
|
24
|
+
contentType: z.string().default('application/octet-stream'),
|
|
25
|
+
/** id of a file stored in core files (fetched at send time) */
|
|
26
|
+
fileId: z.uuid().optional(),
|
|
27
|
+
/** inline content, base64-encoded (small attachments only) */
|
|
28
|
+
base64: z.string().optional(),
|
|
29
|
+
})
|
|
30
|
+
.refine((a) => Boolean(a.fileId) !== Boolean(a.base64), {
|
|
31
|
+
message: 'exactly one of fileId or base64 is required',
|
|
32
|
+
})
|
|
33
|
+
export type MailAttachment = z.infer<typeof MailAttachment>
|
|
34
|
+
|
|
35
|
+
export const SendMailInput = z.object({
|
|
36
|
+
/** omitted for instance-level mail (signup verification…): the platform provider is used */
|
|
37
|
+
workspaceId: WorkspaceId.optional(),
|
|
38
|
+
to: z.array(Email).min(1).max(50),
|
|
39
|
+
cc: z.array(Email).max(50).optional(),
|
|
40
|
+
bcc: z.array(Email).max(50).optional(),
|
|
41
|
+
subject: z.string().min(1).max(500),
|
|
42
|
+
text: z.string().optional(),
|
|
43
|
+
html: z.string().optional(),
|
|
44
|
+
/** render a named MJML template with `data` instead of passing text/html */
|
|
45
|
+
template: z.object({ name: z.string(), data: z.record(z.string(), z.unknown()).default({}) }).optional(),
|
|
46
|
+
replyTo: Email.optional(),
|
|
47
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
48
|
+
attachments: z.array(MailAttachment).max(20).optional(),
|
|
49
|
+
/** free-form tags stored on the delivery (e.g. `digest`, `invite`) */
|
|
50
|
+
tags: z.array(z.string().max(64)).max(10).optional(),
|
|
51
|
+
})
|
|
52
|
+
export type SendMailInput = z.infer<typeof SendMailInput>
|
|
53
|
+
|
|
54
|
+
export const MailDeliveryStatus = z.enum(['queued', 'sent', 'failed', 'bounced'])
|
|
55
|
+
export type MailDeliveryStatus = z.infer<typeof MailDeliveryStatus>
|
|
56
|
+
|
|
57
|
+
export const MailDelivery = z.object({
|
|
58
|
+
id: z.uuid(),
|
|
59
|
+
workspaceId: WorkspaceId.nullable(),
|
|
60
|
+
to: z.array(z.string()),
|
|
61
|
+
subject: z.string(),
|
|
62
|
+
provider: MailProviderKind,
|
|
63
|
+
template: z.string().nullable(),
|
|
64
|
+
status: MailDeliveryStatus,
|
|
65
|
+
providerMessageId: z.string().nullable(),
|
|
66
|
+
error: z.string().nullable(),
|
|
67
|
+
tags: z.array(z.string()),
|
|
68
|
+
createdAt: Timestamp,
|
|
69
|
+
updatedAt: Timestamp,
|
|
70
|
+
})
|
|
71
|
+
export type MailDelivery = z.infer<typeof MailDelivery>
|
|
72
|
+
|
|
73
|
+
export const MailSuppression = z.object({
|
|
74
|
+
id: z.uuid(),
|
|
75
|
+
workspaceId: WorkspaceId.nullable(),
|
|
76
|
+
email: z.string(),
|
|
77
|
+
reason: z.enum(['bounce', 'complaint', 'manual']),
|
|
78
|
+
source: z.string().nullable(),
|
|
79
|
+
createdAt: Timestamp,
|
|
80
|
+
})
|
|
81
|
+
export type MailSuppression = z.infer<typeof MailSuppression>
|
|
82
|
+
|
|
83
|
+
const ws = z.object({ workspaceId: WorkspaceId })
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Workspace mail settings as exposed over the API. Secret fields are replaced with
|
|
87
|
+
* SECRET_PLACEHOLDER on read; sending the placeholder back keeps the stored secret.
|
|
88
|
+
* The stored shape is `core.MailProviderConfig` (contracts).
|
|
89
|
+
*/
|
|
90
|
+
export const mailContract = {
|
|
91
|
+
settings: {
|
|
92
|
+
get: baseContract
|
|
93
|
+
.route({ method: 'GET', path: '/settings', tags: ['mail'] })
|
|
94
|
+
.input(ws)
|
|
95
|
+
.output(z.object({ config: z.record(z.string(), z.unknown()).nullable() })),
|
|
96
|
+
set: baseContract
|
|
97
|
+
.route({ method: 'PUT', path: '/settings', tags: ['mail'] })
|
|
98
|
+
.input(ws.extend({ config: z.record(z.string(), z.unknown()).nullable() }))
|
|
99
|
+
.output(z.object({ ok: z.boolean() })),
|
|
100
|
+
test: baseContract
|
|
101
|
+
.route({ method: 'POST', path: '/settings/test', tags: ['mail'] })
|
|
102
|
+
.input(ws.extend({ to: Email }))
|
|
103
|
+
.output(z.object({ ok: z.boolean(), error: z.string().nullable() })),
|
|
104
|
+
},
|
|
105
|
+
deliveries: {
|
|
106
|
+
list: baseContract
|
|
107
|
+
.route({ method: 'GET', path: '/deliveries', tags: ['mail'] })
|
|
108
|
+
.input(ws.extend(PageInput.shape).extend({ status: MailDeliveryStatus.optional() }))
|
|
109
|
+
.output(page(MailDelivery)),
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
export type MailContract = typeof mailContract
|
|
113
|
+
|
|
114
|
+
const deliveryEvent = z.object({
|
|
115
|
+
deliveryId: z.uuid(),
|
|
116
|
+
workspaceId: WorkspaceId.nullable(),
|
|
117
|
+
to: z.array(z.string()),
|
|
118
|
+
providerMessageId: z.string().nullable(),
|
|
119
|
+
error: z.string().nullable(),
|
|
120
|
+
})
|
|
121
|
+
export const mailEvents = {
|
|
122
|
+
deliverySent: defineEvent('mail.delivery.sent', deliveryEvent),
|
|
123
|
+
deliveryFailed: defineEvent('mail.delivery.failed', deliveryEvent),
|
|
124
|
+
deliveryBounced: defineEvent('mail.delivery.bounced', deliveryEvent),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const mailPermissions = definePermissions([
|
|
128
|
+
{
|
|
129
|
+
key: 'mail.settings.manage',
|
|
130
|
+
label: 'Manage mail settings',
|
|
131
|
+
scope: 'workspace',
|
|
132
|
+
defaultRoles: ['owner', 'admin'],
|
|
133
|
+
dangerous: true,
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
key: 'mail.deliveries.view',
|
|
137
|
+
label: 'View mail deliveries',
|
|
138
|
+
scope: 'workspace',
|
|
139
|
+
defaultRoles: ['owner', 'admin'],
|
|
140
|
+
dangerous: false,
|
|
141
|
+
},
|
|
142
|
+
])
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { SECRET_PLACEHOLDER } from '@kernhq/module-mail/contract'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Workspace email settings.
|
|
6
|
-
*
|
|
7
|
-
* A workspace can send through its own provider; without one it falls back to the instance's SMTP
|
|
8
|
-
* configuration, so a fresh self-hosted install already delivers invitations and password resets.
|
|
9
|
-
*
|
|
10
|
-
* Stored secrets are never returned by the API — reads replace them with a placeholder, and sending
|
|
11
|
-
* the placeholder back leaves the stored value untouched. That is why the fields below start out
|
|
12
|
-
* showing the placeholder rather than empty when a provider is already configured.
|
|
13
|
-
*/
|
|
14
|
-
interface MailApi {
|
|
15
|
-
settings: {
|
|
16
|
-
get(input: { workspaceId: string }): Promise<{ config: Record<string, unknown> | null }>
|
|
17
|
-
set(input: { workspaceId: string; config: Record<string, unknown> | null }): Promise<{ ok: boolean }>
|
|
18
|
-
test(input: { workspaceId: string; to: string }): Promise<{ ok: boolean; error: string | null }>
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface Props {
|
|
23
|
-
workspaceId: string
|
|
24
|
-
/** the caller passes a client bound to /api/mail */
|
|
25
|
-
api: MailApi
|
|
26
|
-
/** address the test message goes to (defaults to the signed-in user) */
|
|
27
|
-
testAddress?: string
|
|
28
|
-
}
|
|
29
|
-
let { workspaceId, api, testAddress = '' }: Props = $props()
|
|
30
|
-
|
|
31
|
-
type Provider = 'platform' | 'smtp' | 'mailgun' | 'ses' | 'postmark' | 'resend'
|
|
32
|
-
|
|
33
|
-
const FIELDS: Record<Provider, Array<{ key: string; label: string; type?: string; hint?: string }>> = {
|
|
34
|
-
platform: [],
|
|
35
|
-
smtp: [
|
|
36
|
-
{ key: 'host', label: 'Host' },
|
|
37
|
-
{ key: 'port', label: 'Port', type: 'number' },
|
|
38
|
-
{ key: 'user', label: 'Username' },
|
|
39
|
-
{ key: 'pass', label: 'Password', type: 'password' },
|
|
40
|
-
{ key: 'from', label: 'From address', hint: 'Kern <no-reply@example.com>' },
|
|
41
|
-
],
|
|
42
|
-
mailgun: [
|
|
43
|
-
{ key: 'apiKey', label: 'API key', type: 'password' },
|
|
44
|
-
{ key: 'domain', label: 'Domain' },
|
|
45
|
-
{ key: 'region', label: 'Region', hint: 'us or eu' },
|
|
46
|
-
{ key: 'from', label: 'From address' },
|
|
47
|
-
],
|
|
48
|
-
ses: [
|
|
49
|
-
{ key: 'accessKeyId', label: 'Access key ID' },
|
|
50
|
-
{ key: 'secretAccessKey', label: 'Secret access key', type: 'password' },
|
|
51
|
-
{ key: 'region', label: 'Region' },
|
|
52
|
-
{ key: 'from', label: 'From address' },
|
|
53
|
-
],
|
|
54
|
-
postmark: [
|
|
55
|
-
{ key: 'serverToken', label: 'Server token', type: 'password' },
|
|
56
|
-
{ key: 'from', label: 'From address' },
|
|
57
|
-
],
|
|
58
|
-
resend: [
|
|
59
|
-
{ key: 'apiKey', label: 'API key', type: 'password' },
|
|
60
|
-
{ key: 'from', label: 'From address' },
|
|
61
|
-
],
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const PROVIDER_LABELS: Record<Provider, string> = {
|
|
65
|
-
platform: 'Use the instance default',
|
|
66
|
-
smtp: 'SMTP',
|
|
67
|
-
mailgun: 'Mailgun',
|
|
68
|
-
ses: 'Amazon SES',
|
|
69
|
-
postmark: 'Postmark',
|
|
70
|
-
resend: 'Resend',
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
let provider = $state<Provider>('platform')
|
|
74
|
-
let values = $state<Record<string, string>>({})
|
|
75
|
-
let loading = $state(true)
|
|
76
|
-
let saving = $state(false)
|
|
77
|
-
let testing = $state(false)
|
|
78
|
-
let message = $state<{ kind: 'ok' | 'error'; text: string } | null>(null)
|
|
79
|
-
let recipient = $state(testAddress)
|
|
80
|
-
|
|
81
|
-
$effect(() => {
|
|
82
|
-
void (async () => {
|
|
83
|
-
const { config } = await api.settings.get({ workspaceId })
|
|
84
|
-
if (config && typeof config.provider === 'string') {
|
|
85
|
-
provider = config.provider as Provider
|
|
86
|
-
values = Object.fromEntries(
|
|
87
|
-
Object.entries(config)
|
|
88
|
-
.filter(([k]) => k !== 'provider')
|
|
89
|
-
.map(([k, v]) => [k, String(v ?? '')]),
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
loading = false
|
|
93
|
-
})()
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
const fields = $derived(FIELDS[provider])
|
|
97
|
-
|
|
98
|
-
async function save(event: SubmitEvent) {
|
|
99
|
-
event.preventDefault()
|
|
100
|
-
saving = true
|
|
101
|
-
message = null
|
|
102
|
-
try {
|
|
103
|
-
const config =
|
|
104
|
-
provider === 'platform'
|
|
105
|
-
? null
|
|
106
|
-
: { provider, ...Object.fromEntries(fields.map((f) => [f.key, values[f.key] ?? ''])) }
|
|
107
|
-
await api.settings.set({ workspaceId, config })
|
|
108
|
-
message = { kind: 'ok', text: 'Email settings saved' }
|
|
109
|
-
} catch (err) {
|
|
110
|
-
message = { kind: 'error', text: err instanceof Error ? err.message : 'Could not save settings' }
|
|
111
|
-
} finally {
|
|
112
|
-
saving = false
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function sendTest() {
|
|
117
|
-
if (!recipient) return
|
|
118
|
-
testing = true
|
|
119
|
-
message = null
|
|
120
|
-
try {
|
|
121
|
-
const result = await api.settings.test({ workspaceId, to: recipient })
|
|
122
|
-
message = result.ok
|
|
123
|
-
? { kind: 'ok', text: `Test message queued for ${recipient}` }
|
|
124
|
-
: { kind: 'error', text: result.error ?? 'The provider rejected the message' }
|
|
125
|
-
} catch (err) {
|
|
126
|
-
message = { kind: 'error', text: err instanceof Error ? err.message : 'Could not send the test' }
|
|
127
|
-
} finally {
|
|
128
|
-
testing = false
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
</script>
|
|
132
|
-
|
|
133
|
-
<section class="grid gap-4">
|
|
134
|
-
<header>
|
|
135
|
-
<h2 class="text-[15px] font-semibold text-[var(--kern-ink-900)]">Email</h2>
|
|
136
|
-
<p class="mt-1 text-[12.5px] leading-relaxed text-[var(--kern-ink-500)]">
|
|
137
|
-
Choose how this workspace sends email. Without a provider it uses the instance default.
|
|
138
|
-
</p>
|
|
139
|
-
</header>
|
|
140
|
-
|
|
141
|
-
{#if loading}
|
|
142
|
-
<p class="text-[13px] text-[var(--kern-ink-400)]">Loading…</p>
|
|
143
|
-
{:else}
|
|
144
|
-
<form class="grid gap-4" onsubmit={save}>
|
|
145
|
-
<label class="grid gap-1.5">
|
|
146
|
-
<span class="text-[12.5px] font-medium text-[var(--kern-ink-700)]">Provider</span>
|
|
147
|
-
<select
|
|
148
|
-
bind:value={provider}
|
|
149
|
-
class="h-[34px] rounded-[9px] border border-[var(--kern-border)] bg-[var(--kern-surface-input)] px-2.5 text-[13px] text-[var(--kern-ink-900)]"
|
|
150
|
-
>
|
|
151
|
-
{#each Object.entries(PROVIDER_LABELS) as [value, label] (value)}
|
|
152
|
-
<option {value}>{label}</option>
|
|
153
|
-
{/each}
|
|
154
|
-
</select>
|
|
155
|
-
</label>
|
|
156
|
-
|
|
157
|
-
{#each fields as field (field.key)}
|
|
158
|
-
<label class="grid gap-1.5">
|
|
159
|
-
<span class="text-[12.5px] font-medium text-[var(--kern-ink-700)]">{field.label}</span>
|
|
160
|
-
<input
|
|
161
|
-
type={field.type ?? 'text'}
|
|
162
|
-
bind:value={values[field.key]}
|
|
163
|
-
placeholder={field.hint ?? ''}
|
|
164
|
-
autocomplete="off"
|
|
165
|
-
class="h-[34px] rounded-[9px] border border-[var(--kern-border)] bg-[var(--kern-surface-input)] px-2.5 text-[13px] text-[var(--kern-ink-900)]"
|
|
166
|
-
/>
|
|
167
|
-
{#if values[field.key] === SECRET_PLACEHOLDER}
|
|
168
|
-
<span class="text-[11.5px] text-[var(--kern-ink-400)]">
|
|
169
|
-
Stored securely — leave as is to keep it
|
|
170
|
-
</span>
|
|
171
|
-
{/if}
|
|
172
|
-
</label>
|
|
173
|
-
{/each}
|
|
174
|
-
|
|
175
|
-
{#if message}
|
|
176
|
-
<p
|
|
177
|
-
role="status"
|
|
178
|
-
class="rounded-[9px] px-3 py-2 text-[12.5px] {message.kind === 'ok'
|
|
179
|
-
? 'bg-[var(--kern-success-tint)] text-[var(--kern-success)]'
|
|
180
|
-
: 'bg-[var(--kern-danger-tint)] text-[var(--kern-danger)]'}"
|
|
181
|
-
>
|
|
182
|
-
{message.text}
|
|
183
|
-
</p>
|
|
184
|
-
{/if}
|
|
185
|
-
|
|
186
|
-
<div class="flex items-center gap-2">
|
|
187
|
-
<button
|
|
188
|
-
type="submit"
|
|
189
|
-
disabled={saving}
|
|
190
|
-
class="h-[34px] rounded-[9px] bg-[var(--kern-ink-900)] px-3.5 text-[13px] font-medium text-[var(--kern-ink-inverse)] disabled:opacity-50"
|
|
191
|
-
>
|
|
192
|
-
{saving ? 'Saving…' : 'Save'}
|
|
193
|
-
</button>
|
|
194
|
-
|
|
195
|
-
<span class="flex-1"></span>
|
|
196
|
-
|
|
197
|
-
<input
|
|
198
|
-
type="email"
|
|
199
|
-
bind:value={recipient}
|
|
200
|
-
placeholder="you@example.com"
|
|
201
|
-
class="h-[34px] w-[220px] rounded-[9px] border border-[var(--kern-border)] bg-[var(--kern-surface-input)] px-2.5 text-[13px]"
|
|
202
|
-
/>
|
|
203
|
-
<button
|
|
204
|
-
type="button"
|
|
205
|
-
onclick={sendTest}
|
|
206
|
-
disabled={testing || !recipient}
|
|
207
|
-
class="h-[34px] rounded-[9px] border border-[var(--kern-border)] px-3 text-[13px] text-[var(--kern-ink-700)] disabled:opacity-50"
|
|
208
|
-
>
|
|
209
|
-
{testing ? 'Sending…' : 'Send test'}
|
|
210
|
-
</button>
|
|
211
|
-
</div>
|
|
212
|
-
</form>
|
|
213
|
-
{/if}
|
|
214
|
-
</section>
|