Detailed changes
@@ -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 != "" {
@@ -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
}
```
@@ -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 := ""
@@ -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
)
@@ -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=
@@ -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}
@@ -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...)
@@ -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,
"",
}
@@ -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)
@@ -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
@@ -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
@@ -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