@kernhq/module-mail 0.1.2 → 0.2.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-mail",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
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",
@@ -47,6 +47,7 @@
47
47
  "@aws-sdk/client-sesv2": "^3.1116.0",
48
48
  "@kernhq/contracts": "^0.1.0",
49
49
  "@kernhq/kernel": "^0.1.0",
50
+ "@kernhq/sdk": "^0.1.0",
50
51
  "@orpc/contract": "^1.15.0",
51
52
  "@orpc/server": "^1.15.0",
52
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
+ }
@@ -1,29 +1,11 @@
1
- import { defineClientModule } from '@kernhq/kernel/client'
2
- import { MODULE_ID } from '../contract.js'
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. The module has no navigation of its own — outbound email is configuration,
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
- * The settings component is still to be written; the entry is registered so the module appears in
10
- * the settings navigation as soon as it lands.
7
+ * There is no client module here: the app composes its own (`registry.ts` registers modules defined
8
+ * under `app/src/lib/modules`), 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
@@ -1,214 +0,0 @@
1
- <script lang="ts">
2
- import { SECRET_PLACEHOLDER } from '../contract.js'
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>