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 63a6b98d2..9ddd6ab8f 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -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. 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..705d4bda5 100644 --- a/web/src/components/app/EmailEditor.tsx +++ b/web/src/components/app/EmailEditor.tsx @@ -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(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)); }} /> Sync HTML & plain @@ -366,7 +372,7 @@ export default function EmailEditor({ {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