diff --git a/backend/imap/imap.go b/backend/imap/imap.go index 894db53fba4d64b1fb3a5c9975972e9294eb66b4..f0facbedca99094940cd6cf3d303f3d424e4851e 100644 --- a/backend/imap/imap.go +++ b/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) { diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index 7283f75386e2532ad34b74e4926181ef35ee787f..818231f7f08d17188731037bcb68b4b1afac7310 100644 --- a/backend/pop3/pop3.go +++ b/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) { diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 7e0d386f72288bf5d0a1ec263a2ecf03a0304977..60b47f4cae5bba84304708ad3abc899606bc4933 100644 --- a/fetcher/fetcher.go +++ b/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 { diff --git a/main.go b/main.go index 7c0832d3b99907010af4983c67d88c8bcb62ddc7..0550ef8fbdf8478de38bf59b76cb2ad7488b2134 100644 --- a/main.go +++ b/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.") } diff --git a/sender/sender.go b/sender/sender.go index c6d3e35b4c83d7099b5365ccc0413f41a96fad20..9d093558cab2d0ddd07575f8221bf1a67163cab1 100644 --- a/sender/sender.go +++ b/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.