@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 +118 -0
- package/index.ts +12 -0
- package/package.json +51 -0
- package/src/react/email-approval-card.tsx +129 -0
- package/src/react/email-body.tsx +29 -0
- package/src/react/email-compose.tsx +345 -0
- package/src/react/fields.ts +33 -0
- package/src/react/index.ts +17 -0
- package/src/styles.css +20 -0
- package/tsconfig.json +43 -0
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
|
+
}
|