feat: s/mime (#225)

Drew Smirnoff created

* feat: s/mime

Signed-off-by: drew <me@andrinoff.com>

* test: fix focus cycling tests

Signed-off-by: drew <me@andrinoff.com>

* feat: email view verification of s/mime

Signed-off-by: drew <me@andrinoff.com>

* feat: add support for encryption

Signed-off-by: drew <me@andrinoff.com>

* fix: settings reconfig

Signed-off-by: drew <me@andrinoff.com>

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

config/config.go           |  62 ++++++---
docs/docs/Configuration.md |   6 
fetcher/fetcher.go         | 261 ++++++++++++++++++++++++++++++++++++++-
go.mod                     |   2 
go.sum                     |   2 
main                       |   0 
main.go                    |   2 
sender/sender.go           | 197 +++++++++++++++++++++++++++---
tui/composer.go            |  68 +++++++---
tui/composer_test.go       |  17 +
tui/email_view.go          |  37 +++++
tui/messages.go            |   4 
tui/settings.go            | 199 +++++++++++++++++++++++++++++
13 files changed, 772 insertions(+), 85 deletions(-)

Detailed changes

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 != "" {

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
 }
 ```
 

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 := ""

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
 )

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=

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}

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...)

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,
 		"",
 	}

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)

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

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

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