fix!: append sent emails to the SENT folder (#496)

Drew Smirnoff created

Change summary

backend/imap/imap.go | 17 +++++++
backend/pop3/pop3.go |  3 
fetcher/fetcher.go   | 13 ++++++
main.go              | 23 ++++++++-
sender/sender.go     | 99 ++++++++++++++++++++++++---------------------
5 files changed, 103 insertions(+), 52 deletions(-)

Detailed changes

backend/imap/imap.go 🔗

@@ -4,6 +4,7 @@ package imap
 
 import (
 	"context"
+	"log"
 
 	"github.com/floatpane/matcha/backend"
 	"github.com/floatpane/matcha/config"
@@ -76,7 +77,7 @@ func (p *Provider) MoveEmails(_ context.Context, uids []uint32, srcFolder, dstFo
 }
 
 func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
-	return sender.SendEmail(
+	rawMsg, err := sender.SendEmail(
 		p.account, msg.To, msg.Cc, msg.Bcc,
 		msg.Subject, msg.PlainBody, msg.HTMLBody,
 		msg.Images, msg.Attachments,
@@ -84,6 +85,20 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro
 		msg.SignSMIME, msg.EncryptSMIME,
 		msg.SignPGP, msg.EncryptPGP,
 	)
+	if err != nil {
+		return err
+	}
+
+	// Gmail automatically saves sent messages server-side; skip APPEND to avoid duplicates.
+	if p.account.ServiceProvider == "gmail" {
+		return nil
+	}
+
+	if err := fetcher.AppendToSentMailbox(p.account, rawMsg); err != nil {
+		log.Printf("Failed to append sent message to Sent folder: %v", err)
+	}
+
+	return nil
 }
 
 func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {

backend/pop3/pop3.go 🔗

@@ -224,7 +224,7 @@ func (p *Provider) MoveEmails(_ context.Context, _ []uint32, _, _ string) error
 }
 
 func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
-	return sender.SendEmail(
+	_, err := sender.SendEmail(
 		p.account, msg.To, msg.Cc, msg.Bcc,
 		msg.Subject, msg.PlainBody, msg.HTMLBody,
 		msg.Images, msg.Attachments,
@@ -232,6 +232,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro
 		msg.SignSMIME, msg.EncryptSMIME,
 		msg.SignPGP, msg.EncryptPGP,
 	)
+	return err
 }
 
 func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {

fetcher/fetcher.go 🔗

@@ -1274,6 +1274,19 @@ func ArchiveSentEmail(account *config.Account, uid uint32) error {
 	return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
 }
 
+// AppendToSentMailbox appends a raw RFC822 message to the Sent mailbox via IMAP APPEND.
+func AppendToSentMailbox(account *config.Account, msg []byte) error {
+	c, err := connect(account)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	sentMailbox := getSentMailbox(account)
+	flags := []string{imap.SeenFlag}
+	return c.Append(sentMailbox, flags, time.Now(), bytes.NewReader(msg))
+}
+
 // getTrashMailbox returns the trash mailbox name for the account
 func getTrashMailbox(account *config.Account) string {
 	switch account.ServiceProvider {

main.go 🔗

@@ -2004,11 +2004,19 @@ 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, msg.SignPGP, false)
+		rawMsg, err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, false)
 		if err != nil {
 			log.Printf("Failed to send email: %v", err)
 			return tui.EmailResultMsg{Err: err}
 		}
+
+		// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
+		if account.ServiceProvider != "gmail" {
+			if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
+				log.Printf("Failed to append sent message to Sent folder: %v", err)
+			}
+		}
+
 		return tui.EmailResultMsg{}
 	}
 }
@@ -2771,12 +2779,19 @@ func runSendCLI(args []string) {
 	ccList := splitEmails(*cc)
 	bccList := splitEmails(*bcc)
 
-	err = sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+	rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
+	if sendErr != nil {
+		fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
 		os.Exit(1)
 	}
 
+	// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
+	if account.ServiceProvider != "gmail" {
+		if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
+			log.Printf("Failed to append sent message to Sent folder: %v", err)
+		}
+	}
+
 	fmt.Println("Email sent successfully.")
 }
 

sender/sender.go 🔗

@@ -155,12 +155,12 @@ func detectPlaintextOnly(body string, images, attachments map[string][]byte) boo
 }
 
 // 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, signPGP bool, encryptPGP 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, signPGP bool, encryptPGP bool) ([]byte, error) {
 	smtpServer := account.GetSMTPServer()
 	smtpPort := account.GetSMTPPort()
 
 	if smtpServer == "" {
-		return fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
+		return nil, fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
 	}
 
 	plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
@@ -210,7 +210,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 	// 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")
+			return nil, errors.New("plaintext-only messages cannot contain attachments or inline images")
 		}
 
 		// Build quoted-printable encoded body
@@ -229,36 +229,36 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 
 		if signSMIME {
 			if account.SMIMECert == "" || account.SMIMEKey == "" {
-				return errors.New("S/MIME certificate or key path is missing")
+				return nil, errors.New("S/MIME certificate or key path is missing")
 			}
 
 			certData, err := os.ReadFile(account.SMIMECert)
 			if err != nil {
-				return err
+				return nil, err
 			}
 			keyData, err := os.ReadFile(account.SMIMEKey)
 			if err != nil {
-				return err
+				return nil, err
 			}
 
 			certBlock, _ := pem.Decode(certData)
 			if certBlock == nil {
-				return errors.New("failed to parse certificate PEM")
+				return nil, errors.New("failed to parse certificate PEM")
 			}
 			cert, err := x509.ParseCertificate(certBlock.Bytes)
 			if err != nil {
-				return err
+				return nil, err
 			}
 
 			keyBlock, _ := pem.Decode(keyData)
 			if keyBlock == nil {
-				return errors.New("failed to parse private key PEM")
+				return nil, 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
+					return nil, err
 				}
 			}
 
@@ -268,14 +268,14 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 
 			signedData, err := pkcs7.NewSignedData(canonicalBody)
 			if err != nil {
-				return err
+				return nil, err
 			}
 			if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
-				return err
+				return nil, err
 			}
 			detachedSig, err := signedData.Finish()
 			if err != nil {
-				return err
+				return nil, err
 			}
 
 			var rb [12]byte
@@ -330,7 +330,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 		relatedHeader.Set("Content-Type", "multipart/related; boundary=\""+relatedBoundary+"\"")
 		relatedPartWriter, err := innerWriter.CreatePart(relatedHeader)
 		if err != nil {
-			return err
+			return nil, err
 		}
 		relatedWriter := multipart.NewWriter(relatedPartWriter)
 		relatedWriter.SetBoundary(relatedBoundary)
@@ -341,7 +341,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 		altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+altBoundary+"\"")
 		altPartWriter, err := relatedWriter.CreatePart(altHeader)
 		if err != nil {
-			return err
+			return nil, err
 		}
 		altWriter := multipart.NewWriter(altPartWriter)
 		altWriter.SetBoundary(altBoundary)
@@ -353,7 +353,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 		}
 		textPart, err := altWriter.CreatePart(textHeader)
 		if err != nil {
-			return err
+			return nil, err
 		}
 		qpText := quotedprintable.NewWriter(textPart)
 		fmt.Fprint(qpText, plainBody)
@@ -366,7 +366,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 		}
 		htmlPart, err := altWriter.CreatePart(htmlHeader)
 		if err != nil {
-			return err
+			return nil, err
 		}
 		qpHTML := quotedprintable.NewWriter(htmlPart)
 		fmt.Fprint(qpHTML, htmlBody)
@@ -390,7 +390,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 
 			imgPart, err := relatedWriter.CreatePart(imgHeader)
 			if err != nil {
-				return err
+				return nil, err
 			}
 			// Encode raw image bytes to base64, then wrap at 76 chars per MIME rules
 			encodedImg := base64.StdEncoding.EncodeToString(data)
@@ -413,7 +413,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 
 			attachmentPart, err := innerWriter.CreatePart(partHeader)
 			if err != nil {
-				return err
+				return nil, err
 			}
 			encodedData := base64.StdEncoding.EncodeToString(data)
 			// MIME requires base64 to be line-wrapped at 76 characters
@@ -434,36 +434,36 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 	// 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")
+			return nil, errors.New("S/MIME certificate or key path is missing")
 		}
 
 		certData, err := os.ReadFile(account.SMIMECert)
 		if err != nil {
-			return err
+			return nil, err
 		}
 		keyData, err := os.ReadFile(account.SMIMEKey)
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		certBlock, _ := pem.Decode(certData)
 		if certBlock == nil {
-			return errors.New("failed to parse certificate PEM")
+			return nil, errors.New("failed to parse certificate PEM")
 		}
 		cert, err := x509.ParseCertificate(certBlock.Bytes)
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		keyBlock, _ := pem.Decode(keyData)
 		if keyBlock == nil {
-			return errors.New("failed to parse private key PEM")
+			return nil, 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
+				return nil, err
 			}
 		}
 
@@ -472,14 +472,14 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 
 		signedData, err := pkcs7.NewSignedData(canonicalBody)
 		if err != nil {
-			return err
+			return nil, err
 		}
 		if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
-			return err
+			return nil, err
 		}
 		detachedSig, err := signedData.Finish()
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		var rb [12]byte
@@ -548,12 +548,12 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 		}
 
 		if len(certs) == 0 {
-			return errors.New("cannot encrypt: no valid public certificates found for recipients")
+			return nil, errors.New("cannot encrypt: no valid public certificates found for recipients")
 		}
 
 		encryptedDer, err := pkcs7.Encrypt(payloadToEncrypt, certs)
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		msg.WriteString("Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=\"smime.p7m\"\r\n")
@@ -577,7 +577,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 
 		signed, err := signEmailPGP(toSign, account)
 		if err != nil {
-			return fmt.Errorf("PGP signing failed: %w", err)
+			return nil, fmt.Errorf("PGP signing failed: %w", err)
 		}
 
 		if encryptPGP {
@@ -610,7 +610,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 
 		encrypted, err := encryptEmailPGP(toEncrypt, allRecipients, account)
 		if err != nil {
-			return fmt.Errorf("PGP encryption failed: %w", err)
+			return nil, fmt.Errorf("PGP encryption failed: %w", err)
 		}
 
 		msg.Reset()
@@ -636,31 +636,31 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 	if smtpPort == 465 {
 		conn, err := tls.Dial("tcp", addr, tlsConfig)
 		if err != nil {
-			return err
+			return nil, err
 		}
 		c, err = smtp.NewClient(conn, smtpServer)
 		if err != nil {
 			conn.Close()
-			return err
+			return nil, err
 		}
 	} else {
 		var err error
 		c, err = smtp.Dial(addr)
 		if err != nil {
-			return err
+			return nil, err
 		}
 	}
 	defer c.Close()
 
 	if err = c.Hello("localhost"); err != nil {
-		return err
+		return nil, err
 	}
 
 	// Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
 	if smtpPort != 465 {
 		if ok, _ := c.Extension("STARTTLS"); ok {
 			if err = c.StartTLS(tlsConfig); err != nil {
-				return err
+				return nil, err
 			}
 		}
 	}
@@ -674,7 +674,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 			// Use XOAUTH2 for OAuth2-enabled accounts
 			token, tokenErr := config.GetOAuth2Token(account.Email)
 			if tokenErr != nil {
-				return fmt.Errorf("oauth2: %w", tokenErr)
+				return nil, fmt.Errorf("oauth2: %w", tokenErr)
 			}
 			err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
 		} else if strings.Contains(mechList, "PLAIN") {
@@ -686,35 +686,42 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 			err = c.Auth(plainAuth)
 		}
 		if err != nil {
-			return err
+			return nil, err
 		}
 	}
 
 	// Send Envelope
 	if err = c.Mail(account.GetFetchEmail()); err != nil {
-		return err
+		return nil, err
 	}
 	for _, r := range allRecipients {
 		if err = c.Rcpt(r); err != nil {
-			return err
+			return nil, err
 		}
 	}
 
 	// Write Data
 	w, err := c.Data()
 	if err != nil {
-		return err
+		return nil, err
 	}
 	_, err = w.Write(msg.Bytes())
 	if err != nil {
-		return err
+		return nil, err
 	}
 	err = w.Close()
 	if err != nil {
-		return err
+		return nil, err
+	}
+
+	rawMsg := make([]byte, len(msg.Bytes()))
+	copy(rawMsg, msg.Bytes())
+
+	if err := c.Quit(); err != nil {
+		return nil, err
 	}
 
-	return c.Quit()
+	return rawMsg, nil
 }
 
 // signEmailPGP signs the message payload with PGP and returns a multipart/signed message.