Merge pull request #772 from warmbly/fix/unibox-template-html-body

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
This commit is contained in:
Matthew Meszaros
2026-10-01 04:22:11 +00:00
committed by GitHub
23 changed files with 735 additions and 92 deletions
+2 -2
View File
@@ -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=<address>` 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=<address>` 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).
+1 -1
View File
@@ -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`). |
+11 -3
View File
@@ -201,7 +201,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
@@ -223,7 +223,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.
@@ -257,9 +257,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 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
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.
+9 -7
View File
@@ -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 {
+24
View File
@@ -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 := `<p>Here is our <a href="https://example.com/brochure.pdf">brochure</a>.</p>`
_, 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)
}
}
+6
View File
@@ -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))
@@ -0,0 +1 @@
ALTER TABLE public.compose_drafts DROP COLUMN IF EXISTS body_html;
@@ -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 '';
@@ -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/<db>?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 := `<p>Here is our <a href="https://example.com/brochure.pdf">brochure</a>.</p>`
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)
}
}
+10 -7
View File
@@ -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 {
+18 -8
View File
@@ -102,6 +102,10 @@ interface EmailEditorProps {
setSync: (v: boolean) => void;
code: boolean;
setCode: (v: boolean) => void;
/** What is being edited, for the editor's own copy. */
kind?: "signature" | "email";
/** Renders the HTML to the synced plain text; signatures keep their own rendering. */
toPlain?: (html: string) => string;
}
export default function EmailEditor({
@@ -114,6 +118,8 @@ export default function EmailEditor({
setSync,
code,
setCode,
kind = "signature",
toPlain = htmlToPlain,
}: EmailEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState<"html" | "preview" | "plain">("html");
@@ -148,24 +154,24 @@ export default function EmailEditor({
function applyUrl() {
const u = url.trim();
const kind = urlPopover;
const target = urlPopover;
setUrl("");
setUrlPopover(null);
if (!u || !kind) return;
if (!u || !target) return;
editorRef.current?.focus();
const sel = window.getSelection();
if (savedRange.current && sel) {
sel.removeAllRanges();
sel.addRange(savedRange.current);
}
exec(kind === "image" ? "insertImage" : "createLink", u);
exec(target === "image" ? "insertImage" : "createLink", u);
}
// commitHtml writes the HTML signature and, while sync is on, keeps the
// plain-text version derived from it so the two stay identical.
function commitHtml(html: string) {
setHtmlText(html);
if (sync) setPlainText(htmlToPlain(html));
if (sync) setPlainText(toPlain(html));
}
function exec(command: string, value?: string) {
@@ -292,7 +298,7 @@ export default function EmailEditor({
disabled={forcedSource}
title={
forcedSource
? "This signature holds markup the visual editor cannot host safely"
? `This ${kind} holds markup the visual editor cannot host safely`
: sourceView
? "Visual editor"
: "Edit HTML source"
@@ -315,7 +321,7 @@ export default function EmailEditor({
onChange={(e) => {
const on = e.target.checked;
setSync(on);
if (on) setPlainText(htmlToPlain(htmlText));
if (on) setPlainText(toPlain(htmlText));
}}
/>
<span className="hidden sm:inline">Sync HTML &amp; plain</span>
@@ -366,7 +372,7 @@ export default function EmailEditor({
<RiEyeLine className="mt-px w-3 h-3 shrink-0" />
<span>
{forcedSource
? "Edited as source because this signature carries a stylesheet, a document wrapper or an event handler. It is sent exactly as written; use Preview to see it."
? `Edited as source because this ${kind} carries a stylesheet, a document wrapper or an event handler. It is sent exactly as written; use Preview to see it.`
: "Sent exactly as written. Any <style> block is copied onto the elements it matches at send time, so it survives Outlook and Yahoo."}
</span>
</p>
@@ -423,7 +429,11 @@ function UrlForm({
e.preventDefault();
onApply();
}
if (e.key === "Escape") onCancel();
if (e.key === "Escape") {
// Closes only this form, not the drawer or composer around it.
e.stopPropagation();
onCancel();
}
}}
/>
<button
+89 -29
View File
@@ -65,7 +65,19 @@ import {
PopoverMenuSeparator,
} from "@/components/ui/popover-menu";
import { cn } from "@/lib/utils";
import { plainToHtml } from "@/lib/email/body";
import {
bodyHasContent,
bodyTooLong,
capPlain,
htmlHasContent,
outgoingParts,
restoreBody,
withTemplate,
withText,
} from "@/lib/email/composerBody";
import { useComposerBody } from "@/lib/email/useComposerBody";
import { HtmlBody, HtmlModeToggle } from "./compose/HtmlBody";
import type Template from "@/lib/api/models/app/templates/Template";
import { bareEmail, nameFromAddr } from "@/lib/helper/emailAddress";
import {
loadReplyDraft,
@@ -182,7 +194,9 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
() => seed ?? (draftKey ? loadReplyDraft(draftKey) : null),
);
const [body, setBody] = React.useState(restored?.body ?? "");
const bodyState = useComposerBody(restored?.body ?? "", restored?.body_html);
const { body, setBody, html } = bodyState;
const setBodyValue = bodyState.setValue;
const [subject, setSubject] = React.useState(restored?.subject ?? initial.subject);
const [to, setTo] = React.useState<string[]>(
restored?.to ?? initial.to,
@@ -212,7 +226,17 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
const [chosenSender, chooseSender] = React.useState<string | null>(() => resolveSender(restored?.email_account_id));
const accountId = chosenSender ?? defaultAccountId;
const [isSending, setIsSending] = React.useState(false);
const draft = useReplyDraft(draftKey, { to, cc, bcc, subject, body, email_account_id: accountId }, {
// body_html only when there is HTML, so a plain or empty draft serializes as it always has.
const draftValue: ReplySeed = {
to,
cc,
bcc,
subject,
body,
...(html ? { body_html: html } : {}),
email_account_id: accountId,
};
const draft = useReplyDraft(draftKey, draftValue, {
to: initial.to, cc: [], bcc: [], subject: initial.subject, body: "", email_account_id: defaultAccountId,
});
const closeKeepingDraft = () => {
@@ -269,9 +293,9 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
setBcc(seed.bcc);
setShowCc(seed.cc.length > 0);
setShowBcc(seed.bcc.length > 0);
setBody(seed.body);
setBodyValue(restoreBody(seed.body, seed.body_html));
chooseSender(resolveSender(seed.email_account_id));
}, [seed, resumeDraft, resolveSender]);
}, [seed, resumeDraft, resolveSender, setBodyValue]);
// The full Inbox record (signature_html, signature_plain, etc) of the
// chosen sender, from the global emails store.
@@ -335,11 +359,18 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
const scheduledCap = overview.data?.scheduled_pending_max ?? 0;
const scheduleAtCap = scheduledCap > 0 && scheduledUsed >= scheduledCap;
const trimmedBody = body.trim();
const htmlTooLong = React.useMemo(() => bodyTooLong(bodyState.value), [bodyState.value]);
// A forward's note is optional: the forwarded message is the content.
const hasContent = !!trimmedBody || mode === "forward";
const hasBody = React.useMemo(() => bodyHasContent(bodyState.value), [bodyState.value]);
const hasContent = hasBody || mode === "forward";
const canSend =
hasContent && to.length > 0 && to.every(looksLikeEmail) && !!accountId && !senderProblem && !isSending;
hasContent &&
!htmlTooLong &&
to.length > 0 &&
to.every(looksLikeEmail) &&
!!accountId &&
!senderProblem &&
!isSending;
const send = async (scheduledAt?: Date) => {
if (!canSend && !isSending) {
@@ -363,9 +394,14 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
toast.error(senderProblem);
return;
}
if (htmlTooLong) {
toast.error("This email's HTML is too long to send");
return;
}
}
const submittedDraft = { to, cc, bcc, subject, body, email_account_id: accountId };
const submittedDraft = draftValue;
const parts = outgoingParts(bodyState.value);
const pauseWith = mode === "reply" ? followUpPause : null;
const pauseTargets = {
...followUps.targets,
@@ -381,8 +417,7 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
cc: cc.length ? cc : undefined,
bcc: bcc.length ? bcc : undefined,
subject: sentSubject,
body_plain: trimmedBody,
body_html: plainToHtml(trimmedBody),
...parts,
thread_id: mode === "reply" ? threadId : undefined,
forward_message_id: mode === "forward" ? replyTo.id : undefined,
...(scheduledAt
@@ -411,7 +446,8 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
cc,
bcc,
subject: sentSubject,
body: trimmedBody,
body: html === null ? parts.body_plain : body,
...(html !== null ? { bodyHtml: html } : {}),
emailAccountId: accountId,
},
});
@@ -461,22 +497,28 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
handleSchedule(new Date(customValue));
};
const applyTemplate = (name: string, plain: string, subj: string) => {
// Replace body if empty; otherwise append under a separator so
// the user keeps whatever they already typed.
if (!body.trim()) {
setBody(plain);
} else {
setBody((b) => `${b.trimEnd()}\n\n${plain}`);
const applyTemplate = (t: Template) => {
// Replace body if empty; otherwise append under a separator so the
// user keeps whatever they already typed. An HTML body switches the
// composer to HTML.
if (aiDraft.phase !== "idle" && htmlHasContent(t.body_html ?? "")) {
toast.error("Keep or discard the AI draft before adding an HTML template");
return;
}
const next = capPlain(withTemplate(bodyState.value, t));
if (bodyTooLong(next)) {
toast.error(`"${t.name}" is too long to add to this email`);
return;
}
setBodyValue(next);
// Only overwrite subject when the user hasn't customised it
// past the default "Re:" / "Fwd:" prefix.
const subj0 = subject.trim();
if (subj && (/^re:\s*$/i.test(subj0) || /^fwd:\s*$/i.test(subj0))) {
setSubject(subj);
if (t.subject && (/^re:\s*$/i.test(subj0) || /^fwd:\s*$/i.test(subj0))) {
setSubject(t.subject);
}
setTemplateOpen(false);
toast.success(`Inserted "${name}"`);
toast.success(`Inserted "${t.name}"`);
};
// Three signature states the user might be in. Surfacing all
@@ -732,6 +774,17 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
light sheen sweeps the textarea while generating and the
status/review card floats over the bottom edge. */}
<div className="relative">
{html !== null ? (
<HtmlBody
id="reply-body"
state={bodyState}
onSend={() => {
if (canSend) handleInstant();
}}
onEscape={closeKeepingDraft}
className="max-h-[28rem] px-4 py-2.5"
/>
) : (
<textarea
ref={bodyRef}
value={body}
@@ -753,6 +806,7 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
}}
className="w-full min-h-[120px] max-h-72 px-4 py-3 text-[13px] text-slate-800 placeholder:text-slate-400 bg-transparent resize-y focus:outline-none"
/>
)}
{aiDraft.phase === "busy" && (
<div className="ai-sheen pointer-events-none absolute inset-0" aria-hidden />
)}
@@ -771,6 +825,8 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
sparkle rides the current line (or ⌘J) and opens the write
menu at the cursor — ask AI to write, draft a full reply from
the thread, or continue the draft. */}
{html === null && (
<>
<TextareaAIEdit
textareaRef={bodyRef}
value={body}
@@ -786,6 +842,8 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
contextHint={`It is a ${mode === "forward" ? "forward note" : "reply"} with the subject "${subject}".`}
maxLen={MAX_BODY_LEN}
/>
</>
)}
{/* Signature preview / status. Three branches so the user
always knows what will (or will not) appear at the
@@ -974,17 +1032,17 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
<PopoverMenuContent minWidth={340} className="max-w-[92vw]">
<TemplatePickerContent
query={templatesQuery}
onPick={(t) => applyTemplate(t.name, t.body_plain, t.subject)}
onPick={applyTemplate}
onClose={() => setTemplateOpen(false)}
/>
</PopoverMenuContent>
</PopoverMenu>
<HtmlModeToggle state={bodyState} disabled={aiDraft.phase !== "idle"} />
<InsertBookingLink
email={to[0]}
onInsert={(text) =>
setBody((b) => (b.trim() ? `${b.trimEnd()}\n\n${text}` : text).slice(0, MAX_BODY_LEN))
}
onInsert={(text) => setBodyValue((b) => capPlain(withText(b, text)))}
/>
<span
@@ -1011,9 +1069,11 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
{signatureState.kind === "none" && "No signature"}
</span>
<span className="font-mono text-[10px] text-slate-400 tabular-nums">
{body.length}/{MAX_BODY_LEN}
</span>
{html === null && (
<span className="font-mono text-[10px] text-slate-400 tabular-nums">
{body.length}/{MAX_BODY_LEN}
</span>
)}
</div>
</motion.div>
);
@@ -8,6 +8,18 @@ import { Link } from "react-router-dom";
import { FileTextIcon, SearchIcon, SettingsIcon, XIcon } from "lucide-react";
import type useTemplates from "@/lib/api/hooks/app/templates/useTemplates";
import type Template from "@/lib/api/models/app/templates/Template";
import { htmlHasContent, htmlToPlain } from "@/lib/email/composerBody";
// What a row previews and search matches (the plain body, else the HTML's text), parsed once per list.
interface TemplateMeta {
text: string;
hasHtml: boolean;
}
function templateMeta(t: Template): TemplateMeta {
const hasHtml = htmlHasContent(t.body_html ?? "");
return { hasHtml, text: t.body_plain?.trim() ? t.body_plain : hasHtml ? htmlToPlain(t.body_html) : "" };
}
export default function TemplatePickerContent({
query,
@@ -20,15 +32,16 @@ export default function TemplatePickerContent({
}) {
const [search, setSearch] = React.useState("");
const all = React.useMemo(() => query.data ?? [], [query.data]);
const meta = React.useMemo(() => new Map(all.map((t) => [t.id, templateMeta(t)])), [all]);
const showSearch = all.length > 5;
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return all;
return all.filter((t) => {
const hay = `${t.name} ${t.subject} ${t.body_plain}`.toLowerCase();
const hay = `${t.name} ${t.subject} ${meta.get(t.id)?.text ?? ""}`.toLowerCase();
return hay.includes(q);
});
}, [all, search]);
}, [all, meta, search]);
return (
<div className="w-[340px] max-w-[92vw]">
@@ -105,6 +118,7 @@ export default function TemplatePickerContent({
<TemplateRow
key={t.id}
template={t}
meta={meta.get(t.id) ?? templateMeta(t)}
onPick={() => {
onPick(t);
onClose();
@@ -139,15 +153,15 @@ export default function TemplatePickerContent({
// background alone.
function TemplateRow({
template,
meta,
onPick,
}: {
template: Template;
meta: TemplateMeta;
onPick: () => void;
}) {
const bodyPreview = (template.body_plain ?? "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
const bodyPreview = meta.text.replace(/\s+/g, " ").trim().slice(0, 120);
const { hasHtml } = meta;
return (
<button
@@ -155,8 +169,18 @@ function TemplateRow({
onClick={onPick}
className="w-full text-left rounded-md px-2.5 py-1.5 flex flex-col gap-0.5 hover:bg-slate-50 active:bg-slate-100 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-200"
>
<span className="text-[12.5px] font-medium text-slate-900 truncate">
{template.name}
<span className="flex items-center gap-1.5 min-w-0">
<span className="text-[12.5px] font-medium text-slate-900 truncate">
{template.name}
</span>
{hasHtml && (
<span
className="shrink-0 h-4 px-1 rounded bg-sky-50 text-sky-700 text-[9.5px] font-medium inline-flex items-center"
title="Has an HTML body, which is inserted with its formatting"
>
HTML
</span>
)}
</span>
{bodyPreview && (
<span className="text-[11px] text-slate-400 truncate leading-snug">
@@ -217,6 +217,7 @@ export function ThreadView({ threadId, emailId, onClose }: ThreadViewProps) {
bcc: pendingRestore.bcc,
subject: pendingRestore.subject,
body: pendingRestore.body,
...(pendingRestore.bodyHtml ? { body_html: pendingRestore.bodyHtml } : {}),
email_account_id: pendingRestore.emailAccountId,
});
}, [pendingRestore, threadId, openReply]);
@@ -68,7 +68,17 @@ import {
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { cn } from "@/lib/utils";
import { plainToHtml } from "@/lib/email/body";
import {
bodyHasContent,
bodyTooLong,
capPlain,
htmlHasContent,
outgoingParts,
withTemplate,
withText,
} from "@/lib/email/composerBody";
import { useComposerBody } from "@/lib/email/useComposerBody";
import { HtmlBody, HtmlModeToggle } from "./HtmlBody";
import { bareEmail } from "@/lib/helper/emailAddress";
const MAX_BODY_LEN = 4000;
@@ -117,9 +127,10 @@ function draftSnapshot(d: {
bcc: string[];
subject: string;
body: string;
body_html?: string;
email_account_id?: string | null;
}): string {
return JSON.stringify([d.to, d.cc, d.bcc, d.subject, d.body, d.email_account_id ?? "auto"]);
return JSON.stringify([d.to, d.cc, d.bcc, d.subject, d.body, d.body_html ?? "", d.email_account_id ?? "auto"]);
}
const SCHEDULE_PRESETS: { label: string; at: () => Date }[] = [
@@ -178,7 +189,8 @@ function ComposeWindowInner({
const [showCc, setShowCc] = React.useState((seed?.cc?.length ?? 0) > 0);
const [showBcc, setShowBcc] = React.useState((seed?.bcc?.length ?? 0) > 0);
const [subject, setSubject] = React.useState(seed?.subject ?? "");
const [body, setBody] = React.useState(seed?.body ?? "");
const bodyState = useComposerBody(seed?.body ?? "", seed?.body_html);
const { body, setBody, html } = bodyState;
const [accountSel, setAccountSel] = React.useState(seed?.email_account_id || "auto");
// Tag scoping the Auto pick ("Auto in Sales"); session-only, not part
// of the draft payload. Meaningless with an explicit account.
@@ -192,11 +204,20 @@ function ComposeWindowInner({
const templatesQuery = useTemplates();
// Body empty → replace; otherwise append under a separator. The template
// subject only fills an empty subject line, never overwrites yours.
// Body empty → replace; otherwise append under a separator. An HTML body
// switches the composer to HTML. The template subject only fills an empty
// subject line, never overwrites yours.
const applyTemplate = (t: Template) => {
const plain = t.body_plain ?? "";
setBody((b) => (b.trim() ? `${b.trimEnd()}\n\n${plain}` : plain).slice(0, MAX_BODY_LEN));
if (aiDraft.phase !== "idle" && htmlHasContent(t.body_html ?? "")) {
toast.error("Keep or discard the AI draft before adding an HTML template");
return;
}
const next = capPlain(withTemplate(bodyState.value, t));
if (bodyTooLong(next)) {
toast.error(`"${t.name}" is too long to add to this email`);
return;
}
bodyState.setValue(next);
if (t.subject && !subject.trim()) setSubject(t.subject);
setTemplateOpen(false);
toast.success(`Inserted "${t.name}"`);
@@ -281,16 +302,18 @@ function ComposeWindowInner({
};
const sendMut = useComposeSend();
const trimmedBody = body.trim();
const hasBody = React.useMemo(() => bodyHasContent(bodyState.value), [bodyState.value]);
const htmlTooLong = React.useMemo(() => bodyTooLong(bodyState.value), [bodyState.value]);
const canSend =
to.length > 0 &&
to.every(looksLikeEmail) &&
!!subject.trim() &&
!!trimmedBody &&
hasBody &&
!htmlTooLong &&
!suppressed &&
!isSending;
const dirty = to.length > 0 || cc.length > 0 || bcc.length > 0 || !!subject.trim() || !!trimmedBody;
const dirty = to.length > 0 || cc.length > 0 || bcc.length > 0 || !!subject.trim() || hasBody;
// ── Autosave. The draft id is client-generated (or the resumed seed's),
// so the debounced PUT is idempotent. Everything the user types survives
@@ -301,7 +324,8 @@ function ComposeWindowInner({
const saveMut = useSaveComposeDraft();
const deleteMut = useDeleteComposeDraft();
const currentSnapshot = draftSnapshot({ to, cc, bcc, subject, body, email_account_id: accountSel });
const bodyHtml = html ?? "";
const currentSnapshot = draftSnapshot({ to, cc, bcc, subject, body, body_html: bodyHtml, email_account_id: accountSel });
const saveNow = React.useCallback(() => {
if (!dirty) return;
if (currentSnapshot === lastSavedRef.current) return;
@@ -316,9 +340,10 @@ function ComposeWindowInner({
bcc,
subject,
body,
body_html: bodyHtml,
},
});
}, [accountSel, bcc, body, cc, currentSnapshot, dirty, saveMut, subject, to]);
}, [accountSel, bcc, body, bodyHtml, cc, currentSnapshot, dirty, saveMut, subject, to]);
React.useEffect(() => {
if (!dirty || currentSnapshot === lastSavedRef.current) return;
@@ -346,9 +371,11 @@ function ComposeWindowInner({
else if (!to.every(looksLikeEmail)) toast.error("Recipient email looks invalid");
else if (suppressed) toast.error("This recipient is suppressed");
else if (!subject.trim()) toast.error("Add a subject");
else if (!trimmedBody) toast.error("Body is empty");
else if (!hasBody) toast.error("Body is empty");
else if (htmlTooLong) toast.error("This email's HTML is too long to send");
return;
}
const parts = outgoingParts(bodyState.value);
setIsSending(true);
try {
const res = await sendMut.mutateAsync({
@@ -358,8 +385,7 @@ function ComposeWindowInner({
cc: cc.length ? cc : undefined,
bcc: bcc.length ? bcc : undefined,
subject: subject.trim(),
body_plain: trimmedBody,
body_html: plainToHtml(trimmedBody),
...parts,
...(scheduledAt
? { send_mode: "scheduled" as const, scheduled_at: scheduledAt.toISOString() }
: { send_mode: "instant" as const }),
@@ -382,7 +408,8 @@ function ComposeWindowInner({
cc,
bcc,
subject: subject.trim(),
body: trimmedBody,
body: html === null ? parts.body_plain : body,
body_html: bodyHtml,
updated_at: now,
created_at: now,
},
@@ -448,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 <body> and stop their own
// Escape; reaching here means nothing else claimed it.
e.stopPropagation();
@@ -669,6 +698,14 @@ function ComposeWindowInner({
{/* Body + in-composer AI */}
<div className="relative flex-1 min-h-0 flex flex-col">
{html !== null ? (
<HtmlBody
id="compose-body"
state={bodyState}
onSend={() => void send()}
className="flex-1 px-3.5 py-2.5"
/>
) : (
<textarea
ref={bodyRef}
value={body}
@@ -682,6 +719,7 @@ function ComposeWindowInner({
}}
className="w-full flex-1 min-h-[160px] px-3.5 py-3 text-[13px] text-slate-800 placeholder:text-slate-400 bg-transparent resize-none focus:outline-none"
/>
)}
{aiDraft.phase === "busy" && (
<div className="ai-sheen pointer-events-none absolute inset-0" aria-hidden />
)}
@@ -696,6 +734,8 @@ function ComposeWindowInner({
]}
/>
</div>
{html === null && (
<>
<TextareaAIEdit
textareaRef={bodyRef}
value={body}
@@ -713,6 +753,8 @@ function ComposeWindowInner({
contextHint={`It is a new outbound email${contactDisplay ? ` to ${contactDisplay}` : ""}${subject.trim() ? ` with the subject "${subject.trim()}"` : ""}.`}
maxLen={MAX_BODY_LEN}
/>
</>
)}
{/* One-time nudge: drafts came back ungrounded in any product
context, so point at the workspace voice profile. */}
@@ -832,17 +874,17 @@ function ComposeWindowInner({
</PopoverMenuContent>
</PopoverMenu>
<HtmlModeToggle state={bodyState} disabled={aiDraft.phase !== "idle"} />
<InsertBookingLink
email={to[0]}
onInsert={(text) =>
setBody((b) => (b.trim() ? `${b.trimEnd()}\n\n${text}` : text).slice(0, MAX_BODY_LEN))
}
onInsert={(text) => bodyState.setValue((b) => capPlain(withText(b, text)))}
/>
{body && (
{(body || html !== null) && (
<button
type="button"
onClick={() => setBody("")}
onClick={() => bodyState.setValue({ plain: "", html: null, sync: false })}
className="h-7 px-2 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 text-[12px] transition-colors"
>
Discard
@@ -867,9 +909,11 @@ function ComposeWindowInner({
? "Signature on"
: "No signature"}
</span>
<span className="font-mono text-[10px] text-slate-400 tabular-nums">
{body.length}/{MAX_BODY_LEN}
</span>
{html === null && (
<span className="font-mono text-[10px] text-slate-400 tabular-nums">
{body.length}/{MAX_BODY_LEN}
</span>
)}
</div>
</>
)}
@@ -0,0 +1,102 @@
// A composer body in HTML mode, and the switch in and out of it. The editing
// surface is the mailbox signature editor: visual, source, preview, plain text.
import React from "react";
import { CodeXmlIcon } from "lucide-react";
import EmailEditor from "@/components/app/EmailEditor";
import { useConfirm } from "@/hooks/context/confirm";
import { htmlHasContent, htmlToPlain, toHtmlMode, toPlainMode } from "@/lib/email/composerBody";
import type { ComposerBodyState } from "@/lib/email/useComposerBody";
import { cn } from "@/lib/utils";
export function HtmlBody({
id,
state,
onSend,
onEscape,
className,
}: {
id: string;
state: ComposerBodyState;
onSend: () => void;
onEscape?: () => void;
className?: string;
}) {
const [code, setCode] = React.useState(false);
if (state.html === null) return null;
return (
<div
className={cn("min-h-0 overflow-y-auto", className)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
onSend();
} 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();
onEscape();
}
}}
>
<EmailEditor
id={id}
kind="email"
htmlText={state.html}
setHtmlText={state.setHtml}
plainText={state.body}
setPlainText={state.setBody}
sync={state.sync}
setSync={state.setSync}
code={code}
setCode={setCode}
toPlain={htmlToPlain}
/>
</div>
);
}
export function HtmlModeToggle({ state, disabled }: { state: ComposerBodyState; disabled?: boolean }) {
const confirm = useConfirm();
const on = state.html !== null;
const toggle = () => {
if (!on) {
state.setValue(toHtmlMode);
return;
}
if (!htmlHasContent(state.html ?? "")) {
state.setValue(toPlainMode);
return;
}
confirm.show(
"Send this email as plain text? Formatting, images and links behind words are removed, and the plain-text version becomes the body.",
() => state.setValue(toPlainMode),
);
};
return (
<button
type="button"
onClick={toggle}
disabled={disabled}
aria-pressed={on}
title={
disabled
? "Keep or discard the AI draft first"
: on
? "Switch back to plain text"
: "Write this email in HTML"
}
className={cn(
"h-7 px-2 rounded-md border text-[12px] inline-flex items-center gap-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
on
? "border-sky-200 bg-sky-50 text-sky-700 hover:border-sky-300"
: "border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900",
)}
>
<CodeXmlIcon className="w-3 h-3" />
HTML
</button>
);
}
@@ -86,11 +86,24 @@ vi.mock("@/hooks/useOutboxStore", () => ({
// is about; they are rendered as nothing so the textarea is the only writer.
vi.mock("@/components/app/ai/AIDraftBar", () => ({
default: () => null,
useAIDraft: () => ({ state: "idle", start: () => {}, keep: () => {}, discard: () => {} }),
useAIDraft: () => ({ state: "idle", phase: "idle", start: () => {}, keep: () => {}, discard: () => {} }),
}));
vi.mock("@/components/app/ai/TextareaAIEdit", () => ({ default: () => null }));
vi.mock("@/components/app/ai/TextareaAICaret", () => ({ default: () => null }));
vi.mock("./TemplatePicker", () => ({ default: () => null }));
const template = vi.hoisted(() => ({
id: "tpl1",
name: "Brochure",
subject: "",
body_plain: "Here is our brochure: https://example.com/brochure.pdf",
body_html: '<p>Here is our <a href="https://example.com/brochure.pdf">brochure</a>.</p>',
}));
vi.mock("./TemplatePicker", () => ({
default: ({ onPick }: { onPick: (t: typeof template) => void }) => (
<button type="button" onClick={() => onPick(template)}>Use Brochure</button>
),
}));
const confirmShow = vi.hoisted(() => vi.fn((_text: string, onSubmit: () => void) => onSubmit()));
vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => ({ show: confirmShow }) }));
vi.mock("./InsertBookingLink", () => ({ default: () => null }));
vi.mock("./compose/ContactRecipientField", () => ({ default: () => null }));
vi.mock("@/components/ui/DateTimePicker", () => ({ DateTimePicker: () => null }));
@@ -530,8 +543,54 @@ describe("reply composer drafts", () => {
expect(body()).toHaveValue("Keep me");
});
});
it("sends a template's HTML body as the HTML part, with its own plain text beside it", async () => {
render(<ReplyComposer threadId="t1" replyTo={message()} mode="reply" onClose={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: /Template/ }));
fireEvent.click(screen.getByRole("button", { name: "Use Brochure" }));
expect(screen.queryByPlaceholderText(/write/i)).toBeNull();
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Send" })));
expect(sendReply).toHaveBeenCalledWith(expect.objectContaining({
body_html: template.body_html,
body_plain: template.body_plain,
}));
});
it("keeps a template's HTML in the saved draft and reopens it as HTML", () => {
const view = render(<ReplyComposer threadId="t1" replyTo={message()} mode="reply" onClose={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: /Template/ }));
fireEvent.click(screen.getByRole("button", { name: "Use Brochure" }));
view.unmount();
expect(JSON.parse(localStorage.getItem(draftKey()) ?? "{}")).toMatchObject({
body: template.body_plain,
body_html: template.body_html,
});
render(<ReplyComposer threadId="t1" replyTo={message()} mode="reply" onClose={() => {}} />);
expect(screen.queryByPlaceholderText(/write/i)).toBeNull();
expect(screen.getByRole("button", { name: "HTML", pressed: true })).toBeInTheDocument();
});
it("goes back to plain text with the template's plain body after confirming", () => {
render(<ReplyComposer threadId="t1" replyTo={message()} mode="reply" onClose={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: /Template/ }));
fireEvent.click(screen.getByRole("button", { name: "Use Brochure" }));
fireEvent.click(screen.getByRole("button", { name: "HTML", pressed: true }));
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(<ReplyComposer threadId="t1" replyTo={message()} mode="reply" onClose={onClose} />);
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() {
const values = new Map<string, string>();
+2
View File
@@ -18,6 +18,8 @@ export interface OutboxReplyPayload {
bcc: string[];
subject: string;
body: string;
/** Set when the reply was sent as HTML. */
bodyHtml?: string;
/** The mailbox the reply was queued from, restored with it. */
emailAccountId: string;
}
@@ -12,6 +12,8 @@ export interface ComposeDraft {
bcc: string[];
subject: string;
body: string;
/** Empty while the draft is plain text only. */
body_html?: string;
updated_at: Date;
created_at: Date;
}
@@ -23,6 +25,7 @@ export interface ComposeDraftSaveInput {
bcc: string[];
subject: string;
body: string;
body_html?: string;
}
export async function listComposeDrafts(): Promise<ComposeDraft[]> {
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
bodyHasContent,
bodyTooLong,
MAX_PART_BYTES,
MAX_PLAIN_LEN,
outgoingParts,
restoreBody,
toHtmlMode,
toPlainMode,
withTemplate,
withText,
type ComposerBody,
} from "./composerBody";
const empty: ComposerBody = { plain: "", html: null, sync: false };
const brochureHtml = '<p>Here is our <a href="https://example.com/brochure.pdf">brochure</a>.</p>';
const brochurePlain = "Here is our brochure: https://example.com/brochure.pdf";
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("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).");
expect(outgoingParts(b)).toEqual({ body_html: brochureHtml, body_plain: "" });
});
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", () => {
expect(withTemplate(empty, { body_plain: "Thanks!", body_html: "<div></div>" }).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,<br /><br />${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}<br /><br />See you &amp; thanks`);
expect(b.plain).toBe(`${brochurePlain}\n\nSee you & thanks`);
});
});
describe("content and limits", () => {
it("does not count a stale plain part once the HTML is emptied", () => {
expect(bodyHasContent({ html: "<br>", plain: brochurePlain, sync: false })).toBe(false);
expect(bodyHasContent({ html: '<img src="https://x.com/a.png">', 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: `<p>${wide}</p>`, 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: "<p>x</p>", 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" });
expect(html).toEqual({ html: "a &lt; b<br />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", () => {
expect(toPlainMode({ html: brochureHtml, plain: brochurePlain, sync: false }).plain).toBe(brochurePlain);
});
it("appends text to an HTML body", () => {
const b = withText({ html: "<p>Hi</p>", plain: "Hi", sync: true }, "Book: https://x.com/m");
expect(b.html).toBe('<p>Hi</p><br /><br />Book: <a href="https://x.com/m">https://x.com/m</a>');
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", "<p>Hi</p>").sync).toBe(true);
expect(restoreBody(brochurePlain, brochureHtml).sync).toBe(false);
});
});
+108
View File
@@ -0,0 +1,108 @@
// 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;
/** 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;
}
/** 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 encoder = new TextEncoder();
/** 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. */
export function htmlHasContent(html: string): boolean {
return /<img\b/i.test(html) || htmlToPlain(html) !== "";
}
/** In HTML mode the HTML is what the reader sees, so it alone decides. */
export function bodyHasContent(b: ComposerBody): boolean {
return b.html !== null ? htmlHasContent(b.html) : !!b.plain.trim();
}
/** A stored body: HTML mode when it has HTML, synced when the text matches it. */
export function restoreBody(plain: string, html?: string | null): ComposerBody {
if (!html?.trim()) return { plain, html: null, sync: false };
return { plain, html, sync: !plain.trim() || plain.trim() === htmlToPlain(html) };
}
function joinPlain(a: string, b: string): string {
return a.trim() ? `${a.trimEnd()}\n\n${b}` : b;
}
function joinHtml(a: string, b: string): string {
return htmlHasContent(a) ? `${a.trimEnd()}<br /><br />${b}` : b;
}
/** 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 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) };
}
/** 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 capPlain({ html: null, plain: b.sync ? htmlToPlain(b.html) : b.plain, sync: false });
}
/** 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 ? "" : b.plain.trim(), body_html: b.html };
}
+17
View File
@@ -0,0 +1,17 @@
import React from "react";
import { restoreBody, type ComposerBody } from "./composerBody";
/** 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<ComposerBody>(() => 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) => ({ ...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 };
}
export type ComposerBodyState = ReturnType<typeof useComposerBody>;
+3
View File
@@ -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 }
: {}),