From 957a233a14595cc514499f96ddf6901f83fba4c3 Mon Sep 17 00:00:00 2001 From: marwan051 Date: Wed, 25 Mar 2026 18:16:52 +0200 Subject: [PATCH 1/5] feat: add support for plaintext-only email sending --- main.go | 2 +- sender/sender.go | 336 +++++++++++++++++++++++++++++-------------- tui/composer.go | 48 +++++-- tui/composer_test.go | 19 ++- tui/messages.go | 1 + 5 files changed, 285 insertions(+), 121 deletions(-) diff --git a/main.go b/main.go index 95820ed..076bc60 100644 --- a/main.go +++ b/main.go @@ -1538,7 +1538,7 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { attachments[filename] = fileData } - err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME) + err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.PlaintextOnly, msg.SignSMIME, msg.EncryptSMIME) if err != nil { log.Printf("Failed to send email: %v", err) return tui.EmailResultMsg{Err: err} diff --git a/sender/sender.go b/sender/sender.go index 20c5a74..8bb3d1e 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -60,7 +60,7 @@ func generateMessageID(from string) string { } // SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments. -func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME bool, encryptSMIME bool) error { +func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, plaintextOnly bool, signSMIME bool, encryptSMIME bool) error { smtpServer := account.GetSMTPServer() smtpPort := account.GetSMTPPort() @@ -76,12 +76,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.FetchEmail) } - // Main message buffer - var innerMsg bytes.Buffer - innerWriter := multipart.NewWriter(&innerMsg) - innerHeaders := fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary()) - - // Set top-level headers for a mixed message type to support content and attachments + // Set top-level headers (From/To/Subject/Date/etc) headers := map[string]string{ "From": fromHeader, "To": strings.Join(to, ", "), @@ -104,115 +99,245 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody } } - // --- Body Part (multipart/related) --- - // This part contains the multipart/alternative (text/html) and any inline images. - relatedHeader := textproto.MIMEHeader{} - relatedBoundary := "related-" + innerWriter.Boundary() - relatedHeader.Set("Content-Type", "multipart/related; boundary=\""+relatedBoundary+"\"") - relatedPartWriter, err := innerWriter.CreatePart(relatedHeader) - if err != nil { - return err - } - relatedWriter := multipart.NewWriter(relatedPartWriter) - relatedWriter.SetBoundary(relatedBoundary) - - // --- Alternative Part (text and html) --- - altHeader := textproto.MIMEHeader{} - altBoundary := "alt-" + innerWriter.Boundary() - altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+altBoundary+"\"") - altPartWriter, err := relatedWriter.CreatePart(altHeader) - if err != nil { - return err + // prepare final message buffer and S/MIME payload placeholder + var msg bytes.Buffer + headerOrder := []string{"From", "To", "Cc", "Subject", "Date", "Message-ID", "MIME-Version", "In-Reply-To", "References"} + for _, k := range headerOrder { + if v, ok := headers[k]; ok { + fmt.Fprintf(&msg, "%s: %s\r\n", k, v) + } } - altWriter := multipart.NewWriter(altPartWriter) - altWriter.SetBoundary(altBoundary) - // Plain text part - textHeader := textproto.MIMEHeader{ - "Content-Type": {"text/plain; charset=UTF-8"}, - "Content-Transfer-Encoding": {"quoted-printable"}, - } - textPart, err := altWriter.CreatePart(textHeader) - if err != nil { - return err - } - qpText := quotedprintable.NewWriter(textPart) - fmt.Fprint(qpText, plainBody) - qpText.Close() - - // HTML part - htmlHeader := textproto.MIMEHeader{ - "Content-Type": {"text/html; charset=UTF-8"}, - "Content-Transfer-Encoding": {"quoted-printable"}, - } - htmlPart, err := altWriter.CreatePart(htmlHeader) - if err != nil { - return err - } - qpHTML := quotedprintable.NewWriter(htmlPart) - fmt.Fprint(qpHTML, htmlBody) - qpHTML.Close() + var payloadToEncrypt []byte + var innerBodyBytes []byte + var err error + + // If plaintext-only mode is requested, build a single text/plain part (or a multipart/signed wrapper when signing) + if plaintextOnly { + if len(images) > 0 || len(attachments) > 0 { + return errors.New("plaintext-only messages cannot contain attachments or inline images") + } + + // Build quoted-printable encoded body + var encBody bytes.Buffer + qp := quotedprintable.NewWriter(&encBody) + fmt.Fprint(qp, plainBody) + qp.Close() + encodedBody := encBody.Bytes() + + // Build the canonical MIME part (headers + body) used for signing/encryption + var partBuf bytes.Buffer + fmt.Fprintf(&partBuf, "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n") + fmt.Fprintf(&partBuf, "Content-Transfer-Encoding: quoted-printable\r\n\r\n") + partBuf.Write(encodedBody) + canonicalPart := partBuf.Bytes() + + if signSMIME { + if account.SMIMECert == "" || account.SMIMEKey == "" { + return errors.New("S/MIME certificate or key path is missing") + } + + certData, err := os.ReadFile(account.SMIMECert) + if err != nil { + return err + } + keyData, err := os.ReadFile(account.SMIMEKey) + if err != nil { + return err + } + + certBlock, _ := pem.Decode(certData) + if certBlock == nil { + return errors.New("failed to parse certificate PEM") + } + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return err + } + + keyBlock, _ := pem.Decode(keyData) + if keyBlock == nil { + return errors.New("failed to parse private key PEM") + } + privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes) + if err != nil { + privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + if err != nil { + return err + } + } - altWriter.Close() // Finish the alternative part + // canonicalize the part (normalize newlines) + canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n")) + canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n")) - // --- Inline Images --- - for cid, data := range images { - ext := filepath.Ext(strings.Split(cid, "@")[0]) - mimeType := mime.TypeByExtension(ext) - if mimeType == "" { - mimeType = "application/octet-stream" + signedData, err := pkcs7.NewSignedData(canonicalBody) + if err != nil { + return err + } + if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil { + return err + } + detachedSig, err := signedData.Finish() + if err != nil { + return err + } + + var rb [12]byte + var outerBoundary string + if _, rerr := rand.Read(rb[:]); rerr == nil { + outerBoundary = "signed-" + fmt.Sprintf("%x", rb[:]) + } else { + // fallback to time-based boundary if crypto/rand fails + outerBoundary = "signed-" + fmt.Sprintf("%d", time.Now().UnixNano()) + } + var signedMsg bytes.Buffer + fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary) + fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n") + fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary) + signedMsg.Write(canonicalBody) + fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary) + fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n") + fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n") + fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n") + signedMsg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(detachedSig))) + fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary) + + if encryptSMIME { + payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n")) + payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n")) + } else { + msg.Write(signedMsg.Bytes()) + } + } else { + // Not signing: either encrypt the canonical part or send as plain single-part + canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n")) + canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n")) + if encryptSMIME { + payloadToEncrypt = canonicalBody + } else { + // Write Content-Type and body as top-level single part + fmt.Fprintf(&msg, "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n") + fmt.Fprintf(&msg, "Content-Transfer-Encoding: quoted-printable\r\n\r\n") + msg.Write(encodedBody) + } } - imgHeader := textproto.MIMEHeader{} - imgHeader.Set("Content-Type", mimeType) - imgHeader.Set("Content-Transfer-Encoding", "base64") - imgHeader.Set("Content-ID", "<"+cid+">") - imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"") + } else { + // --- Non-plaintext path: build multipart/mixed with related/alternative, images and attachments --- + var innerMsg bytes.Buffer + innerWriter := multipart.NewWriter(&innerMsg) + innerHeaders := fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary()) + + // --- Body Part (multipart/related) --- + relatedHeader := textproto.MIMEHeader{} + relatedBoundary := "related-" + innerWriter.Boundary() + relatedHeader.Set("Content-Type", "multipart/related; boundary=\""+relatedBoundary+"\"") + relatedPartWriter, err := innerWriter.CreatePart(relatedHeader) + if err != nil { + return err + } + relatedWriter := multipart.NewWriter(relatedPartWriter) + relatedWriter.SetBoundary(relatedBoundary) + + // --- Alternative Part (text and html) --- + altHeader := textproto.MIMEHeader{} + altBoundary := "alt-" + innerWriter.Boundary() + altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+altBoundary+"\"") + altPartWriter, err := relatedWriter.CreatePart(altHeader) + if err != nil { + return err + } + altWriter := multipart.NewWriter(altPartWriter) + altWriter.SetBoundary(altBoundary) - imgPart, err := relatedWriter.CreatePart(imgHeader) + // Plain text part + textHeader := textproto.MIMEHeader{ + "Content-Type": {"text/plain; charset=UTF-8"}, + "Content-Transfer-Encoding": {"quoted-printable"}, + } + textPart, err := altWriter.CreatePart(textHeader) if err != nil { return err } - // data is already base64 encoded, but needs MIME line wrapping (76 chars per line) - imgPart.Write([]byte(clib.WrapBase64(string(data)))) - } + qpText := quotedprintable.NewWriter(textPart) + fmt.Fprint(qpText, plainBody) + qpText.Close() + + // HTML part + htmlHeader := textproto.MIMEHeader{ + "Content-Type": {"text/html; charset=UTF-8"}, + "Content-Transfer-Encoding": {"quoted-printable"}, + } + htmlPart, err := altWriter.CreatePart(htmlHeader) + if err != nil { + return err + } + qpHTML := quotedprintable.NewWriter(htmlPart) + fmt.Fprint(qpHTML, htmlBody) + qpHTML.Close() + + altWriter.Close() // Finish the alternative part + + // --- Inline Images --- + for cid, data := range images { + ext := filepath.Ext(strings.Split(cid, "@")[0]) + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + mimeType = "application/octet-stream" + } - relatedWriter.Close() // Finish the related part + imgHeader := textproto.MIMEHeader{} + imgHeader.Set("Content-Type", mimeType) + imgHeader.Set("Content-Transfer-Encoding", "base64") + imgHeader.Set("Content-ID", "<"+cid+">") + imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"") - // --- Attachments --- - for filename, data := range attachments { - mimeType := mime.TypeByExtension(filepath.Ext(filename)) - if mimeType == "" { - mimeType = "application/octet-stream" + imgPart, err := relatedWriter.CreatePart(imgHeader) + if err != nil { + return err + } + // Encode raw image bytes to base64, then wrap at 76 chars per MIME rules + encodedImg := base64.StdEncoding.EncodeToString(data) + imgPart.Write([]byte(clib.WrapBase64(encodedImg))) } - partHeader := textproto.MIMEHeader{} - partHeader.Set("Content-Type", mimeType) - partHeader.Set("Content-Transfer-Encoding", "base64") - partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + relatedWriter.Close() // Finish the related part - attachmentPart, err := innerWriter.CreatePart(partHeader) - if err != nil { - return err - } - encodedData := base64.StdEncoding.EncodeToString(data) - // MIME requires base64 to be line-wrapped at 76 characters - attachmentPart.Write([]byte(clib.WrapBase64(encodedData))) - } + // --- Attachments --- + for filename, data := range attachments { + mimeType := mime.TypeByExtension(filepath.Ext(filename)) + if mimeType == "" { + mimeType = "application/octet-stream" + } - innerWriter.Close() // Finish the inner message + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Type", mimeType) + partHeader.Set("Content-Transfer-Encoding", "base64") + partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - var msg bytes.Buffer - for k, v := range headers { - fmt.Fprintf(&msg, "%s: %s\r\n", k, v) - } + attachmentPart, err := innerWriter.CreatePart(partHeader) + if err != nil { + return err + } + encodedData := base64.StdEncoding.EncodeToString(data) + // MIME requires base64 to be line-wrapped at 76 characters + attachmentPart.Write([]byte(clib.WrapBase64(encodedData))) + } - innerBodyBytes := append([]byte(innerHeaders), innerMsg.Bytes()...) + innerWriter.Close() // Finish the inner message - var payloadToEncrypt []byte + innerBodyBytes = append([]byte(innerHeaders), innerMsg.Bytes()...) + + // If not signing, and not encrypting, write the multipart body now + if !signSMIME && !encryptSMIME { + fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary()) + msg.Write(innerMsg.Bytes()) + } + } - // Handle S/MIME Detached Signing - if signSMIME { + // Handle S/MIME Detached Signing for non-plaintext messages + if signSMIME && len(innerBodyBytes) > 0 { if account.SMIMECert == "" || account.SMIMEKey == "" { return errors.New("S/MIME certificate or key path is missing") } @@ -262,7 +387,14 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody return err } - outerBoundary := "signed-" + innerWriter.Boundary() + var rb [12]byte + var outerBoundary string + if _, rerr := rand.Read(rb[:]); rerr == nil { + outerBoundary = "signed-" + fmt.Sprintf("%x", rb[:]) + } else { + // fallback to time-based boundary if crypto/rand fails + outerBoundary = "signed-" + fmt.Sprintf("%d", time.Now().UnixNano()) + } var signedMsg bytes.Buffer fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary) fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n") @@ -281,16 +413,6 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody } else { msg.Write(signedMsg.Bytes()) } - } else { - canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n")) - canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n")) - - if encryptSMIME { - payloadToEncrypt = canonicalBody - } else { - fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary()) - msg.Write(innerMsg.Bytes()) - } } // Handle S/MIME Encryption diff --git a/tui/composer.go b/tui/composer.go index 15d13e2..6cfd1f0 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "path/filepath" + "slices" "strings" "charm.land/bubbles/v2/textarea" @@ -43,6 +44,7 @@ const ( focusSignature focusAttachment focusEncryptSMIME + focusPlaintextOnly focusSend ) @@ -57,6 +59,7 @@ type Composer struct { signatureInput textarea.Model attachmentPaths []string encryptSMIME bool + plaintextOnly bool width int height int confirmingExit bool @@ -144,6 +147,8 @@ func NewComposer(from, to, subject, body string, hideTips bool) *Composer { m.focusIndex = focusTo m.toInput.Focus() + m.plaintextOnly = false + return m } @@ -209,10 +214,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Fixed rows: title, from, to, cc, bcc, subject, sig label, // attachment, smime, button, blank, tip, help = 13 const fixedRows = 13 - available := msg.Height - fixedRows - if available < 6 { - available = 6 - } + available := max(msg.Height-fixedRows, 6) bodyHeight := (available * 55) / 100 sigHeight := (available * 15) / 100 if bodyHeight < 3 { @@ -227,12 +229,15 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case FileSelectedMsg: // Avoid duplicates - for _, p := range m.attachmentPaths { - if p == msg.Path { - return m, nil - } + if slices.Contains(m.attachmentPaths, msg.Path) { + return m, nil } m.attachmentPaths = append(m.attachmentPaths, msg.Path) + // If attachments were added, disable plaintext-only mode (plain text cannot carry attachments) + if m.plaintextOnly { + m.plaintextOnly = false + m.pluginStatus = "Plaintext-only disabled because attachment was added" + } return m, nil case tea.KeyPressMsg: @@ -383,6 +388,9 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "backspace", "delete", "d": if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 { m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1] + if len(m.attachmentPaths) == 0 && strings.Contains(m.pluginStatus, "attachment") { + m.pluginStatus = "" + } return m, nil } @@ -402,6 +410,17 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.encryptSMIME = !m.encryptSMIME } return m, nil + case focusPlaintextOnly: + if msg.String() == "enter" || msg.String() == " " { + if len(m.attachmentPaths) > 0 { + // Do not allow enabling plaintext-only when there are attachments + m.pluginStatus = "Cannot enable plaintext-only while attachments are present" + } else { + m.plaintextOnly = !m.plaintextOnly + m.pluginStatus = "" + } + } + return m, nil case focusSend: if msg.String() == "enter" { acc := m.getSelectedAccount() @@ -424,6 +443,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Signature: m.signatureInput.Value(), SignSMIME: acc != nil && acc.SMIMESignByDefault, EncryptSMIME: m.encryptSMIME, + PlaintextOnly: m.plaintextOnly, } } } @@ -529,6 +549,15 @@ func (m *Composer) View() tea.View { encField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (S/MIME): %s", encToggle)) } + ptToggle := "[ ]" + if m.plaintextOnly { + ptToggle = "[x]" + } + ptField := blurredStyle.Render(fmt.Sprintf(" Send Plain Text Only: %s", ptToggle)) + if m.focusIndex == focusPlaintextOnly { + ptField = focusedStyle.Render(fmt.Sprintf("> Send Plain Text Only: %s", ptToggle)) + } + // Build To field with suggestions toFieldView := m.toInput.View() if m.showSuggestions && len(m.suggestions) > 0 { @@ -575,6 +604,8 @@ func (m *Composer) View() tea.View { tip = "Enter: add file • backspace/d: remove last attachment" case focusEncryptSMIME: tip = "Press Space or Enter to toggle S/MIME encryption on or off." + case focusPlaintextOnly: + tip = "Send message as plain text only (no HTML or attachments)." case focusSend: tip = "Press Enter to send the email." } @@ -591,6 +622,7 @@ func (m *Composer) View() tea.View { m.signatureInput.View(), attachmentStyle.Render(attachmentField), smimeToggleStyle.Render(encField), + smimeToggleStyle.Render(ptField), button, "", } diff --git a/tui/composer_test.go b/tui/composer_test.go index ba011d8..76f3dc6 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -71,11 +71,18 @@ func TestComposerUpdate(t *testing.T) { t.Errorf("After seven Tabs, focusIndex should be %d (focusEncryptSMIME), got %d", focusEncryptSMIME, composer.focusIndex) } + // Simulate pressing Tab again to move to the 'PlaintextOnly' toggle. + model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + composer = model.(*Composer) + if composer.focusIndex != focusPlaintextOnly { + t.Errorf("After eight Tabs, focusIndex should be %d (focusPlaintextOnly), got %d", focusPlaintextOnly, composer.focusIndex) + } + // Simulate pressing Tab again to move to the 'Send' button. model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) composer = model.(*Composer) if composer.focusIndex != focusSend { - t.Errorf("After eight Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex) + t.Errorf("After nine Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex) } // Simulate one more Tab to wrap around. @@ -83,7 +90,7 @@ func TestComposerUpdate(t *testing.T) { model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) composer = model.(*Composer) if composer.focusIndex != focusTo { - t.Errorf("After nine Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex) + t.Errorf("After ten Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex) } }) @@ -196,7 +203,7 @@ func TestComposerUpdate(t *testing.T) { t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex) } - // Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> Send -> From (wrap) + // Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> PlaintextOnly -> Send -> From (wrap) -> To (wrap) model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // To -> Cc multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Cc -> Bcc @@ -211,7 +218,9 @@ func TestComposerUpdate(t *testing.T) { multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> EncryptSMIME multiComposer = model.(*Composer) - model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> Send + model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> PlaintextOnly + multiComposer = model.(*Composer) + model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // PlaintextOnly -> Send multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap) multiComposer = model.(*Composer) @@ -220,7 +229,7 @@ func TestComposerUpdate(t *testing.T) { // With multiple accounts, From field should be included in tab order if multiComposer.focusIndex != focusTo { - t.Errorf("After nine Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex) + t.Errorf("After eleven Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex) } }) } diff --git a/tui/messages.go b/tui/messages.go index e3d7bc3..d078ecf 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -35,6 +35,7 @@ type SendEmailMsg struct { Signature string // Signature to append to email body SignSMIME bool // Whether to sign the email using S/MIME EncryptSMIME bool // Whether to encrypt the email using S/MIME + PlaintextOnly bool // Send as plain text only (no multipart) } type Credentials struct { From 954ef434418ab606d090af75078d0332aafceff3 Mon Sep 17 00:00:00 2001 From: marwan051 Date: Wed, 25 Mar 2026 21:31:39 +0200 Subject: [PATCH 2/5] feat: implement detection for plaintext-only emails and remove related UI elements --- main.go | 31 ++++++++++++++++++++++++++++--- tui/composer.go | 38 ++++++-------------------------------- tui/composer_test.go | 17 ++++------------- 3 files changed, 38 insertions(+), 48 deletions(-) diff --git a/main.go b/main.go index 076bc60..9017adb 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,6 @@ import ( "archive/tar" "archive/zip" "compress/gzip" - "encoding/base64" "encoding/json" "fmt" "io" @@ -1476,6 +1475,29 @@ func markdownToHTML(md []byte) []byte { return clib.MarkdownToHTML(md) } +// detectPlaintextOnly returns true when the body contains only plain text +// (no images, no attachments, no links, and no common markdown/HTML formatting). +func detectPlaintextOnly(body string, images map[string][]byte, attachments map[string][]byte) bool { + if len(images) > 0 || len(attachments) > 0 { + return false + } + + // Patterns indicating non-plaintext content: markdown links/images, raw URLs, HTML tags, + // headers, lists, blockquotes, inline code, bold/italic markers. + linkRe := regexp.MustCompile(`\[[^\]]+\]\([^\)]+\)`) + urlRe := regexp.MustCompile(`https?://\S+|www\.\S+`) + htmlRe := regexp.MustCompile(`<[^>]+>`) // crude HTML tag detection + mdHeaderRe := regexp.MustCompile(`(?m)^\s*#{1,6}\s+`) // markdown headers + mdListRe := regexp.MustCompile(`(?m)^\s*[-*+]\s+`) // markdown lists + mdQuoteRe := regexp.MustCompile(`(?m)^\s*>\s+`) // blockquote + mdFmtRe := regexp.MustCompile(`\*\*.+\*\*|__.+__|\*[^\*]+\*|_[^_]+_|` + "`" + `[^` + "`" + `]+` + "`") + + if linkRe.MatchString(body) || urlRe.MatchString(body) || htmlRe.MatchString(body) || mdHeaderRe.MatchString(body) || mdListRe.MatchString(body) || mdQuoteRe.MatchString(body) || mdFmtRe.MatchString(body) { + return false + } + return true +} + func splitEmails(s string) []string { if s == "" { return nil @@ -1522,7 +1544,8 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { continue } cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha") - images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData)) + // store raw image bytes; sender will base64-encode and wrap + images[cid] = imgData body = strings.Replace(body, imgPath, "cid:"+cid, 1) } @@ -1538,7 +1561,9 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { attachments[filename] = fileData } - err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.PlaintextOnly, msg.SignSMIME, msg.EncryptSMIME) + // Auto-detect whether message is plaintext-only: no attachments, no images, and no links/formatting + plaintextOnly := detectPlaintextOnly(body, images, attachments) + err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, plaintextOnly, msg.SignSMIME, msg.EncryptSMIME) if err != nil { log.Printf("Failed to send email: %v", err) return tui.EmailResultMsg{Err: err} diff --git a/tui/composer.go b/tui/composer.go index 6cfd1f0..1b27b0e 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -44,7 +44,6 @@ const ( focusSignature focusAttachment focusEncryptSMIME - focusPlaintextOnly focusSend ) @@ -59,7 +58,6 @@ type Composer struct { signatureInput textarea.Model attachmentPaths []string encryptSMIME bool - plaintextOnly bool width int height int confirmingExit bool @@ -147,8 +145,6 @@ func NewComposer(from, to, subject, body string, hideTips bool) *Composer { m.focusIndex = focusTo m.toInput.Focus() - m.plaintextOnly = false - return m } @@ -233,10 +229,9 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.attachmentPaths = append(m.attachmentPaths, msg.Path) - // If attachments were added, disable plaintext-only mode (plain text cannot carry attachments) - if m.plaintextOnly { - m.plaintextOnly = false - m.pluginStatus = "Plaintext-only disabled because attachment was added" + // Clear any attachment-related plugin status + if strings.Contains(m.pluginStatus, "attachment") { + m.pluginStatus = "" } return m, nil @@ -410,17 +405,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.encryptSMIME = !m.encryptSMIME } return m, nil - case focusPlaintextOnly: - if msg.String() == "enter" || msg.String() == " " { - if len(m.attachmentPaths) > 0 { - // Do not allow enabling plaintext-only when there are attachments - m.pluginStatus = "Cannot enable plaintext-only while attachments are present" - } else { - m.plaintextOnly = !m.plaintextOnly - m.pluginStatus = "" - } - } - return m, nil + case focusSend: if msg.String() == "enter" { acc := m.getSelectedAccount() @@ -443,7 +428,6 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Signature: m.signatureInput.Value(), SignSMIME: acc != nil && acc.SMIMESignByDefault, EncryptSMIME: m.encryptSMIME, - PlaintextOnly: m.plaintextOnly, } } } @@ -549,15 +533,6 @@ func (m *Composer) View() tea.View { encField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (S/MIME): %s", encToggle)) } - ptToggle := "[ ]" - if m.plaintextOnly { - ptToggle = "[x]" - } - ptField := blurredStyle.Render(fmt.Sprintf(" Send Plain Text Only: %s", ptToggle)) - if m.focusIndex == focusPlaintextOnly { - ptField = focusedStyle.Render(fmt.Sprintf("> Send Plain Text Only: %s", ptToggle)) - } - // Build To field with suggestions toFieldView := m.toInput.View() if m.showSuggestions && len(m.suggestions) > 0 { @@ -604,8 +579,7 @@ func (m *Composer) View() tea.View { tip = "Enter: add file • backspace/d: remove last attachment" case focusEncryptSMIME: tip = "Press Space or Enter to toggle S/MIME encryption on or off." - case focusPlaintextOnly: - tip = "Send message as plain text only (no HTML or attachments)." + case focusSend: tip = "Press Enter to send the email." } @@ -622,7 +596,7 @@ func (m *Composer) View() tea.View { m.signatureInput.View(), attachmentStyle.Render(attachmentField), smimeToggleStyle.Render(encField), - smimeToggleStyle.Render(ptField), + button, "", } diff --git a/tui/composer_test.go b/tui/composer_test.go index 76f3dc6..5901646 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -71,18 +71,11 @@ func TestComposerUpdate(t *testing.T) { t.Errorf("After seven Tabs, focusIndex should be %d (focusEncryptSMIME), got %d", focusEncryptSMIME, composer.focusIndex) } - // Simulate pressing Tab again to move to the 'PlaintextOnly' toggle. - model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) - composer = model.(*Composer) - if composer.focusIndex != focusPlaintextOnly { - t.Errorf("After eight Tabs, focusIndex should be %d (focusPlaintextOnly), got %d", focusPlaintextOnly, composer.focusIndex) - } - // Simulate pressing Tab again to move to the 'Send' button. model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) composer = model.(*Composer) if composer.focusIndex != focusSend { - t.Errorf("After nine Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex) + t.Errorf("After eight Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex) } // Simulate one more Tab to wrap around. @@ -90,7 +83,7 @@ func TestComposerUpdate(t *testing.T) { model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) composer = model.(*Composer) if composer.focusIndex != focusTo { - t.Errorf("After ten Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex) + t.Errorf("After nine Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex) } }) @@ -203,7 +196,7 @@ func TestComposerUpdate(t *testing.T) { t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex) } - // Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> PlaintextOnly -> Send -> From (wrap) -> To (wrap) + // Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> Send -> From (wrap) -> To (wrap) model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // To -> Cc multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Cc -> Bcc @@ -218,9 +211,7 @@ func TestComposerUpdate(t *testing.T) { multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> EncryptSMIME multiComposer = model.(*Composer) - model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> PlaintextOnly - multiComposer = model.(*Composer) - model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // PlaintextOnly -> Send + model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> Send multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap) multiComposer = model.(*Composer) From 575c3b8cb533064379d8fd7f5e66f1b304b83786 Mon Sep 17 00:00:00 2001 From: marwan051 Date: Wed, 25 Mar 2026 22:11:27 +0200 Subject: [PATCH 3/5] chore: revert unessecary changes to commit --- tui/composer.go | 22 ++++++++-------------- tui/composer_test.go | 4 ++-- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/tui/composer.go b/tui/composer.go index 1b27b0e..15d13e2 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -3,7 +3,6 @@ package tui import ( "fmt" "path/filepath" - "slices" "strings" "charm.land/bubbles/v2/textarea" @@ -210,7 +209,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Fixed rows: title, from, to, cc, bcc, subject, sig label, // attachment, smime, button, blank, tip, help = 13 const fixedRows = 13 - available := max(msg.Height-fixedRows, 6) + available := msg.Height - fixedRows + if available < 6 { + available = 6 + } bodyHeight := (available * 55) / 100 sigHeight := (available * 15) / 100 if bodyHeight < 3 { @@ -225,14 +227,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case FileSelectedMsg: // Avoid duplicates - if slices.Contains(m.attachmentPaths, msg.Path) { - return m, nil + for _, p := range m.attachmentPaths { + if p == msg.Path { + return m, nil + } } m.attachmentPaths = append(m.attachmentPaths, msg.Path) - // Clear any attachment-related plugin status - if strings.Contains(m.pluginStatus, "attachment") { - m.pluginStatus = "" - } return m, nil case tea.KeyPressMsg: @@ -383,9 +383,6 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "backspace", "delete", "d": if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 { m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1] - if len(m.attachmentPaths) == 0 && strings.Contains(m.pluginStatus, "attachment") { - m.pluginStatus = "" - } return m, nil } @@ -405,7 +402,6 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.encryptSMIME = !m.encryptSMIME } return m, nil - case focusSend: if msg.String() == "enter" { acc := m.getSelectedAccount() @@ -579,7 +575,6 @@ func (m *Composer) View() tea.View { tip = "Enter: add file • backspace/d: remove last attachment" case focusEncryptSMIME: tip = "Press Space or Enter to toggle S/MIME encryption on or off." - case focusSend: tip = "Press Enter to send the email." } @@ -596,7 +591,6 @@ func (m *Composer) View() tea.View { m.signatureInput.View(), attachmentStyle.Render(attachmentField), smimeToggleStyle.Render(encField), - button, "", } diff --git a/tui/composer_test.go b/tui/composer_test.go index 5901646..ba011d8 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -196,7 +196,7 @@ func TestComposerUpdate(t *testing.T) { t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex) } - // Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> Send -> From (wrap) -> To (wrap) + // Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> Send -> From (wrap) model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // To -> Cc multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Cc -> Bcc @@ -220,7 +220,7 @@ func TestComposerUpdate(t *testing.T) { // With multiple accounts, From field should be included in tab order if multiComposer.focusIndex != focusTo { - t.Errorf("After eleven Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex) + t.Errorf("After nine Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex) } }) } From 1893caf689dcea9c0fe4c9e1ba8840b77bf9a41c Mon Sep 17 00:00:00 2001 From: marwan051 Date: Wed, 25 Mar 2026 22:24:50 +0200 Subject: [PATCH 4/5] fchore: move all plaintext logic to `sender.go` only --- main.go | 31 +++---------------------------- sender/sender.go | 27 ++++++++++++++++++++++++++- tui/messages.go | 1 - 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/main.go b/main.go index 9017adb..95820ed 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "archive/tar" "archive/zip" "compress/gzip" + "encoding/base64" "encoding/json" "fmt" "io" @@ -1475,29 +1476,6 @@ func markdownToHTML(md []byte) []byte { return clib.MarkdownToHTML(md) } -// detectPlaintextOnly returns true when the body contains only plain text -// (no images, no attachments, no links, and no common markdown/HTML formatting). -func detectPlaintextOnly(body string, images map[string][]byte, attachments map[string][]byte) bool { - if len(images) > 0 || len(attachments) > 0 { - return false - } - - // Patterns indicating non-plaintext content: markdown links/images, raw URLs, HTML tags, - // headers, lists, blockquotes, inline code, bold/italic markers. - linkRe := regexp.MustCompile(`\[[^\]]+\]\([^\)]+\)`) - urlRe := regexp.MustCompile(`https?://\S+|www\.\S+`) - htmlRe := regexp.MustCompile(`<[^>]+>`) // crude HTML tag detection - mdHeaderRe := regexp.MustCompile(`(?m)^\s*#{1,6}\s+`) // markdown headers - mdListRe := regexp.MustCompile(`(?m)^\s*[-*+]\s+`) // markdown lists - mdQuoteRe := regexp.MustCompile(`(?m)^\s*>\s+`) // blockquote - mdFmtRe := regexp.MustCompile(`\*\*.+\*\*|__.+__|\*[^\*]+\*|_[^_]+_|` + "`" + `[^` + "`" + `]+` + "`") - - if linkRe.MatchString(body) || urlRe.MatchString(body) || htmlRe.MatchString(body) || mdHeaderRe.MatchString(body) || mdListRe.MatchString(body) || mdQuoteRe.MatchString(body) || mdFmtRe.MatchString(body) { - return false - } - return true -} - func splitEmails(s string) []string { if s == "" { return nil @@ -1544,8 +1522,7 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { continue } cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha") - // store raw image bytes; sender will base64-encode and wrap - images[cid] = imgData + images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData)) body = strings.Replace(body, imgPath, "cid:"+cid, 1) } @@ -1561,9 +1538,7 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { attachments[filename] = fileData } - // Auto-detect whether message is plaintext-only: no attachments, no images, and no links/formatting - plaintextOnly := detectPlaintextOnly(body, images, attachments) - err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, plaintextOnly, msg.SignSMIME, msg.EncryptSMIME) + err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME) if err != nil { log.Printf("Failed to send email: %v", err) return tui.EmailResultMsg{Err: err} diff --git a/sender/sender.go b/sender/sender.go index 8bb3d1e..c3d4337 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -16,6 +16,7 @@ import ( "net/textproto" "os" "path/filepath" + "regexp" "strings" "time" @@ -59,8 +60,29 @@ func generateMessageID(from string) string { return fmt.Sprintf("<%x@%s>", buf, from) } +// detectPlaintextOnly returns true when the body contains only plain text +// (no images, no attachments, no links, and no common markdown/HTML formatting). +func detectPlaintextOnly(body string, images map[string][]byte, attachments map[string][]byte) bool { + if len(images) > 0 || len(attachments) > 0 { + return false + } + + linkRe := regexp.MustCompile(`\[[^\]]+\]\([^\)]+\)`) + urlRe := regexp.MustCompile(`https?://\S+|www\.\S+`) + htmlRe := regexp.MustCompile(`<[^>]+>`) // crude HTML tag detection + mdHeaderRe := regexp.MustCompile(`(?m)^\s*#{1,6}\s+`) // markdown headers + mdListRe := regexp.MustCompile(`(?m)^\s*[-*+]\s+`) // markdown lists + mdQuoteRe := regexp.MustCompile(`(?m)^\s*>\s+`) // blockquote + mdFmtRe := regexp.MustCompile(`\*\*.+\*\*|__.+__|\*[^\*]+\*|_[^_]+_|` + "`" + `[^` + "`" + `]+` + "`") + + if linkRe.MatchString(body) || urlRe.MatchString(body) || htmlRe.MatchString(body) || mdHeaderRe.MatchString(body) || mdListRe.MatchString(body) || mdQuoteRe.MatchString(body) || mdFmtRe.MatchString(body) { + return false + } + return true +} + // SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments. -func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, plaintextOnly bool, signSMIME bool, encryptSMIME bool) error { +func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME bool, encryptSMIME bool) error { smtpServer := account.GetSMTPServer() smtpPort := account.GetSMTPPort() @@ -112,6 +134,9 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody var innerBodyBytes []byte var err error + // Detect plaintext-only mode + plaintextOnly := detectPlaintextOnly(plainBody, images, attachments) + // If plaintext-only mode is requested, build a single text/plain part (or a multipart/signed wrapper when signing) if plaintextOnly { if len(images) > 0 || len(attachments) > 0 { diff --git a/tui/messages.go b/tui/messages.go index d078ecf..e3d7bc3 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -35,7 +35,6 @@ type SendEmailMsg struct { Signature string // Signature to append to email body SignSMIME bool // Whether to sign the email using S/MIME EncryptSMIME bool // Whether to encrypt the email using S/MIME - PlaintextOnly bool // Send as plain text only (no multipart) } type Credentials struct { From ab4d32bca2eb31d8249891ef2e74117c66dc576e Mon Sep 17 00:00:00 2001 From: marwan051 Date: Wed, 25 Mar 2026 22:57:25 +0200 Subject: [PATCH 5/5] feat: refactor plaintext detection logic to use library instead of regex --- go.mod | 2 +- go.sum | 2 ++ sender/sender.go | 82 ++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 8659abe..4db4805 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/emersion/go-imap v1.2.1 github.com/emersion/go-message v0.18.2 github.com/google/uuid v1.6.0 - github.com/yuin/goldmark v1.8.1 + github.com/yuin/goldmark v1.8.2 github.com/yuin/gopher-lua v1.1.1 github.com/zalando/go-keyring v0.2.8 go.mozilla.org/pkcs7 v0.9.0 diff --git a/go.sum b/go.sum index 6321e27..32506cd 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.8.1 h1:id2TeYXe5FpqwLco0Pso4cNM5Z6Okt4g7kDw9QBMhTA= github.com/yuin/goldmark v1.8.1/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= diff --git a/sender/sender.go b/sender/sender.go index c3d4337..85f125e 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -16,12 +16,14 @@ import ( "net/textproto" "os" "path/filepath" - "regexp" "strings" "time" "github.com/floatpane/matcha/clib" "github.com/floatpane/matcha/config" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" "go.mozilla.org/pkcs7" ) @@ -60,25 +62,73 @@ func generateMessageID(from string) string { return fmt.Sprintf("<%x@%s>", buf, from) } -// detectPlaintextOnly returns true when the body contains only plain text -// (no images, no attachments, no links, and no common markdown/HTML formatting). -func detectPlaintextOnly(body string, images map[string][]byte, attachments map[string][]byte) bool { - if len(images) > 0 || len(attachments) > 0 { - return false - } +// containsMarkup returns true if the string contains Markdown or HTML elements. +func containsMarkup(body string) bool { + // Parse the Markdown into an AST. We will consider most AST node kinds as + // markup, but treat bare/autolinks (raw URLs) as plaintext for this + // detection: if a link node's visible text equals its destination (or is + // the destination wrapped in <>), we allow it. + source := []byte(body) + md := goldmark.New() + reader := text.NewReader(source) + doc := md.Parser().Parse(reader) + + var hasMarkup bool + ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch node.Kind() { + case ast.KindDocument, ast.KindParagraph, ast.KindText: + // not considered formatting + return ast.WalkContinue, nil + case ast.KindLink: + // Check if this is an autolink/raw URL: the link's text equals the + // destination. If so, don't treat it as markup for our purposes. + linkNode, ok := node.(*ast.Link) + if !ok { + hasMarkup = true + return ast.WalkStop, nil + } + + // Collect the visible text of the link + var b strings.Builder + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + if txt, ok := c.(*ast.Text); ok { + b.Write(txt.Segment.Value(source)) + } else { + // non-text content inside link -> treat as markup + hasMarkup = true + return ast.WalkStop, nil + } + } + linkText := b.String() + dest := string(linkNode.Destination) - linkRe := regexp.MustCompile(`\[[^\]]+\]\([^\)]+\)`) - urlRe := regexp.MustCompile(`https?://\S+|www\.\S+`) - htmlRe := regexp.MustCompile(`<[^>]+>`) // crude HTML tag detection - mdHeaderRe := regexp.MustCompile(`(?m)^\s*#{1,6}\s+`) // markdown headers - mdListRe := regexp.MustCompile(`(?m)^\s*[-*+]\s+`) // markdown lists - mdQuoteRe := regexp.MustCompile(`(?m)^\s*>\s+`) // blockquote - mdFmtRe := regexp.MustCompile(`\*\*.+\*\*|__.+__|\*[^\*]+\*|_[^_]+_|` + "`" + `[^` + "`" + `]+` + "`") + // Normalize common autolink representations and allow them. + if linkText == dest || linkText == "<"+dest+">" { + return ast.WalkContinue, nil + } - if linkRe.MatchString(body) || urlRe.MatchString(body) || htmlRe.MatchString(body) || mdHeaderRe.MatchString(body) || mdListRe.MatchString(body) || mdQuoteRe.MatchString(body) || mdFmtRe.MatchString(body) { + // Otherwise treat as markup + hasMarkup = true + return ast.WalkStop, nil + default: + hasMarkup = true + return ast.WalkStop, nil + } + }) + return hasMarkup +} + +// detectPlaintextOnly returns true when the body contains only plain text +// (no images, no attachments, no markdown/HTML formatting that requires multipart). +func detectPlaintextOnly(body string, images, attachments map[string][]byte) bool { + if len(images) > 0 || len(attachments) > 0 { return false } - return true + return !containsMarkup(body) } // SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.