@@ -21,6 +21,9 @@ import (
"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"
)
@@ -78,6 +81,75 @@ func generateMessageID(from string) string {
return fmt.Sprintf("<%x@%s>", buf, from)
}
+// 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)
+
+ // Normalize common autolink representations and allow them.
+ if linkText == dest || linkText == "<"+dest+">" {
+ return ast.WalkContinue, nil
+ }
+
+ // 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 !containsMarkup(body)
+}
+
// 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 {
smtpServer := account.GetSMTPServer()
@@ -95,12 +167,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, ", "),
@@ -123,115 +190,248 @@ 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
- altWriter.Close() // Finish the alternative part
+ // Detect plaintext-only mode
+ plaintextOnly := detectPlaintextOnly(plainBody, images, attachments)
- // --- Inline Images ---
- for cid, data := range images {
- ext := filepath.Ext(strings.Split(cid, "@")[0])
- mimeType := mime.TypeByExtension(ext)
- if mimeType == "" {
- mimeType = "application/octet-stream"
+ // 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")
}
- 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+"\"")
+ // 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")
+ }
- imgPart, err := relatedWriter.CreatePart(imgHeader)
- 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))))
- }
+ certData, err := os.ReadFile(account.SMIMECert)
+ if err != nil {
+ return err
+ }
+ keyData, err := os.ReadFile(account.SMIMEKey)
+ if err != nil {
+ return err
+ }
- relatedWriter.Close() // Finish the related part
+ 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
+ }
+ }
+
+ // canonicalize the part (normalize newlines)
+ canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n"))
+ canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
- // --- Attachments ---
- for filename, data := range attachments {
- mimeType := mime.TypeByExtension(filepath.Ext(filename))
- 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)
+ }
}
- partHeader := textproto.MIMEHeader{}
- partHeader.Set("Content-Type", mimeType)
- partHeader.Set("Content-Transfer-Encoding", "base64")
- partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
+ } 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)
- attachmentPart, err := innerWriter.CreatePart(partHeader)
+ // 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
}
- encodedData := base64.StdEncoding.EncodeToString(data)
- // MIME requires base64 to be line-wrapped at 76 characters
- attachmentPart.Write([]byte(clib.WrapBase64(encodedData)))
- }
+ 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"
+ }
- innerWriter.Close() // Finish the inner message
+ 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+"\"")
- var msg bytes.Buffer
- for k, v := range headers {
- fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
- }
+ 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)))
+ }
- innerBodyBytes := append([]byte(innerHeaders), innerMsg.Bytes()...)
+ relatedWriter.Close() // Finish the related part
- var payloadToEncrypt []byte
+ // --- Attachments ---
+ for filename, data := range attachments {
+ mimeType := mime.TypeByExtension(filepath.Ext(filename))
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+
+ partHeader := textproto.MIMEHeader{}
+ partHeader.Set("Content-Type", mimeType)
+ partHeader.Set("Content-Transfer-Encoding", "base64")
+ partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
+
+ 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)))
+ }
+
+ innerWriter.Close() // Finish the inner message
+
+ 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")
}
@@ -281,7 +481,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")
@@ -300,16 +507,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