From a3c3f786e28ccaaf1ba0c3060b8119dac6a3bcf8 Mon Sep 17 00:00:00 2001 From: Brams-s <76213579+Brams-s@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:29:31 +0200 Subject: [PATCH 1/4] fix: preserve HTML signature marker position --- AGENTS.md | 5 + CHANGELOG.md | 4 + internal/smtp/sender.go | 139 +++++++++++++++++++--- internal/smtp/sender_test.go | 153 +++++++++++++++++++++++++ internal/ui/model.go | 36 +++--- internal/ui/model_test.go | 14 +++ internal/ui/workflow_hardening_test.go | 27 ++++- 7 files changed, 339 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab17e6e..30cb12c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,11 @@ that conversation; "the test was too strict" is not a decision an agent makes al all-or-nothing via `Config.Signature(account)`; text signature goes to editor + plain part, HTML signature to HTML part only; `[html-signature]` placeholder controls inclusion per-email and is extracted right before send. Test: `TestSignature`. +- **HTML signature marker position** — a line-trim-exact `[html-signature]` is removed + from plain text and replaces its first HTML occurrence in place (before reply history); + duplicate markers never duplicate the signature, and malformed marker contexts fall + back without leaking a marker or sentinel. Pinning test: + `TestBuildMessage_HTMLSignatureMarkerPosition`. - **Drafts** — saved as plain text only (multipart caused round-trip corruption), keep `Bcc`; every compose session is backed up to `~/.cache/neomd/drafts/` (`:recover`); discarding unsent mail always asks y/n confirmation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 685a156..dd2d58c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ - **Out-of-office auto-replies — screened-in senders only** — new `[ooo]` config block (`enabled`, `from`, `until`, `subject`, `body`/`body_file`; `from`/`until` take `"YYYY-MM-DD"` or `"YYYY-MM-DD HH:MM"`, interpreted in the optional `timezone` (IANA name, e.g. `"Europe/Zurich"`; default: the daemon machine's local time — servers often run UTC, so set it), so you can arm OOO in advance — e.g. `from = "2026-08-31 16:00"` the afternoon before leaving; date-only `until` is inclusive through end of day, with a time it's exact; `accounts = ["Work", "WorkInfo"]` watches those accounts' inboxes instead of the daemon's own, each replying from its own From address/signature with the Sent copy in its own Sent folder — unknown/`imap_disabled` names are hard errors, and the reply-once cache stays shared across accounts so a sender mailing several of your addresses still gets one reply) processed exclusively by the headless daemon: each sync cycle it answers new Inbox mail from **screened-in senders only** — spam, sales pitches, and unscreened senders never learn you're away, which no server-side autoresponder can do. Replies go solely to the sender (`Reply-To` pref, `From` fallback), never to Cc; exactly once per sender per OOO period (persisted `~/.cache/neomd/ooo_replied` cache, marked BEFORE the SMTP send so a crash can never duplicate; changing `from`/`until` starts a fresh period); mail that arrived before activation is never answered; RFC 3834 loop protection both ways (skips incoming `Auto-Submitted`/`Precedence: bulk|junk|list`/`List-Id`/`List-Unsubscribe` mail, stamps outgoing `Auto-Submitted: auto-replied` + `X-Auto-Response-Suppress: All`); auto-expires after the inclusive `until` day. Every reply ends with a transparency footer (`*automatically sent from [neomd](https://neomd.ssp.sh)*`, `ooo.Footer`, test `TestBody_FooterMarksAutomatedReply`) — skipped when the text or HTML signature already links neomd.ssp.sh so the line never duplicates (tests `TestBody_SkipsFooterWhen*`). The reply body is markdown built with the same `BuildMessageWithThreading` pipeline as composed mail — identical MIME shape, text/HTML signatures, and proper `In-Reply-To`/`References` threading — plus a Sent copy. New `internal/ooo/` package, daemon glue `processOOO` in `internal/daemon/daemon.go`, `config.OOOConfig` + `OOOCachePath()`. Docs: headless page → "Out-of-Office Auto-Replies". Tests: `TestActive_*`, `TestIsAutoGenerated`, `TestReplyAddress_*`, `TestBody_*`, `TestBuildReply_LooksLikeNormalNeomdMailPlusAutoHeaders`, `TestCache_*`, `TestShouldConsider` - **`ooo.toml` + `make ooo` — manage out-of-office from your laptop, no server config editing** — an `ooo.toml` next to `config.toml` (bare top-level `enabled`/`until`/`subject`/`body` keys) replaces the whole `[ooo]` block when present, and the daemon **re-reads it every sync cycle**: `make ooo` scp's your local `~/.config/neomd/ooo.toml` to the server and the daemon hot-applies it — enable, edit the message, or disable OOO without SSH-ing into the server config or restarting the daemon; invalid TOML is logged and fails safe (no replies). The reply subject is the configured `subject` verbatim (default "Out of Office" — the original subject is never appended; threading headers keep the reply in-conversation). `config.LoadOOOOverride` + `Config.OOOFile` in `internal/config/config.go`, hot reload in `processOOO`, `ooo` target in `Makefile`. Tests: `TestLoadOOOOverride_*`, `TestBuildReply_SubjectIsConfiguredValueVerbatim`, `TestBuildReply_EmptySubjectUsesDefault` +# 2026-08-27 + +- **Fix: HTML signatures now stay at their reply marker** — SMTP renders the first line-trim-exact `[html-signature]` marker as the configured account's raw HTML signature instead of always appending it after quoted history; plain text and duplicate markers are cleaned, malformed marker contexts safely fall back to the historical append position, and browser preview shares the same renderer. The reply workflow now passes the original draft to SMTP so selected-account signatures retain their authored position, while Listmonk receives its marker-free body. Tests: `TestBuildMessage_HTMLSignatureMarkerPosition`, `TestHardening_Workflow_ReplyAllUsesReceivingAccount`. + # 2026-08-26 diff --git a/internal/smtp/sender.go b/internal/smtp/sender.go index 199a994..ad6d9c7 100644 --- a/internal/smtp/sender.go +++ b/internal/smtp/sender.go @@ -85,12 +85,26 @@ func (a *xoauth2Auth) Next(_ []byte, more bool) ([]byte, error) { // This separation ensures we never mix the two formats - plain text gets readable callout formatting, // HTML gets full goldmark rendering with styled callout boxes. func prepareEmailBodies(markdownBody string) (plainText, htmlBody string, err error) { + return prepareEmailBodiesWithHTMLSignature(markdownBody, "") +} + +const ( + htmlSignatureMarker = "[html-signature]" + htmlSignatureSentinelBase = "NEOMD_HTML_SIGNATURE_SENTINEL_7e3a4c0d_54b8_4cf0_a826_2b79f1d63e91" +) + +// prepareEmailBodiesWithHTMLSignature renders the two multipart alternatives +// from a single Markdown source. Signature marker lines are never sent in the +// plain part; the HTML renderer replaces the first marker with the configured +// raw HTML signature at that exact document position. +func prepareEmailBodiesWithHTMLSignature(markdownBody, htmlSignature string) (plainText, htmlBody string, err error) { // Plain text part: Format callouts as emoji text without blockquotes (> [!note] → 📘 Note) // Blockquote markers are removed because terminal renderers strip them during display anyway. - plainText = render.FormatCalloutsForPlainText(markdownBody) + plainText = render.FormatCalloutsForPlainText(removeHTMLSignatureMarkers(markdownBody)) - // HTML part: Full goldmark rendering with styled callout boxes - htmlBody, err = render.ToHTML(markdownBody) + // HTML part: Full goldmark rendering with styled callout boxes and the + // marker-aware signature placement used by browser preview as well. + htmlBody, err = RenderHTMLWithSignature(markdownBody, htmlSignature) if err != nil { return "", "", fmt.Errorf("markdown to html: %w", err) } @@ -98,6 +112,111 @@ func prepareEmailBodies(markdownBody string) (plainText, htmlBody string, err er return plainText, htmlBody, nil } +// RenderHTMLWithSignature renders Markdown for an email HTML part and places a +// configured raw HTML signature at the first line-trim-exact [html-signature] +// marker. Without a marker it preserves the historical append-before- +// behavior. This is exported so browser preview and SMTP delivery share the +// same marker handling. +func RenderHTMLWithSignature(markdownBody, htmlSignature string) (string, error) { + markerFreeSource, hasMarker := htmlSignatureMarkerSource(markdownBody, "", false) + markerFreeHTML, err := render.ToHTML(markerFreeSource) + if err != nil { + return "", err + } + if htmlSignature == "" { + return markerFreeHTML, nil + } + if !hasMarker { + return appendHTMLSignature(markerFreeHTML, htmlSignature), nil + } + + // Marker-free rendering is part of collision avoidance: Goldmark can turn + // escaped source text into a token that never appeared literally in Markdown. + nextSuffix := 0 + for { + sentinel, next := nextHTMLSignatureSentinel(markdownBody, markerFreeHTML, nextSuffix) + nextSuffix = next + htmlSource, _ := htmlSignatureMarkerSource(markdownBody, sentinel, true) + htmlBody, err := render.ToHTML(htmlSource) + if err != nil { + return "", err + } + + switch strings.Count(htmlBody, sentinel) { + case 0: + // The renderer omitted the inserted token. The marker is still gone, + // so preserve the rendered body and use the compatibility fallback. + return appendHTMLSignature(htmlBody, htmlSignature), nil + case 1: + standaloneSentinel := "

" + sentinel + "

" + if idx := strings.Index(htmlBody, standaloneSentinel); idx >= 0 { + return htmlBody[:idx] + htmlSignature + htmlBody[idx+len(standaloneSentinel):], nil + } + // A sole non-standalone token comes from the inserted marker (the + // candidate is absent from source and marker-free HTML), such as in + // a fenced code block. Scrub it and retain append fallback behavior. + return appendHTMLSignature(strings.ReplaceAll(htmlBody, sentinel, ""), htmlSignature), nil + default: + // Rendering synthesized an additional occurrence. Advance to the + // next deterministic candidate and render again rather than risking + // replacement of user-authored output. + } + } +} + +// htmlSignatureMarkerSource removes every marker line. When insertSentinel is +// true, it replaces only the first marker with a collision-resistant token on +// blank-line boundaries so Goldmark renders it as a standalone paragraph. +func htmlSignatureMarkerSource(markdownBody, sentinel string, insertSentinel bool) (string, bool) { + var source []string + foundMarker := false + insertedSentinel := false + for _, line := range strings.Split(markdownBody, "\n") { + if strings.TrimSpace(line) != htmlSignatureMarker { + source = append(source, line) + continue + } + foundMarker = true + if !insertSentinel || insertedSentinel { + continue + } + if len(source) == 0 || strings.TrimSpace(source[len(source)-1]) != "" { + source = append(source, "") + } + source = append(source, sentinel, "") + insertedSentinel = true + } + return strings.Join(source, "\n"), foundMarker +} + +func removeHTMLSignatureMarkers(markdownBody string) string { + source, _ := htmlSignatureMarkerSource(markdownBody, "", false) + return source +} + +// nextHTMLSignatureSentinel returns a deterministic candidate absent from both +// original Markdown and marker-free rendered HTML. The next suffix lets callers +// make finite forward progress when tokenized rendering creates a collision. +func nextHTMLSignatureSentinel(markdownBody, markerFreeHTML string, suffix int) (string, int) { + for { + sentinel := htmlSignatureSentinelBase + if suffix > 0 { + sentinel = fmt.Sprintf("%s_%d", htmlSignatureSentinelBase, suffix) + } + suffix++ + if !strings.Contains(markdownBody, sentinel) && !strings.Contains(markerFreeHTML, sentinel) { + return sentinel, suffix + } + } +} + +func appendHTMLSignature(htmlBody, htmlSignature string) string { + if idx := strings.LastIndex(htmlBody, ""); idx >= 0 { + return htmlBody[:idx] + "\n" + htmlSignature + "\n" + htmlBody[idx:] + } + return htmlBody +} + // Send composes and sends an email. // markdownBody is converted to both plain text and HTML (multipart/alternative). // cc and bcc may be empty. BCC recipients receive the email but are not visible @@ -278,7 +397,8 @@ func sendSTARTTLSWithConfig(addr, host string, tlsCfg *tls.Config, auth smtp.Aut // BCC must not be passed — it must never appear in message headers. // When attachments is non-empty the message is wrapped in multipart/mixed; // otherwise the structure is unchanged (multipart/alternative only). -// htmlSignature, if non-empty, is injected before the closing tag in the HTML part. +// htmlSignature is placed at the first [html-signature] marker when present, +// otherwise it is appended before the closing tag for compatibility. func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature string) ([]byte, error) { return BuildMessageWithThreading(from, to, cc, subject, markdownBody, attachments, htmlSignature, "", "") } @@ -287,19 +407,10 @@ func BuildMessage(from, to, cc, subject, markdownBody string, attachments []stri // Used for replies and forwards to maintain proper email conversation threading. func BuildMessageWithThreading(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature, inReplyTo, references string) ([]byte, error) { // Convert markdown to both formats (plain text with formatted callouts, HTML with styled boxes) - plainText, htmlBody, err := prepareEmailBodies(markdownBody) + plainText, htmlBody, err := prepareEmailBodiesWithHTMLSignature(markdownBody, htmlSignature) if err != nil { return nil, err } - // Inject HTML signature before tag if provided - if htmlSignature != "" { - // Replace the last occurrence of with signature + - // This ensures the signature is inside the HTML document structure - idx := strings.LastIndex(htmlBody, "") - if idx >= 0 { - htmlBody = htmlBody[:idx] + "\n" + htmlSignature + "\n" + htmlBody[idx:] - } - } return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments, inReplyTo, buildRefChain(references, inReplyTo)) } diff --git a/internal/smtp/sender_test.go b/internal/smtp/sender_test.go index 98c7f63..778fcc8 100644 --- a/internal/smtp/sender_test.go +++ b/internal/smtp/sender_test.go @@ -1109,3 +1109,156 @@ func extractPlainTextPart(t *testing.T, raw []byte) string { } return "" } + +func TestBuildMessage_HTMLSignatureMarkerPosition(t *testing.T) { + const signature = `
Work Signature
` + + buildBodies := func(t *testing.T, markdown, htmlSignature string) (string, string) { + t.Helper() + raw, err := BuildMessage("Alice ", "Bob ", "", "Signature position", markdown, nil, htmlSignature) + if err != nil { + t.Fatalf("BuildMessage: %v", err) + } + msg, _, params := parseMIME(t, raw) + parts := multipart.NewReader(msg.Body, params["boundary"]) + decode := func(part *multipart.Part) string { + t.Helper() + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("decode quoted-printable part: %v", err) + } + return string(body) + } + plainPart, err := parts.NextPart() + if err != nil { + t.Fatalf("read plain part: %v", err) + } + plain := decode(plainPart) + htmlPart, err := parts.NextPart() + if err != nil { + t.Fatalf("read HTML part: %v", err) + } + return plain, decode(htmlPart) + } + assertReplyOrder := func(t *testing.T, html, reply string) { + t.Helper() + replyAt := strings.Index(html, reply) + separatorAt := strings.Index(html, "

--

") + sigAt := strings.Index(html, "signature-position-test") + hrAt := strings.Index(html, "") + if replyAt < 0 || separatorAt < 0 || sigAt < 0 || hrAt < 0 || quoteAt < 0 { + t.Fatalf("missing reply, separator, signature, rule, or quote:\n%s", html) + } + if !(replyAt < separatorAt && separatorAt < sigAt && sigAt < hrAt && hrAt < quoteAt) { + t.Errorf("reply ordering must be reply < separator < signature < hr < quote: reply=%d separator=%d signature=%d hr=%d quote=%d\n%s", replyAt, separatorAt, sigAt, hrAt, quoteAt, html) + } + } + + t.Run("reply marker stays before history", func(t *testing.T) { + markdown := "Thanks for the update.\n\n-- \n \t[html-signature] \n\n---\n\n> Original message" + plain, html := buildBodies(t, markdown, signature) + plain = strings.ReplaceAll(plain, "\r\n", "\n") + + for _, want := range []string{"Thanks for the update.", "\n-- \n", "> Original message"} { + if !strings.Contains(plain, want) { + t.Errorf("plain part lost %q:\n%s", want, plain) + } + } + for _, forbidden := range []string{"[html-signature]", "NEOMD_HTML_SIGNATURE_SENTINEL"} { + if strings.Contains(plain, forbidden) || strings.Contains(html, forbidden) { + t.Errorf("internal value %q leaked:\nplain=%s\nhtml=%s", forbidden, plain, html) + } + } + assertReplyOrder(t, html, "Thanks for the update.") + }) + + t.Run("CRLF reply marker stays before history", func(t *testing.T) { + markdown := "Thanks from CRLF.\r\n\r\n-- \r\n[html-signature]\r\n\r\n---\r\n\r\n> Original message" + _, html := buildBodies(t, markdown, signature) + assertReplyOrder(t, html, "Thanks from CRLF.") + }) + + t.Run("no marker preserves append fallback", func(t *testing.T) { + _, html := buildBodies(t, "Body without a marker", signature) + bodyAt := strings.Index(html, "Body without a marker") + sigAt := strings.Index(html, "signature-position-test") + bodyClose := strings.Index(html, "") + if bodyAt < 0 || sigAt < 0 || bodyClose < 0 || !(bodyAt < sigAt && sigAt < bodyClose) { + t.Errorf("fallback ordering must be body < signature < : body=%d signature=%d close=%d\n%s", bodyAt, sigAt, bodyClose, html) + } + }) + + t.Run("marker with empty signature is removed", func(t *testing.T) { + plain, html := buildBodies(t, "Body\n\n[html-signature]\n\nAfter", "") + for _, got := range []string{plain, html} { + if strings.Contains(got, "[html-signature]") || strings.Contains(got, "NEOMD_HTML_SIGNATURE_SENTINEL") { + t.Errorf("marker leaked with empty HTML signature:\n%s", got) + } + } + if !strings.Contains(html, "Body") || !strings.Contains(html, "After") { + t.Errorf("body content lost with empty HTML signature:\n%s", html) + } + }) + + t.Run("marker in a code fence scrubs sentinel and falls back", func(t *testing.T) { + _, html := buildBodies(t, "Before\n\n```\n[html-signature]\n```\n\nAfter", signature) + if strings.Contains(html, "[html-signature]") || strings.Contains(html, "NEOMD_HTML_SIGNATURE_SENTINEL") { + t.Errorf("marker or sentinel leaked from code fence:\n%s", html) + } + sigAt := strings.Index(html, "signature-position-test") + bodyClose := strings.Index(html, "") + if sigAt < 0 || bodyClose < 0 || sigAt >= bodyClose { + t.Errorf("code-fence fallback must append signature before :\n%s", html) + } + }) + + t.Run("base sentinel collision is preserved", func(t *testing.T) { + markdown := "User-authored " + htmlSignatureSentinelBase + " text.\n\n[html-signature]\n\nAfter" + plain, html := buildBodies(t, markdown, signature) + generated := htmlSignatureSentinelBase + "_1" + if !strings.Contains(plain, htmlSignatureSentinelBase) || !strings.Contains(html, htmlSignatureSentinelBase) { + t.Errorf("user-authored base sentinel was removed:\nplain=%s\nhtml=%s", plain, html) + } + if strings.Contains(html, generated) || strings.Contains(html, "[html-signature]") { + t.Errorf("generated sentinel or marker leaked:\n%s", html) + } + if got := strings.Count(html, "signature-position-test"); got != 1 { + t.Errorf("signature count = %d, want 1:\n%s", got, html) + } + }) + + t.Run("rendered escaped sentinel collision is preserved", func(t *testing.T) { + escapedBase := strings.ReplaceAll(htmlSignatureSentinelBase, "_", "\\_") + markdown := escapedBase + "\n\nReply before marker.\n\n[html-signature]\n\nAfter" + plain, html := buildBodies(t, markdown, signature) + userAt := strings.Index(html, htmlSignatureSentinelBase) + replyAt := strings.Index(html, "Reply before marker.") + sigAt := strings.Index(html, "signature-position-test") + if !strings.Contains(plain, escapedBase) || userAt < 0 { + t.Errorf("escaped user text was not preserved:\nplain=%s\nhtml=%s", plain, html) + } + if got := strings.Count(html, "signature-position-test"); got != 1 || replyAt < 0 || sigAt < 0 || !(userAt < replyAt && replyAt < sigAt) { + t.Errorf("signature must replace the marker after escaped user text: user=%d reply=%d signature=%d count=%d\n%s", userAt, replyAt, sigAt, got, html) + } + if strings.Contains(html, htmlSignatureSentinelBase+"_1") || strings.Contains(html, "[html-signature]") { + t.Errorf("generated sentinel or marker leaked:\n%s", html) + } + }) + + t.Run("duplicate markers insert once at the first", func(t *testing.T) { + markdown := "First paragraph\n\n[html-signature]\n\nMiddle paragraph\n\n[html-signature]\n\n---\n\n> Original message" + _, html := buildBodies(t, markdown, signature) + if got := strings.Count(html, "signature-position-test"); got != 1 { + t.Errorf("signature count = %d, want 1:\n%s", got, html) + } + sigAt := strings.Index(html, "signature-position-test") + middleAt := strings.Index(html, "Middle paragraph") + if sigAt < 0 || middleAt < 0 || sigAt > middleAt { + t.Errorf("signature must replace the first marker: signature=%d middle=%d\n%s", sigAt, middleAt, html) + } + if strings.Contains(html, "[html-signature]") || strings.Contains(html, "NEOMD_HTML_SIGNATURE_SENTINEL") { + t.Errorf("marker or sentinel leaked:\n%s", html) + } + }) +} diff --git a/internal/ui/model.go b/internal/ui/model.go index f8a8652..6ce9f08 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -4907,12 +4907,12 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { from := m.presendFrom() smtpAcct := m.presendSMTPAccount() attachments := m.attachments - includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body) + includeHTMLSig, _ := extractHTMLSignatureMarker(ps.body) m.attachments = nil m.pendingSend = nil m.pendingIsReply = false m.harvestTypedRecipients(ps.to, ps.cc, ps.bcc) - return m, tea.Batch(m.spinner.Tick, m.scheduleSendCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, cleanBody, attachments, includeHTMLSig, ps.inReplyTo, ps.references, at)) + return m, tea.Batch(m.spinner.Tick, m.scheduleSendCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, ps.body, attachments, includeHTMLSig, ps.inReplyTo, ps.references, at)) } var cmd tea.Cmd m.sendLaterInput, cmd = m.sendLaterInput.Update(msg) @@ -4926,7 +4926,9 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { smtpAcct := m.presendSMTPAccount() attachments := m.attachments replyUID, replyFolder := ps.replyToUID, ps.replyToFolder - // Extract [html-signature] marker from body now (right before sending) + // Keep [html-signature] in the SMTP source so the shared renderer can + // place the active account's HTML signature at the marker. Listmonk + // still receives the clean marker-free body below. includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body) m.attachments = nil m.pendingSend = nil @@ -4942,7 +4944,7 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.spinner.Tick, m.sendListmonkCmd(ps.subject, cleanBody, listIDs, templateID)) } } - return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, cleanBody, attachments, includeHTMLSig, replyUID, replyFolder, ps.replyToAccount, ps.inReplyTo, ps.references)) + return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, ps.body, attachments, includeHTMLSig, replyUID, replyFolder, ps.replyToAccount, ps.inReplyTo, ps.references)) case "ctrl+f": froms := m.presendFroms() if len(froms) <= 1 { @@ -5191,29 +5193,21 @@ func (m Model) previewInBrowser() (tea.Model, tea.Cmd) { return m, nil } - // Extract [html-signature] marker the same way as the send path - // so the preview matches what recipients will actually receive - includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body) - - htmlBody, err := render.ToHTML(cleanBody) + // Keep extractHTMLSignatureMarker as the inclusion control, while using the + // shared SMTP renderer with the original source so preview and delivery + // place the HTML signature at the same marker. + includeHTMLSig, _ := extractHTMLSignatureMarker(ps.body) + htmlSig := "" + if includeHTMLSig { + htmlSig = m.cfg.Signature(m.presendSMTPAccount()).HTML + } + htmlBody, err := smtp.RenderHTMLWithSignature(ps.body, htmlSig) if err != nil { m.status = "preview: " + err.Error() m.isError = true return m, nil } - // Inject HTML signature before tag if enabled (matching send path) - if includeHTMLSig { - acct := m.presendSMTPAccount() - htmlSig := m.cfg.Signature(acct).HTML - if htmlSig != "" { - idx := strings.LastIndex(htmlBody, "") - if idx >= 0 { - htmlBody = htmlBody[:idx] + "\n" + htmlSig + "\n" + htmlBody[idx:] - } - } - } - // Convert absolute image paths to file:// URLs so the browser can display them. // goldmark renders ![](/abs/path) as which browsers // treat as server-relative; file:///abs/path loads from disk. diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index eac2e78..0940823 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -36,6 +36,20 @@ func TestMaskEmail(t *testing.T) { } } +func TestExtractHTMLSignatureMarkerRemovesEveryTrimmedMarker(t *testing.T) { + body := "Before\n [html-signature]\t\nBetween\n[html-signature]\nAfter" + include, clean := extractHTMLSignatureMarker(body) + if !include { + t.Fatal("marker was not detected") + } + if strings.Contains(clean, "[html-signature]") { + t.Fatalf("marker remained in Listmonk-clean body: %q", clean) + } + if clean != "Before\nBetween\nAfter" { + t.Errorf("clean body = %q, want marker-free source", clean) + } +} + // isURLSchemeAllowed replicates the inline URL scheme check from model.go Update(). func isURLSchemeAllowed(url string) bool { lower := strings.ToLower(url) diff --git a/internal/ui/workflow_hardening_test.go b/internal/ui/workflow_hardening_test.go index e141377..401c66d 100644 --- a/internal/ui/workflow_hardening_test.go +++ b/internal/ui/workflow_hardening_test.go @@ -363,9 +363,11 @@ func TestHardening_Workflow_ReplyAllUsesReceivingAccount(t *testing.T) { cfg := &config.Config{ Accounts: []config.AccountConfig{ {Name: "Personal", User: "personal-login@provider.example", Password: "pw1", - From: "Simon Späti ", SMTP: personalAddr}, + From: "Simon Späti ", SMTP: personalAddr, + Signature: config.SignatureConfig{HTML: `
Personal signature
`}}, {Name: "Work", User: "work-login@provider.example", Password: "pw2", - From: "Work Persona ", SMTP: workAddr}, + From: "Work Persona ", SMTP: workAddr, + Signature: config.SignatureConfig{HTML: `
Work signature
`}}, }, Senders: []config.SenderConfig{ {Name: "Support", From: "Support ", Account: "Work"}, @@ -403,8 +405,12 @@ func TestHardening_Workflow_ReplyAllUsesReceivingAccount(t *testing.T) { if err != nil { t.Fatal(err) } - replyText := "Grüezi Louise,\n\nGerne — der Termin **passt**.\n" - edited := string(content) + "\n" + replyText + replyText := "Grüezi Louise,\n\nGerne — der Termin **passt**.\n\n-- \n[html-signature]\n\n" + headerEnd := strings.Index(string(content), "\n\n") + if headerEnd < 0 { + t.Fatal("reply compose file has no header/body separator") + } + edited := string(content[:headerEnd+2]) + replyText + string(content[headerEnd+2:]) if err := os.WriteFile(composePath, []byte(edited), 0o600); err != nil { t.Fatal(err) } @@ -490,6 +496,19 @@ func TestHardening_Workflow_ReplyAllUsesReceivingAccount(t *testing.T) { if !strings.Contains(w.html, "passt") { t.Error("HTML part lost markdown rendering of the reply text") } + replyAt := strings.Index(w.html, "passt") + separatorAt := strings.Index(w.html, "

--

") + workSignatureAt := strings.Index(w.html, "work-signature") + quoteAt := strings.Index(w.html, "
") + if strings.Count(w.html, "work-signature") != 1 || replyAt < 0 || separatorAt < 0 || workSignatureAt < 0 || quoteAt < 0 || !(replyAt < separatorAt && separatorAt < workSignatureAt && workSignatureAt < quoteAt) { + t.Errorf("Work HTML signature ordering must be reply < separator < signature < quote: reply=%d separator=%d signature=%d quote=%d\n%s", replyAt, separatorAt, workSignatureAt, quoteAt, w.html) + } + if strings.Contains(w.html, "personal-signature") { + t.Error("Personal account HTML signature was used instead of the receiving Work account") + } + if strings.Contains(w.plain, "[html-signature]") || strings.Contains(w.html, "[html-signature]") { + t.Error("HTML signature marker leaked to the recipient") + } if !strings.Contains(w.plain, "> Grüezi Simon,") { t.Error("quoted original message missing from the reply") } From 942cd5f1329a2906093b8d65f48d8182c125a4d8 Mon Sep 17 00:00:00 2001 From: Brams-s <76213579+Brams-s@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:11:25 +0200 Subject: [PATCH 2/4] fix: harden HTML signature marker rendering --- AGENTS.md | 13 ++-- CHANGELOG.md | 2 +- internal/smtp/sender.go | 66 ++++++++++---------- internal/smtp/sender_test.go | 113 +++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30cb12c..f0f1c22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,11 +167,14 @@ that conversation; "the test was too strict" is not a decision an agent makes al all-or-nothing via `Config.Signature(account)`; text signature goes to editor + plain part, HTML signature to HTML part only; `[html-signature]` placeholder controls inclusion per-email and is extracted right before send. Test: `TestSignature`. -- **HTML signature marker position** — a line-trim-exact `[html-signature]` is removed - from plain text and replaces its first HTML occurrence in place (before reply history); - duplicate markers never duplicate the signature, and malformed marker contexts fall - back without leaking a marker or sentinel. Pinning test: - `TestBuildMessage_HTMLSignatureMarkerPosition`. +- **HTML signature marker position** — a line-trim-exact `[html-signature]` is replaced + with a blank line in SMTP rendering, so it cannot fuse Markdown blocks; its first HTML + occurrence is replaced in place (before reply history). Duplicate markers never duplicate + the signature, and malformed or collision-exhausted contexts use a bounded marker-free + append fallback without leaking a marker or sentinel. Pinning tests: + `TestBuildMessage_HTMLSignatureMarkerPosition`, + `TestPrepareEmailBodiesWithHTMLSignature_PreservesMarkerBoundaries`, + `TestRenderHTMLWithSignature_BoundedSentinelCollisionFallback`. - **Drafts** — saved as plain text only (multipart caused round-trip corruption), keep `Bcc`; every compose session is backed up to `~/.cache/neomd/drafts/` (`:recover`); discarding unsent mail always asks y/n confirmation. diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2d58c..77a9cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ # 2026-08-27 -- **Fix: HTML signatures now stay at their reply marker** — SMTP renders the first line-trim-exact `[html-signature]` marker as the configured account's raw HTML signature instead of always appending it after quoted history; plain text and duplicate markers are cleaned, malformed marker contexts safely fall back to the historical append position, and browser preview shares the same renderer. The reply workflow now passes the original draft to SMTP so selected-account signatures retain their authored position, while Listmonk receives its marker-free body. Tests: `TestBuildMessage_HTMLSignatureMarkerPosition`, `TestHardening_Workflow_ReplyAllUsesReceivingAccount`. +- **Fix: HTML signatures now stay at their reply marker** — SMTP renders the first line-trim-exact `[html-signature]` marker as the configured account's raw HTML signature instead of always appending it after quoted history; SMTP marker removal preserves a blank Markdown boundary, duplicate markers are cleaned, and malformed or collision-exhausted contexts use a bounded marker-free append fallback. Browser preview shares the same renderer; Listmonk keeps its marker-free body. Tests: `TestBuildMessage_HTMLSignatureMarkerPosition`, `TestPrepareEmailBodiesWithHTMLSignature_PreservesMarkerBoundaries`, `TestRenderHTMLWithSignature_BoundedSentinelCollisionFallback`, `TestHardening_Workflow_ReplyAllUsesReceivingAccount`. # 2026-08-26 diff --git a/internal/smtp/sender.go b/internal/smtp/sender.go index ad6d9c7..e5f9ff3 100644 --- a/internal/smtp/sender.go +++ b/internal/smtp/sender.go @@ -91,6 +91,7 @@ func prepareEmailBodies(markdownBody string) (plainText, htmlBody string, err er const ( htmlSignatureMarker = "[html-signature]" htmlSignatureSentinelBase = "NEOMD_HTML_SIGNATURE_SENTINEL_7e3a4c0d_54b8_4cf0_a826_2b79f1d63e91" + htmlSignatureMaxAttempts = 8 ) // prepareEmailBodiesWithHTMLSignature renders the two multipart alternatives @@ -132,41 +133,42 @@ func RenderHTMLWithSignature(markdownBody, htmlSignature string) (string, error) // Marker-free rendering is part of collision avoidance: Goldmark can turn // escaped source text into a token that never appeared literally in Markdown. - nextSuffix := 0 - for { - sentinel, next := nextHTMLSignatureSentinel(markdownBody, markerFreeHTML, nextSuffix) - nextSuffix = next + // Keep the candidate set bounded so adversarial body content cannot make the + // send or preview path retry indefinitely. + for attempt := 0; attempt < htmlSignatureMaxAttempts; attempt++ { + sentinel := htmlSignatureSentinel(attempt) + if strings.Contains(markdownBody, sentinel) || strings.Contains(markerFreeHTML, sentinel) { + continue + } htmlSource, _ := htmlSignatureMarkerSource(markdownBody, sentinel, true) htmlBody, err := render.ToHTML(htmlSource) if err != nil { return "", err } - switch strings.Count(htmlBody, sentinel) { - case 0: - // The renderer omitted the inserted token. The marker is still gone, - // so preserve the rendered body and use the compatibility fallback. - return appendHTMLSignature(htmlBody, htmlSignature), nil - case 1: - standaloneSentinel := "

" + sentinel + "

" + standaloneSentinel := "

" + sentinel + "

" + if strings.Count(htmlBody, sentinel) == 1 { if idx := strings.Index(htmlBody, standaloneSentinel); idx >= 0 { return htmlBody[:idx] + htmlSignature + htmlBody[idx+len(standaloneSentinel):], nil } - // A sole non-standalone token comes from the inserted marker (the - // candidate is absent from source and marker-free HTML), such as in - // a fenced code block. Scrub it and retain append fallback behavior. - return appendHTMLSignature(strings.ReplaceAll(htmlBody, sentinel, ""), htmlSignature), nil - default: - // Rendering synthesized an additional occurrence. Advance to the - // next deterministic candidate and render again rather than risking - // replacement of user-authored output. } + // A missing, non-standalone, or duplicated token is unsafe (for + // example, a marker in a fenced code block). Return the already-rendered + // marker-free body so fallback cannot leak a sentinel or alter Markdown + // block boundaries. + return appendHTMLSignature(markerFreeHTML, htmlSignature), nil } + + // Every deterministic candidate collided with user-authored source or + // rendered output. Preserve the marker-free rendering and compatibility + // append behavior rather than searching without bound. + return appendHTMLSignature(markerFreeHTML, htmlSignature), nil } -// htmlSignatureMarkerSource removes every marker line. When insertSentinel is -// true, it replaces only the first marker with a collision-resistant token on -// blank-line boundaries so Goldmark renders it as a standalone paragraph. +// htmlSignatureMarkerSource replaces every marker line with a blank line so +// removing a marker cannot fuse adjacent Markdown blocks. When insertSentinel +// is true, it replaces only the first marker with a collision-resistant token +// on blank-line boundaries so Goldmark renders it as a standalone paragraph. func htmlSignatureMarkerSource(markdownBody, sentinel string, insertSentinel bool) (string, bool) { var source []string foundMarker := false @@ -178,6 +180,7 @@ func htmlSignatureMarkerSource(markdownBody, sentinel string, insertSentinel boo } foundMarker = true if !insertSentinel || insertedSentinel { + source = append(source, "") continue } if len(source) == 0 || strings.TrimSpace(source[len(source)-1]) != "" { @@ -194,20 +197,13 @@ func removeHTMLSignatureMarkers(markdownBody string) string { return source } -// nextHTMLSignatureSentinel returns a deterministic candidate absent from both -// original Markdown and marker-free rendered HTML. The next suffix lets callers -// make finite forward progress when tokenized rendering creates a collision. -func nextHTMLSignatureSentinel(markdownBody, markerFreeHTML string, suffix int) (string, int) { - for { - sentinel := htmlSignatureSentinelBase - if suffix > 0 { - sentinel = fmt.Sprintf("%s_%d", htmlSignatureSentinelBase, suffix) - } - suffix++ - if !strings.Contains(markdownBody, sentinel) && !strings.Contains(markerFreeHTML, sentinel) { - return sentinel, suffix - } +// htmlSignatureSentinel returns one of the bounded deterministic candidates +// used only while rendering a marker position. +func htmlSignatureSentinel(attempt int) string { + if attempt == 0 { + return htmlSignatureSentinelBase } + return fmt.Sprintf("%s_%d", htmlSignatureSentinelBase, attempt) } func appendHTMLSignature(htmlBody, htmlSignature string) string { diff --git a/internal/smtp/sender_test.go b/internal/smtp/sender_test.go index 778fcc8..88fd2b3 100644 --- a/internal/smtp/sender_test.go +++ b/internal/smtp/sender_test.go @@ -1262,3 +1262,116 @@ func TestBuildMessage_HTMLSignatureMarkerPosition(t *testing.T) { } }) } + +func TestPrepareEmailBodiesWithHTMLSignature_PreservesMarkerBoundaries(t *testing.T) { + tests := []struct { + name string + markdown string + markerFreeSource string + }{ + { + name: "setext and horizontal rule stay separate", + markdown: "Intro\n[html-signature]\n---\nAfter", + markerFreeSource: "Intro\n\n---\nAfter", + }, + { + name: "blockquote starts a new block", + markdown: "Intro\n[html-signature]\n> quoted text", + markerFreeSource: "Intro\n\n> quoted text", + }, + { + name: "list starts a new block", + markdown: "Intro\n[html-signature]\n- first item\n- second item", + markerFreeSource: "Intro\n\n- first item\n- second item", + }, + { + name: "callout starts a new block", + markdown: "Intro\n[html-signature]\n> [!note]\n> Callout text", + markerFreeSource: "Intro\n\n> [!note]\n> Callout text", + }, + { + name: "duplicate markers preserve every boundary", + markdown: "Intro\n[html-signature]\n[html-signature]\n---\nAfter", + markerFreeSource: "Intro\n\n\n---\nAfter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source, found := htmlSignatureMarkerSource(tt.markdown, "", false) + if !found { + t.Fatal("marker was not found") + } + if source != tt.markerFreeSource { + t.Fatalf("marker-free source = %q, want %q", source, tt.markerFreeSource) + } + + wantHTML, err := render.ToHTML(tt.markerFreeSource) + if err != nil { + t.Fatalf("render expected HTML: %v", err) + } + wantPlain := render.FormatCalloutsForPlainText(tt.markerFreeSource) + gotPlain, gotHTML, err := prepareEmailBodiesWithHTMLSignature(tt.markdown, "") + if err != nil { + t.Fatalf("prepare email bodies: %v", err) + } + if gotHTML != wantHTML { + t.Errorf("HTML changed Markdown semantics:\ngot:\n%s\nwant:\n%s", gotHTML, wantHTML) + } + if gotPlain != wantPlain { + t.Errorf("plain text changed Markdown semantics:\ngot:\n%s\nwant:\n%s", gotPlain, wantPlain) + } + }) + } +} + +func TestHTMLSignatureMarkerSource_DuplicateSentinelPreservesBoundaries(t *testing.T) { + const sentinel = "signature-sentinel" + got, found := htmlSignatureMarkerSource("Intro\n[html-signature]\n[html-signature]\n---\nAfter", sentinel, true) + if !found { + t.Fatal("marker was not found") + } + want := "Intro\n\n" + sentinel + "\n\n\n---\nAfter" + if got != want { + t.Errorf("sentinel source = %q, want %q", got, want) + } +} + +func TestRenderHTMLWithSignature_BoundedSentinelCollisionFallback(t *testing.T) { + const signature = `
Work Signature
` + candidates := make([]string, 0, htmlSignatureMaxAttempts) + for attempt := 0; attempt < htmlSignatureMaxAttempts; attempt++ { + candidates = append(candidates, htmlSignatureSentinel(attempt)) + } + markdown := strings.Join(candidates, "\n\n") + "\n\n[html-signature]\n\nAfter" + markerFreeSource, found := htmlSignatureMarkerSource(markdown, "", false) + if !found { + t.Fatal("marker was not found") + } + markerFreeHTML, err := render.ToHTML(markerFreeSource) + if err != nil { + t.Fatalf("render marker-free source: %v", err) + } + closeAt := strings.LastIndex(markerFreeHTML, "") + if closeAt < 0 { + t.Fatal("marker-free HTML has no closing body tag") + } + want := markerFreeHTML[:closeAt] + "\n" + signature + "\n" + markerFreeHTML[closeAt:] + + got, err := RenderHTMLWithSignature(markdown, signature) + if err != nil { + t.Fatalf("render HTML with signature: %v", err) + } + if got != want { + t.Errorf("exhausted candidates must use exact marker-free append fallback:\ngot:\n%s\nwant:\n%s", got, want) + } + if strings.Count(got, "signature-position-test") != 1 { + t.Errorf("signature count = %d, want 1:\n%s", strings.Count(got, "signature-position-test"), got) + } + if strings.Contains(got, htmlSignatureSentinel(htmlSignatureMaxAttempts)) { + t.Errorf("fallback leaked an out-of-bounds generated sentinel:\n%s", got) + } + if strings.Contains(got, htmlSignatureMarker) { + t.Errorf("fallback leaked marker:\n%s", got) + } +} From 084859ebc78b3aaa4754619ab206d57f79bfb1bc Mon Sep 17 00:00:00 2001 From: Brams-s <76213579+Brams-s@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:29:57 +0200 Subject: [PATCH 3/4] fix: preserve Listmonk marker boundaries --- AGENTS.md | 6 ++-- CHANGELOG.md | 2 +- internal/ui/model.go | 10 +++++- internal/ui/model_test.go | 75 +++++++++++++++++++++++++++++++++------ 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f0f1c22..473b240 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,10 +171,12 @@ that conversation; "the test was too strict" is not a decision an agent makes al with a blank line in SMTP rendering, so it cannot fuse Markdown blocks; its first HTML occurrence is replaced in place (before reply history). Duplicate markers never duplicate the signature, and malformed or collision-exhausted contexts use a bounded marker-free - append fallback without leaking a marker or sentinel. Pinning tests: + append fallback without leaking a marker or sentinel. Listmonk cleanup is marker-free + while preserving blank Markdown boundaries. Pinning tests: `TestBuildMessage_HTMLSignatureMarkerPosition`, `TestPrepareEmailBodiesWithHTMLSignature_PreservesMarkerBoundaries`, - `TestRenderHTMLWithSignature_BoundedSentinelCollisionFallback`. + `TestRenderHTMLWithSignature_BoundedSentinelCollisionFallback`, + `TestExtractHTMLSignatureMarkerPreservesBlankMarkdownBoundaries`. - **Drafts** — saved as plain text only (multipart caused round-trip corruption), keep `Bcc`; every compose session is backed up to `~/.cache/neomd/drafts/` (`:recover`); discarding unsent mail always asks y/n confirmation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a9cf5..e13aa6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ # 2026-08-27 -- **Fix: HTML signatures now stay at their reply marker** — SMTP renders the first line-trim-exact `[html-signature]` marker as the configured account's raw HTML signature instead of always appending it after quoted history; SMTP marker removal preserves a blank Markdown boundary, duplicate markers are cleaned, and malformed or collision-exhausted contexts use a bounded marker-free append fallback. Browser preview shares the same renderer; Listmonk keeps its marker-free body. Tests: `TestBuildMessage_HTMLSignatureMarkerPosition`, `TestPrepareEmailBodiesWithHTMLSignature_PreservesMarkerBoundaries`, `TestRenderHTMLWithSignature_BoundedSentinelCollisionFallback`, `TestHardening_Workflow_ReplyAllUsesReceivingAccount`. +- **Fix: HTML signatures now stay at their reply marker** — SMTP renders the first line-trim-exact `[html-signature]` marker as the configured account's raw HTML signature instead of always appending it after quoted history; SMTP marker removal preserves a blank Markdown boundary, duplicate markers are cleaned, and malformed or collision-exhausted contexts use a bounded marker-free append fallback. Browser preview shares the same renderer; Listmonk cleanup is marker-free while preserving blank Markdown boundaries. Tests: `TestBuildMessage_HTMLSignatureMarkerPosition`, `TestPrepareEmailBodiesWithHTMLSignature_PreservesMarkerBoundaries`, `TestRenderHTMLWithSignature_BoundedSentinelCollisionFallback`, `TestExtractHTMLSignatureMarkerPreservesBlankMarkdownBoundaries`, `TestHardening_Workflow_ReplyAllUsesReceivingAccount`. # 2026-08-26 diff --git a/internal/ui/model.go b/internal/ui/model.go index 6ce9f08..8cdbbaf 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -5859,7 +5859,8 @@ func extractInlineAttachments(body string) (files []string, clean string) { } // extractHTMLSignatureMarker scans body for [html-signature] marker. -// If found, removes it and returns (true, cleanBody). +// If found, replaces each marker line with a blank line and returns +// (true, cleanBody), preserving Markdown block boundaries for Listmonk. // If not found, returns (false, body unchanged). func extractHTMLSignatureMarker(body string) (includeHTMLSig bool, clean string) { const marker = "[html-signature]" @@ -5868,6 +5869,13 @@ func extractHTMLSignatureMarker(body string) (includeHTMLSig bool, clean string) trimmed := strings.TrimSpace(line) if trimmed == marker { includeHTMLSig = true + // Keep the CR payload from a CRLF marker line. strings.Join below + // supplies the LF, yielding a real CRLF blank line. + if strings.HasSuffix(line, "\r") { + kept = append(kept, "\r") + } else { + kept = append(kept, "") + } continue } kept = append(kept, line) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 0940823..0ac3e42 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -36,17 +36,72 @@ func TestMaskEmail(t *testing.T) { } } -func TestExtractHTMLSignatureMarkerRemovesEveryTrimmedMarker(t *testing.T) { - body := "Before\n [html-signature]\t\nBetween\n[html-signature]\nAfter" - include, clean := extractHTMLSignatureMarker(body) - if !include { - t.Fatal("marker was not detected") - } - if strings.Contains(clean, "[html-signature]") { - t.Fatalf("marker remained in Listmonk-clean body: %q", clean) +func TestExtractHTMLSignatureMarkerPreservesBlankMarkdownBoundaries(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + { + name: "horizontal rule stays separate", + body: "Before\n[html-signature]\n---\nAfter", + want: "Before\n\n---\nAfter", + }, + { + name: "blockquote starts a new block", + body: "Before\n[html-signature]\n> quoted text", + want: "Before\n\n> quoted text", + }, + { + name: "list starts a new block", + body: "Before\n[html-signature]\n- first item\n- second item", + want: "Before\n\n- first item\n- second item", + }, + { + name: "callout starts a new block", + body: "Before\n[html-signature]\n> [!note]\n> Callout text", + want: "Before\n\n> [!note]\n> Callout text", + }, + { + name: "duplicate markers preserve every boundary", + body: "Before\n[html-signature]\n[html-signature]\nAfter", + want: "Before\n\n\nAfter", + }, + { + name: "leading marker preserves blank first line", + body: "[html-signature]\nAfter", + want: "\nAfter", + }, + { + name: "trailing marker preserves blank final line", + body: "Before\n[html-signature]", + want: "Before\n", + }, + { + name: "whitespace-trimmed marker", + body: "Before\n \t[html-signature] \t\nAfter", + want: "Before\n\nAfter", + }, + { + name: "CRLF marker preserves CRLF blank line", + body: "Before\r\n \t[html-signature] \r\nAfter", + want: "Before\r\n\r\nAfter", + }, } - if clean != "Before\nBetween\nAfter" { - t.Errorf("clean body = %q, want marker-free source", clean) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + include, clean := extractHTMLSignatureMarker(tt.body) + if !include { + t.Fatal("marker was not detected") + } + if clean != tt.want { + t.Errorf("clean body = %q, want marker lines replaced by blanks %q", clean, tt.want) + } + if strings.Contains(clean, "[html-signature]") { + t.Errorf("marker remained in Listmonk-clean body: %q", clean) + } + }) } } From 71888bf69f3b5a80ce812283f303cc9dcb6cdc27 Mon Sep 17 00:00:00 2001 From: Brams-s <76213579+Brams-s@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:32:43 +0200 Subject: [PATCH 4/4] chore: format rebased upstream code --- cmd/neomd/read_test.go | 8 ++++---- cmd/neomd/screen_test.go | 14 +++++++------- internal/config/config.go | 14 +++++++------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cmd/neomd/read_test.go b/cmd/neomd/read_test.go index 45a2638..2bfe2de 100644 --- a/cmd/neomd/read_test.go +++ b/cmd/neomd/read_test.go @@ -36,10 +36,10 @@ func TestParseReadArgs(t *testing.T) { t.Errorf("opts = %+v", opts) } for _, bad := range [][]string{ - {"--uid", "42"}, // missing folder - {"--folder", "Feed"}, // missing uid - {"--folder", "Feed", "--uid", "x"}, // bad uid - {"--folder", "Feed", "--uid", "0"}, // zero uid + {"--uid", "42"}, // missing folder + {"--folder", "Feed"}, // missing uid + {"--folder", "Feed", "--uid", "x"}, // bad uid + {"--folder", "Feed", "--uid", "0"}, // zero uid } { if _, err := parseReadArgs(bad); err == nil { t.Errorf("parseReadArgs(%v): expected error, got nil", bad) diff --git a/cmd/neomd/screen_test.go b/cmd/neomd/screen_test.go index cbe165f..eeacd4c 100644 --- a/cmd/neomd/screen_test.go +++ b/cmd/neomd/screen_test.go @@ -75,10 +75,10 @@ func TestParseScreenArgs(t *testing.T) { t.Errorf("opts = %+v", opts) } for _, bad := range [][]string{ - {"--action", "in"}, // missing from - {"--from", "a@b.c"}, // missing action - {"--from", "a@b.c", "--action", "yolo"}, // bad action - {"--from", "a@b.c", "--action", "in", "--x"}, // unknown flag + {"--action", "in"}, // missing from + {"--from", "a@b.c"}, // missing action + {"--from", "a@b.c", "--action", "yolo"}, // bad action + {"--from", "a@b.c", "--action", "in", "--x"}, // unknown flag } { if _, err := parseScreenArgs(bad); err == nil { t.Errorf("parseScreenArgs(%v): expected error, got nil", bad) @@ -128,9 +128,9 @@ func TestRunScreen_ApproveMovesAllFromSender(t *testing.T) { func TestRunScreen_ActionDestinations(t *testing.T) { cases := []struct { - action string - screen string - dst string + action string + screen string + dst string }{ {"in", "approve", "INBOX"}, {"out", "block", "ScreenedOutFolder"}, diff --git a/internal/config/config.go b/internal/config/config.go index 7674a5c..52940c9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -388,13 +388,13 @@ type Config struct { // Only the headless daemon acts on it; the TUI ignores this block. type OOOConfig struct { Enabled bool `toml:"enabled"` - Accounts []string `toml:"accounts"` // [[accounts]] names whose inboxes get auto-replies, each from its own address (e.g. ["Work", "WorkInfo"]); empty = the daemon's own account - Timezone string `toml:"timezone"` // IANA name (e.g. "Europe/Zurich") that from/until are interpreted in; empty = the daemon machine's local time - From string `toml:"from"` // "YYYY-MM-DD" — active starting at 00:00 of this day (local time); empty = active immediately - Until string `toml:"until"` // "YYYY-MM-DD" — active through the END of this day (local time); empty = active until enabled=false - Subject string `toml:"subject"` // reply subject; default "Out of Office" - Body string `toml:"body"` // reply body in markdown (same rendering as composed emails) - BodyFile string `toml:"body_file"` // optional path to a markdown file; overrides body when set + Accounts []string `toml:"accounts"` // [[accounts]] names whose inboxes get auto-replies, each from its own address (e.g. ["Work", "WorkInfo"]); empty = the daemon's own account + Timezone string `toml:"timezone"` // IANA name (e.g. "Europe/Zurich") that from/until are interpreted in; empty = the daemon machine's local time + From string `toml:"from"` // "YYYY-MM-DD" — active starting at 00:00 of this day (local time); empty = active immediately + Until string `toml:"until"` // "YYYY-MM-DD" — active through the END of this day (local time); empty = active until enabled=false + Subject string `toml:"subject"` // reply subject; default "Out of Office" + Body string `toml:"body"` // reply body in markdown (same rendering as composed emails) + BodyFile string `toml:"body_file"` // optional path to a markdown file; overrides body when set } // ListmonkTrigger maps a virtual email address to Listmonk list IDs.