@dbx-tools/ui-email 0.6.46 → 0.6.49

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/README.md CHANGED
@@ -13,9 +13,12 @@ Key features:
13
13
  - Approval card for suspended `send_email` tool calls.
14
14
  - Read-only draft preview for review queues, chat transcripts, and test pages.
15
15
  - Standalone compose form that emits shared `EmailMessage` payloads.
16
- - Compact Markdown body renderer shared across preview and compose surfaces.
16
+ - Shared React Email body renderer matching the delivered message.
17
+ - dbx-tools branding by default, with consumer brand overrides available.
17
18
  - Recipient parsing, address display, and attachment-label helpers that mirror
18
19
  server expectations.
20
+ - `AuthGate` sign-in screen for the email one-time-code auth plugin, branded from
21
+ the shared brand context and shaped for platform autofill.
19
22
  - Styles wired to the AppKit UI/Tailwind foundation so host apps do not need a
20
23
  separate email component theme.
21
24
 
@@ -46,8 +49,9 @@ const draft = email.emailMessageSchema.parse(toolCall.args);
46
49
  ```
47
50
 
48
51
  `EmailApprovalCard` is the chat-facing component for the `send_email` tool. It
49
- renders the draft fields, Markdown body, attachment names, and Approve/Deny
50
- actions while leaving tool-call state and transport decisions to the host app.
52
+ renders the complete branded React Email card, including envelope metadata and
53
+ attachments, plus Approve/Deny actions while leaving tool-call state and
54
+ transport decisions to the host app.
51
55
 
52
56
  Wire `onApprove` and `onDeny` to the chat framework's tool-result mechanism.
53
57
  The component deliberately does not call the email API itself; the server-side
@@ -61,7 +65,7 @@ import { EmailPreview } from "@dbx-tools/ui-email/react";
61
65
  <EmailPreview email={draft} />;
62
66
  ```
63
67
 
64
- Use `EmailPreview` when a page needs a compact read-only summary without action
68
+ Use `EmailPreview` when a page needs the same full branded card without action
65
69
  buttons, such as a review queue, audit log, or test harness.
66
70
 
67
71
  ## Provide A Compose View
@@ -81,7 +85,7 @@ attached files to base64 email attachments, and emits the assembled
81
85
  `EmailMessage`. Fetch sender options and dispatch the final send through the
82
86
  server package.
83
87
 
84
- ## Render A Markdown Body
88
+ ## Render An Email Body
85
89
 
86
90
  ```tsx
87
91
  import { EmailBody } from "@dbx-tools/ui-email/react";
@@ -89,9 +93,10 @@ import { EmailBody } from "@dbx-tools/ui-email/react";
89
93
  <EmailBody className="text-sm">{message.body}</EmailBody>;
90
94
  ```
91
95
 
92
- `EmailBody` uses Streamdown to render compact Markdown for email text. It is
93
- shared by the compose preview and approval card so drafts look the same before
94
- and after submission.
96
+ `EmailBody` reuses `@dbx-tools/shared-email-template`, the same React Email body
97
+ component used by the server renderer. Drafts therefore keep the delivered
98
+ message's typography, rich-content styling, and default brand while they are
99
+ edited and approved.
95
100
 
96
101
  ## Reuse Field Helpers
97
102
 
@@ -106,13 +111,42 @@ const files = attachmentNames(message.attachments);
106
111
  The helpers keep free-text recipient parsing and attachment labels consistent
107
112
  across approval, compose, and custom UI surfaces.
108
113
 
114
+ ## Gate An App Behind An Email Code
115
+
116
+ ```tsx
117
+ import { AuthGate } from "@dbx-tools/ui-email/react";
118
+
119
+ <AuthGate>
120
+ <App />
121
+ </AuthGate>;
122
+ ```
123
+
124
+ `AuthGate` is the sign-in screen for an app fronted by the `@dbx-tools/email`
125
+ auth plugin - typically one published through
126
+ [`@dbx-tools/cli-tunnel`](../../cli/tunnel), where the hosting platform's own
127
+ identity-aware proxy is not in the request path. It calls the plugin's
128
+ `/api/email/auth/*` routes: on mount it checks `status`, renders `children`
129
+ straight through when the gate is off or a session already exists, and otherwise
130
+ runs the email -> code flow, revealing `children` once a verified code sets the
131
+ session cookie.
132
+
133
+ It holds no token: the session lives in an HttpOnly cookie the browser sends
134
+ automatically. `title` and `description` override the default copy, which
135
+ otherwise names the app from the brand context.
136
+
137
+ The code field carries `autocomplete="one-time-code"`, which is what lets iOS,
138
+ Android, and Safari offer the code straight from the notification. That only pays
139
+ off while the email keeps the conventional `Your verification code is: / <code>`
140
+ shape the gate sends, so change one and check the other.
141
+
109
142
  ## Modules
110
143
 
111
144
  - `./react` - `EmailPreview`, `EmailApprovalCard`, `EmailComposeView`,
112
- `EmailBody`, address/attachment helpers, shared email message types, and prop
113
- types.
145
+ `EmailBody`, `AuthGate`, address/attachment helpers, shared email message
146
+ types, and prop types.
114
147
  - `./styles.css` - Tailwind/AppKit style entrypoint for the email components.
115
148
 
116
149
  Pair this package with [`@dbx-tools/email`](../../node/email) for SMTP or
117
- outbox delivery, and with [`@dbx-tools/shared-email`](../../shared/email) for
118
- schema validation in client/server boundaries.
150
+ outbox delivery, [`@dbx-tools/shared-email-template`](../../shared/email-template)
151
+ for the universal presentation, and [`@dbx-tools/shared-email`](../../shared/email)
152
+ for schema validation in client/server boundaries.
package/package.json CHANGED
@@ -24,19 +24,20 @@
24
24
  "typescript": "^5.9.3"
25
25
  },
26
26
  "dependencies": {
27
- "@dbx-tools/shared-core": "0.6.46",
28
- "@dbx-tools/shared-email": "0.6.46",
29
- "@dbx-tools/ui-appkit": "0.6.46",
27
+ "@dbx-tools/shared-core": "0.6.49",
28
+ "@dbx-tools/shared-email": "0.6.49",
29
+ "@dbx-tools/shared-email-template": "0.6.49",
30
+ "@dbx-tools/ui-appkit": "0.6.49",
31
+ "@dbx-tools/ui-branding": "0.6.49",
30
32
  "lucide-react": "^0.554.0",
31
33
  "react": "^19.2.4",
32
- "react-dom": "^19.2.4",
33
- "streamdown": "^2.5.0"
34
+ "react-dom": "^19.2.4"
34
35
  },
35
36
  "license": "UNLICENSED",
36
37
  "publishConfig": {
37
38
  "access": "public"
38
39
  },
39
- "version": "0.6.46",
40
+ "version": "0.6.49",
40
41
  "type": "module",
41
42
  "exports": {
42
43
  "./react": "./src/react/index.ts",
@@ -1,12 +1,13 @@
1
+ import { string } from "@dbx-tools/shared-core";
1
2
  import type { AuthStatus } from "@dbx-tools/shared-email";
2
3
  import { Button, Input } from "@dbx-tools/ui-appkit/react";
3
- import { MailIcon } from "lucide-react";
4
+ import { BrandIcon, useBrand } from "@dbx-tools/ui-branding/react";
4
5
  import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
5
6
 
6
7
  /**
7
- * Email-OTP login gate for an AppKit app fronted by the `@dbx-tools/email` auth
8
- * plugin (an app exposed publicly, e.g. through a portr tunnel that bypasses the
9
- * Databricks OAuth proxy).
8
+ * Email one-time-code sign-in gate for an app fronted by the `@dbx-tools/email`
9
+ * auth plugin - an app reachable on the public internet, where the hosting
10
+ * platform's own identity-aware proxy is not in the request path.
10
11
  *
11
12
  * Wrap the app in `<AuthGate>...</AuthGate>`. It calls the plugin's
12
13
  * `/api/email/auth/*` routes: on mount it checks `status`; if the gate is
@@ -17,7 +18,12 @@ import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from
17
18
  * Presentational + fetch only: the session lives in an HttpOnly cookie the
18
19
  * browser sends automatically, so this component holds no token. Anti-enumeration
19
20
  * is server-side (every request-code call reports success), so the UI always
20
- * advances to the code step after "send code".
21
+ * advances to the code step after the code is requested.
22
+ *
23
+ * Branding comes from the repo-wide `@dbx-tools/ui-branding` context, so the
24
+ * sign-in screen carries the host app's mark and name - the same brand the gate's
25
+ * code email is themed with - instead of a generic icon and a hardcoded product
26
+ * name. With no `BrandProvider` above it, the dbx-tools default context applies.
21
27
  */
22
28
 
23
29
  /** Base path the email auth routes are mounted under. */
@@ -51,6 +57,7 @@ export interface AuthGateProps {
51
57
  * off) or the two-step login.
52
58
  */
53
59
  export function AuthGate({ children, title, description }: AuthGateProps): ReactNode {
60
+ const { context: brand } = useBrand();
54
61
  const [phase, setPhase] = useState<Phase>("loading");
55
62
  const [email, setEmail] = useState("");
56
63
  const [code, setCode] = useState("");
@@ -90,8 +97,8 @@ export function AuthGate({ children, title, description }: AuthGateProps): React
90
97
  // rate-limit cooldown, which leaks no allow-list state.
91
98
  setNotice(
92
99
  result.retryAfter
93
- ? `Please wait ${result.retryAfter}s before requesting another code.`
94
- : "If that address is allowed, a code is on its way.",
100
+ ? `Too many requests. Try again in ${string.pluralize(result.retryAfter, "second")}.`
101
+ : "If an account exists for that email address, a verification code is on its way.",
95
102
  );
96
103
  setPhase("code");
97
104
  } finally {
@@ -117,8 +124,8 @@ export function AuthGate({ children, title, description }: AuthGateProps): React
117
124
  } else {
118
125
  setNotice(
119
126
  result.retryAfter
120
- ? `Too many attempts. Wait ${result.retryAfter}s and request a new code.`
121
- : "That code didn't match. Check it or request a new one.",
127
+ ? `Too many attempts. Try again in ${string.pluralize(result.retryAfter, "second")}.`
128
+ : "That verification code is incorrect or has expired.",
122
129
  );
123
130
  }
124
131
  } finally {
@@ -135,11 +142,19 @@ export function AuthGate({ children, title, description }: AuthGateProps): React
135
142
  <div className="flex min-h-screen items-center justify-center bg-background p-6">
136
143
  <div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-sm">
137
144
  <div className="mb-4 flex items-center gap-2 text-foreground">
138
- <MailIcon className="size-5" aria-hidden />
139
- <h1 className="text-lg font-semibold">{title ?? "Sign in"}</h1>
145
+ <BrandIcon className="size-5" alt="" aria-hidden />
146
+ {/*
147
+ Names the app, which is the convention for a sign-in screen and the
148
+ reassurance a recipient checks the code against. `brand.name` is the
149
+ same value that names the app in the code email.
150
+ */}
151
+ <h1 className="text-lg font-semibold">{title ?? `Sign in to ${brand.name}`}</h1>
140
152
  </div>
141
153
  <p className="mb-4 text-sm text-muted-foreground">
142
- {description ?? "Enter your email to receive a one-time sign-in code."}
154
+ {description ??
155
+ (phase === "code"
156
+ ? "Enter the 6-digit verification code sent to your email address."
157
+ : "Enter your email address and we will send you a verification code.")}
143
158
  </p>
144
159
 
145
160
  {phase === "email" ? (
@@ -147,27 +162,37 @@ export function AuthGate({ children, title, description }: AuthGateProps): React
147
162
  <Input
148
163
  type="email"
149
164
  autoComplete="email"
165
+ aria-label="Email address"
150
166
  placeholder="you@company.com"
151
167
  value={email}
152
168
  onChange={(e) => setEmail(e.target.value)}
153
169
  required
154
170
  />
155
171
  <Button type="submit" disabled={busy} className="w-full">
156
- {busy ? "Sending…" : "Send code"}
172
+ {busy ? "Sending…" : "Send verification code"}
157
173
  </Button>
158
174
  </form>
159
175
  ) : (
160
176
  <form onSubmit={verifyCode} className="space-y-3">
177
+ {/*
178
+ `autoComplete="one-time-code"` is what lets iOS/Android/Safari offer
179
+ the code straight from the notification, and it only pays off when
180
+ the email keeps the conventional "Your verification code is: /
181
+ <code>" shape the gate sends. `inputMode="numeric"` raises the
182
+ number pad without rejecting a paste.
183
+ */}
161
184
  <Input
162
185
  inputMode="numeric"
163
186
  autoComplete="one-time-code"
164
- placeholder="6-digit code"
187
+ aria-label="Verification code"
188
+ placeholder="6-digit verification code"
189
+ maxLength={6}
165
190
  value={code}
166
191
  onChange={(e) => setCode(e.target.value)}
167
192
  required
168
193
  />
169
194
  <Button type="submit" disabled={busy} className="w-full">
170
- {busy ? "Verifying…" : "Verify"}
195
+ {busy ? "Verifying…" : "Continue"}
171
196
  </Button>
172
197
  <button
173
198
  type="button"
@@ -178,12 +203,16 @@ export function AuthGate({ children, title, description }: AuthGateProps): React
178
203
  setNotice(null);
179
204
  }}
180
205
  >
181
- Use a different email
206
+ Use a different email address
182
207
  </button>
183
208
  </form>
184
209
  )}
185
210
 
186
- {notice ? <p className="mt-3 text-xs text-muted-foreground">{notice}</p> : null}
211
+ {notice ? (
212
+ <p role="status" aria-live="polite" className="mt-3 text-xs text-muted-foreground">
213
+ {notice}
214
+ </p>
215
+ ) : null}
187
216
  </div>
188
217
  </div>
189
218
  );
@@ -1,11 +1,11 @@
1
+ import { EmailCard } from "@dbx-tools/shared-email-template";
1
2
  import { Button } from "@dbx-tools/ui-appkit/react";
2
3
  import { CheckIcon, MailIcon, XIcon } from "lucide-react";
3
- import { EmailBody } from "./email-body.tsx";
4
4
  import { attachmentNames, joinAddresses, type EmailDraft } from "./fields.ts";
5
5
 
6
6
  // Presentational pieces for an outbound email awaiting a human Approve /
7
- // Deny: the field preview (To / Cc / Subject / Body / Files, body
8
- // rendered as markdown) and a self-contained approval card wrapping it.
7
+ // Deny: the field preview (To / Cc / Subject / Body / Files, body rendered
8
+ // through the shared React Email presentation) and an approval card wrapping it.
9
9
  // State and the resolve transport belong to the caller; these components
10
10
  // only render and report intent. The editable counterpart is
11
11
  // `EmailComposeView` in `./email-compose`; both share `./fields` and
@@ -21,48 +21,23 @@ export interface EmailPreviewProps {
21
21
  /**
22
22
  * Render an email draft as a labelled `To` / `Cc` / `Subject` / `Body` /
23
23
  * `Files` list. `to` / `cc` may carry one or more addresses; the body is
24
- * markdown so links, lists, and emphasis render rather than showing raw
25
- * syntax. Fields that are empty are omitted.
24
+ * rendered through the same React Email body used for delivery. Fields that
25
+ * are empty are omitted.
26
26
  */
27
27
  export const EmailPreview = ({ email }: EmailPreviewProps) => {
28
28
  const to = joinAddresses(email.to);
29
29
  const cc = joinAddresses(email.cc);
30
+ const bcc = joinAddresses(email.bcc);
30
31
  const attachments = attachmentNames(email.attachments);
32
+ const headers: Array<readonly [string, string]> = [];
33
+ if (to) headers.push(["To", to]);
34
+ if (cc) headers.push(["Cc", cc]);
35
+ if (bcc) headers.push(["Bcc", bcc]);
36
+ if (attachments) headers.push(["Files", attachments]);
31
37
  return (
32
- <dl className="space-y-1 text-xs">
33
- {to && (
34
- <div className="flex gap-2">
35
- <dt className="w-16 shrink-0 text-muted-foreground">To</dt>
36
- <dd className="truncate">{to}</dd>
37
- </div>
38
- )}
39
- {cc && (
40
- <div className="flex gap-2">
41
- <dt className="w-16 shrink-0 text-muted-foreground">Cc</dt>
42
- <dd className="truncate">{cc}</dd>
43
- </div>
44
- )}
45
- {email.subject && (
46
- <div className="flex gap-2">
47
- <dt className="w-16 shrink-0 text-muted-foreground">Subject</dt>
48
- <dd className="truncate font-medium">{email.subject}</dd>
49
- </div>
50
- )}
51
- {email.body && (
52
- <div className="flex gap-2">
53
- <dt className="w-16 shrink-0 text-muted-foreground">Body</dt>
54
- <dd className="min-w-0 flex-1 break-words text-foreground">
55
- <EmailBody>{email.body}</EmailBody>
56
- </dd>
57
- </div>
58
- )}
59
- {attachments && (
60
- <div className="flex gap-2">
61
- <dt className="w-16 shrink-0 text-muted-foreground">Files</dt>
62
- <dd className="min-w-0 flex-1 truncate">{attachments}</dd>
63
- </div>
64
- )}
65
- </dl>
38
+ <div className="overflow-x-auto rounded-2xl bg-muted/20 p-2">
39
+ <EmailCard subject={email.subject || "Message"} body={email.body || ""} headers={headers} />
40
+ </div>
66
41
  );
67
42
  };
68
43
 
@@ -3,27 +3,21 @@
3
3
  // render the drafted Markdown identically (links, lists, emphasis, and
4
4
  // tables rather than raw syntax).
5
5
 
6
+ import { EmailBody as ReactEmailBody, type EmailBrand } from "@dbx-tools/shared-email-template";
6
7
  import { cn } from "@dbx-tools/ui-appkit/react";
7
- import { Streamdown } from "streamdown";
8
8
 
9
9
  /** Props for {@link EmailBody}. */
10
10
  export interface EmailBodyProps {
11
11
  children: string;
12
12
  /** Extra classes merged onto the prose container. */
13
13
  className?: string;
14
+ /** Optional brand override; dbx-tools branding is the default. */
15
+ brand?: EmailBrand;
14
16
  }
15
17
 
16
- /** Render an email body (Markdown) as compact, muted prose. */
17
- export const EmailBody = ({ children, className }: EmailBodyProps) => (
18
- <Streamdown
19
- controls={false}
20
- className={cn(
21
- "prose prose-sm dark:prose-invert max-w-none break-words",
22
- "text-[11px] leading-snug text-muted-foreground",
23
- "[&_strong]:text-foreground [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_ul]:pl-4 [&_ol]:pl-4",
24
- className,
25
- )}
26
- >
27
- {children}
28
- </Streamdown>
18
+ /** Render an email body with the same React Email component used for delivery. */
19
+ export const EmailBody = ({ children, className, brand }: EmailBodyProps) => (
20
+ <div className={cn("max-w-none break-words", className)}>
21
+ <ReactEmailBody body={children} brand={brand} />
22
+ </div>
29
23
  );
@@ -13,12 +13,12 @@ import {
13
13
  } from "@dbx-tools/ui-appkit/react";
14
14
  import { EyeIcon, PaperclipIcon, PencilIcon, SendIcon, XIcon } from "lucide-react";
15
15
  import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
16
- import { EmailBody } from "./email-body.tsx";
16
+ import { EmailPreview } from "./email-approval-card.tsx";
17
17
  import { joinAddresses, parseAddresses, type EmailDraft } from "./fields.ts";
18
18
 
19
19
  // A standard, editable email compose form usable outside a chat bubble
20
20
  // (a settings page, a standalone "send" view, etc.). It shares the
21
- // address / attachment helpers (`./fields`) and the Markdown body
21
+ // address / attachment helpers (`./fields`) and the React Email body
22
22
  // renderer (`./email-body`) with the read-only `EmailPreview`, so the
23
23
  // two surfaces stay visually and semantically in sync.
24
24
  //
@@ -236,7 +236,7 @@ export const EmailComposeView = ({
236
236
  <div className="grid gap-1.5">
237
237
  <div className="flex items-center justify-between">
238
238
  <Label htmlFor={fieldId("body")} className="text-xs text-muted-foreground">
239
- Body (Markdown)
239
+ Body
240
240
  </Label>
241
241
  <Button
242
242
  type="button"
@@ -258,15 +258,13 @@ export const EmailComposeView = ({
258
258
  </Button>
259
259
  </div>
260
260
  {showPreview ? (
261
- <div className="min-h-32 rounded-md border border-border bg-background p-3">
262
- <EmailBody>{body || "_Nothing to preview yet._"}</EmailBody>
263
- </div>
261
+ <EmailPreview email={{ ...buildMessage(), body: body || "_Nothing to preview yet._" }} />
264
262
  ) : (
265
263
  <Textarea
266
264
  id={fieldId("body")}
267
265
  value={body}
268
266
  onChange={(e) => setBody(e.target.value)}
269
- placeholder="Write your message in Markdown..."
267
+ placeholder="Write your message..."
270
268
  disabled={blocked}
271
269
  className="min-h-32 font-mono text-xs"
272
270
  />