From 588591684b17ab69298c253d87b256e6a6a75e88 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 30 Sep 2026 20:46:21 -0700 Subject: [PATCH 1/3] feat: send a unibox template's HTML body as written by giving the compose and reply composers an HTML mode, keep that HTML in compose and reply drafts, and render the text part server-side for HTML-only sends --- docs/content/docs/api/endpoints.mdx | 4 +- docs/content/docs/api/reference/unibox.mdx | 2 +- docs/content/docs/guides/unibox.mdx | 14 +- internal/api/handler/compose_drafts.go | 16 +- internal/app/emailsend/forward_test.go | 24 +++ internal/app/emailsend/service.go | 6 + .../000239_compose_draft_html.down.sql | 1 + .../000239_compose_draft_html.up.sql | 2 + .../compose_draft_html_live_test.go | 60 ++++++++ internal/repository/pg_compose.go | 17 ++- web/src/components/app/EmailEditor.tsx | 37 ++--- .../components/app/unibox/ReplyComposer.tsx | 118 +++++++++++---- .../components/app/unibox/TemplatePicker.tsx | 28 +++- web/src/components/app/unibox/ThreadView.tsx | 1 + .../app/unibox/compose/ComposeWindow.tsx | 90 ++++++++--- .../app/unibox/compose/HtmlBody.tsx | 91 ++++++++++++ .../app/unibox/replyComposerDraft.test.tsx | 51 ++++++- web/src/hooks/useOutboxStore.ts | 2 + .../api/client/app/unibox/composeDrafts.ts | 3 + web/src/lib/email/composerBody.test.ts | 112 ++++++++++++++ web/src/lib/email/composerBody.ts | 140 ++++++++++++++++++ web/src/lib/email/useComposerBody.ts | 21 +++ web/src/lib/unibox/replyDraft.ts | 3 + 23 files changed, 738 insertions(+), 105 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000239_compose_draft_html.down.sql create mode 100644 internal/infrastructure/db/migrations/000239_compose_draft_html.up.sql create mode 100644 internal/repository/compose_draft_html_live_test.go create mode 100644 web/src/components/app/unibox/compose/HtmlBody.tsx create mode 100644 web/src/lib/email/composerBody.test.ts create mode 100644 web/src/lib/email/composerBody.ts create mode 100644 web/src/lib/email/useComposerBody.ts diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 341bde31a..d701a49c0 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -240,9 +240,9 @@ A source's `column_mapping` is validated when the source is written, not on its `POST /unibox/compose/draft` returns a grounded AI draft for a new email (it never sends): the recipient's contact record, correspondence history, and the workspace voice profile feed the prompt, and the response carries either `text` or a clarifying `question` plus a `grounding` report. Charges AI credits like the reply draft; see [AI credits](/guides/ai-credits/). -`POST /unibox/compose` sends a brand-new outbound email (not a reply). Omit `email_account_id` (or pass `"auto"`) and the backend picks the best mailbox for the first recipient; the response reports `account_id`, `account_email`, `auto`, and a `picked_reason`. Recipients on the workspace suppression list are rejected with a `400` before anything queues. `GET /unibox/compose/candidates?to=
` returns every active mailbox scored for that recipient (conversation history, remaining daily budget, domain-auth health) plus the resolved contact and suppression state; see [composing email](/guides/unibox/#composing-a-new-email). +`POST /unibox/compose` sends a brand-new outbound email (not a reply). `body_html` goes out as written; with `body_plain` left empty, the plain-text part is rendered from it, which `POST /unibox/reply` does too. Omit `email_account_id` (or pass `"auto"`) and the backend picks the best mailbox for the first recipient; the response reports `account_id`, `account_email`, `auto`, and a `picked_reason`. Recipients on the workspace suppression list are rejected with a `400` before anything queues. `GET /unibox/compose/candidates?to=
` returns every active mailbox scored for that recipient (conversation history, remaining daily budget, domain-auth health) plus the resolved contact and suppression state; see [composing email](/guides/unibox/#composing-a-new-email). -The `/unibox/drafts` endpoints hold autosaved compose drafts, scoped to the calling user within the organization. The draft id is client-generated, so `PUT` is idempotent (safe for debounced autosave and retries); deleting a missing draft is a no-op. +The `/unibox/drafts` endpoints hold autosaved compose drafts, scoped to the calling user within the organization. The draft id is client-generated, so `PUT` is idempotent (safe for debounced autosave and retries); deleting a missing draft is a no-op. A draft carries `body` (the plain text) and `body_html`, which is empty for a plain-text draft; each is capped at 100,000 characters. `GET /emails/:id/auth-check` runs a live SPF/DKIM/DMARC lookup on the mailbox's sending domain and reports it without changing anything. `POST /emails/:id/auth-check` runs the same lookup and records the verdict against every active mailbox on that domain, which is what clears the send gate after a DNS fix, so it needs `WRITE_EMAILS` rather than `READ_EMAILS`. The write takes no `Idempotency-Key`: it is derived entirely from public DNS with no request body, so repeating it converges on the same result. See [domain authentication](/guides/deliverability/#domain-authentication). diff --git a/docs/content/docs/api/reference/unibox.mdx b/docs/content/docs/api/reference/unibox.mdx index 0354a6e9c..d59dd2f2b 100644 --- a/docs/content/docs/api/reference/unibox.mdx +++ b/docs/content/docs/api/reference/unibox.mdx @@ -312,7 +312,7 @@ Sends or schedules a reply or a forward from any mailbox in the organization. Th | `bcc` | string[] | No | BCC addresses. | | `subject` | string | Yes | Subject line. | | `body_html` | string | No | HTML body. | -| `body_plain` | string | No | Plain-text body. | +| `body_plain` | string | No | Plain-text body. Leave it empty with `body_html` set and the plain-text part is rendered from the HTML, link destinations included. | | `in_reply_to` | string[] | No | Message-ID(s) this reply threads under. When omitted, the newest Message-ID in `thread_id` is used. | | `thread_id` | string | No | The conversation being answered. | | `send_mode` | string | No | `instant` (default), `smart` (next mailbox gap), or `scheduled` (use `scheduled_at`). | diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index b7ae50a06..80fadf26a 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -197,7 +197,7 @@ To keep filing inside Warmbly for one mailbox, turn off **Mirror Archive and Del ## Replying -Reply, forward, or hover any single message to reply to it specifically. The composer appears when you ask for it or return to a conversation with a saved reply or forward. You can apply a saved **template** or **Insert booking link**. +Reply, forward, or hover any single message to reply to it specifically. The composer appears when you ask for it or return to a conversation with a saved reply or forward. You can apply a saved **template** or **Insert booking link**. A template with an HTML body keeps its formatting; see [templates and HTML](#templates-and-html). ### Choosing the sending mailbox @@ -219,7 +219,7 @@ The reply composer offers the same thing for the moment you answer. When the rec ### Reply drafts -Replies and forwards save after a short typing pause, and save the latest edits when you close the composer, leave the conversation, or reload. Returning to the thread reopens a saved draft, including one addressed to an older message, with the mailbox you picked. **Discard** removes it; successfully queuing a send removes it too. Undoing a send restores the message for editing, from the same mailbox. +Replies and forwards save after a short typing pause, and save the latest edits when you close the composer, leave the conversation, or reload. Returning to the thread reopens a saved draft, including one addressed to an older message, with the mailbox you picked and its HTML body, if it has one. **Discard** removes it; successfully queuing a send removes it too. Undoing a send restores the message for editing, from the same mailbox. These reply drafts are personal to your signed-in user and workspace in this browser. They do not sync to other devices, appear in the mailbox's **Drafts** folder, or travel in a [workspace export](/guides/workspace-export-import/). Clearing browser data removes them. **Draft saved** confirms browser storage succeeded; **Draft not saved** means you should copy your text before leaving. @@ -253,9 +253,17 @@ The window title bar carries **Full screen**, which lifts the same composer to t The picker shows the pick and its reason before sending. Opening it reveals every mailbox with a budget bar (sent today against its limit), a history badge, and an auth-health dot, so overriding is informed. The menu filters by mailbox tag; with a tag selected, Auto picks within that group only, which is the `from_tag_id` field on `POST /unibox/compose`. +### Templates and HTML + +**Template** in either composer inserts a saved template from **Templates**. Into an empty body it becomes the body; otherwise it goes under what you have written, and its subject fills the subject line only when that is still empty (in a reply, only when it is a bare `Re:` or `Fwd:`). The picker marks templates that have an HTML body. + +A template with an HTML body (**Also send an HTML body** on the template) switches the composer to HTML, and the recipient gets that HTML exactly as written, links behind words and all. The template's plain-text body goes out beside it as the plain-text part, so write both when you want both. A template with only an HTML body gets a plain-text part generated from the HTML, with each link's address after its text. + +In HTML mode the body becomes the same editor as a mailbox signature: **HTML** edits the formatted text (or the source, with the code button), **Preview** shows it as a mail client renders it, and **Plain** holds the plain-text part. With **Sync HTML & plain** on, the plain-text part is regenerated from the HTML as you edit; it starts off when the template brought its own plain text. The mailbox signature is still added on send. **HTML** in the action bar switches a plain email to HTML, or back to plain text after asking first, which keeps the plain-text part as the body. AI writing works on plain text only, so it is unavailable in HTML mode. + ### Drafts and history -Typing autosaves to **Drafts** after a pause. Closing keeps the draft; sending or emptying removes it. Drafts sit at the top of the inbox's left rail, resume on any device, and are personal rather than shared. +Typing autosaves to **Drafts** after a pause, including an HTML body. Closing keeps the draft; sending or emptying removes it. Drafts sit at the top of the inbox's left rail, resume on any device, and are personal rather than shared. With a recipient set, **History** slides out every conversation you have had with them: tabs split the full thread from just your sent mail, search narrows by subject, rows open the real thread, and the panel notes which mailbox you usually use. diff --git a/internal/api/handler/compose_drafts.go b/internal/api/handler/compose_drafts.go index e62bab34c..6efbcea4f 100644 --- a/internal/api/handler/compose_drafts.go +++ b/internal/api/handler/compose_drafts.go @@ -48,6 +48,7 @@ type composeDraftUpsertRequest struct { BCC []string `json:"bcc"` Subject string `json:"subject"` Body string `json:"body"` + BodyHTML string `json:"body_html"` } // UpsertComposeDraft — PUT /unibox/drafts/:id @@ -73,19 +74,20 @@ func (h *Handler) UpsertComposeDraft(c *gin.Context) { errx.Handle(c, errx.InvalidBody(err)) return } - if len(req.Body) > maxDraftBody || len(req.Subject) > maxDraftSubject || + if len(req.Body) > maxDraftBody || len(req.BodyHTML) > maxDraftBody || len(req.Subject) > maxDraftSubject || len(req.To) > maxDraftRcpts || len(req.CC) > maxDraftRcpts || len(req.BCC) > maxDraftRcpts { errx.Handle(c, errx.New(errx.BadRequest, "draft is too large")) return } d := &repository.ComposeDraft{ - ID: id, - To: emptyIfNil(req.To), - CC: emptyIfNil(req.CC), - BCC: emptyIfNil(req.BCC), - Subject: req.Subject, - Body: req.Body, + ID: id, + To: emptyIfNil(req.To), + CC: emptyIfNil(req.CC), + BCC: emptyIfNil(req.BCC), + Subject: req.Subject, + Body: req.Body, + BodyHTML: req.BodyHTML, } if v := strings.TrimSpace(req.EmailAccountID); v != "" && v != "auto" { if accountID, perr := uuid.Parse(v); perr == nil { diff --git a/internal/app/emailsend/forward_test.go b/internal/app/emailsend/forward_test.go index 7483e0747..b2dfc7b27 100644 --- a/internal/app/emailsend/forward_test.go +++ b/internal/app/emailsend/forward_test.go @@ -228,3 +228,27 @@ func TestUntrackForwardedRestoresDestinations(t *testing.T) { t.Fatalf("unknown or unsafe tickets must stay as they are:\n%s", h) } } + +// An HTML-only send keeps its HTML verbatim and still ships a text part. +func TestSendEmailRendersTheTextPartOfAnHTMLOnlyBody(t *testing.T) { + orgID := uuid.New() + tasks := &fakeSendTaskRepo{} + svc := &emailSendService{ + taskRepo: tasks, + emailRepo: &fakeSendEmailRepo{account: &models.Email{OrganizationID: &orgID}}, + } + body := `

Here is our brochure.

` + _, xerr := svc.SendEmail(context.Background(), uuid.New(), orgID, uuid.New(), &SendEmailRequest{ + To: []string{"them@example.com"}, Subject: "Brochure", BodyHTML: body, + }) + if xerr != nil { + t.Fatal(xerr) + } + et := tasks.stored + if et.BodyHTML != body { + t.Fatalf("html changed: %q", et.BodyHTML) + } + if et.BodyPlain != "Here is our brochure (https://example.com/brochure.pdf)." || et.Body != et.BodyPlain { + t.Fatalf("plain: %q body %q", et.BodyPlain, et.Body) + } +} diff --git a/internal/app/emailsend/service.go b/internal/app/emailsend/service.go index d8e579322..2b0b89ea9 100644 --- a/internal/app/emailsend/service.go +++ b/internal/app/emailsend/service.go @@ -3,6 +3,7 @@ package emailsend import ( "context" "fmt" + "strings" "time" "github.com/google/uuid" @@ -12,6 +13,7 @@ import ( "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/pkg/mailhtml" "github.com/warmbly/warmbly/internal/repository" "github.com/warmbly/warmbly/internal/scheduler" "github.com/warmbly/warmbly/internal/tasks" @@ -264,6 +266,10 @@ func (s *emailSendService) SendEmail(ctx context.Context, userID, orgID, account } bodyHTML, bodyPlain := req.BodyHTML, req.BodyPlain + // An HTML-only body still ships a text part, rendered from the HTML. + if strings.TrimSpace(bodyPlain) == "" && mailhtml.HasContent(bodyHTML) { + bodyPlain = mailhtml.ToPlainText(bodyHTML) + } var forwardedHTML, forwardedPlain string if req.Forward != nil { forwardedHTML, forwardedPlain = renderForwarded(req.Forward, mailboxLocation(account)) diff --git a/internal/infrastructure/db/migrations/000239_compose_draft_html.down.sql b/internal/infrastructure/db/migrations/000239_compose_draft_html.down.sql new file mode 100644 index 000000000..a3e2b4ea3 --- /dev/null +++ b/internal/infrastructure/db/migrations/000239_compose_draft_html.down.sql @@ -0,0 +1 @@ +ALTER TABLE public.compose_drafts DROP COLUMN IF EXISTS body_html; diff --git a/internal/infrastructure/db/migrations/000239_compose_draft_html.up.sql b/internal/infrastructure/db/migrations/000239_compose_draft_html.up.sql new file mode 100644 index 000000000..294d6f862 --- /dev/null +++ b/internal/infrastructure/db/migrations/000239_compose_draft_html.up.sql @@ -0,0 +1,2 @@ +-- A compose draft keeps the HTML body a template or the HTML editor gave it. +ALTER TABLE public.compose_drafts ADD COLUMN IF NOT EXISTS body_html text NOT NULL DEFAULT ''; diff --git a/internal/repository/compose_draft_html_live_test.go b/internal/repository/compose_draft_html_live_test.go new file mode 100644 index 000000000..f7d585a08 --- /dev/null +++ b/internal/repository/compose_draft_html_live_test.go @@ -0,0 +1,60 @@ +package repository + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +// A compose draft keeps the HTML body a template gave it, and a later save +// that drops back to plain text clears it. +// +// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/?sslmode=disable \ +// go test ./internal/repository/ -run LiveComposeDraftHTML -v +func TestLiveComposeDraftHTML(t *testing.T) { + handle, pool := liveContactDB(t) + ctx := context.Background() + user, org := uuid.New(), uuid.New() + tag := org.String()[:8] + for _, q := range []struct { + sql string + args []any + }{ + {`INSERT INTO users (id, first_name, last_name, email, password_hash) VALUES ($1, 'Draft', 'Live', $2, 'x')`, []any{user, "drafts-" + tag + "@fixture.invalid"}}, + {`INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Drafts', $2, $3)`, []any{org, "drafts-" + tag, user}}, + } { + if _, err := pool.Exec(ctx, q.sql, q.args...); err != nil { + t.Fatalf("fixture: %v", err) + } + } + t.Cleanup(func() { + c := context.Background() + _, _ = pool.Exec(c, `DELETE FROM compose_drafts WHERE organization_id = $1`, org) + _, _ = pool.Exec(c, `DELETE FROM organizations WHERE id = $1`, org) + _, _ = pool.Exec(c, `DELETE FROM users WHERE id = $1`, user) + }) + + repo := NewComposeRepository(handle) + html := `

Here is our brochure.

` + d := &ComposeDraft{ID: uuid.New(), To: []string{"a@example.com"}, CC: []string{}, BCC: []string{}, Subject: "Brochure", Body: "Here is our brochure", BodyHTML: html} + if err := repo.UpsertDraft(ctx, user, org, d); err != nil { + t.Fatalf("upsert: %v", err) + } + got, err := repo.ListDrafts(ctx, user, org) + if err != nil || len(got) != 1 { + t.Fatalf("list: %v %d", err, len(got)) + } + if got[0].BodyHTML != html || got[0].Body != d.Body { + t.Fatalf("draft: body %q html %q", got[0].Body, got[0].BodyHTML) + } + + d.BodyHTML = "" + if err := repo.UpsertDraft(ctx, user, org, d); err != nil { + t.Fatalf("re-upsert: %v", err) + } + got, err = repo.ListDrafts(ctx, user, org) + if err != nil || len(got) != 1 || got[0].BodyHTML != "" { + t.Fatalf("plain re-save kept html: %v %+v", err, got) + } +} diff --git a/internal/repository/pg_compose.go b/internal/repository/pg_compose.go index 87ac55c51..1ef264b24 100644 --- a/internal/repository/pg_compose.go +++ b/internal/repository/pg_compose.go @@ -25,8 +25,10 @@ type ComposeDraft struct { BCC []string `json:"bcc"` Subject string `json:"subject"` Body string `json:"body"` - UpdatedAt time.Time `json:"updated_at"` - CreatedAt time.Time `json:"created_at"` + // BodyHTML is empty while the draft is plain text only. + BodyHTML string `json:"body_html"` + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` } // ComposeRepository backs the compose mailbox picker: which of the org's @@ -110,8 +112,8 @@ func (r *composeRepository) AddressHistoryByAccount(ctx context.Context, orgID u func (r *composeRepository) UpsertDraft(ctx context.Context, userID, orgID uuid.UUID, d *ComposeDraft) error { query := ` - INSERT INTO compose_drafts (id, user_id, organization_id, email_account_id, to_addrs, cc, bcc, subject, body, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW()) + INSERT INTO compose_drafts (id, user_id, organization_id, email_account_id, to_addrs, cc, bcc, subject, body, body_html, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) ON CONFLICT (id) DO UPDATE SET email_account_id = EXCLUDED.email_account_id, to_addrs = EXCLUDED.to_addrs, @@ -119,10 +121,11 @@ func (r *composeRepository) UpsertDraft(ctx context.Context, userID, orgID uuid. bcc = EXCLUDED.bcc, subject = EXCLUDED.subject, body = EXCLUDED.body, + body_html = EXCLUDED.body_html, updated_at = NOW() WHERE compose_drafts.user_id = $2 ` - args := []any{d.ID, userID, orgID, d.EmailAccountID, d.To, d.CC, d.BCC, d.Subject, d.Body} + args := []any{d.ID, userID, orgID, d.EmailAccountID, d.To, d.CC, d.BCC, d.Subject, d.Body, d.BodyHTML} if _, err := r.db.Exec(ctx, query, args...); err != nil { db.CaptureError(err, query, args, "exec") return err @@ -132,7 +135,7 @@ func (r *composeRepository) UpsertDraft(ctx context.Context, userID, orgID uuid. func (r *composeRepository) ListDrafts(ctx context.Context, userID, orgID uuid.UUID) ([]ComposeDraft, error) { query := ` - SELECT id, email_account_id, to_addrs, cc, bcc, subject, body, updated_at, created_at + SELECT id, email_account_id, to_addrs, cc, bcc, subject, body, body_html, updated_at, created_at FROM compose_drafts WHERE user_id = $1 AND organization_id = $2 ORDER BY updated_at DESC @@ -148,7 +151,7 @@ func (r *composeRepository) ListDrafts(ctx context.Context, userID, orgID uuid.U out := []ComposeDraft{} for rows.Next() { var d ComposeDraft - if err := rows.Scan(&d.ID, &d.EmailAccountID, &d.To, &d.CC, &d.BCC, &d.Subject, &d.Body, &d.UpdatedAt, &d.CreatedAt); err != nil { + if err := rows.Scan(&d.ID, &d.EmailAccountID, &d.To, &d.CC, &d.BCC, &d.Subject, &d.Body, &d.BodyHTML, &d.UpdatedAt, &d.CreatedAt); err != nil { continue } if d.To == nil { diff --git a/web/src/components/app/EmailEditor.tsx b/web/src/components/app/EmailEditor.tsx index 9f375c3ad..3ee89154c 100644 --- a/web/src/components/app/EmailEditor.tsx +++ b/web/src/components/app/EmailEditor.tsx @@ -38,23 +38,7 @@ import { PopoverMenuTrigger, } from "@/components/ui/popover-menu"; import { Checkbox } from "@/components/ui/checkbox"; - -// htmlToPlain renders the HTML signature down to a plain-text equivalent, -// turning block elements and
into line breaks. Used to keep the plain -// version in lockstep with the HTML one while "sync" is on. -function htmlToPlain(html: string): string { - const withBreaks = html - .replace(/<\s*br\s*\/?>/gi, "\n") - .replace(/<\/\s*(p|div|h[1-6]|li|tr)\s*>/gi, "\n"); - if (typeof DOMParser === "undefined") return withBreaks.replace(/<[^>]+>/g, ""); - // DOMParser builds an inert document: nothing is fetched and no handler - // runs. Assigning innerHTML on a detached div does fire . - const doc = new DOMParser().parseFromString(withBreaks, "text/html"); - // Without this a signature carrying a +
preheader
+

Hello + there

`; + expect(htmlToPlain(html)).toBe("Hello there"); + }); + + it("breaks lines at
and bullets list items", () => { + expect(htmlToPlain("a
b
  • x
  • y
c")).toBe("a\nb\n\n- x\n- y\n\nc"); + expect(htmlToPlain("
one
two
")).toBe("one\ntwo"); + }); + + it("does not repeat a link whose label is its address", () => { + expect(htmlToPlain('https://x.com')).toBe("https://x.com"); + expect(htmlToPlain('a@x.com')).toBe("a@x.com"); + }); +}); + +describe("withTemplate", () => { + it("carries a template's HTML body verbatim into an empty composer (#761)", () => { + const b = withTemplate(empty, { body_plain: brochurePlain, body_html: brochureHtml }); + expect(b).toEqual({ html: brochureHtml, plain: brochurePlain, sync: false }); + expect(outgoingParts(b)).toEqual({ body_html: brochureHtml, body_plain: brochurePlain }); + }); + + it("derives the plain part from an HTML-only template and keeps it in sync", () => { + const b = withTemplate(empty, { body_plain: "", body_html: brochureHtml }); + expect(b.sync).toBe(true); + expect(b.plain).toBe("Here is our brochure (https://example.com/brochure.pdf)."); + const edited = withHtml(b, "

Hi

"); + expect(edited.plain).toBe("Hi"); + }); + + it("keeps a plain template in a plain composer", () => { + const b = withTemplate({ ...empty, plain: "Hi Ann," }, { body_plain: "Thanks!", body_html: "" }); + expect(b).toEqual({ plain: "Hi Ann,\n\nThanks!", html: null, sync: false }); + }); + + it("treats an empty placeholder HTML body as no HTML", () => { + const b = withTemplate(empty, { body_plain: "Thanks!", body_html: "
" }); + expect(b.html).toBeNull(); + }); + + it("appends an HTML template under text already typed", () => { + const b = withTemplate({ ...empty, plain: "Hi Ann," }, { body_plain: brochurePlain, body_html: brochureHtml }); + expect(b.html).toBe(`Hi Ann,

${brochureHtml}`); + expect(b.plain).toBe(`Hi Ann,\n\n${brochurePlain}`); + expect(b.sync).toBe(false); + }); + + it("appends a plain template to an HTML body as HTML", () => { + const start = withTemplate(empty, { body_plain: brochurePlain, body_html: brochureHtml }); + const b = withTemplate(start, { body_plain: "See you & thanks", body_html: "" }); + expect(b.html).toBe(`${brochureHtml}

See you & thanks`); + expect(b.plain).toBe(`${brochurePlain}\n\nSee you & thanks`); + }); +}); + +describe("mode switches", () => { + it("turns typed text into HTML and back", () => { + const html = toHtmlMode({ ...empty, plain: "a < b\nc" }); + expect(html).toEqual({ html: "a < b
c", plain: "a < b\nc", sync: true }); + expect(toPlainMode(html)).toEqual({ html: null, plain: "a < b\nc", sync: false }); + }); + + it("keeps an authored plain part when leaving HTML", () => { + const b: ComposerBody = { html: brochureHtml, plain: brochurePlain, sync: false }; + expect(toPlainMode(b).plain).toBe(brochurePlain); + }); + + it("appends text to an HTML body", () => { + const b = withText({ html: "

Hi

", plain: "Hi", sync: true }, "Book: https://x.com/m"); + expect(b.html).toBe('

Hi



Book: https://x.com/m'); + expect(b.plain).toBe("Hi\n\nBook: https://x.com/m"); + }); +}); + +describe("restoreBody", () => { + it("restores a plain draft as plain", () => { + expect(restoreBody("hello", "")).toEqual({ plain: "hello", html: null, sync: false }); + }); + + it("restores sync only when the plain part matches the HTML", () => { + expect(restoreBody("Hi", "

Hi

").sync).toBe(true); + expect(restoreBody(brochurePlain, brochureHtml).sync).toBe(false); + }); +}); diff --git a/web/src/lib/email/composerBody.ts b/web/src/lib/email/composerBody.ts new file mode 100644 index 000000000..9fdba5928 --- /dev/null +++ b/web/src/lib/email/composerBody.ts @@ -0,0 +1,140 @@ +// A unibox composer body: plain text, or HTML with its plain-text alternative. + +import { plainToHtml } from "./body"; + +export interface ComposerBody { + /** The text/plain part. In HTML mode with sync on it is derived from `html`. */ + plain: string; + /** The text/html part; null while the composer is plain text only. */ + html: string | null; + /** The plain part is generated from the HTML and follows every edit. */ + sync: boolean; +} + +/** Longest HTML body a composer keeps; the drafts endpoint refuses more. */ +export const MAX_HTML_LEN = 100_000; + +const SKIP = "style, script, title, noscript, template, head, iframe, object"; +// Private-use break markers, so a newline in the source collapses like any whitespace. +const BR = "\uE000"; +const BREAK1 = "\uE001"; +const BREAK2 = "\uE002"; +const BLOCK2 = new Set(["P", "BLOCKQUOTE", "TABLE", "UL", "OL", "PRE", "H1", "H2", "H3", "H4", "H5", "H6"]); +const BLOCK1 = new Set(["DIV", "LI", "TR", "SECTION", "ARTICLE", "HEADER", "FOOTER", "ADDRESS", "DT", "DD"]); + +/** Renders HTML to its text alternative the way the server's mailhtml.ToPlainText does. */ +export function htmlToPlain(html: string): string { + if (!html.trim()) return ""; + if (typeof DOMParser === "undefined") { + return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + } + // DOMParser builds an inert document: nothing is fetched and no handler runs. + const doc = new DOMParser().parseFromString(html, "text/html"); + doc.querySelectorAll(SKIP).forEach((el) => el.remove()); + doc.querySelectorAll("[style]").forEach((el) => { + if (/display\s*:\s*none/i.test(el.getAttribute("style") ?? "")) el.remove(); + }); + doc.querySelectorAll("a[href]").forEach((a) => { + const href = (a.getAttribute("href") ?? "").trim(); + if (!/^(https?:|mailto:)/i.test(href)) return; + const label = (a.textContent ?? "").replace(/\s+/g, " ").trim(); + if (!label) a.append(href.replace(/^mailto:/i, "")); + else if (label !== href && `mailto:${label}` !== href) a.append(` (${href})`); + }); + doc.querySelectorAll("br").forEach((br) => br.replaceWith(BR)); + doc.querySelectorAll("li").forEach((li) => li.prepend("- ")); + doc.body.querySelectorAll("*").forEach((el) => { + const mark = BLOCK2.has(el.tagName) ? BREAK2 : BLOCK1.has(el.tagName) ? BREAK1 : ""; + if (mark) { + el.prepend(mark); + el.append(mark); + } else if (el.tagName === "TD" || el.tagName === "TH") el.append(" "); + }); + return (doc.body.textContent ?? "") + .replace(/\s+/g, " ") + // Adjacent block edges owe the larger break, not the sum. + .replace(new RegExp(`[ ${BREAK1}${BREAK2}]*[${BREAK1}${BREAK2}][ ${BREAK1}${BREAK2}]*`, "g"), (run) => + run.includes(BREAK2) ? "\n\n" : "\n", + ) + .replace(new RegExp(BR, "g"), "\n") + .replace(/ *\n */g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +/** Whether an HTML body would show the reader anything: text or an image. */ +export function htmlHasContent(html: string): boolean { + return /
${b}` : b; +} + +/** Edits the HTML; a synced plain part follows it. */ +export function withHtml(b: ComposerBody, html: string): ComposerBody { + return { ...b, html, plain: b.sync ? htmlToPlain(html) : b.plain }; +} + +/** Appends text (a booking link, a plain template) in whichever mode is on. */ +export function withText(b: ComposerBody, text: string): ComposerBody { + if (!text.trim()) return b; + if (b.html === null) return { ...b, plain: joinPlain(b.plain, text) }; + const html = joinHtml(b.html, plainToHtml(text)); + return { html, sync: b.sync, plain: b.sync ? htmlToPlain(html) : joinPlain(b.plain, text) }; +} + +/** Applies a saved template, carrying its HTML body verbatim and its own plain text as the alternative. */ +export function withTemplate(b: ComposerBody, t: { body_plain?: string | null; body_html?: string | null }): ComposerBody { + const tHtml = t.body_html ?? ""; + const tPlain = t.body_plain ?? ""; + if (!htmlHasContent(tHtml)) return withText(b, tPlain); + + if (!bodyHasContent(b)) { + return tPlain.trim() + ? { html: tHtml, plain: tPlain, sync: false } + : { html: tHtml, plain: htmlToPlain(tHtml), sync: true }; + } + const html = joinHtml(b.html ?? plainToHtml(b.plain), tHtml); + const sync = !tPlain.trim() && (b.html === null || b.sync); + return { + html, + sync, + plain: sync ? htmlToPlain(html) : joinPlain(b.plain, tPlain.trim() ? tPlain : htmlToPlain(tHtml)), + }; +} + +/** Switches a plain body to HTML, keeping what was typed. */ +export function toHtmlMode(b: ComposerBody): ComposerBody { + if (b.html !== null) return b; + return { html: plainToHtml(b.plain), plain: b.plain, sync: true }; +} + +/** Drops the HTML; the plain-text alternative becomes the body. */ +export function toPlainMode(b: ComposerBody): ComposerBody { + if (b.html === null) return b; + return { html: null, plain: b.sync ? htmlToPlain(b.html) : b.plain, sync: false }; +} + +/** The two parts a send carries. */ +export function outgoingParts(b: ComposerBody): { body_plain: string; body_html: string } { + if (b.html === null) { + const plain = b.plain.trim(); + return { body_plain: plain, body_html: plainToHtml(plain) }; + } + return { body_plain: (b.sync ? htmlToPlain(b.html) : b.plain).trim(), body_html: b.html }; +} diff --git a/web/src/lib/email/useComposerBody.ts b/web/src/lib/email/useComposerBody.ts new file mode 100644 index 000000000..cb2f06772 --- /dev/null +++ b/web/src/lib/email/useComposerBody.ts @@ -0,0 +1,21 @@ +import React from "react"; +import { htmlToPlain, restoreBody, withHtml, type ComposerBody } from "./composerBody"; + +/** State for a composer body that can switch between plain text and HTML. */ +export function useComposerBody(plain: string, html?: string | null) { + const [value, setValue] = React.useState(() => restoreBody(plain, html)); + const setBody = React.useCallback( + (next: string | ((prev: string) => string)) => + setValue((v) => ({ ...v, plain: typeof next === "function" ? next(v.plain) : next })), + [], + ); + const setHtml = React.useCallback((next: string) => setValue((v) => withHtml(v, next)), []); + const setSync = React.useCallback( + (on: boolean) => + setValue((v) => ({ ...v, sync: on, plain: on && v.html !== null ? htmlToPlain(v.html) : v.plain })), + [], + ); + return { value, body: value.plain, html: value.html, sync: value.sync, setValue, setBody, setHtml, setSync }; +} + +export type ComposerBodyState = ReturnType; diff --git a/web/src/lib/unibox/replyDraft.ts b/web/src/lib/unibox/replyDraft.ts index b33f2b03b..dc7c8ab10 100644 --- a/web/src/lib/unibox/replyDraft.ts +++ b/web/src/lib/unibox/replyDraft.ts @@ -6,6 +6,8 @@ export interface ReplySeed { bcc: string[]; subject: string; body: string; + /** Present only while the reply is in HTML mode. */ + body_html?: string; /** The sending mailbox when the draft chose one; absent means the thread's own. */ email_account_id?: string; } @@ -30,6 +32,7 @@ export function loadReplyDraft(key: string): ReplySeed | null { bcc: recipients(d.bcc), subject: typeof d.subject === "string" ? d.subject : "", body: d.body, + ...(typeof d.body_html === "string" && d.body_html ? { body_html: d.body_html } : {}), ...(typeof d.email_account_id === "string" && d.email_account_id ? { email_account_id: d.email_account_id } : {}), From cfdc0c3d01dc9ab80ef408a512030aa5a99a312a Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 30 Sep 2026 21:15:29 -0700 Subject: [PATCH 2/3] feat: keep the signature editor's own plain-text rendering, reuse the campaign HTML-to-text helper in the composers, leave a synced text part to the server, cap HTML bodies in bytes, refuse a send whose HTML was emptied, and keep Escape and AI drafts from discarding HTML composer work --- docs/content/docs/guides/unibox.mdx | 2 +- web/src/components/app/EmailEditor.tsx | 25 +++++- .../components/app/unibox/ReplyComposer.tsx | 24 +++--- .../components/app/unibox/TemplatePicker.tsx | 24 ++++-- .../app/unibox/compose/ComposeWindow.tsx | 20 ++--- .../app/unibox/compose/HtmlBody.tsx | 19 ++++- .../app/unibox/replyComposerDraft.test.tsx | 2 +- web/src/lib/email/composerBody.test.ts | 62 ++++++-------- web/src/lib/email/composerBody.ts | 82 ++++++------------- web/src/lib/email/useComposerBody.ts | 12 +-- 10 files changed, 134 insertions(+), 138 deletions(-) diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index 80fadf26a..1eb33d95a 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -259,7 +259,7 @@ The picker shows the pick and its reason before sending. Opening it reveals ever A template with an HTML body (**Also send an HTML body** on the template) switches the composer to HTML, and the recipient gets that HTML exactly as written, links behind words and all. The template's plain-text body goes out beside it as the plain-text part, so write both when you want both. A template with only an HTML body gets a plain-text part generated from the HTML, with each link's address after its text. -In HTML mode the body becomes the same editor as a mailbox signature: **HTML** edits the formatted text (or the source, with the code button), **Preview** shows it as a mail client renders it, and **Plain** holds the plain-text part. With **Sync HTML & plain** on, the plain-text part is regenerated from the HTML as you edit; it starts off when the template brought its own plain text. The mailbox signature is still added on send. **HTML** in the action bar switches a plain email to HTML, or back to plain text after asking first, which keeps the plain-text part as the body. AI writing works on plain text only, so it is unavailable in HTML mode. +In HTML mode the body becomes the same editor as a mailbox signature: **HTML** edits the formatted text (or the source, with the code button), **Preview** shows it as a mail client renders it, and **Plain** holds the plain-text part. With **Sync HTML & plain** on, the plain-text part follows the HTML: **Plain** previews it, and the one that goes out is rendered from the HTML when the email is sent. Sync starts off when the template brought its own plain text. The mailbox signature is still added on send. **HTML** in the action bar switches a plain email to HTML, or back to plain text after asking first, which keeps the plain-text part as the body. AI writing works on plain text only, so it is unavailable in HTML mode, and an AI draft has to be kept or discarded before switching to HTML or adding an HTML template. ### Drafts and history diff --git a/web/src/components/app/EmailEditor.tsx b/web/src/components/app/EmailEditor.tsx index 3ee89154c..705d4bda5 100644 --- a/web/src/components/app/EmailEditor.tsx +++ b/web/src/components/app/EmailEditor.tsx @@ -38,7 +38,23 @@ import { PopoverMenuTrigger, } from "@/components/ui/popover-menu"; import { Checkbox } from "@/components/ui/checkbox"; -import { htmlToPlain } from "@/lib/email/composerBody"; + +// htmlToPlain renders the HTML signature down to a plain-text equivalent, +// turning block elements and
into line breaks. Used to keep the plain +// version in lockstep with the HTML one while "sync" is on. +function htmlToPlain(html: string): string { + const withBreaks = html + .replace(/<\s*br\s*\/?>/gi, "\n") + .replace(/<\/\s*(p|div|h[1-6]|li|tr)\s*>/gi, "\n"); + if (typeof DOMParser === "undefined") return withBreaks.replace(/<[^>]+>/g, ""); + // DOMParser builds an inert document: nothing is fetched and no handler + // runs. Assigning innerHTML on a detached div does fire . + const doc = new DOMParser().parseFromString(withBreaks, "text/html"); + // Without this a signature carrying a -
preheader
-

Hello - there

`; - expect(htmlToPlain(html)).toBe("Hello there"); - }); - - it("breaks lines at
and bullets list items", () => { - expect(htmlToPlain("a
b
  • x
  • y
c")).toBe("a\nb\n\n- x\n- y\n\nc"); - expect(htmlToPlain("
one
two
")).toBe("one\ntwo"); - }); - - it("does not repeat a link whose label is its address", () => { - expect(htmlToPlain('https://x.com')).toBe("https://x.com"); - expect(htmlToPlain('a@x.com')).toBe("a@x.com"); - }); -}); - describe("withTemplate", () => { it("carries a template's HTML body verbatim into an empty composer (#761)", () => { const b = withTemplate(empty, { body_plain: brochurePlain, body_html: brochureHtml }); @@ -48,12 +24,11 @@ describe("withTemplate", () => { expect(outgoingParts(b)).toEqual({ body_html: brochureHtml, body_plain: brochurePlain }); }); - it("derives the plain part from an HTML-only template and keeps it in sync", () => { + it("previews a plain part for an HTML-only template and leaves the sent one to the server", () => { const b = withTemplate(empty, { body_plain: "", body_html: brochureHtml }); expect(b.sync).toBe(true); expect(b.plain).toBe("Here is our brochure (https://example.com/brochure.pdf)."); - const edited = withHtml(b, "

Hi

"); - expect(edited.plain).toBe("Hi"); + expect(outgoingParts(b)).toEqual({ body_html: brochureHtml, body_plain: "" }); }); it("keeps a plain template in a plain composer", () => { @@ -62,8 +37,7 @@ describe("withTemplate", () => { }); it("treats an empty placeholder HTML body as no HTML", () => { - const b = withTemplate(empty, { body_plain: "Thanks!", body_html: "
" }); - expect(b.html).toBeNull(); + expect(withTemplate(empty, { body_plain: "Thanks!", body_html: "
" }).html).toBeNull(); }); it("appends an HTML template under text already typed", () => { @@ -81,6 +55,25 @@ describe("withTemplate", () => { }); }); +describe("content and limits", () => { + it("does not count a stale plain part once the HTML is emptied", () => { + expect(bodyHasContent({ html: "
", plain: brochurePlain, sync: false })).toBe(false); + expect(bodyHasContent({ html: '', plain: "", sync: true })).toBe(true); + }); + + it("measures HTML in bytes, as the drafts endpoint does", () => { + const wide = "é".repeat(MAX_PART_BYTES / 2 + 1); + expect(wide.length).toBeLessThan(MAX_PART_BYTES); + expect(bodyTooLong({ html: `

${wide}

`, plain: "", sync: true })).toBe(true); + expect(bodyTooLong({ html: brochureHtml, plain: brochurePlain, sync: false })).toBe(false); + }); + + it("caps the plain body when leaving HTML", () => { + const long = "a".repeat(MAX_PLAIN_LEN + 50); + expect(toPlainMode({ html: "

x

", plain: long, sync: false }).plain).toHaveLength(MAX_PLAIN_LEN); + }); +}); + describe("mode switches", () => { it("turns typed text into HTML and back", () => { const html = toHtmlMode({ ...empty, plain: "a < b\nc" }); @@ -89,8 +82,7 @@ describe("mode switches", () => { }); it("keeps an authored plain part when leaving HTML", () => { - const b: ComposerBody = { html: brochureHtml, plain: brochurePlain, sync: false }; - expect(toPlainMode(b).plain).toBe(brochurePlain); + expect(toPlainMode({ html: brochureHtml, plain: brochurePlain, sync: false }).plain).toBe(brochurePlain); }); it("appends text to an HTML body", () => { diff --git a/web/src/lib/email/composerBody.ts b/web/src/lib/email/composerBody.ts index 9fdba5928..5c01a0cd1 100644 --- a/web/src/lib/email/composerBody.ts +++ b/web/src/lib/email/composerBody.ts @@ -1,7 +1,10 @@ // A unibox composer body: plain text, or HTML with its plain-text alternative. +import { htmlToPlain } from "@/components/app/campaigns/sequences/emailPreview"; import { plainToHtml } from "./body"; +export { htmlToPlain }; + export interface ComposerBody { /** The text/plain part. In HTML mode with sync on it is derived from `html`. */ plain: string; @@ -11,55 +14,24 @@ export interface ComposerBody { sync: boolean; } -/** Longest HTML body a composer keeps; the drafts endpoint refuses more. */ -export const MAX_HTML_LEN = 100_000; +/** The plain-text composer's cap, in characters. */ +export const MAX_PLAIN_LEN = 4000; +/** The drafts endpoint's cap on each body part, in UTF-8 bytes. */ +export const MAX_PART_BYTES = 100_000; -const SKIP = "style, script, title, noscript, template, head, iframe, object"; -// Private-use break markers, so a newline in the source collapses like any whitespace. -const BR = "\uE000"; -const BREAK1 = "\uE001"; -const BREAK2 = "\uE002"; -const BLOCK2 = new Set(["P", "BLOCKQUOTE", "TABLE", "UL", "OL", "PRE", "H1", "H2", "H3", "H4", "H5", "H6"]); -const BLOCK1 = new Set(["DIV", "LI", "TR", "SECTION", "ARTICLE", "HEADER", "FOOTER", "ADDRESS", "DT", "DD"]); +const encoder = new TextEncoder(); -/** Renders HTML to its text alternative the way the server's mailhtml.ToPlainText does. */ -export function htmlToPlain(html: string): string { - if (!html.trim()) return ""; - if (typeof DOMParser === "undefined") { - return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); - } - // DOMParser builds an inert document: nothing is fetched and no handler runs. - const doc = new DOMParser().parseFromString(html, "text/html"); - doc.querySelectorAll(SKIP).forEach((el) => el.remove()); - doc.querySelectorAll("[style]").forEach((el) => { - if (/display\s*:\s*none/i.test(el.getAttribute("style") ?? "")) el.remove(); - }); - doc.querySelectorAll("a[href]").forEach((a) => { - const href = (a.getAttribute("href") ?? "").trim(); - if (!/^(https?:|mailto:)/i.test(href)) return; - const label = (a.textContent ?? "").replace(/\s+/g, " ").trim(); - if (!label) a.append(href.replace(/^mailto:/i, "")); - else if (label !== href && `mailto:${label}` !== href) a.append(` (${href})`); - }); - doc.querySelectorAll("br").forEach((br) => br.replaceWith(BR)); - doc.querySelectorAll("li").forEach((li) => li.prepend("- ")); - doc.body.querySelectorAll("*").forEach((el) => { - const mark = BLOCK2.has(el.tagName) ? BREAK2 : BLOCK1.has(el.tagName) ? BREAK1 : ""; - if (mark) { - el.prepend(mark); - el.append(mark); - } else if (el.tagName === "TD" || el.tagName === "TH") el.append(" "); - }); - return (doc.body.textContent ?? "") - .replace(/\s+/g, " ") - // Adjacent block edges owe the larger break, not the sum. - .replace(new RegExp(`[ ${BREAK1}${BREAK2}]*[${BREAK1}${BREAK2}][ ${BREAK1}${BREAK2}]*`, "g"), (run) => - run.includes(BREAK2) ? "\n\n" : "\n", - ) - .replace(new RegExp(BR, "g"), "\n") - .replace(/ *\n */g, "\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); +/** Whether a part is over what the drafts endpoint stores. */ +export function bodyTooLong(b: ComposerBody): boolean { + return ( + b.html !== null && + (encoder.encode(b.html).length > MAX_PART_BYTES || encoder.encode(b.plain).length > MAX_PART_BYTES) + ); +} + +/** A plain body keeps the textarea's cap; HTML mode is capped by bodyTooLong. */ +export function capPlain(b: ComposerBody): ComposerBody { + return b.html === null && b.plain.length > MAX_PLAIN_LEN ? { ...b, plain: b.plain.slice(0, MAX_PLAIN_LEN) } : b; } /** Whether an HTML body would show the reader anything: text or an image. */ @@ -67,8 +39,9 @@ export function htmlHasContent(html: string): boolean { return /
${b}` : b; } -/** Edits the HTML; a synced plain part follows it. */ -export function withHtml(b: ComposerBody, html: string): ComposerBody { - return { ...b, html, plain: b.sync ? htmlToPlain(html) : b.plain }; -} - /** Appends text (a booking link, a plain template) in whichever mode is on. */ export function withText(b: ComposerBody, text: string): ComposerBody { if (!text.trim()) return b; - if (b.html === null) return { ...b, plain: joinPlain(b.plain, text) }; + if (b.html === null) return capPlain({ ...b, plain: joinPlain(b.plain, text) }); const html = joinHtml(b.html, plainToHtml(text)); return { html, sync: b.sync, plain: b.sync ? htmlToPlain(html) : joinPlain(b.plain, text) }; } @@ -127,14 +95,14 @@ export function toHtmlMode(b: ComposerBody): ComposerBody { /** Drops the HTML; the plain-text alternative becomes the body. */ export function toPlainMode(b: ComposerBody): ComposerBody { if (b.html === null) return b; - return { html: null, plain: b.sync ? htmlToPlain(b.html) : b.plain, sync: false }; + return capPlain({ html: null, plain: b.sync ? htmlToPlain(b.html) : b.plain, sync: false }); } -/** The two parts a send carries. */ +/** The two parts a send carries. A synced text part is left to the server's renderer. */ export function outgoingParts(b: ComposerBody): { body_plain: string; body_html: string } { if (b.html === null) { const plain = b.plain.trim(); return { body_plain: plain, body_html: plainToHtml(plain) }; } - return { body_plain: (b.sync ? htmlToPlain(b.html) : b.plain).trim(), body_html: b.html }; + return { body_plain: b.sync ? "" : b.plain.trim(), body_html: b.html }; } diff --git a/web/src/lib/email/useComposerBody.ts b/web/src/lib/email/useComposerBody.ts index cb2f06772..5d7eb9153 100644 --- a/web/src/lib/email/useComposerBody.ts +++ b/web/src/lib/email/useComposerBody.ts @@ -1,7 +1,7 @@ import React from "react"; -import { htmlToPlain, restoreBody, withHtml, type ComposerBody } from "./composerBody"; +import { restoreBody, type ComposerBody } from "./composerBody"; -/** State for a composer body that can switch between plain text and HTML. */ +/** State for a composer body that can switch between plain text and HTML; the editor derives a synced plain part. */ export function useComposerBody(plain: string, html?: string | null) { const [value, setValue] = React.useState(() => restoreBody(plain, html)); const setBody = React.useCallback( @@ -9,12 +9,8 @@ export function useComposerBody(plain: string, html?: string | null) { setValue((v) => ({ ...v, plain: typeof next === "function" ? next(v.plain) : next })), [], ); - const setHtml = React.useCallback((next: string) => setValue((v) => withHtml(v, next)), []); - const setSync = React.useCallback( - (on: boolean) => - setValue((v) => ({ ...v, sync: on, plain: on && v.html !== null ? htmlToPlain(v.html) : v.plain })), - [], - ); + const setHtml = React.useCallback((next: string) => setValue((v) => ({ ...v, html: next })), []); + const setSync = React.useCallback((on: boolean) => setValue((v) => ({ ...v, sync: on })), []); return { value, body: value.plain, html: value.html, sync: value.sync, setValue, setBody, setHtml, setSync }; } From e20efd254422a7f402f517d167977c77830b1048 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 30 Sep 2026 21:17:28 -0700 Subject: [PATCH 3/3] feat: let a portaled popover inside the unibox composers close itself on Escape instead of the composer closing around it --- .../components/app/unibox/compose/ComposeWindow.tsx | 2 ++ web/src/components/app/unibox/compose/HtmlBody.tsx | 6 +++--- .../app/unibox/replyComposerDraft.test.tsx | 12 ++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/web/src/components/app/unibox/compose/ComposeWindow.tsx b/web/src/components/app/unibox/compose/ComposeWindow.tsx index cfd2fa7a9..4feb43715 100644 --- a/web/src/components/app/unibox/compose/ComposeWindow.tsx +++ b/web/src/components/app/unibox/compose/ComposeWindow.tsx @@ -475,6 +475,8 @@ function ComposeWindowInner({ )} onKeyDown={(e) => { if (e.key === "Escape") { + // A portaled popover (template picker, link form) closes itself first. + if ((e.target as HTMLElement).closest?.("[data-floating]")) return; // Floating AI layers portal to and stop their own // Escape; reaching here means nothing else claimed it. e.stopPropagation(); diff --git a/web/src/components/app/unibox/compose/HtmlBody.tsx b/web/src/components/app/unibox/compose/HtmlBody.tsx index 9f7a96e27..39a5712b8 100644 --- a/web/src/components/app/unibox/compose/HtmlBody.tsx +++ b/web/src/components/app/unibox/compose/HtmlBody.tsx @@ -31,9 +31,9 @@ export function HtmlBody({ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); onSend(); - } else if (e.key === "Escape" && document.querySelector('[data-floating], [role="alertdialog"]')) { - // A link or image popover is the innermost layer; the composer stays. - e.stopPropagation(); + } else if (e.key === "Escape" && (e.target as HTMLElement).closest?.("[data-floating]")) { + // A link or image popover is the innermost layer and closes itself. + return; } else if (e.key === "Escape" && onEscape) { e.preventDefault(); e.stopPropagation(); diff --git a/web/src/components/app/unibox/replyComposerDraft.test.tsx b/web/src/components/app/unibox/replyComposerDraft.test.tsx index 81c0f27a0..706d29dcc 100644 --- a/web/src/components/app/unibox/replyComposerDraft.test.tsx +++ b/web/src/components/app/unibox/replyComposerDraft.test.tsx @@ -578,6 +578,18 @@ describe("reply composer drafts", () => { expect(confirmShow).toHaveBeenCalledOnce(); expect(body()).toHaveValue(template.body_plain); }); + + it("closes the link popover, not the composer, on Escape in HTML mode", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /Template/ })); + fireEvent.click(screen.getByRole("button", { name: "Use Brochure" })); + fireEvent.click(screen.getByTitle("Insert link")); + fireEvent.keyDown(screen.getByRole("button", { name: "Apply" }), { key: "Escape" }); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.keyDown(document.getElementById("reply-body") as HTMLElement, { key: "Escape" }); + expect(onClose).toHaveBeenCalledOnce(); + }); }); function setupStorage() {