Components

Email Composer

Rich text email composer with sender-account picker, formatting toolbar, recipients, attachments, and i18n support.

Client Only

Installation

pnpm dlx @docyrus/cli add @docyrus/ui-email-composer
UI Primitives(5 components)
npx shadcn@latest add badge button separator toggle tooltip

Usage

import {
  EmailComposer
} from "@docyrus/ui/components/email-composer";

<EmailComposer
  variant="default"
  size="sm"
  accounts={EmailComposerAccount[]}
  selectedAccountId="..."
  onSelectedAccountChange={(id: string) => {}}
  to={string[]}
  onToChange={(to: string) = void[]}
  cc={string[]}
  onCcChange={(cc: string) = void[]}
  bcc={string[]}
  onBccChange={(bcc: string) = void[]}
  subject="..."
  onSubjectChange={() => {}}
  body="..."
  onBodyChange={() => {}}
  mentionUsers={EmailComposerMentionUser[]}
  slashCommands={EmailComposerSlashCommand[]}
  onSend={() => {}}
  onAttach={() => {}}
  onDiscard={() => {}}
  sending
  disabled
  attachments={EmailAttachment[]}
  onRemoveAttachment={() => {}}
  showToolbar
  signature="<p>Best regards,<br/><strong>John Doe</strong></p>"
  onSignatureChange={() => {}}
  signatureVisible
  onSignatureVisibleChange={() => {}}
/>

From dropdown

When accounts is provided and non-empty, the composer renders a From row above To that mirrors the recipient-row styling. Each entry shows initials, a provider icon, the sender name, and <sender@email>.

  • The provider icon is the brand mark for Google (gmail), Microsoft (microsoft-graph), and AWS (aws) accounts; every other provider falls back to a generic mail icon.
  • A single account still renders the row but skips the dropdown chrome.
  • Two or more accounts get a click-to-open dropdown listing every account.
  • Accounts with isUserAccessible: false are dropped entirely — they never appear in the From list.
  • When accounts is omitted (or all entries are inaccessible) the From row is hidden — existing usages stay backwards-compatible.

For Docyrus-backed pages, hand the wiring off to useDocyrusEmailComposer, which loads the accounts via /v1/messaging/email/accounts and sends through the selected account.

@ Mentions

When mentionUsers is provided and non-empty, typing @ in the body editor opens a caret-anchored autocomplete list of users — each entry shows the avatar photo, first/last name, and email. The list filters as you type (matching name and email), supports full keyboard navigation (↑ / ↓ to move, Enter / Tab to select, Escape to dismiss), and flips above the caret near the composer's bottom edge.

Selecting a user replaces the @query with an atomic mention chip — a non-editable <a data-docyrus-mention="<userId>" href="mailto:<email>">@First Last</a> anchor styled with inline CSS only, so the chip keeps its look when the HTML body is delivered to recipients' email clients (where the app's stylesheet does not exist). Backspace removes the whole chip at once.

  • Omit mentionUsers (or pass an empty array) to disable the feature entirely — existing usages are unaffected.
  • The mentioned user's id is recoverable from the sent HTML via the data-docyrus-mention attribute.
  • useDocyrusEmailComposer populates mentionUsers automatically from /v1/users (default on, disable with mentions: false).

/ Slash Commands

When slashCommands is provided and non-empty, typing / in the body editor opens the same caret-anchored autocomplete UI as mentions, listing developer-defined commands (icon + label + description). The list filters on label, id, description, and keywords; keyboard semantics are identical to mentions (↑ / ↓, Enter / Tab, Escape with per-token dismissal, upward flip near the bottom edge). A / inside a word or URL (e.g. https://) does not trigger.

Picking a command first consumes the /query text, then runs one of two flows:

Content command — getContent. Return the HTML string to insert at the trigger position. Async is supported (the promise result is inserted when it resolves); return null / undefined to insert nothing.

const commands: EmailComposerSlashCommand[] = [
  {
    id: 'date',
    label: 'Insert date',
    description: "Inserts today's date",
    keywords: ['today'],
    getContent: () => new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(new Date())
  },
  {
    id: 'meeting-link',
    label: 'Meeting link',
    getContent: async () => {
      const { url } = await createMeeting();
      return `<a href="${url}">Join the meeting</a>`;
    }
  }
];

<EmailComposer slashCommands={commands} ... />

Dialog command — renderDialog. The dialog is entirely developer-owned — you render the whole thing (shell, form, and your own Cancel / Send buttons); the composer only provides the bridge callbacks. Call context.insert(html) from your Send button to inject the content built in the dialog at the position where the user typed /, or context.cancel() to close without inserting. getContent is ignored when renderDialog is present.

{
  id: 'template',
  label: 'Insert template',
  description: 'Compose in a dialog, then send into the body',
  renderDialog: (context) => <TemplateDialog context={context} />
}

function TemplateDialog({ context }: { context: EmailComposerSlashCommandContext }) {
  const [text, setText] = useState('');

  return (
    <Dialog open onOpenChange={(open) => { if (!open) context.cancel(); }}>
      <DialogContent>
        <Textarea value={text} onChange={(e) => setText(e.target.value)} />
        <DialogFooter>
          <Button variant="ghost" onClick={context.cancel}>Cancel</Button>
          <Button onClick={() => context.insert(`<p>${text}</p>`)}>Send</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Notes:

  • The insertion position is saved when the command is picked, so the dialog can stay open as long as needed — insert lands the HTML exactly where the / was typed (falling back to the end of the body if that position no longer exists).
  • Render whatever dialog implementation you like (the Dialog primitive, a custom modal, a drawer) — the returned node is rendered inside the composer, and portal-based dialogs work as expected.
  • Inserted HTML is delivered to recipients as-is: use inline styles for any formatting, the app's stylesheet does not exist in email clients.
  • Omit slashCommands (or pass an empty array) to disable the feature entirely.

Variants

VariantDescription
defaultDefault style
outlineOutline — border-border
minimalMinimal — border-transparent shadow-none

Sizes

SizeDescription
smSmall — text-xs
defaultDefault — text-sm
lgLarge — text-base

API Reference

PropTypeDefault
variant"default" | "outline" | "minimal""default"
size"sm" | "default" | "lg""default"
accountsEmailComposerAccount[]—
selectedAccountIdstring | null—
onSelectedAccountChange(accountId: string) => void—
tostring[]—
onToChange(to: string[]) => void—
ccstring[]—
onCcChange(cc: string[]) => void—
bccstring[]—
onBccChange(bcc: string[]) => void—
subjectstring—
onSubjectChange(subject: string) => void—
bodystring—
onBodyChange(body: string) => void—
mentionUsersEmailComposerMentionUser[]—
slashCommandsEmailComposerSlashCommand[]—
onSend() => void—
onAttach() => void—
onDiscard() => void—
sendingboolean—
disabledboolean—
attachmentsEmailAttachment[]—
onRemoveAttachment(index: number) => void—
showToolbarbooleantrue
signaturestring—
onSignatureChange(signature: string) => void—
signatureVisiblebooleantrue
onSignatureVisibleChange(visible: boolean) => void—
classNamestring—

Type Exports

TypeDescription
EmailComposerAccount{ id; name; senderEmail; senderName?; kind?: 'tenant' | 'user'; provider?: string | null; isUserAccessible? } — minimum shape consumed by the From dropdown. provider drives the brand icon (Google / Microsoft / AWS, else a generic mail icon). The Docyrus-backed account DTO (DocyrusEmailAccount) extends this.
EmailAttachment{ name; size } — local attachment metadata for display.
EmailComposerMentionUser{ id; firstname?; lastname?; email?; photo? } — user entry consumed by the @ mention autocomplete. email powers the chip's mailto: link, photo the list avatar.
EmailComposerSlashCommand{ id; label; description?; icon?; keywords?; getContent?; renderDialog? } — a / command definition. getContent returns HTML to insert (async supported); renderDialog renders a fully developer-owned dialog instead.
EmailComposerSlashCommandContext{ query; insert(html); cancel() } — bridge API handed to renderDialog. insert injects at the trigger position and closes; cancel closes without inserting.
EmailComposerVariant'default' | 'outline' | 'minimal'.
EmailComposerSize'sm' | 'default' | 'lg'.

On this page