@dbx-tools/ui-email 0.1.9

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 ADDED
@@ -0,0 +1,118 @@
1
+ # @dbx-tools/ui-email
2
+
3
+ React email surfaces for AppKit chat and admin workflows.
4
+
5
+ Import this package when a Databricks App needs to render model-drafted email,
6
+ collect a human approval decision, or provide a standalone compose form using
7
+ the same message contract as [`@dbx-tools/shared-email`](../../shared/email).
8
+ Server-side sending and AppKit routes live in
9
+ [`@dbx-tools/node-email`](../../node/email).
10
+
11
+ Key features:
12
+
13
+ - Approval card for suspended `send_email` tool calls.
14
+ - Read-only draft preview for review queues, chat transcripts, and test pages.
15
+ - Standalone compose form that emits shared `EmailMessage` payloads.
16
+ - Compact Markdown body renderer shared across preview and compose surfaces.
17
+ - Recipient parsing, address display, and attachment-label helpers that mirror
18
+ server expectations.
19
+ - Styles wired to the AppKit UI/Tailwind foundation so host apps do not need a
20
+ separate email component theme.
21
+
22
+ ## Add The Styles
23
+
24
+ ```css
25
+ @import "@databricks/appkit-ui/styles.css";
26
+ @import "@dbx-tools/ui-email/styles.css";
27
+ ```
28
+
29
+ The stylesheet pulls in the AppKit UI base styles and scans the email React
30
+ components for Tailwind classes. Import it once from the app's global CSS entry.
31
+
32
+ ## Render A Send Approval
33
+
34
+ ```tsx
35
+ import { EmailApprovalCard } from "@dbx-tools/ui-email/react";
36
+ import { email } from "@dbx-tools/shared-email";
37
+
38
+ const draft = email.emailMessageSchema.parse(toolCall.args);
39
+
40
+ <EmailApprovalCard
41
+ email={draft}
42
+ pending={pending}
43
+ onApprove={() => addToolResult({ toolCallId: toolCall.id, result: { approved: true } })}
44
+ onDeny={() => addToolResult({ toolCallId: toolCall.id, result: { approved: false } })}
45
+ />;
46
+ ```
47
+
48
+ `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.
51
+
52
+ Wire `onApprove` and `onDeny` to the chat framework's tool-result mechanism.
53
+ The component deliberately does not call the email API itself; the server-side
54
+ tool resumes only after the host app records the user's decision.
55
+
56
+ ## Preview A Draft Inline
57
+
58
+ ```tsx
59
+ import { EmailPreview } from "@dbx-tools/ui-email/react";
60
+
61
+ <EmailPreview email={draft} />;
62
+ ```
63
+
64
+ Use `EmailPreview` when a page needs a compact read-only summary without action
65
+ buttons, such as a review queue, audit log, or test harness.
66
+
67
+ ## Provide A Compose View
68
+
69
+ ```tsx
70
+ import { EmailComposeView } from "@dbx-tools/ui-email/react";
71
+
72
+ <EmailComposeView
73
+ senders={senderOptions.senders}
74
+ defaultFrom={senderOptions.defaultSender}
75
+ onSend={(message, from) => sendEmail(message, from)}
76
+ />;
77
+ ```
78
+
79
+ `EmailComposeView` owns the form state, normalizes recipient fields, converts
80
+ attached files to base64 email attachments, and emits the assembled
81
+ `EmailMessage`. Fetch sender options and dispatch the final send through the
82
+ server package.
83
+
84
+ ## Render A Markdown Body
85
+
86
+ ```tsx
87
+ import { EmailBody } from "@dbx-tools/ui-email/react";
88
+
89
+ <EmailBody className="text-sm">{message.body}</EmailBody>;
90
+ ```
91
+
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.
95
+
96
+ ## Reuse Field Helpers
97
+
98
+ ```ts
99
+ import { attachmentNames, joinAddresses, parseAddresses } from "@dbx-tools/ui-email/react";
100
+
101
+ const to = parseAddresses("alice@example.com; bob@example.com");
102
+ const label = joinAddresses(to);
103
+ const files = attachmentNames(message.attachments);
104
+ ```
105
+
106
+ The helpers keep free-text recipient parsing and attachment labels consistent
107
+ across approval, compose, and custom UI surfaces.
108
+
109
+ ## Modules
110
+
111
+ - `./react` - `EmailPreview`, `EmailApprovalCard`, `EmailComposeView`,
112
+ `EmailBody`, address/attachment helpers, shared email message types, and prop
113
+ types.
114
+ - `./styles.css` - Tailwind/AppKit style entrypoint for the email components.
115
+
116
+ Pair this package with [`@dbx-tools/node-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.
package/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+
5
+ export * as reactEmailApprovalCard from "./src/react/email-approval-card";
6
+ export * as reactEmailBody from "./src/react/email-body";
7
+ export * as reactEmailCompose from "./src/react/email-compose";
8
+ export * as reactFields from "./src/react/fields";
9
+ export type { EmailPreviewProps, EmailApprovalCardProps } from "./src/react/email-approval-card";
10
+ export type { EmailBodyProps } from "./src/react/email-body";
11
+ export type { EmailComposeProps } from "./src/react/email-compose";
12
+ export type { EmailDraft } from "./src/react/fields";
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@dbx-tools/ui-email",
3
+ "repository": {
4
+ "type": "git",
5
+ "url": "git+https://github.com/reggie-db/dbx-tools.git",
6
+ "directory": "workspaces/ui/email"
7
+ },
8
+ "devDependencies": {
9
+ "@types/node": "^24.6.0",
10
+ "@types/react": "^19.2.2",
11
+ "@types/react-dom": "^19.2.2",
12
+ "tsx": "^4.23.0",
13
+ "typescript": "^5.9.3"
14
+ },
15
+ "dependencies": {
16
+ "lucide-react": "^0.554.0",
17
+ "react": "^19.2.4",
18
+ "react-dom": "^19.2.4",
19
+ "streamdown": "^2.5.0",
20
+ "@dbx-tools/shared-core": "0.1.9",
21
+ "@dbx-tools/shared-email": "0.1.9",
22
+ "@dbx-tools/ui-appkit": "0.1.9"
23
+ },
24
+ "main": "index.ts",
25
+ "license": "UNLICENSED",
26
+ "version": "0.1.9",
27
+ "types": "index.ts",
28
+ "type": "module",
29
+ "exports": {
30
+ "./react": "./src/react/index.ts",
31
+ "./styles.css": "./src/styles.css",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "dbxToolsConfig": {
35
+ "tags": [
36
+ "ui"
37
+ ]
38
+ },
39
+ "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
40
+ "scripts": {
41
+ "build": "projen build",
42
+ "compile": "projen compile",
43
+ "default": "projen default",
44
+ "package": "projen package",
45
+ "post-compile": "projen post-compile",
46
+ "pre-compile": "projen pre-compile",
47
+ "test": "projen test",
48
+ "watch": "projen watch",
49
+ "projen": "projen"
50
+ }
51
+ }
@@ -0,0 +1,129 @@
1
+ import { Button } from "@dbx-tools/ui-appkit/react";
2
+ import { CheckIcon, MailIcon, XIcon } from "lucide-react";
3
+ import { EmailBody } from "./email-body";
4
+ import { attachmentNames, joinAddresses, type EmailDraft } from "./fields";
5
+
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.
9
+ // State and the resolve transport belong to the caller; these components
10
+ // only render and report intent. The editable counterpart is
11
+ // `EmailComposeView` in `./email-compose`; both share `./fields` and
12
+ // `./email-body`.
13
+
14
+ export type { EmailDraft } from "./fields";
15
+
16
+ /** Props for {@link EmailPreview}. */
17
+ export interface EmailPreviewProps {
18
+ email: EmailDraft;
19
+ }
20
+
21
+ /**
22
+ * Render an email draft as a labelled `To` / `Cc` / `Subject` / `Body` /
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.
26
+ */
27
+ export const EmailPreview = ({ email }: EmailPreviewProps) => {
28
+ const to = joinAddresses(email.to);
29
+ const cc = joinAddresses(email.cc);
30
+ const attachments = attachmentNames(email.attachments);
31
+ 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>
66
+ );
67
+ };
68
+
69
+ /** Props for {@link EmailApprovalCard}. */
70
+ export interface EmailApprovalCardProps {
71
+ email: EmailDraft;
72
+ /** Called when the user approves the send. */
73
+ onApprove?: () => void | Promise<void>;
74
+ /** Called when the user denies the send. */
75
+ onDeny?: () => void | Promise<void>;
76
+ /** Disable both actions while a decision is in flight. */
77
+ pending?: boolean;
78
+ /** Disable both actions regardless of pending state. */
79
+ disabled?: boolean;
80
+ /** Header label. Defaults to "Approval needed: send email". */
81
+ title?: string;
82
+ }
83
+
84
+ /**
85
+ * A drop-in approval card for the `send_email` tool: the email preview
86
+ * plus Approve / Deny actions. Wire `onApprove` / `onDeny` to whatever
87
+ * resumes the suspended tool call (e.g. the AI-SDK `addToolResult`).
88
+ */
89
+ export const EmailApprovalCard = ({
90
+ email,
91
+ onApprove,
92
+ onDeny,
93
+ pending,
94
+ disabled,
95
+ title = "Approval needed: send email",
96
+ }: EmailApprovalCardProps) => {
97
+ const blocked = Boolean(disabled) || Boolean(pending);
98
+ return (
99
+ <div className="not-prose my-2 rounded-md border border-warning/40 bg-warning/5 p-3">
100
+ <div className="mb-2 flex items-center gap-2 text-xs font-medium text-warning">
101
+ <MailIcon className="size-3.5" />
102
+ <span>{title}</span>
103
+ </div>
104
+ <EmailPreview email={email} />
105
+ <div className="mt-3 flex items-center gap-2">
106
+ <Button
107
+ type="button"
108
+ size="sm"
109
+ variant="default"
110
+ disabled={blocked || !onApprove}
111
+ onClick={() => onApprove?.()}
112
+ >
113
+ <CheckIcon className="size-3" />
114
+ Approve
115
+ </Button>
116
+ <Button
117
+ type="button"
118
+ size="sm"
119
+ variant="outline"
120
+ disabled={blocked || !onDeny}
121
+ onClick={() => onDeny?.()}
122
+ >
123
+ <XIcon className="size-3" />
124
+ Deny
125
+ </Button>
126
+ </div>
127
+ </div>
128
+ );
129
+ };
@@ -0,0 +1,29 @@
1
+ // Compact, muted Markdown renderer for an email body. Shared by the
2
+ // read-only approval preview and the compose view's live preview so both
3
+ // render the drafted Markdown identically (links, lists, emphasis, and
4
+ // tables rather than raw syntax).
5
+
6
+ import { cn } from "@dbx-tools/ui-appkit/react";
7
+ import { Streamdown } from "streamdown";
8
+
9
+ /** Props for {@link EmailBody}. */
10
+ export interface EmailBodyProps {
11
+ children: string;
12
+ /** Extra classes merged onto the prose container. */
13
+ className?: string;
14
+ }
15
+
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>
29
+ );
@@ -0,0 +1,345 @@
1
+ import {
2
+ Button,
3
+ Input,
4
+ Label,
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ Textarea,
11
+ cn,
12
+ } from "@dbx-tools/ui-appkit/react";
13
+ import type { EmailAttachment, EmailMessage } from "@dbx-tools/shared-email";
14
+ import { EyeIcon, PaperclipIcon, PencilIcon, SendIcon, XIcon } from "lucide-react";
15
+ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
16
+ import { EmailBody } from "./email-body";
17
+ import { joinAddresses, parseAddresses, type EmailDraft } from "./fields";
18
+
19
+ // A standard, editable email compose form usable outside a chat bubble
20
+ // (a settings page, a standalone "send" view, etc.). It shares the
21
+ // address / attachment helpers (`./fields`) and the Markdown body
22
+ // renderer (`./email-body`) with the read-only `EmailPreview`, so the
23
+ // two surfaces stay visually and semantically in sync.
24
+ //
25
+ // The component is presentational and self-contained: it owns the draft
26
+ // as local state (seeded from `defaultValue`), emits changes via
27
+ // `onChange`, and hands the assembled `EmailMessage` (plus the chosen
28
+ // `From`, when a sender list is provided) to `onSend`. Wiring the actual
29
+ // dispatch - and fetching the `senders` list from the plugin's
30
+ // `GET /senders` route - is the caller's job.
31
+
32
+ /** Props for {@link EmailComposeView}. */
33
+ export interface EmailComposeProps {
34
+ /** Initial draft to seed the form (uncontrolled thereafter). */
35
+ defaultValue?: EmailDraft;
36
+ /** Called on every edit with the current assembled message. */
37
+ onChange?: (message: EmailMessage) => void;
38
+ /** Called when the user submits; receives the message and chosen `From`. */
39
+ onSend?: (message: EmailMessage, from?: string) => void | Promise<void>;
40
+ /** Called when the user cancels. Omit to hide the Cancel button. */
41
+ onCancel?: () => void;
42
+ /**
43
+ * Permitted `From` addresses (e.g. from the plugin's `GET /senders`).
44
+ * When non-empty a `From` dropdown is shown and its value is passed to
45
+ * `onSend`; omit for a server-resolved sender (no dropdown).
46
+ */
47
+ senders?: string[];
48
+ /** Preselected `From`. Defaults to the first {@link senders} entry. */
49
+ defaultFrom?: string;
50
+ /** Disable actions while a send is in flight. */
51
+ pending?: boolean;
52
+ /** Disable all actions regardless of pending state. */
53
+ disabled?: boolean;
54
+ /** Show the attachment control. Defaults to `true`. */
55
+ allowAttachments?: boolean;
56
+ /** Card header label. Defaults to "New message". */
57
+ title?: string;
58
+ /** Submit button label. Defaults to "Send". */
59
+ sendLabel?: string;
60
+ }
61
+
62
+ /** Read a `File` into a base64 {@link EmailAttachment}. */
63
+ async function fileToAttachment(file: File): Promise<EmailAttachment> {
64
+ const dataUrl = await new Promise<string>((resolve, reject) => {
65
+ const reader = new FileReader();
66
+ reader.onload = () => resolve(String(reader.result));
67
+ reader.onerror = () => reject(reader.error);
68
+ reader.readAsDataURL(file);
69
+ });
70
+ // A data URL is `data:<mime>;base64,<payload>`; keep only the payload.
71
+ const content = dataUrl.slice(dataUrl.indexOf(",") + 1);
72
+ return {
73
+ filename: file.name,
74
+ content,
75
+ encoding: "base64",
76
+ ...(file.type ? { contentType: file.type } : {}),
77
+ };
78
+ }
79
+
80
+ /** A labelled field row: label above its control. */
81
+ const Field = ({
82
+ label,
83
+ htmlFor,
84
+ children,
85
+ }: {
86
+ label: string;
87
+ htmlFor?: string;
88
+ children: ReactNode;
89
+ }) => (
90
+ <div className="grid gap-1.5">
91
+ <Label htmlFor={htmlFor} className="text-xs text-muted-foreground">
92
+ {label}
93
+ </Label>
94
+ {children}
95
+ </div>
96
+ );
97
+
98
+ /**
99
+ * A standard, editable email compose form. Reuses the shared field and
100
+ * body helpers so a drafted message renders the same here as in the
101
+ * read-only approval preview.
102
+ */
103
+ export const EmailComposeView = ({
104
+ defaultValue,
105
+ onChange,
106
+ onSend,
107
+ onCancel,
108
+ senders,
109
+ defaultFrom,
110
+ pending,
111
+ disabled,
112
+ allowAttachments = true,
113
+ title = "New message",
114
+ sendLabel = "Send",
115
+ }: EmailComposeProps) => {
116
+ const [to, setTo] = useState(joinAddresses(defaultValue?.to));
117
+ const [cc, setCc] = useState(joinAddresses(defaultValue?.cc));
118
+ const [bcc, setBcc] = useState(joinAddresses(defaultValue?.bcc));
119
+ const [subject, setSubject] = useState(defaultValue?.subject ?? "");
120
+ const [body, setBody] = useState(defaultValue?.body ?? "");
121
+ const [attachments, setAttachments] = useState<EmailAttachment[]>(
122
+ defaultValue?.attachments ?? [],
123
+ );
124
+ const [from, setFrom] = useState(defaultFrom ?? senders?.[0] ?? "");
125
+ const [showPreview, setShowPreview] = useState(false);
126
+
127
+ const fileInput = useRef<HTMLInputElement>(null);
128
+ const ids = useId();
129
+ const fieldId = (name: string) => `${ids}-${name}`;
130
+
131
+ const hasSenderPicker = Boolean(senders && senders.length > 0);
132
+ const blocked = Boolean(disabled) || Boolean(pending);
133
+ const recipients = parseAddresses(to);
134
+ const canSend = recipients.length > 0 && Boolean(onSend) && !blocked;
135
+
136
+ // Assemble the current draft into a wire-format message, omitting empty
137
+ // optional fields so the payload stays minimal.
138
+ const buildMessage = useCallback((): EmailMessage => {
139
+ const ccList = parseAddresses(cc);
140
+ const bccList = parseAddresses(bcc);
141
+ return {
142
+ to: parseAddresses(to),
143
+ subject,
144
+ body,
145
+ ...(ccList.length ? { cc: ccList } : {}),
146
+ ...(bccList.length ? { bcc: bccList } : {}),
147
+ ...(attachments.length ? { attachments } : {}),
148
+ };
149
+ }, [to, cc, bcc, subject, body, attachments]);
150
+
151
+ useEffect(() => {
152
+ onChange?.(buildMessage());
153
+ }, [buildMessage, onChange]);
154
+
155
+ const addFiles = useCallback(async (files: FileList | null) => {
156
+ if (!files || files.length === 0) return;
157
+ const read = await Promise.all([...files].map(fileToAttachment));
158
+ setAttachments((prev) => [...prev, ...read]);
159
+ }, []);
160
+
161
+ const removeAttachment = useCallback((index: number) => {
162
+ setAttachments((prev) => prev.filter((_, i) => i !== index));
163
+ }, []);
164
+
165
+ const submit = useCallback(() => {
166
+ if (!canSend) return;
167
+ void onSend?.(buildMessage(), hasSenderPicker ? from : undefined);
168
+ }, [canSend, onSend, buildMessage, hasSenderPicker, from]);
169
+
170
+ return (
171
+ <div className="not-prose flex flex-col gap-3 rounded-md border border-border bg-card p-4 text-sm">
172
+ <div className="text-sm font-medium">{title}</div>
173
+
174
+ {hasSenderPicker && (
175
+ <Field label="From" htmlFor={fieldId("from")}>
176
+ <Select value={from} onValueChange={setFrom} disabled={blocked}>
177
+ <SelectTrigger id={fieldId("from")} className="h-8">
178
+ <SelectValue placeholder="Select a sender" />
179
+ </SelectTrigger>
180
+ <SelectContent>
181
+ {senders?.map((address) => (
182
+ <SelectItem key={address} value={address}>
183
+ {address}
184
+ </SelectItem>
185
+ ))}
186
+ </SelectContent>
187
+ </Select>
188
+ </Field>
189
+ )}
190
+
191
+ <Field label="To" htmlFor={fieldId("to")}>
192
+ <Input
193
+ id={fieldId("to")}
194
+ value={to}
195
+ onChange={(e) => setTo(e.target.value)}
196
+ placeholder="alice@example.com, bob@example.com"
197
+ disabled={blocked}
198
+ className="h-8"
199
+ />
200
+ </Field>
201
+
202
+ <div className="grid gap-3 sm:grid-cols-2">
203
+ <Field label="Cc" htmlFor={fieldId("cc")}>
204
+ <Input
205
+ id={fieldId("cc")}
206
+ value={cc}
207
+ onChange={(e) => setCc(e.target.value)}
208
+ placeholder="Optional"
209
+ disabled={blocked}
210
+ className="h-8"
211
+ />
212
+ </Field>
213
+ <Field label="Bcc" htmlFor={fieldId("bcc")}>
214
+ <Input
215
+ id={fieldId("bcc")}
216
+ value={bcc}
217
+ onChange={(e) => setBcc(e.target.value)}
218
+ placeholder="Optional"
219
+ disabled={blocked}
220
+ className="h-8"
221
+ />
222
+ </Field>
223
+ </div>
224
+
225
+ <Field label="Subject" htmlFor={fieldId("subject")}>
226
+ <Input
227
+ id={fieldId("subject")}
228
+ value={subject}
229
+ onChange={(e) => setSubject(e.target.value)}
230
+ placeholder="Subject"
231
+ disabled={blocked}
232
+ className="h-8"
233
+ />
234
+ </Field>
235
+
236
+ <div className="grid gap-1.5">
237
+ <div className="flex items-center justify-between">
238
+ <Label htmlFor={fieldId("body")} className="text-xs text-muted-foreground">
239
+ Body (Markdown)
240
+ </Label>
241
+ <Button
242
+ type="button"
243
+ size="sm"
244
+ variant="ghost"
245
+ className="h-6 px-2 text-xs"
246
+ disabled={!body}
247
+ onClick={() => setShowPreview((v) => !v)}
248
+ >
249
+ {showPreview ? (
250
+ <>
251
+ <PencilIcon className="size-3" /> Edit
252
+ </>
253
+ ) : (
254
+ <>
255
+ <EyeIcon className="size-3" /> Preview
256
+ </>
257
+ )}
258
+ </Button>
259
+ </div>
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>
264
+ ) : (
265
+ <Textarea
266
+ id={fieldId("body")}
267
+ value={body}
268
+ onChange={(e) => setBody(e.target.value)}
269
+ placeholder="Write your message in Markdown..."
270
+ disabled={blocked}
271
+ className="min-h-32 font-mono text-xs"
272
+ />
273
+ )}
274
+ </div>
275
+
276
+ {allowAttachments && (
277
+ <div className="grid gap-1.5">
278
+ <div className="flex items-center gap-2">
279
+ <input
280
+ ref={fileInput}
281
+ type="file"
282
+ multiple
283
+ hidden
284
+ onChange={(e) => {
285
+ void addFiles(e.target.files);
286
+ e.target.value = "";
287
+ }}
288
+ />
289
+ <Button
290
+ type="button"
291
+ size="sm"
292
+ variant="outline"
293
+ className="h-7"
294
+ disabled={blocked}
295
+ onClick={() => fileInput.current?.click()}
296
+ >
297
+ <PaperclipIcon className="size-3" /> Attach files
298
+ </Button>
299
+ </div>
300
+ {attachments.length > 0 && (
301
+ <ul className="flex flex-wrap gap-1.5">
302
+ {attachments.map((att, index) => (
303
+ <li
304
+ key={`${att.filename}-${index}`}
305
+ className="flex items-center gap-1 rounded bg-muted px-2 py-1 text-xs"
306
+ >
307
+ <span className="max-w-40 truncate">{att.filename}</span>
308
+ <button
309
+ type="button"
310
+ aria-label={`Remove ${att.filename}`}
311
+ className={cn(
312
+ "text-muted-foreground hover:text-foreground",
313
+ blocked && "pointer-events-none opacity-50",
314
+ )}
315
+ onClick={() => removeAttachment(index)}
316
+ >
317
+ <XIcon className="size-3" />
318
+ </button>
319
+ </li>
320
+ ))}
321
+ </ul>
322
+ )}
323
+ </div>
324
+ )}
325
+
326
+ <div className="mt-1 flex items-center gap-2">
327
+ <Button type="button" size="sm" disabled={!canSend} onClick={submit}>
328
+ <SendIcon className="size-3" />
329
+ {pending ? "Sending..." : sendLabel}
330
+ </Button>
331
+ {onCancel && (
332
+ <Button
333
+ type="button"
334
+ size="sm"
335
+ variant="outline"
336
+ disabled={blocked}
337
+ onClick={() => onCancel()}
338
+ >
339
+ Cancel
340
+ </Button>
341
+ )}
342
+ </div>
343
+ </div>
344
+ );
345
+ };
@@ -0,0 +1,33 @@
1
+ // Shared, presentational helpers for the email field UIs (the read-only
2
+ // approval preview and the editable compose view). Keeping the address
3
+ // and attachment formatting here lets both surfaces agree on how a draft
4
+ // is displayed and how free-text address input is normalized.
5
+
6
+ import { net } from "@dbx-tools/shared-core";
7
+ import type { EmailAttachment, EmailMessage } from "@dbx-tools/shared-email";
8
+
9
+ /** A partially-filled email, as it streams in from a tool call or a form. */
10
+ export type EmailDraft = Partial<EmailMessage>;
11
+
12
+ /** Render an address array as a single comma-separated display string. */
13
+ export const joinAddresses = (addresses: string[] | undefined): string =>
14
+ (addresses ?? [])
15
+ .map((a) => a.trim())
16
+ .filter(Boolean)
17
+ .join(", ");
18
+
19
+ /**
20
+ * Split a free-text address field (comma / semicolon / whitespace
21
+ * separated) into a trimmed, de-duplicated address array - the inverse
22
+ * of {@link joinAddresses} for compose inputs. Delegates to the shared
23
+ * {@link net.parseEmails} so the UI reads addresses the same way the
24
+ * server does.
25
+ */
26
+ export const parseAddresses = (raw: string): string[] => net.parseEmails(raw);
27
+
28
+ /** Comma-separated list of attachment filenames for a compact display. */
29
+ export const attachmentNames = (attachments: EmailAttachment[] | undefined): string =>
30
+ (attachments ?? [])
31
+ .map((a) => a.filename)
32
+ .filter(Boolean)
33
+ .join(", ");
@@ -0,0 +1,17 @@
1
+ // React surface for `@dbx-tools/ui-email`: a read-only Approve / Deny card for
2
+ // the `send_email` tool's approval flow, the field preview it wraps, and a
3
+ // standard editable compose view for use outside a chat bubble. All three share
4
+ // `./fields` and `./email-body`, so a drafted message renders identically across
5
+ // them. Styled with AppKit tokens.
6
+
7
+ export type { EmailAttachment, EmailMessage } from "@dbx-tools/shared-email";
8
+ export {
9
+ EmailApprovalCard,
10
+ EmailPreview,
11
+ type EmailApprovalCardProps,
12
+ type EmailPreviewProps,
13
+ } from "./email-approval-card";
14
+ export { EmailBody, type EmailBodyProps } from "./email-body";
15
+ export { EmailComposeView, type EmailComposeProps } from "./email-compose";
16
+ export { attachmentNames, joinAddresses, parseAddresses } from "./fields";
17
+ export type { EmailDraft } from "./fields";
package/src/styles.css ADDED
@@ -0,0 +1,20 @@
1
+ /*
2
+ * @dbx-tools/ui-email stylesheet.
3
+ *
4
+ * Import once from the host app's Tailwind entry, after Tailwind and
5
+ * your AppKit-UI theme:
6
+ *
7
+ * @import "tailwindcss";
8
+ * @import "@databricks/appkit-ui/styles.css";
9
+ * @import "@dbx-tools/ui-email/styles.css";
10
+ *
11
+ * Defines NO design tokens - the email preview styles exclusively with
12
+ * AppKit semantic tokens and inherits the host app's theme.
13
+ *
14
+ * Pulls shared Streamdown / shiki styling from `@dbx-tools/ui-appkit` and
15
+ * registers this package's React components with Tailwind via `@source`.
16
+ */
17
+
18
+ @import "@dbx-tools/ui-appkit/styles.css";
19
+
20
+ @source "./react";
package/tsconfig.json ADDED
@@ -0,0 +1,43 @@
1
+ // ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
2
+ {
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib",
6
+ "alwaysStrict": true,
7
+ "declaration": true,
8
+ "esModuleInterop": true,
9
+ "experimentalDecorators": true,
10
+ "inlineSourceMap": true,
11
+ "inlineSources": true,
12
+ "lib": [
13
+ "ES2022",
14
+ "DOM",
15
+ "DOM.Iterable"
16
+ ],
17
+ "module": "ESNext",
18
+ "noEmitOnError": false,
19
+ "noFallthroughCasesInSwitch": true,
20
+ "noImplicitAny": true,
21
+ "noImplicitReturns": true,
22
+ "noImplicitThis": true,
23
+ "noUnusedLocals": true,
24
+ "noUnusedParameters": true,
25
+ "resolveJsonModule": true,
26
+ "strict": true,
27
+ "strictNullChecks": true,
28
+ "strictPropertyInitialization": true,
29
+ "stripInternal": true,
30
+ "target": "ES2022",
31
+ "types": [],
32
+ "moduleResolution": "bundler",
33
+ "skipLibCheck": true,
34
+ "jsx": "react-jsx"
35
+ },
36
+ "include": [
37
+ "src/**/*.ts",
38
+ "src/**/*.tsx"
39
+ ],
40
+ "exclude": [
41
+ "node_modules"
42
+ ]
43
+ }