diff --git a/config/config.go b/config/config.go index e62b3a45b5c801f1ce9457084126ad84bb21a316..5326850e87fb8b695248fb49981a7d162d39e354 100644 --- a/config/config.go +++ b/config/config.go @@ -28,6 +28,11 @@ type Account struct { SMTPServer string `json:"smtp_server,omitempty"` SMTPPort int `json:"smtp_port,omitempty"` Insecure bool `json:"insecure,omitempty"` + + // S/MIME settings + SMIMECert string `json:"smime_cert,omitempty"` // Path to the public certificate PEM + SMIMEKey string `json:"smime_key,omitempty"` // Path to the private key PEM + SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` // Whether to enable S/MIME signing by default } // MailingList represents a named group of email addresses. @@ -102,7 +107,12 @@ func (a *Account) GetSMTPPort() int { } } -// configDir returns the path to the configuration directory. +// GetConfigDir returns the path to the configuration directory (exported). +func GetConfigDir() (string, error) { + return configDir() +} + +// configDir returns the path to the configuration directory (internal). func configDir() (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -161,21 +171,25 @@ func LoadConfig() (*Config, error) { var needsMigration bool type rawAccount struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Password string `json:"password,omitempty"` - ServiceProvider string `json:"service_provider"` - FetchEmail string `json:"fetch_email,omitempty"` - IMAPServer string `json:"imap_server,omitempty"` - IMAPPort int `json:"imap_port,omitempty"` - SMTPServer string `json:"smtp_server,omitempty"` - SMTPPort int `json:"smtp_port,omitempty"` - Insecure bool `json:"insecure,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password,omitempty"` + ServiceProvider string `json:"service_provider"` + FetchEmail string `json:"fetch_email,omitempty"` + IMAPServer string `json:"imap_server,omitempty"` + IMAPPort int `json:"imap_port,omitempty"` + SMTPServer string `json:"smtp_server,omitempty"` + SMTPPort int `json:"smtp_port,omitempty"` + Insecure bool `json:"insecure,omitempty"` + SMIMECert string `json:"smime_cert,omitempty"` + SMIMEKey string `json:"smime_key,omitempty"` + SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` } type diskConfig struct { Accounts []rawAccount `json:"accounts"` DisableImages bool `json:"disable_images,omitempty"` + HideTips bool `json:"hide_tips,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` } @@ -205,19 +219,23 @@ func LoadConfig() (*Config, error) { } config.DisableImages = raw.DisableImages + config.HideTips = raw.HideTips config.MailingLists = raw.MailingLists for _, rawAcc := range raw.Accounts { acc := Account{ - ID: rawAcc.ID, - Name: rawAcc.Name, - Email: rawAcc.Email, - ServiceProvider: rawAcc.ServiceProvider, - FetchEmail: rawAcc.FetchEmail, - IMAPServer: rawAcc.IMAPServer, - IMAPPort: rawAcc.IMAPPort, - SMTPServer: rawAcc.SMTPServer, - SMTPPort: rawAcc.SMTPPort, - Insecure: rawAcc.Insecure, + ID: rawAcc.ID, + Name: rawAcc.Name, + Email: rawAcc.Email, + ServiceProvider: rawAcc.ServiceProvider, + FetchEmail: rawAcc.FetchEmail, + IMAPServer: rawAcc.IMAPServer, + IMAPPort: rawAcc.IMAPPort, + SMTPServer: rawAcc.SMTPServer, + SMTPPort: rawAcc.SMTPPort, + Insecure: rawAcc.Insecure, + SMIMECert: rawAcc.SMIMECert, + SMIMEKey: rawAcc.SMIMEKey, + SMIMESignByDefault: rawAcc.SMIMESignByDefault, } if rawAcc.Password != "" { diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index a1f8523d4ee8cc899e6f890a5f2f53175be7ce7d..6610397d4628912df4f9f80b7f217c22e60055a2 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -15,6 +15,8 @@ Configuration is stored in `~/.config/matcha/config.json`. "email": "john@gmail.com", "service_provider": "gmail", "fetch_email": "john@gmail.com" + "smime_cert": "/home/jane/.certs/jane_smime_cert.pem", + "smime_key": "/home/jane/.certs/jane_smime_private.pem" }, { "id": "unique-id-2", @@ -33,7 +35,9 @@ Configuration is stored in `~/.config/matcha/config.json`. "name": "Team", "addresses": ["alice@example.com", "bob@example.com"] } - ] + ], + "disable_images": true, + "hide_tips": true } ``` diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index c3d134ef03623e1315eb2bd59976557d13f125ef..49097f8ef5538efcb3f30c6e0a9d394bf466a4ec 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -3,7 +3,10 @@ package fetcher import ( "bytes" "crypto/tls" + "crypto/x509" "encoding/base64" + "encoding/pem" + "errors" "fmt" "io" "io/ioutil" @@ -17,19 +20,23 @@ import ( "github.com/emersion/go-imap/client" "github.com/emersion/go-message/mail" "github.com/floatpane/matcha/config" + "go.mozilla.org/pkcs7" "golang.org/x/text/encoding/ianaindex" "golang.org/x/text/transform" ) // Attachment holds data for an email attachment. type Attachment struct { - Filename string - PartID string // Keep PartID to fetch on demand - Data []byte - Encoding string // Store encoding for proper decoding - MIMEType string // Full MIME type (e.g., image/png) - ContentID string // Content-ID for inline assets (e.g., cid: references) - Inline bool // True when the part is meant to be displayed inline + Filename string + PartID string // Keep PartID to fetch on demand + Data []byte + Encoding string // Store encoding for proper decoding + MIMEType string // Full MIME type (e.g., image/png) + ContentID string // Content-ID for inline assets (e.g., cid: references) + Inline bool // True when the part is meant to be displayed inline + IsSMIMESignature bool // True if this attachment is an S/MIME signature + SMIMEVerified bool // True if the S/MIME signature was verified successfully + IsSMIMEEncrypted bool // True if the S/MIME content was successfully decrypted } type Email struct { @@ -342,6 +349,27 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint seqset := new(imap.SeqSet) seqset.AddNum(uid) + fetchWholeMessage := func() ([]byte, error) { + fetchItem := imap.FetchItem("BODY.PEEK[]") + section, _ := imap.ParseBodySectionName(fetchItem) + partMessages := make(chan *imap.Message, 1) + partDone := make(chan error, 1) + go func() { + partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages) + }() + if err := <-partDone; err != nil { + return nil, err + } + partMsg := <-partMessages + if partMsg != nil { + literal := partMsg.GetBody(section) + if literal != nil { + return ioutil.ReadAll(literal) + } + } + return nil, fmt.Errorf("could not fetch whole message") + } + fetchInlinePart := func(partID, encoding string) ([]byte, error) { fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID)) section, err := imap.ParseBodySectionName(fetchItem) @@ -396,6 +424,8 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint var plainPartID, plainPartEncoding string var htmlPartID, htmlPartEncoding string var attachments []Attachment + var extractedBody string // Used if we intercept and decrypt a payload + var checkPart func(part *imap.BodyStructure, partID string) checkPart = func(part *imap.BodyStructure, partID string) { // Check for text content (prefer html over plain) @@ -450,7 +480,217 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") { filename = "inline" } - if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") { + + // === S/MIME ENCRYPTION AND OPAQUE VERIFICATION === + if filename == "smime.p7m" || mimeType == "application/pkcs7-mime" { + data, err := fetchInlinePart(partID, part.Encoding) + if err != nil && partID == "1" { + // Fallback for single-part messages where PEEK[1] fails + data, err = fetchInlinePart("TEXT", part.Encoding) + } + + if err != nil { + extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to fetch encrypted part from IMAP server: %v\n", err) + htmlPartID = "extracted" + } else { + p7, parseErr := pkcs7.Parse(data) + if parseErr != nil { + // Fallback: IMAP servers sometimes drop the transfer-encoding header. + // We manually strip newlines and attempt a base64 decode just in case. + cleanData := bytes.ReplaceAll(data, []byte("\n"), []byte("")) + cleanData = bytes.ReplaceAll(cleanData, []byte("\r"), []byte("")) + if decoded, b64err := base64.StdEncoding.DecodeString(string(cleanData)); b64err == nil { + p7, parseErr = pkcs7.Parse(decoded) + } + } + + if parseErr != nil { + extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to parse PKCS7 payload: %v\n", parseErr) + htmlPartID = "extracted" + } else { + var innerBytes []byte + isEncrypted, isOpaqueSigned, smimeTrusted := false, false, false + decryptionErr := "" + + // 1. Try to Decrypt + if account.SMIMECert != "" && account.SMIMEKey != "" { + cData, err1 := os.ReadFile(account.SMIMECert) + kData, err2 := os.ReadFile(account.SMIMEKey) + if err1 != nil || err2 != nil { + decryptionErr = fmt.Sprintf("Failed to read cert/key files. Cert: %v, Key: %v", err1, err2) + } else { + cBlock, _ := pem.Decode(cData) + kBlock, _ := pem.Decode(kData) + if cBlock == nil || kBlock == nil { + decryptionErr = "Failed to decode PEM blocks from cert/key files." + } else { + cert, err3 := x509.ParseCertificate(cBlock.Bytes) + var privKey any + var err4 error + if key, err := x509.ParsePKCS8PrivateKey(kBlock.Bytes); err == nil { + privKey = key + } else if key, err := x509.ParsePKCS1PrivateKey(kBlock.Bytes); err == nil { + privKey = key + } else if key, err := x509.ParseECPrivateKey(kBlock.Bytes); err == nil { + privKey = key + } else { + err4 = errors.New("unsupported private key format") + } + + if err3 != nil || err4 != nil { + decryptionErr = fmt.Sprintf("Failed to parse cert/key. Cert: %v, Key: %v", err3, err4) + } else { + dec, err := p7.Decrypt(cert, privKey) + if err == nil { + innerBytes = dec + isEncrypted = true + } else { + decryptionErr = fmt.Sprintf("PKCS7 Decrypt failed: %v", err) + } + } + } + } + } else { + // Only set error if it actually is enveloped data (encrypted) + // If it's just opaque signed, we shouldn't error out. + decryptionErr = "S/MIME Cert or Key path is missing in settings." + } + + // 2. If not encrypted, check if it's an opaque signature + if !isEncrypted && len(p7.Signers) > 0 { + isOpaqueSigned = true + innerBytes = p7.Content + decryptionErr = "" // Clear encryption error because it wasn't encrypted to begin with + roots, _ := x509.SystemCertPool() + if roots == nil { + roots = x509.NewCertPool() + } + if err := p7.VerifyWithChain(roots); err == nil { + smimeTrusted = true + } + } + + // 3. Parse Inner MIME payload + if len(innerBytes) > 0 { + mr, err := mail.CreateReader(bytes.NewReader(innerBytes)) + if err == nil { + for { + p, err := mr.NextPart() + if err != nil { + break + } + cType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type")) + disp, dParams, _ := mime.ParseMediaType(p.Header.Get("Content-Disposition")) + b, _ := ioutil.ReadAll(p.Body) // Auto-decodes quoted-printable/base64 + + if disp == "attachment" || disp == "inline" || (!strings.HasPrefix(cType, "multipart/") && cType != "text/plain" && cType != "text/html") { + fn := dParams["filename"] + if fn == "" { + _, cp, _ := mime.ParseMediaType(p.Header.Get("Content-Type")) + fn = cp["name"] + } + attachments = append(attachments, Attachment{ + Filename: fn, Data: b, MIMEType: cType, Inline: disp == "inline", + }) + } else { + if cType == "text/html" { + extractedBody = string(b) + htmlPartID = "extracted" // Skip IMAP fetch + } else if cType == "text/plain" && extractedBody == "" { + extractedBody = string(b) + plainPartID = "extracted" + } + } + } + } else { + extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to read inner decrypted MIME: %v\n\n```\n%s\n```", err, string(innerBytes)) + htmlPartID = "extracted" + } + + attachments = append(attachments, Attachment{ + Filename: "smime-status.internal", + IsSMIMESignature: isOpaqueSigned, + SMIMEVerified: smimeTrusted, + IsSMIMEEncrypted: isEncrypted, + }) + return // Stop checking IMAP structure, we hijacked it + } else { + extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr) + htmlPartID = "extracted" + } + } + } + } + + // === S/MIME DETACHED SIGNATURE VERIFICATION === + if filename == "smime.p7s" || mimeType == "application/pkcs7-signature" { + att := Attachment{ + Filename: filename, + PartID: partID, + Encoding: part.Encoding, + MIMEType: mimeType, + ContentID: contentID, + Inline: isInline, + IsSMIMESignature: true, + } + if data, err := fetchInlinePart(partID, part.Encoding); err == nil { + att.Data = data + p7, err := pkcs7.Parse(data) + if err == nil { + boundary := msg.BodyStructure.Params["boundary"] + if boundary != "" { + rawEmail, err := fetchWholeMessage() + if err == nil { + fullBoundary := []byte("--" + boundary) + firstIdx := bytes.Index(rawEmail, fullBoundary) + if firstIdx != -1 { + startIdx := firstIdx + len(fullBoundary) + if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' { + startIdx++ + } + if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' { + startIdx++ + } + secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary) + if secondIdx != -1 { + endIdx := startIdx + secondIdx + if endIdx > 0 && rawEmail[endIdx-1] == '\n' { + endIdx-- + } + if endIdx > 0 && rawEmail[endIdx-1] == '\r' { + endIdx-- + } + signedData := rawEmail[startIdx:endIdx] + canonical := bytes.ReplaceAll(signedData, []byte("\r\n"), []byte("\n")) + canonical = bytes.ReplaceAll(canonical, []byte("\n"), []byte("\r\n")) + + roots, _ := x509.SystemCertPool() + if roots == nil { + roots = x509.NewCertPool() + } + + p7.Content = canonical + if err := p7.VerifyWithChain(roots); err == nil { + att.SMIMEVerified = true + } else { + p7.Content = append(canonical, '\r', '\n') + if err := p7.VerifyWithChain(roots); err == nil { + att.SMIMEVerified = true + } else { + p7.Content = bytes.TrimRight(canonical, "\r\n") + if err := p7.VerifyWithChain(roots); err == nil { + att.SMIMEVerified = true + } + } + } + } + } + } + } + } + } + attachments = append(attachments, att) + } else if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") { att := Attachment{ Filename: filename, PartID: partID, @@ -496,6 +736,11 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } findParts(msg.BodyStructure, "") + // If we hijacked and decrypted the body, return it immediately + if extractedBody != "" { + return extractedBody, attachments, nil + } + var body string textPartID := "" textPartEncoding := "" diff --git a/go.mod b/go.mod index 784977b234d8d0c97341ef39e021eee2c7549907..bc0647ddee737d391aea8f272b169685bc3d2f0a 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,6 @@ require ( require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect - charm.land/bubbles/v2 v2.0.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect @@ -38,6 +37,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.mozilla.org/pkcs7 v0.9.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index 3da5507b88f81dfd929589a55bf71d57bfba7734..2c089b54e0efc6e3e7554bb4f1e741774632d711 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= +go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= diff --git a/main b/main deleted file mode 100755 index 62745bf2fd27c89a149b394efbc959f36ef0e287..0000000000000000000000000000000000000000 Binary files a/main and /dev/null differ diff --git a/main.go b/main.go index a66129af812fabe940fd78ba00d7e00870cad21c..b51692e27d63a502622572541a350b932e99036e 100644 --- a/main.go +++ b/main.go @@ -1461,7 +1461,7 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { } } - err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References) + 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 3e3125d3364299b83a9fba1a5e2e21b963466de6..6205ee643c7cfd8d94fe57abb92e43a697237b87 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -4,17 +4,23 @@ import ( "bytes" "crypto/rand" "crypto/tls" + "crypto/x509" "encoding/base64" + "encoding/pem" + "errors" "fmt" "mime" "mime/multipart" + "mime/quotedprintable" "net/smtp" "net/textproto" + "os" "path/filepath" "strings" "time" "github.com/floatpane/matcha/config" + "go.mozilla.org/pkcs7" ) // generateMessageID creates a unique Message-ID header. @@ -28,7 +34,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) 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() @@ -44,8 +50,9 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody } // Main message buffer - var msg bytes.Buffer - mainWriter := multipart.NewWriter(&msg) + 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 headers := map[string]string{ @@ -54,7 +61,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody "Subject": subject, "Date": time.Now().Format(time.RFC1123Z), "Message-ID": generateMessageID(account.FetchEmail), - "Content-Type": "multipart/mixed; boundary=" + mainWriter.Boundary(), + "MIME-Version": "1.0", } if len(cc) > 0 { @@ -70,17 +77,12 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody } } - for k, v := range headers { - fmt.Fprintf(&msg, "%s: %s\r\n", k, v) - } - fmt.Fprintf(&msg, "\r\n") // End of headers - // --- Body Part (multipart/related) --- // This part contains the multipart/alternative (text/html) and any inline images. relatedHeader := textproto.MIMEHeader{} - relatedBoundary := "related-" + mainWriter.Boundary() - relatedHeader.Set("Content-Type", "multipart/related; boundary="+relatedBoundary) - relatedPartWriter, err := mainWriter.CreatePart(relatedHeader) + relatedBoundary := "related-" + innerWriter.Boundary() + relatedHeader.Set("Content-Type", "multipart/related; boundary=\""+relatedBoundary+"\"") + relatedPartWriter, err := innerWriter.CreatePart(relatedHeader) if err != nil { return err } @@ -89,8 +91,8 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody // --- Alternative Part (text and html) --- altHeader := textproto.MIMEHeader{} - altBoundary := "alt-" + mainWriter.Boundary() - altHeader.Set("Content-Type", "multipart/alternative; boundary="+altBoundary) + altBoundary := "alt-" + innerWriter.Boundary() + altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+altBoundary+"\"") altPartWriter, err := relatedWriter.CreatePart(altHeader) if err != nil { return err @@ -99,18 +101,30 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody altWriter.SetBoundary(altBoundary) // Plain text part - textPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}}) + 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 } - fmt.Fprint(textPart, plainBody) + qpText := quotedprintable.NewWriter(textPart) + fmt.Fprint(qpText, plainBody) + qpText.Close() // HTML part - htmlPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}}) + 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 } - fmt.Fprint(htmlPart, htmlBody) + qpHTML := quotedprintable.NewWriter(htmlPart) + fmt.Fprint(qpHTML, htmlBody) + qpHTML.Close() altWriter.Close() // Finish the alternative part @@ -150,7 +164,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody partHeader.Set("Content-Transfer-Encoding", "base64") partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - attachmentPart, err := mainWriter.CreatePart(partHeader) + attachmentPart, err := innerWriter.CreatePart(partHeader) if err != nil { return err } @@ -159,7 +173,150 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody attachmentPart.Write([]byte(wrapBase64(encodedData))) } - mainWriter.Close() // Finish the main message + innerWriter.Close() // Finish the inner message + + var msg bytes.Buffer + for k, v := range headers { + fmt.Fprintf(&msg, "%s: %s\r\n", k, v) + } + + innerBodyBytes := append([]byte(innerHeaders), innerMsg.Bytes()...) + + var payloadToEncrypt []byte + + // Handle S/MIME Detached Signing + 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 + } + } + + canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n")) + canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n")) + + 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 + } + + outerBoundary := "signed-" + innerWriter.Boundary() + 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(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 { + 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 + if encryptSMIME { + // Include the sender's own email so it can be decrypted in the Sent folder + allRecipients := append([]string{account.Email}, to...) + allRecipients = append(allRecipients, cc...) + allRecipients = append(allRecipients, bcc...) + + cfgDir, _ := config.GetConfigDir() + certsDir := filepath.Join(cfgDir, "certs") + var certs []*x509.Certificate + + for _, em := range allRecipients { + em = strings.TrimSpace(em) + if strings.Contains(em, "<") { + parts := strings.Split(em, "<") + if len(parts) == 2 { + em = strings.TrimSuffix(parts[1], ">") + } + } + + var certPath string + // If this is our own account, use the path from settings rather than requiring it in the certs folder + if strings.EqualFold(em, account.Email) && account.SMIMECert != "" { + certPath = account.SMIMECert + } else { + certPath = filepath.Join(certsDir, em+".pem") + } + + if certData, err := os.ReadFile(certPath); err == nil { + if block, _ := pem.Decode(certData); block != nil { + if cert, err := x509.ParseCertificate(block.Bytes); err == nil { + certs = append(certs, cert) + } + } + } + } + + if len(certs) == 0 { + return errors.New("cannot encrypt: no valid public certificates found for recipients") + } + + encryptedDer, err := pkcs7.Encrypt(payloadToEncrypt, certs) + if err != nil { + return err + } + + msg.WriteString("Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=\"smime.p7m\"\r\n") + msg.WriteString("Content-Transfer-Encoding: base64\r\n") + msg.WriteString("Content-Disposition: attachment; filename=\"smime.p7m\"\r\n\r\n") + msg.WriteString(wrapBase64(base64.StdEncoding.EncodeToString(encryptedDer))) + } // Combine all recipients for the envelope allRecipients := append([]string{}, to...) diff --git a/tui/composer.go b/tui/composer.go index 85b9686de505c682def7163aaa44b7be956a856c..cc1e0f329d43fbc2d4d19305de9d29d0235d5daf 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -29,6 +29,7 @@ var ( emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) fromSelectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + smimeToggleStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) ) const ( @@ -40,6 +41,7 @@ const ( focusBody focusSignature focusAttachment + focusEncryptSMIME focusSend ) @@ -53,6 +55,7 @@ type Composer struct { bodyInput textarea.Model signatureInput textarea.Model attachmentPath string + encryptSMIME bool width int height int confirmingExit bool @@ -337,34 +340,45 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) - case "enter": + case "enter", " ": switch m.focusIndex { case focusFrom: - if len(m.accounts) > 1 { + if len(m.accounts) > 1 && msg.String() == "enter" { m.showAccountPicker = true } return m, nil case focusAttachment: - return m, func() tea.Msg { return GoToFilePickerMsg{} } - case focusSend: - acc := m.getSelectedAccount() - accountID := "" - if acc != nil { - accountID = acc.ID + if msg.String() == "enter" { + return m, func() tea.Msg { return GoToFilePickerMsg{} } + } + case focusEncryptSMIME: + if msg.String() == "enter" || msg.String() == " " { + m.encryptSMIME = !m.encryptSMIME } - return m, func() tea.Msg { - return SendEmailMsg{ - To: m.toInput.Value(), - Cc: m.ccInput.Value(), - Bcc: m.bccInput.Value(), - Subject: m.subjectInput.Value(), - Body: m.bodyInput.Value(), - AttachmentPath: m.attachmentPath, - AccountID: accountID, - QuotedText: m.quotedText, - InReplyTo: m.inReplyTo, - References: m.references, - Signature: m.signatureInput.Value(), + return m, nil + case focusSend: + if msg.String() == "enter" { + acc := m.getSelectedAccount() + accountID := "" + if acc != nil { + accountID = acc.ID + } + return m, func() tea.Msg { + return SendEmailMsg{ + To: m.toInput.Value(), + Cc: m.ccInput.Value(), + Bcc: m.bccInput.Value(), + Subject: m.subjectInput.Value(), + Body: m.bodyInput.Value(), + AttachmentPath: m.attachmentPath, + AccountID: accountID, + QuotedText: m.quotedText, + InReplyTo: m.inReplyTo, + References: m.references, + Signature: m.signatureInput.Value(), + SignSMIME: acc != nil && acc.SMIMESignByDefault, + EncryptSMIME: m.encryptSMIME, + } } } } @@ -451,6 +465,15 @@ func (m *Composer) View() tea.View { attachmentField = blurredStyle.Render(fmt.Sprintf(" Attachment: %s", attachmentText)) } + encToggle := "[ ]" + if m.encryptSMIME { + encToggle = "[x]" + } + encField := blurredStyle.Render(fmt.Sprintf(" Encrypt Email (S/MIME): %s", encToggle)) + if m.focusIndex == focusEncryptSMIME { + encField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (S/MIME): %s", encToggle)) + } + // Build To field with suggestions toFieldView := m.toInput.View() if m.showSuggestions && len(m.suggestions) > 0 { @@ -495,6 +518,8 @@ func (m *Composer) View() tea.View { tip = "Your email signature. This will be appended to the end of the email." case focusAttachment: tip = "Press Enter to select a file to attach to this email." + case focusEncryptSMIME: + tip = "Press Space or Enter to toggle S/MIME encryption on or off." case focusSend: tip = "Press Enter to send the email." } @@ -510,6 +535,7 @@ func (m *Composer) View() tea.View { signatureLabel, m.signatureInput.View(), attachmentStyle.Render(attachmentField), + smimeToggleStyle.Render(encField), button, "", } diff --git a/tui/composer_test.go b/tui/composer_test.go index a650857653ff35e7cc646666d5448c1c56499aa9..17bb00d3bef4923e41c2e0387f29cda6ece141cf 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -64,11 +64,18 @@ func TestComposerUpdate(t *testing.T) { t.Errorf("After six Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex) } + // Simulate pressing Tab again to move to the 'EncryptSMIME' toggle. + model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + composer = model.(*Composer) + if composer.focusIndex != focusEncryptSMIME { + t.Errorf("After seven Tabs, focusIndex should be %d (focusEncryptSMIME), got %d", focusEncryptSMIME, 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 seven 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. @@ -76,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 eight 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) } }) @@ -189,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 -> Send -> From (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 @@ -202,7 +209,9 @@ func TestComposerUpdate(t *testing.T) { multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Signature -> Attachment multiComposer = model.(*Composer) - model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> Send + model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> EncryptSMIME + multiComposer = model.(*Composer) + 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) diff --git a/tui/email_view.go b/tui/email_view.go index 5ed92fc97be25da4cb3847e7969bb28812467ee9..57983084959db6386459372850a23ec766c3d6ca 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -35,9 +35,30 @@ type EmailView struct { mailbox MailboxKind disableImages bool showImages bool + isSMIME bool + smimeTrusted bool + isEncrypted bool } func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView { + isSMIME := false + smimeTrusted := false + isEncrypted := false + var filteredAtts []fetcher.Attachment + + for _, att := range email.Attachments { + if att.Filename == "smime-status.internal" { + isSMIME = att.IsSMIMESignature || att.IsSMIMEEncrypted + smimeTrusted = att.SMIMEVerified + isEncrypted = att.IsSMIMEEncrypted + } else if att.IsSMIMESignature || att.Filename == "smime.p7s" || att.Filename == "smime.p7m" || strings.HasPrefix(att.MIMEType, "application/pkcs7") { + // Skip UI rendering + } else { + filteredAtts = append(filteredAtts, att) + } + } + email.Attachments = filteredAtts + // Pass the styles from the tui package to the view package inlineImages := inlineImagesFromAttachments(email.Attachments) @@ -73,6 +94,9 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma mailbox: mailbox, disableImages: disableImages, showImages: showImages, + isSMIME: isSMIME, + smimeTrusted: smimeTrusted, + isEncrypted: isEncrypted, } } @@ -205,7 +229,18 @@ func (m *EmailView) View() tea.View { // as escape sequences in the return string execute too late. clearKittyGraphics() - header := fmt.Sprintf("From: %s | Subject: %s", m.email.From, m.email.Subject) + smimeStatus := "" + if m.isEncrypted { + smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(" [S/MIME: 🔒 Encrypted]") + } else if m.isSMIME { + if m.smimeTrusted { + smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(" [S/MIME: ✅ Trusted]") + } else { + smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(" [S/MIME: ❌ Untrusted]") + } + } + + header := fmt.Sprintf("From: %s | Subject: %s%s", m.email.From, m.email.Subject, smimeStatus) styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header) var help string diff --git a/tui/messages.go b/tui/messages.go index 7f68317107d7fe15bd66768ae42312d5af7ed64f..2212953d309ae97812312d7d000916a83153f012 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -33,12 +33,14 @@ type SendEmailMsg struct { AccountID string // ID of the account to send from QuotedText string // Hidden quoted text appended when sending 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 } type Credentials struct { Provider string Name string - Host string // Host (this was the previous \"Email Address\" field in the UI) + Host string // Host (this was the previous "Email Address" field in the UI) FetchEmail string // Single email address to fetch messages for. If empty, code should default this to Host when creating the account. Password string IMAPServer string diff --git a/tui/settings.go b/tui/settings.go index 855703e0831a59b71693c0d7660c695fe89fc258..6e7d968edae15c8e6302310547cdb3381b2c75a7 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/floatpane/matcha/config" @@ -14,6 +15,9 @@ var ( selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42")) accountEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + + settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + settingsBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) ) type SettingsState int @@ -22,6 +26,7 @@ const ( SettingsMain SettingsState = iota SettingsAccounts SettingsMailingLists + SettingsSMIMEConfig ) // Settings displays the settings screen. @@ -32,6 +37,12 @@ type Settings struct { confirmingDelete bool width int height int + + // S/MIME Config fields + editingAccountIdx int + focusIndex int + smimeCertInput textinput.Model + smimeKeyInput textinput.Model } // NewSettings creates a new settings model. @@ -39,24 +50,42 @@ func NewSettings(cfg *config.Config) *Settings { if cfg == nil { cfg = &config.Config{} } + + certInput := textinput.New() + certInput.Placeholder = "/path/to/cert.pem" + certInput.Prompt = "> " + certInput.CharLimit = 256 + + keyInput := textinput.New() + keyInput.Placeholder = "/path/to/private_key.pem" + keyInput.Prompt = "> " + keyInput.CharLimit = 256 + return &Settings{ - cfg: cfg, - state: SettingsMain, - cursor: 0, + cfg: cfg, + state: SettingsMain, + cursor: 0, + smimeCertInput: certInput, + smimeKeyInput: keyInput, } } // Init initializes the settings model. func (m *Settings) Init() tea.Cmd { - return nil + return textinput.Blink } // Update handles messages for the settings model. func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.smimeCertInput.SetWidth(m.width - 6) + m.smimeKeyInput.SetWidth(m.width - 6) return m, nil case tea.KeyPressMsg: @@ -64,11 +93,24 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateMain(msg) } else if m.state == SettingsMailingLists { return m.updateMailingLists(msg) + } else if m.state == SettingsSMIMEConfig { + var m2 *Settings + m2, cmd = m.updateSMIMEConfig(msg) + cmds = append(cmds, cmd) + return m2, tea.Batch(cmds...) } else { return m.updateAccounts(msg) } } - return m, nil + + if m.state == SettingsSMIMEConfig { + m.smimeCertInput, cmd = m.smimeCertInput.Update(msg) + cmds = append(cmds, cmd) + m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) } func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { @@ -148,6 +190,15 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // If cursor is on "Add Account" if m.cursor == len(m.cfg.Accounts) { return m, func() tea.Msg { return GoToAddAccountMsg{} } + } else if m.cursor < len(m.cfg.Accounts) { + m.editingAccountIdx = m.cursor + m.state = SettingsSMIMEConfig + m.smimeCertInput.SetValue(m.cfg.Accounts[m.cursor].SMIMECert) + m.smimeKeyInput.SetValue(m.cfg.Accounts[m.cursor].SMIMEKey) + m.focusIndex = 0 + m.smimeCertInput.Focus() + m.smimeKeyInput.Blur() + return m, textinput.Blink } case "esc": m.state = SettingsMain @@ -157,6 +208,74 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg.String() { + case "esc": + m.state = SettingsAccounts + return m, nil + case "tab", "shift+tab", "up", "down": + if msg.String() == "shift+tab" || msg.String() == "up" { + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = 4 + } + } else { + m.focusIndex++ + if m.focusIndex > 4 { + m.focusIndex = 0 + } + } + + m.smimeCertInput.Blur() + m.smimeKeyInput.Blur() + + if m.focusIndex == 0 { + cmds = append(cmds, m.smimeCertInput.Focus()) + } else if m.focusIndex == 1 { + cmds = append(cmds, m.smimeKeyInput.Focus()) + } + return m, tea.Batch(cmds...) + case "enter", " ": + if m.focusIndex == 0 && msg.String() == "enter" { + m.focusIndex = 1 + m.smimeCertInput.Blur() + cmds = append(cmds, m.smimeKeyInput.Focus()) + return m, tea.Batch(cmds...) + } else if m.focusIndex == 1 && msg.String() == "enter" { + m.focusIndex = 2 + m.smimeKeyInput.Blur() + return m, nil + } else if m.focusIndex == 2 { + if msg.String() == "enter" || msg.String() == " " { + m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault + } + return m, nil + } else if m.focusIndex == 3 && msg.String() == "enter" { + m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value() + m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value() + _ = config.SaveConfig(m.cfg) + m.state = SettingsAccounts + return m, nil + } else if m.focusIndex == 4 && msg.String() == "enter" { + m.state = SettingsAccounts + return m, nil + } + } + + if m.focusIndex == 0 { + m.smimeCertInput, cmd = m.smimeCertInput.Update(msg) + cmds = append(cmds, cmd) + } else if m.focusIndex == 1 { + m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.confirmingDelete { switch msg.String() { @@ -207,6 +326,8 @@ func (m *Settings) View() tea.View { return tea.NewView(m.viewMain()) } else if m.state == SettingsMailingLists { return tea.NewView(m.viewMailingLists()) + } else if m.state == SettingsSMIMEConfig { + return tea.NewView(m.viewSMIMEConfig()) } return tea.NewView(m.viewAccounts()) } @@ -328,6 +449,10 @@ func (m *Settings) viewAccounts() string { providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer) } + if account.SMIMECert != "" && account.SMIMEKey != "" { + providerInfo += " [S/MIME Configured]" + } + line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo)) if m.cursor == i { @@ -375,6 +500,70 @@ func (m *Settings) viewAccounts() string { return docStyle.Render(mainContent + helpView) } +func (m *Settings) viewSMIMEConfig() string { + var b strings.Builder + + account := m.cfg.Accounts[m.editingAccountIdx] + b.WriteString(titleStyle.Render(fmt.Sprintf("S/MIME Configuration for %s", account.Email))) + b.WriteString("\n\n") + + if m.focusIndex == 0 { + b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n")) + } else { + b.WriteString(settingsBlurredStyle.Render("Certificate (PEM) Path:\n")) + } + b.WriteString(m.smimeCertInput.View() + "\n\n") + + if m.focusIndex == 1 { + b.WriteString(settingsFocusedStyle.Render("Private Key (PEM) Path:\n")) + } else { + b.WriteString(settingsBlurredStyle.Render("Private Key (PEM) Path:\n")) + } + b.WriteString(m.smimeKeyInput.View() + "\n\n") + + signStatus := "OFF" + if account.SMIMESignByDefault { + signStatus = "ON" + } + if m.focusIndex == 2 { + b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", signStatus))) + } else { + b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Sign By Default: %s\n\n", signStatus))) + } + + saveBtn := "[ Save ]" + cancelBtn := "[ Cancel ]" + + if m.focusIndex == 3 { + saveBtn = settingsFocusedStyle.Render(saveBtn) + } else { + saveBtn = settingsBlurredStyle.Render(saveBtn) + } + + if m.focusIndex == 4 { + cancelBtn = settingsFocusedStyle.Render(cancelBtn) + } else { + cancelBtn = settingsBlurredStyle.Render(cancelBtn) + } + + b.WriteString(saveBtn + " " + cancelBtn + "\n\n") + + mainContent := b.String() + helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • esc: back") + + if m.height > 0 { + currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView)) + gap := m.height - currentHeight + if gap > 0 { + mainContent += strings.Repeat("\n", gap) + } + } else { + mainContent += "\n\n" + } + + return docStyle.Render(mainContent + helpView) +} + func (m *Settings) viewMailingLists() string { var b strings.Builder