feat: PGP signing and encryption (#438)

Drew Smirnoff , Steve Evans , and Lea created

Co-authored-by: Steve Evans <steve@floatpane.com>
Co-authored-by: Lea <lea@floatpane.com>

Change summary

.github/workflows/ci.yml  |   4 
backend/backend.go        |   5 
backend/imap/imap.go      |   1 
backend/pop3/pop3.go      |   1 
config/config.go          | 117 ++++++---
docs/docs/Features/PGP.md | 322 ++++++++++++++++++++++++++
fetcher/fetcher.go        | 245 ++++++++++++++++++++
go.mod                    |   7 
go.sum                    |  32 ++
main.go                   |  13 
pgp/yubikey.go            | 490 +++++++++++++++++++++++++++++++++++++++++
sender/sender.go          | 271 ++++++++++++++++++++++
tui/composer.go           |  21 +
tui/composer_test.go      |  17 +
tui/email_view.go         |  39 ++
tui/messages.go           |   2 
tui/settings.go           | 273 +++++++++++++++++++---
17 files changed, 1,773 insertions(+), 87 deletions(-)

Detailed changes

.github/workflows/ci.yml 🔗

@@ -20,6 +20,10 @@ jobs:
         with:
           go-version: "1.26.1"
 
+      - name: Install system dependencies
+        if: runner.os == 'Linux'
+        run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev
+
       - name: Tidy modules
         run: go mod tidy
 

backend/backend.go 🔗

@@ -82,6 +82,9 @@ type Attachment struct {
 	IsSMIMESignature bool
 	SMIMEVerified    bool
 	IsSMIMEEncrypted bool
+	IsPGPSignature   bool
+	PGPVerified      bool
+	IsPGPEncrypted   bool
 }
 
 // Folder represents a mailbox/folder.
@@ -105,6 +108,8 @@ type OutgoingEmail struct {
 	References   []string
 	SignSMIME    bool
 	EncryptSMIME bool
+	SignPGP      bool
+	EncryptPGP   bool
 }
 
 // NotifyType indicates the kind of notification event.

backend/imap/imap.go 🔗

@@ -70,6 +70,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro
 		msg.Images, msg.Attachments,
 		msg.InReplyTo, msg.References,
 		msg.SignSMIME, msg.EncryptSMIME,
+		msg.SignPGP, msg.EncryptPGP,
 	)
 }
 

backend/pop3/pop3.go 🔗

@@ -212,6 +212,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro
 		msg.Images, msg.Attachments,
 		msg.InReplyTo, msg.References,
 		msg.SignSMIME, msg.EncryptSMIME,
+		msg.SignPGP, msg.EncryptPGP,
 	)
 }
 

config/config.go 🔗

@@ -34,6 +34,14 @@ type Account struct {
 	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
 
+	// PGP settings
+	PGPPublicKey        string `json:"pgp_public_key,omitempty"`         // Path to public key (.asc or .gpg)
+	PGPPrivateKey       string `json:"pgp_private_key,omitempty"`        // Path to private key (.asc or .gpg)
+	PGPKeySource        string `json:"pgp_key_source,omitempty"`         // "file" (default) or "yubikey" for hardware key
+	PGPPIN              string `json:"-"`                                // YubiKey PIN (stored in keyring, not JSON)
+	PGPSignByDefault    bool   `json:"pgp_sign_by_default,omitempty"`    // Auto-sign outgoing emails
+	PGPEncryptByDefault bool   `json:"pgp_encrypt_by_default,omitempty"` // Auto-encrypt when recipient keys available
+
 	// OAuth2 settings
 	AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
 
@@ -159,13 +167,17 @@ func configFile() (string, error) {
 
 // SaveConfig saves the given configuration to the config file and passwords to the keyring.
 func SaveConfig(config *Config) error {
-	// Save passwords to the OS keyring before writing the JSON file
+	// Save passwords and PGP PINs to the OS keyring before writing the JSON file
 	for _, acc := range config.Accounts {
 		if acc.Password != "" {
 			// We ignore the error here because some environments (like headless CI)
 			// might not have a keyring service, but we still want to save the rest of the config.
 			_ = keyring.Set(keyringServiceName, acc.Email, acc.Password)
 		}
+		// Save YubiKey PIN if present
+		if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
+			_ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN)
+		}
 	}
 
 	path, err := configFile()
@@ -198,25 +210,30 @@ 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"`
-		SMIMECert          string `json:"smime_cert,omitempty"`
-		SMIMEKey           string `json:"smime_key,omitempty"`
-		SMIMESignByDefault bool   `json:"smime_sign_by_default,omitempty"`
-		AuthMethod         string `json:"auth_method,omitempty"`
-		Protocol           string `json:"protocol,omitempty"`
-		JMAPEndpoint       string `json:"jmap_endpoint,omitempty"`
-		POP3Server         string `json:"pop3_server,omitempty"`
-		POP3Port           int    `json:"pop3_port,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"`
+		PGPPublicKey        string `json:"pgp_public_key,omitempty"`
+		PGPPrivateKey       string `json:"pgp_private_key,omitempty"`
+		PGPKeySource        string `json:"pgp_key_source,omitempty"`
+		PGPSignByDefault    bool   `json:"pgp_sign_by_default,omitempty"`
+		PGPEncryptByDefault bool   `json:"pgp_encrypt_by_default,omitempty"`
+		AuthMethod          string `json:"auth_method,omitempty"`
+		Protocol            string `json:"protocol,omitempty"`
+		JMAPEndpoint        string `json:"jmap_endpoint,omitempty"`
+		POP3Server          string `json:"pop3_server,omitempty"`
+		POP3Port            int    `json:"pop3_port,omitempty"`
 	}
 	type diskConfig struct {
 		Accounts             []rawAccount  `json:"accounts"`
@@ -259,24 +276,29 @@ func LoadConfig() (*Config, error) {
 	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,
-			SMIMECert:          rawAcc.SMIMECert,
-			SMIMEKey:           rawAcc.SMIMEKey,
-			SMIMESignByDefault: rawAcc.SMIMESignByDefault,
-			AuthMethod:         rawAcc.AuthMethod,
-			Protocol:           rawAcc.Protocol,
-			JMAPEndpoint:       rawAcc.JMAPEndpoint,
-			POP3Server:         rawAcc.POP3Server,
-			POP3Port:           rawAcc.POP3Port,
+			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,
+			PGPPublicKey:        rawAcc.PGPPublicKey,
+			PGPPrivateKey:       rawAcc.PGPPrivateKey,
+			PGPKeySource:        rawAcc.PGPKeySource,
+			PGPSignByDefault:    rawAcc.PGPSignByDefault,
+			PGPEncryptByDefault: rawAcc.PGPEncryptByDefault,
+			AuthMethod:          rawAcc.AuthMethod,
+			Protocol:            rawAcc.Protocol,
+			JMAPEndpoint:        rawAcc.JMAPEndpoint,
+			POP3Server:          rawAcc.POP3Server,
+			POP3Port:            rawAcc.POP3Port,
 		}
 
 		if rawAcc.Password != "" {
@@ -291,6 +313,13 @@ func LoadConfig() (*Config, error) {
 			}
 		}
 
+		// Load YubiKey PIN from keyring if using YubiKey
+		if acc.PGPKeySource == "yubikey" {
+			if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
+				acc.PGPPIN = pin
+			}
+		}
+
 		config.Accounts = append(config.Accounts, acc)
 	}
 
@@ -329,6 +358,8 @@ func (c *Config) RemoveAccount(id string) bool {
 		if acc.ID == id {
 			// Delete password from OS Keyring when account is removed
 			_ = keyring.Delete(keyringServiceName, acc.Email)
+			// Delete PGP PIN from OS Keyring if present
+			_ = keyring.Delete(keyringServiceName, acc.Email+":pgp-pin")
 
 			c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
 			return true
@@ -369,3 +400,13 @@ func (c *Config) GetFirstAccount() *Account {
 	}
 	return nil
 }
+
+// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
+func EnsurePGPDir() error {
+	dir, err := configDir()
+	if err != nil {
+		return err
+	}
+	pgpDir := filepath.Join(dir, "pgp")
+	return os.MkdirAll(pgpDir, 0700)
+}

docs/docs/Features/PGP.md 🔗

@@ -0,0 +1,322 @@
+# PGP Email Security
+
+Matcha supports PGP (Pretty Good Privacy) for signing and encrypting your emails. PGP is widely used in open-source communities, journalism, and privacy-focused environments.
+
+## Features
+
+- **Digital Signing**: Cryptographically sign outgoing emails so recipients can verify they came from you.
+- **Encryption**: Encrypt emails so only the intended recipients can read them.
+- **Signature Verification**: Automatically verify PGP signatures on incoming emails.
+- **Encrypted Email Decryption**: Decrypt incoming PGP-encrypted emails using your private key.
+- **Per-Account Configuration**: Configure separate keys for each email account.
+- **Sign by Default**: Optionally enable automatic signing for all outgoing emails.
+- **Encrypt by Default**: Optionally encrypt all outgoing emails when recipient keys are available.
+- **YubiKey Support**: Sign emails using a YubiKey or other OpenPGP smartcard.
+
+## Setting Up PGP (File-Based Keys)
+
+### 1. Generate a PGP Key Pair
+
+If you don't already have a PGP key, generate one with GnuPG:
+
+```bash
+gpg --full-generate-key
+```
+
+Follow the prompts to select:
+- Key type: **RSA and RSA** (default)
+- Key size: **4096** bits (recommended)
+- Expiration: your preference
+- Name and email address
+
+### 2. Export Your Keys
+
+Export your public and private keys to files:
+
+```bash
+# Create a directory for your keys
+mkdir -p ~/.config/matcha/pgp
+chmod 700 ~/.config/matcha/pgp
+
+# Export your public key
+gpg --export --armor your@email.com > ~/.config/matcha/pgp/public.asc
+
+# Export your private key
+gpg --export-secret-keys --armor your@email.com > ~/.config/matcha/pgp/private.asc
+
+# Protect the private key
+chmod 600 ~/.config/matcha/pgp/private.asc
+```
+
+### 3. Configure in Matcha
+
+Open **Settings**, select an account, and configure the PGP section:
+
+| Field | Description |
+|-------|-------------|
+| **Public Key Path** | Path to your public key file (e.g. `~/.config/matcha/pgp/public.asc`) |
+| **Private Key Path** | Path to your private key file (e.g. `~/.config/matcha/pgp/private.asc`) |
+| **Key Source** | `File` for file-based keys, `YubiKey` for hardware keys |
+| **Sign by Default** | Toggle to automatically sign all outgoing emails |
+| **Encrypt by Default** | Toggle to encrypt when recipient keys are available |
+
+Your configuration is stored per-account in `~/.config/matcha/config.json`:
+
+```json
+{
+  "accounts": [
+    {
+      "email": "you@example.com",
+      "pgp_public_key": "/home/you/.config/matcha/pgp/public.asc",
+      "pgp_private_key": "/home/you/.config/matcha/pgp/private.asc",
+      "pgp_sign_by_default": true,
+      "pgp_encrypt_by_default": false
+    }
+  ]
+}
+```
+
+### 4. Sending Signed Emails
+
+When **Sign by Default** is enabled, all outgoing emails are automatically signed with your PGP key. Recipients with PGP-capable email clients will see a verification indicator.
+
+### 5. Sending Encrypted Emails
+
+To encrypt an email, toggle the **Encrypt Email (PGP)** checkbox in the composer. For encryption to work, you need the recipient's public key stored in:
+
+```
+~/.config/matcha/pgp/<recipient-email>.asc
+```
+
+For example, to encrypt an email to `alice@example.com`, place her public key at:
+
+```
+~/.config/matcha/pgp/alice@example.com.asc
+```
+
+You can obtain someone's public key from:
+- A keyserver: `gpg --recv-keys <key-id> && gpg --export --armor alice@example.com > ~/.config/matcha/pgp/alice@example.com.asc`
+- Their website or email signature
+- Direct exchange
+
+Matcha automatically includes your own public key when encrypting, so you can still read the email in your Sent folder.
+
+### Supported Key Formats
+
+Matcha supports both common OpenPGP key formats:
+
+| Format | Extension | Description |
+|--------|-----------|-------------|
+| ASCII-armored | `.asc` | Text-based format, starts with `-----BEGIN PGP PUBLIC KEY BLOCK-----` |
+| Binary | `.gpg` | Compact binary format |
+
+## Setting Up PGP with YubiKey
+
+Matcha supports signing emails directly on a YubiKey or other OpenPGP-compatible smartcard. The private key never leaves the hardware device.
+
+### Prerequisites
+
+1. **A YubiKey with an OpenPGP key**: Your YubiKey must have a signing key loaded. You can either generate a key on-device or import an existing one using `gpg --edit-key`.
+
+2. **PC/SC daemon**: The `pcscd` service must be running. This is the middleware that communicates with smartcards.
+
+3. **CCID driver**: Required for USB smartcard communication.
+
+#### Install on Arch Linux
+
+```bash
+sudo pacman -S pcsclite ccid
+sudo systemctl enable --now pcscd.socket
+```
+
+#### Install on Debian/Ubuntu
+
+```bash
+sudo apt install pcscd libccid
+sudo systemctl enable --now pcscd.socket
+```
+
+#### Install on Fedora
+
+```bash
+sudo dnf install pcsc-lite ccid
+sudo systemctl enable --now pcscd.socket
+```
+
+#### Install on macOS
+
+PC/SC is built into macOS. No additional installation needed.
+
+### Configure in Matcha
+
+1. Open **Settings** and select your account.
+2. In the PGP section, set **Key Source** to `YubiKey`.
+3. Enter your YubiKey PIN (stored securely in your OS keyring, never written to disk).
+4. Enable **Sign by Default** if desired.
+
+Your configuration:
+
+```json
+{
+  "accounts": [
+    {
+      "email": "you@example.com",
+      "pgp_key_source": "yubikey",
+      "pgp_sign_by_default": true
+    }
+  ]
+}
+```
+
+Note: The YubiKey PIN is stored in your OS keyring (e.g. GNOME Keyring, KDE Wallet, macOS Keychain) and is never saved to `config.json`.
+
+### Moving Your GPG Key to a YubiKey
+
+If you have an existing GPG key and want to move it to your YubiKey:
+
+```bash
+# Find your key ID
+gpg --list-secret-keys --keyid-format long
+
+# Edit the key
+gpg --edit-key <KEY-ID>
+
+# In the GPG prompt, move the signing subkey to the card
+gpg> keytocard
+
+# Select slot 1 (Signature key)
+# Save and quit
+gpg> save
+```
+
+### Generating a Key Directly on YubiKey
+
+```bash
+# Edit the card
+gpg --card-edit
+
+# Enter admin mode
+gpg/card> admin
+
+# Generate keys on-device (private key never leaves the YubiKey)
+gpg/card> generate
+```
+
+## Status Indicators
+
+When viewing an email, Matcha shows the PGP status in the header:
+
+| Badge | Meaning |
+|-------|---------|
+| `[PGP: Verified]` | The PGP signature was verified successfully |
+| `[PGP: Unverified]` | A PGP signature is present but could not be verified |
+| `[PGP: Encrypted]` | The email was PGP-encrypted and decrypted successfully |
+
+## PGP vs S/MIME
+
+Matcha supports both PGP and S/MIME. They are mutually exclusive per message: you cannot sign the same email with both. Choose based on your needs:
+
+| | PGP | S/MIME |
+|---|-----|--------|
+| **Key management** | Manual (keyservers, direct exchange) | Certificate Authorities |
+| **Trust model** | Web of Trust / TOFU | Hierarchical (CA-based) |
+| **Popular with** | Open-source, privacy communities | Enterprise, corporate |
+| **Hardware keys** | YubiKey, smartcards | Smart cards, USB tokens |
+| **Setup effort** | Lower (self-managed keys) | Higher (need CA-issued certificate) |
+
+## Troubleshooting
+
+### "Failed to connect to PC/SC daemon"
+
+The `pcscd` service is not running.
+
+```bash
+# Start pcscd
+sudo systemctl enable --now pcscd.socket
+
+# Verify it's running
+systemctl status pcscd.socket
+```
+
+### "No OpenPGP smartcard found"
+
+The YubiKey is not detected. Check:
+
+1. **Is the YubiKey plugged in?**
+   ```bash
+   lsusb | grep -i yubi
+   ```
+
+2. **Is the CCID driver installed?**
+   ```bash
+   # Arch Linux
+   sudo pacman -S ccid
+
+   # Debian/Ubuntu
+   sudo apt install libccid
+   ```
+
+3. **Can pcscd see the card readers?**
+   ```bash
+   systemctl status pcscd
+   ```
+   Look for `LIBUSB_ERROR_ACCESS` or `LIBUSB_ERROR_BUSY` in the logs.
+
+### "LIBUSB_ERROR_ACCESS" in pcscd logs
+
+The `pcscd` user doesn't have permission to access the USB device. Add a udev rule:
+
+```bash
+# Create udev rule (adjust idProduct if needed)
+echo 'ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="1050", MODE="0666"' | \
+  sudo tee /etc/udev/rules.d/70-yubikey.rules
+
+# Reload rules and replug YubiKey
+sudo udevadm control --reload-rules
+sudo udevadm trigger
+```
+
+Then restart pcscd:
+
+```bash
+sudo systemctl restart pcscd.socket pcscd.service
+```
+
+### "LIBUSB_ERROR_BUSY" in pcscd logs
+
+Another process (usually GnuPG's `scdaemon`) has an exclusive lock on the YubiKey. Configure `scdaemon` to share access through `pcscd`:
+
+```bash
+# Add to ~/.gnupg/scdaemon.conf
+echo -e "disable-ccid\npcsc-shared" >> ~/.gnupg/scdaemon.conf
+
+# Restart scdaemon
+gpgconf --kill scdaemon
+```
+
+This tells `scdaemon` to use `pcscd` as its backend instead of grabbing the USB device directly, allowing both GnuPG and Matcha to share the YubiKey.
+
+### "PIN verification failed"
+
+- The default YubiKey PIN is `123456` (change it with `gpg --card-edit` then `passwd`).
+- After 3 wrong PIN attempts, the PIN is locked. Reset it with the Admin PIN (default `12345678`) using `gpg --card-edit` then `admin` then `passwd`.
+
+### "No PGP keys found in keyring"
+
+Your exported key file may be empty or corrupted. Verify it:
+
+```bash
+# Check the public key
+gpg --show-keys ~/.config/matcha/pgp/public.asc
+
+# Check the private key
+gpg --show-keys ~/.config/matcha/pgp/private.asc
+```
+
+### Signature shows as "Unverified"
+
+This happens when the sender's public key is not available to verify the signature. To verify signatures from a contact, store their public key at:
+
+```
+~/.config/matcha/pgp/<sender-email>.asc
+```

fetcher/fetcher.go 🔗

@@ -17,9 +17,11 @@ import (
 	"strings"
 	"time"
 
+	"github.com/ProtonMail/go-crypto/openpgp"
 	"github.com/emersion/go-imap"
 	"github.com/emersion/go-imap/client"
 	"github.com/emersion/go-message/mail"
+	"github.com/emersion/go-pgpmail"
 	"github.com/floatpane/matcha/config"
 	"go.mozilla.org/pkcs7"
 	"golang.org/x/text/encoding/ianaindex"
@@ -38,6 +40,9 @@ type Attachment struct {
 	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
+	IsPGPSignature   bool   // True if this attachment is a PGP signature
+	PGPVerified      bool   // True if the PGP signature was verified successfully
+	IsPGPEncrypted   bool   // True if the PGP content was successfully decrypted
 }
 
 type Email struct {
@@ -729,6 +734,122 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint
 				}
 			}
 			attachments = append(attachments, att)
+		}
+
+		// === PGP ENCRYPTED MESSAGE DETECTION ===
+		if mimeType == "application/pgp-encrypted" || (mimeType == "multipart/encrypted" && strings.Contains(part.MIMESubType, "pgp")) {
+			// PGP encrypted messages typically have two parts:
+			// 1. Version info (application/pgp-encrypted)
+			// 2. Encrypted data (application/octet-stream)
+			// We'll handle decryption when we find the encrypted data part
+			// Skip this part and continue processing
+		}
+
+		// Detect encrypted data part of PGP message
+		if strings.Contains(filename, ".asc") || (mimeType == "application/octet-stream" && part.Encoding == "7bit") {
+			// This might be PGP encrypted data
+			data, err := fetchInlinePart(partID, part.Encoding)
+			if err == nil && bytes.Contains(data, []byte("-----BEGIN PGP MESSAGE-----")) {
+				// This is PGP encrypted content
+				if account.PGPPrivateKey != "" {
+					decrypted, err := decryptPGPMessage(data, account)
+					if err == nil {
+						// Parse the decrypted MIME content
+						mr, err := mail.CreateReader(bytes.NewReader(decrypted))
+						if err == nil {
+							for {
+								p, err := mr.NextPart()
+								if err == io.EOF {
+									break
+								}
+								if err != nil {
+									break
+								}
+
+								switch h := p.Header.(type) {
+								case *mail.InlineHeader:
+									ct, _, _ := h.ContentType()
+									if strings.HasPrefix(ct, "text/html") {
+										body, _ := io.ReadAll(p.Body)
+										extractedBody = string(body)
+										htmlPartID = "decrypted"
+									} else if strings.HasPrefix(ct, "text/plain") && extractedBody == "" {
+										body, _ := io.ReadAll(p.Body)
+										extractedBody = string(body)
+										htmlPartID = "decrypted"
+									}
+								}
+							}
+
+							// Add status marker
+							attachments = append(attachments, Attachment{
+								Filename:       "pgp-status.internal",
+								IsPGPEncrypted: true,
+								PGPVerified:    true, // Decryption succeeded
+							})
+						}
+					} else {
+						extractedBody = fmt.Sprintf("**PGP Decryption Failed:** %s\n", err)
+						htmlPartID = "extracted"
+					}
+				} else {
+					extractedBody = "**PGP Encrypted:** Private key not configured\n"
+					htmlPartID = "extracted"
+				}
+			}
+		}
+
+		// === PGP DETACHED SIGNATURE VERIFICATION ===
+		if filename == "signature.asc" || mimeType == "application/pgp-signature" {
+			att := Attachment{
+				Filename:       filename,
+				PartID:         partID,
+				Encoding:       part.Encoding,
+				MIMEType:       mimeType,
+				ContentID:      contentID,
+				Inline:         isInline,
+				IsPGPSignature: true,
+			}
+
+			if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
+				att.Data = data
+
+				// Try to verify the signature
+				boundary := msg.BodyStructure.Params["boundary"]
+				if boundary != "" {
+					rawEmail, err := fetchWholeMessage()
+					if err == nil {
+						// Extract signed content (similar to S/MIME)
+						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]
+
+								// Verify PGP signature
+								verified := verifyPGPSignature(signedData, data, account)
+								att.PGPVerified = verified
+							}
+						}
+					}
+				}
+			}
+			attachments = append(attachments, att)
 		} else if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
 			att := Attachment{
 				Filename:  filename,
@@ -1336,3 +1457,127 @@ func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error
 func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error {
 	return ArchiveEmailFromMailbox(account, folder, uid)
 }
+
+// decryptPGPMessage decrypts a PGP-encrypted message using the account's private key.
+func decryptPGPMessage(encryptedData []byte, account *config.Account) ([]byte, error) {
+	if account.PGPPrivateKey == "" {
+		return nil, errors.New("PGP private key not configured")
+	}
+
+	// Load private key
+	keyFile, err := os.ReadFile(account.PGPPrivateKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read PGP private key: %w", err)
+	}
+
+	// Try armored format first
+	entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
+	if err != nil {
+		// Try binary format
+		entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse PGP private key: %w", err)
+		}
+	}
+
+	if len(entityList) == 0 {
+		return nil, errors.New("no PGP keys found in private keyring")
+	}
+
+	// Decrypt using go-pgpmail
+	mr, err := pgpmail.Read(bytes.NewReader(encryptedData), openpgp.EntityList{entityList[0]}, nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to decrypt PGP message: %w", err)
+	}
+
+	// Read decrypted content from UnverifiedBody
+	if mr.MessageDetails == nil || mr.MessageDetails.UnverifiedBody == nil {
+		return nil, errors.New("no decrypted content available")
+	}
+
+	var decrypted bytes.Buffer
+	if _, err := io.Copy(&decrypted, mr.MessageDetails.UnverifiedBody); err != nil {
+		return nil, fmt.Errorf("failed to read decrypted content: %w", err)
+	}
+
+	return decrypted.Bytes(), nil
+}
+
+// loadPGPKeyring builds an openpgp.EntityList from the account's public key
+// and any keys stored in the pgp/ config directory.
+func loadPGPKeyring(account *config.Account) openpgp.EntityList {
+	var keyring openpgp.EntityList
+
+	readKeys := func(path string) {
+		data, err := os.ReadFile(path)
+		if err != nil {
+			return
+		}
+		entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
+		if err != nil {
+			entities, err = openpgp.ReadKeyRing(bytes.NewReader(data))
+			if err != nil {
+				return
+			}
+		}
+		keyring = append(keyring, entities...)
+	}
+
+	// Load account's own public key
+	if account.PGPPublicKey != "" {
+		readKeys(account.PGPPublicKey)
+	}
+
+	// Load all keys from the pgp/ config directory
+	cfgDir, err := config.GetConfigDir()
+	if err == nil {
+		pgpDir := cfgDir + "/pgp"
+		entries, err := os.ReadDir(pgpDir)
+		if err == nil {
+			for _, entry := range entries {
+				if entry.IsDir() {
+					continue
+				}
+				name := entry.Name()
+				if strings.HasSuffix(name, ".asc") || strings.HasSuffix(name, ".gpg") {
+					readKeys(pgpDir + "/" + name)
+				}
+			}
+		}
+	}
+
+	return keyring
+}
+
+// verifyPGPSignature verifies a PGP detached signature against signed content.
+func verifyPGPSignature(signedContent, signatureData []byte, account *config.Account) bool {
+	keyring := loadPGPKeyring(account)
+	if len(keyring) == 0 {
+		return false
+	}
+
+	// Build a complete multipart/signed message for go-pgpmail
+	boundary := "pgp-verify-boundary"
+	var msg bytes.Buffer
+	msg.WriteString("Content-Type: multipart/signed; boundary=\"" + boundary + "\"; micalg=pgp-sha256; protocol=\"application/pgp-signature\"\r\n\r\n")
+	msg.WriteString("--" + boundary + "\r\n")
+	msg.Write(signedContent)
+	msg.WriteString("\r\n--" + boundary + "\r\n")
+	msg.WriteString("Content-Type: application/pgp-signature\r\n\r\n")
+	msg.Write(signatureData)
+	msg.WriteString("\r\n--" + boundary + "--\r\n")
+
+	mr, err := pgpmail.Read(&msg, keyring, nil, nil)
+	if err != nil {
+		return false
+	}
+
+	if mr.MessageDetails == nil {
+		return false
+	}
+
+	// Must read UnverifiedBody to EOF to trigger signature verification
+	_, _ = io.ReadAll(mr.MessageDetails.UnverifiedBody)
+
+	return mr.MessageDetails.SignatureError == nil
+}

go.mod 🔗

@@ -6,10 +6,15 @@ require (
 	charm.land/bubbles/v2 v2.1.0
 	charm.land/bubbletea/v2 v2.0.2
 	charm.land/lipgloss/v2 v2.0.2
+	cunicu.li/go-iso7816 v0.8.8
+	cunicu.li/go-openpgp-card v0.3.11
 	git.sr.ht/~rockorager/go-jmap v0.5.3
+	github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c
 	github.com/PuerkitoBio/goquery v1.12.0
+	github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25
 	github.com/emersion/go-imap v1.2.1
 	github.com/emersion/go-message v0.18.2
+	github.com/emersion/go-pgpmail v0.2.2
 	github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
 	github.com/google/uuid v1.6.0
 	github.com/knadh/go-pop3 v1.0.2
@@ -32,6 +37,7 @@ require (
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
 	github.com/clipperhouse/displaywidth v0.11.0 // indirect
 	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+	github.com/cloudflare/circl v1.3.7 // indirect
 	github.com/danieljoos/wincred v1.2.3 // indirect
 	github.com/godbus/dbus/v5 v5.2.2 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
@@ -41,6 +47,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
+	golang.org/x/crypto v0.49.0 // indirect
 	golang.org/x/net v0.52.0 // indirect
 	golang.org/x/oauth2 v0.4.0 // indirect
 	golang.org/x/sync v0.20.0 // indirect

go.sum 🔗

@@ -4,10 +4,16 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
 charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
 charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
 charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
+cunicu.li/go-iso7816 v0.8.8 h1:Srk2XLxWvS5GI1Hu5XJfrmW1R0mf3ghJnipo/aKJiZM=
+cunicu.li/go-iso7816 v0.8.8/go.mod h1:tiWdoe9DcrVlHVRrNoQ2sn/QDbfiL7OIcKuTVzJqf0I=
+cunicu.li/go-openpgp-card v0.3.11 h1:UjOon8J3kbYav9282MlbjSqxLgDHzkKQp5rq5Z0knBw=
+cunicu.li/go-openpgp-card v0.3.11/go.mod h1:SHrDSmwX/wMWUugtEJgkDxDy6LuCnXHxLL/OnxlL2SY=
 git.sr.ht/~rockorager/go-jmap v0.5.3 h1:PjnobX0ySPHKG5TiUqLM6PlM3ngYHlLJeWLOeorZ7IY=
 git.sr.ht/~rockorager/go-jmap v0.5.3/go.mod h1:aOTCtwpZSINpDDSOkLGpHU0Kbbm5lcSDMcobX3ZtOjY=
 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE=
+github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
 github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
 github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
 github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
@@ -16,6 +22,7 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
 github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
@@ -34,15 +41,23 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
 github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
 github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
+github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
+github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
 github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 h1:vXmXuiy1tgifTqWAAaU+ESu1goRp4B3fdhemWMMrS4g=
+github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25/go.mod h1:BkYEeWL6FbT4Ek+TcOBnPzEKnL7kOq2g19tTQXkorHY=
 github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
 github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
 github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
+github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw=
 github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
 github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
+github.com/emersion/go-pgpmail v0.2.2 h1:cO2jwsE0gb8aDdCcVH5Dfe1XV3Rhhw2GVWsmQd3CbaI=
+github.com/emersion/go-pgpmail v0.2.2/go.mod h1:mRB5P7QKiAuOvcT36tdRZvm7nSt7V+f6jbzzup3HuvU=
 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
@@ -91,10 +106,15 @@ 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.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -106,7 +126,9 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
@@ -130,9 +152,13 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -141,9 +167,12 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
@@ -152,8 +181,11 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=

main.go 🔗

@@ -1000,8 +1000,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if composer, ok := m.current.(*tui.Composer); ok {
 			draftID = composer.GetDraftID()
 		}
-		m.current = tui.NewStatus("Sending email...")
-
 		// Get the account to send from
 		var account *config.Account
 		if msg.AccountID != "" && m.config != nil {
@@ -1011,6 +1009,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			account = m.config.GetFirstAccount()
 		}
 
+		statusText := "Sending email..."
+		if msg.SignPGP && account != nil && account.PGPKeySource == "yubikey" {
+			statusText = "Touch your YubiKey to sign..."
+		}
+		m.current = tui.NewStatus(statusText)
+
 		// Save contact and delete draft in background
 		go func() {
 			// Save the recipient as a contact
@@ -1658,7 +1662,7 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
 			attachments[filename] = fileData
 		}
 
-		err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)
+		err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, msg.EncryptPGP)
 		if err != nil {
 			log.Printf("Failed to send email: %v", err)
 			return tui.EmailResultMsg{Err: err}
@@ -2514,6 +2518,9 @@ func main() {
 	}
 	tui.RebuildStyles()
 
+	// Ensure PGP keys directory exists
+	_ = config.EnsurePGPDir()
+
 	var initialModel *mainModel
 	if err != nil {
 		initialModel = newInitialModel(nil)

pgp/yubikey.go 🔗

@@ -0,0 +1,490 @@
+package pgp
+
+import (
+	"bytes"
+	"crypto"
+	"encoding/binary"
+	"fmt"
+	"io"
+	"math/big"
+	"os"
+	"time"
+
+	pgpcrypto "github.com/ProtonMail/go-crypto/openpgp"
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
+	"github.com/ebfe/scard"
+
+	iso "cunicu.li/go-iso7816"
+	"cunicu.li/go-iso7816/drivers/pcsc"
+	"cunicu.li/go-iso7816/filter"
+
+	openpgp "cunicu.li/go-openpgp-card"
+)
+
+// openCard connects to the first available OpenPGP smartcard via PC/SC.
+func openCard() (*openpgp.Card, error) {
+	ctx, err := scard.EstablishContext()
+	if err != nil {
+		return nil, fmt.Errorf(
+			"failed to connect to PC/SC daemon: %w\n"+
+				"Make sure pcscd is running:\n"+
+				"  sudo systemctl enable --now pcscd.socket\n"+
+				"You may also need the ccid package for USB smartcard support.",
+			err,
+		)
+	}
+
+	pcscCard, err := pcsc.OpenFirstCard(ctx, filter.HasApplet(iso.AidOpenPGP), true)
+	if err != nil {
+		ctx.Release()
+		return nil, fmt.Errorf(
+			"no OpenPGP smartcard found: %w\n"+
+				"Make sure your YubiKey is plugged in and has an OpenPGP key configured.",
+			err,
+		)
+	}
+
+	isoCard := iso.NewCard(pcscCard)
+	card, err := openpgp.NewCard(isoCard)
+	if err != nil {
+		pcscCard.Close()
+		ctx.Release()
+		return nil, fmt.Errorf("failed to initialize OpenPGP card: %w", err)
+	}
+
+	return card, nil
+}
+
+// BuildPGPSignedMessage creates a multipart/signed MIME message using a YubiKey.
+// publicKeyPath is the path to the account's PGP public key file, used to read
+// key metadata (fingerprint, key ID, algorithm) for building a valid OpenPGP
+// signature packet.
+func BuildPGPSignedMessage(payload []byte, pin string, publicKeyPath string) ([]byte, error) {
+	card, err := openCard()
+	if err != nil {
+		return nil, err
+	}
+	defer card.Close()
+
+	// Verify PIN (PW1 for signing operations)
+	if err := card.VerifyPassword(openpgp.PW1, pin); err != nil {
+		return nil, fmt.Errorf("PIN verification failed: %w", err)
+	}
+
+	// Get the signing private key from the card.
+	privKey, err := card.PrivateKey(openpgp.KeySign, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get signing key from card: %w", err)
+	}
+
+	signer, ok := privKey.(crypto.Signer)
+	if !ok {
+		return nil, fmt.Errorf("signing key does not implement crypto.Signer")
+	}
+
+	// Load the public key entity to get metadata for the signature packet
+	signingKey, err := loadSigningPublicKey(publicKeyPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to load public key: %w", err)
+	}
+
+	// Split payload into headers and body for MIME structure
+	headers, body := splitPayload(payload)
+
+	// Build the signed body part (this is what gets hashed)
+	boundary := fmt.Sprintf("----=_Part_%d", time.Now().Unix())
+	signedPart := buildSignedPart(headers, body, boundary)
+
+	// Build the OpenPGP signature packet
+	sigPacket, err := buildSignaturePacket(signedPart, signer, signingKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed to build signature: %w", err)
+	}
+
+	// Armor the signature
+	armoredSig, err := armorSignature(sigPacket)
+	if err != nil {
+		return nil, fmt.Errorf("failed to armor signature: %w", err)
+	}
+
+	return buildMultipartSigned(headers, body, boundary, armoredSig), nil
+}
+
+// loadSigningPublicKey reads a PGP public key file and returns the signing
+// subkey's PublicKey (or the primary key if no signing subkey exists).
+func loadSigningPublicKey(path string) (*packet.PublicKey, error) {
+	keyData, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+
+	entities, err := pgpcrypto.ReadArmoredKeyRing(bytes.NewReader(keyData))
+	if err != nil {
+		entities, err = pgpcrypto.ReadKeyRing(bytes.NewReader(keyData))
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse PGP key: %w", err)
+		}
+	}
+	if len(entities) == 0 {
+		return nil, fmt.Errorf("no keys found in keyring")
+	}
+
+	entity := entities[0]
+
+	// Look for a signing subkey first
+	now := time.Now()
+	for _, subkey := range entity.Subkeys {
+		if subkey.Sig != nil && subkey.Sig.FlagsValid && subkey.Sig.FlagSign && !subkey.PublicKey.KeyExpired(subkey.Sig, now) {
+			return subkey.PublicKey, nil
+		}
+	}
+
+	// Fall back to primary key
+	return entity.PrimaryKey, nil
+}
+
+// buildSignaturePacket creates a valid OpenPGP v4 signature packet.
+func buildSignaturePacket(signedContent []byte, signer crypto.Signer, pubKey *packet.PublicKey) ([]byte, error) {
+	now := time.Now()
+	hashAlgo := crypto.SHA256
+	hashAlgoID := byte(8) // SHA-256 in OpenPGP
+
+	// Build hashed subpackets
+	var hashedSubpackets bytes.Buffer
+
+	// Subpacket: signature creation time (type 2)
+	writeSubpacket(&hashedSubpackets, 2, func(buf *bytes.Buffer) {
+		ts := make([]byte, 4)
+		binary.BigEndian.PutUint32(ts, uint32(now.Unix()))
+		buf.Write(ts)
+	})
+
+	// Subpacket: issuer key ID (type 16)
+	writeSubpacket(&hashedSubpackets, 16, func(buf *bytes.Buffer) {
+		kid := make([]byte, 8)
+		binary.BigEndian.PutUint64(kid, pubKey.KeyId)
+		buf.Write(kid)
+	})
+
+	// Subpacket: issuer fingerprint (type 33)
+	writeSubpacket(&hashedSubpackets, 33, func(buf *bytes.Buffer) {
+		buf.WriteByte(byte(pubKey.Version))
+		buf.Write(pubKey.Fingerprint)
+	})
+
+	// Build hash suffix (RFC 4880, Section 5.2.4)
+	var hashSuffix bytes.Buffer
+	hashSuffix.WriteByte(4)                       // version
+	hashSuffix.WriteByte(0x00)                    // signature type: binary
+	hashSuffix.WriteByte(byte(pubKey.PubKeyAlgo)) // public key algorithm
+	hashSuffix.WriteByte(hashAlgoID)              // hash algorithm
+	hsLen := hashedSubpackets.Len()
+	hashSuffix.WriteByte(byte(hsLen >> 8))
+	hashSuffix.WriteByte(byte(hsLen))
+	hashSuffix.Write(hashedSubpackets.Bytes())
+
+	// V4 hash trailer
+	trailer := hashSuffix.Bytes()
+	var hashTrailer bytes.Buffer
+	hashTrailer.WriteByte(4)    // version
+	hashTrailer.WriteByte(0xff) // marker
+	tLen := make([]byte, 4)
+	binary.BigEndian.PutUint32(tLen, uint32(len(trailer)))
+	hashTrailer.Write(tLen)
+
+	// Hash the signed content + hash suffix + trailer
+	hasher := hashAlgo.New()
+	hasher.Write(signedContent)
+	hasher.Write(trailer)
+	hasher.Write(hashTrailer.Bytes())
+	digest := hasher.Sum(nil)
+
+	// Sign with the YubiKey
+	rawSig, err := signer.Sign(nil, digest, hashAlgo)
+	if err != nil {
+		return nil, fmt.Errorf("signing failed: %w", err)
+	}
+
+	// Build the complete signature packet body
+	var body bytes.Buffer
+	body.Write(trailer) // version + sig type + algo + hash algo + hashed subpackets
+
+	// Unhashed subpackets (empty)
+	body.WriteByte(0)
+	body.WriteByte(0)
+
+	// Hash tag (first 2 bytes of digest)
+	body.WriteByte(digest[0])
+	body.WriteByte(digest[1])
+
+	// Encode the signature MPIs based on algorithm
+	switch pubKey.PubKeyAlgo {
+	case packet.PubKeyAlgoEdDSA:
+		// EdDSA: raw signature is r || s, 32 bytes each
+		if len(rawSig) != 64 {
+			return nil, fmt.Errorf("unexpected EdDSA signature length: %d", len(rawSig))
+		}
+		writeMPI(&body, rawSig[:32]) // r
+		writeMPI(&body, rawSig[32:]) // s
+
+	case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSASignOnly:
+		// RSA: single MPI
+		writeMPI(&body, rawSig)
+
+	case packet.PubKeyAlgoECDSA:
+		// ECDSA: card returns ASN.1 DER encoded (R, S)
+		r, s, err := parseASN1Signature(rawSig)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse ECDSA signature: %w", err)
+		}
+		writeMPI(&body, r)
+		writeMPI(&body, s)
+
+	default:
+		return nil, fmt.Errorf("unsupported key algorithm: %d", pubKey.PubKeyAlgo)
+	}
+
+	// Wrap in an OpenPGP packet (new-format header)
+	var pkt bytes.Buffer
+	bodyBytes := body.Bytes()
+	pkt.WriteByte(0xC2) // new-format packet tag for signature (type 2)
+	writeNewFormatLength(&pkt, len(bodyBytes))
+	pkt.Write(bodyBytes)
+
+	return pkt.Bytes(), nil
+}
+
+// armorSignature wraps a binary OpenPGP signature in ASCII armor.
+func armorSignature(sigPacket []byte) ([]byte, error) {
+	var buf bytes.Buffer
+	w, err := armor.Encode(&buf, "PGP SIGNATURE", nil)
+	if err != nil {
+		return nil, err
+	}
+	if _, err := w.Write(sigPacket); err != nil {
+		return nil, err
+	}
+	if err := w.Close(); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+// splitPayload splits a MIME message into headers and body.
+func splitPayload(payload []byte) (headers, body []byte) {
+	if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 {
+		return payload[:idx], payload[idx+4:]
+	}
+	return nil, payload
+}
+
+// buildSignedPart constructs the first MIME part content that gets hashed.
+// This must exactly match what appears between the boundary markers.
+func buildSignedPart(headers, body []byte, boundary string) []byte {
+	var originalContentType []byte
+	if len(headers) > 0 {
+		for _, line := range bytes.Split(headers, []byte("\r\n")) {
+			upper := bytes.ToUpper(line)
+			if bytes.HasPrefix(upper, []byte("CONTENT-TYPE:")) {
+				originalContentType = line
+				break
+			}
+		}
+	}
+
+	var part bytes.Buffer
+	if len(originalContentType) > 0 {
+		part.Write(originalContentType)
+		part.WriteString("\r\n\r\n")
+	}
+	part.Write(body)
+	return part.Bytes()
+}
+
+// buildMultipartSigned assembles the complete multipart/signed MIME message.
+func buildMultipartSigned(headers, body []byte, boundary string, armoredSig []byte) []byte {
+	var result bytes.Buffer
+
+	// Write transport headers (From, To, Subject, etc.) excluding Content-Type and MIME-Version
+	var originalContentType []byte
+	if len(headers) > 0 {
+		for _, line := range bytes.Split(headers, []byte("\r\n")) {
+			upper := bytes.ToUpper(line)
+			if bytes.HasPrefix(upper, []byte("CONTENT-TYPE:")) {
+				originalContentType = line
+				continue
+			}
+			if bytes.HasPrefix(upper, []byte("MIME-VERSION:")) {
+				continue
+			}
+			if len(line) > 0 {
+				result.Write(line)
+				result.WriteString("\r\n")
+			}
+		}
+	}
+
+	// Write the new top-level Content-Type for multipart/signed
+	result.WriteString("MIME-Version: 1.0\r\n")
+	result.WriteString("Content-Type: multipart/signed; ")
+	result.WriteString("boundary=\"" + boundary + "\"; ")
+	result.WriteString("micalg=pgp-sha256; ")
+	result.WriteString("protocol=\"application/pgp-signature\"\r\n")
+	result.WriteString("\r\n")
+
+	// Write first part (original body with its original Content-Type)
+	result.WriteString("--" + boundary + "\r\n")
+	if len(originalContentType) > 0 {
+		result.Write(originalContentType)
+		result.WriteString("\r\n\r\n")
+	}
+	result.Write(body)
+	result.WriteString("\r\n")
+
+	// Write second part (signature)
+	result.WriteString("--" + boundary + "\r\n")
+	result.WriteString("Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n")
+	result.WriteString("Content-Description: OpenPGP digital signature\r\n")
+	result.WriteString("Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n")
+	result.Write(armoredSig)
+	result.WriteString("\r\n")
+	result.WriteString("--" + boundary + "--\r\n")
+
+	return result.Bytes()
+}
+
+// writeSubpacket writes a single OpenPGP subpacket.
+func writeSubpacket(w *bytes.Buffer, typ byte, writeContent func(*bytes.Buffer)) {
+	var content bytes.Buffer
+	writeContent(&content)
+	length := content.Len() + 1 // +1 for type byte
+	if length < 192 {
+		w.WriteByte(byte(length))
+	} else {
+		// Two-octet length
+		length -= 192
+		w.WriteByte(byte(length>>8) + 192)
+		w.WriteByte(byte(length))
+	}
+	w.WriteByte(typ)
+	w.Write(content.Bytes())
+}
+
+// writeMPI writes a big-endian integer as an OpenPGP MPI (2-byte bit count + data).
+func writeMPI(w io.Writer, data []byte) {
+	// Strip leading zero bytes
+	for len(data) > 0 && data[0] == 0 {
+		data = data[1:]
+	}
+	if len(data) == 0 {
+		data = []byte{0}
+	}
+	bitLen := uint16((len(data)-1)*8 + bitLength(data[0]))
+	buf := make([]byte, 2)
+	binary.BigEndian.PutUint16(buf, bitLen)
+	w.Write(buf)  //nolint:errcheck
+	w.Write(data) //nolint:errcheck
+}
+
+// bitLength returns the number of significant bits in a byte.
+func bitLength(b byte) int {
+	n := 0
+	for b > 0 {
+		n++
+		b >>= 1
+	}
+	return n
+}
+
+// writeNewFormatLength writes an OpenPGP new-format packet body length.
+func writeNewFormatLength(w *bytes.Buffer, length int) {
+	if length < 192 {
+		w.WriteByte(byte(length))
+	} else if length < 8384 {
+		length -= 192
+		w.WriteByte(byte(length>>8) + 192)
+		w.WriteByte(byte(length))
+	} else {
+		w.WriteByte(255)
+		buf := make([]byte, 4)
+		binary.BigEndian.PutUint32(buf, uint32(length))
+		w.Write(buf)
+	}
+}
+
+// parseASN1Signature extracts r and s from an ASN.1 DER encoded ECDSA signature.
+func parseASN1Signature(der []byte) (r, s []byte, err error) {
+	// ASN.1 SEQUENCE { INTEGER r, INTEGER s }
+	if len(der) < 6 || der[0] != 0x30 {
+		return nil, nil, fmt.Errorf("invalid ASN.1 signature")
+	}
+
+	pos := 2 // skip SEQUENCE tag and length
+
+	// Parse R
+	if der[pos] != 0x02 {
+		return nil, nil, fmt.Errorf("expected INTEGER tag for R")
+	}
+	pos++
+	rLen := int(der[pos])
+	pos++
+	rVal := new(big.Int).SetBytes(der[pos : pos+rLen])
+	pos += rLen
+
+	// Parse S
+	if der[pos] != 0x02 {
+		return nil, nil, fmt.Errorf("expected INTEGER tag for S")
+	}
+	pos++
+	sLen := int(der[pos])
+	pos++
+	sVal := new(big.Int).SetBytes(der[pos : pos+sLen])
+
+	return rVal.Bytes(), sVal.Bytes(), nil
+}
+
+// VerifyYubiKeyAvailable checks if a YubiKey with OpenPGP support is connected.
+func VerifyYubiKeyAvailable() error {
+	card, err := openCard()
+	if err != nil {
+		return err
+	}
+	card.Close()
+	return nil
+}
+
+// GetYubiKeyInfo returns human-readable information about the connected card.
+func GetYubiKeyInfo() (string, error) {
+	card, err := openCard()
+	if err != nil {
+		return "", err
+	}
+	defer card.Close()
+
+	var info string
+
+	aid := card.ApplicationRelated.AID
+	info += fmt.Sprintf("Manufacturer: %s\n", aid.Manufacturer)
+	info += fmt.Sprintf("Serial:       %X\n", aid.Serial)
+	info += fmt.Sprintf("Version:      %s\n", aid.Version)
+
+	ch, err := card.GetCardholder()
+	if err == nil && ch.Name != "" {
+		info += fmt.Sprintf("Cardholder:   %s\n", ch.Name)
+	}
+
+	if keys := card.ApplicationRelated.Keys; keys != nil {
+		if ki, ok := keys[openpgp.KeySign]; ok {
+			info += fmt.Sprintf("Sign Key:     %s", ki.AlgAttrs)
+			if ki.Status == openpgp.KeyGenerated {
+				info += " (generated)"
+			} else if ki.Status == openpgp.KeyImported {
+				info += " (imported)"
+			}
+			info += "\n"
+		}
+	}
+
+	return info, nil
+}

sender/sender.go 🔗

@@ -19,8 +19,12 @@ import (
 	"strings"
 	"time"
 
+	"github.com/ProtonMail/go-crypto/openpgp"
+	messagetextproto "github.com/emersion/go-message/textproto"
+	"github.com/emersion/go-pgpmail"
 	"github.com/floatpane/matcha/clib"
 	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/pgp"
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/ast"
 	"github.com/yuin/goldmark/text"
@@ -151,7 +155,7 @@ func detectPlaintextOnly(body string, images, attachments map[string][]byte) boo
 }
 
 // SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
-func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME bool, encryptSMIME bool) error {
+func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME bool, encryptSMIME bool, signPGP bool, encryptPGP bool) error {
 	smtpServer := account.GetSMTPServer()
 	smtpPort := account.GetSMTPPort()
 
@@ -561,6 +565,61 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 		msg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(encryptedDer)))
 	}
 
+	// Handle PGP Signing (if enabled and not already signed with S/MIME)
+	var pgpPayload []byte
+	if signPGP && !signSMIME {
+		// Determine what to sign
+		var toSign []byte
+		if len(payloadToEncrypt) > 0 {
+			// We have content prepared for encryption
+			toSign = payloadToEncrypt
+		} else {
+			// Use what we've built so far
+			toSign = msg.Bytes()
+		}
+
+		signed, err := signEmailPGP(toSign, account)
+		if err != nil {
+			return fmt.Errorf("PGP signing failed: %w", err)
+		}
+
+		if encryptPGP {
+			// Will encrypt the signed message
+			pgpPayload = signed
+		} else {
+			// Not encrypting, so write signed message now
+			msg.Reset()
+			msg.Write(signed)
+		}
+	}
+
+	// Handle PGP Encryption (if enabled and not already encrypted with S/MIME)
+	if encryptPGP && !encryptSMIME {
+		allRecipients := append([]string{}, to...)
+		allRecipients = append(allRecipients, cc...)
+		allRecipients = append(allRecipients, bcc...)
+
+		var toEncrypt []byte
+		if len(pgpPayload) > 0 {
+			// Encrypt the signed message
+			toEncrypt = pgpPayload
+		} else if len(payloadToEncrypt) > 0 {
+			// Encrypt pre-prepared payload
+			toEncrypt = payloadToEncrypt
+		} else {
+			// Encrypt what we've built so far
+			toEncrypt = msg.Bytes()
+		}
+
+		encrypted, err := encryptEmailPGP(toEncrypt, allRecipients, account)
+		if err != nil {
+			return fmt.Errorf("PGP encryption failed: %w", err)
+		}
+
+		msg.Reset()
+		msg.Write(encrypted)
+	}
+
 	// Combine all recipients for the envelope
 	allRecipients := append([]string{}, to...)
 	allRecipients = append(allRecipients, cc...)
@@ -660,3 +719,213 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 
 	return c.Quit()
 }
+
+// signEmailPGP signs the message payload with PGP and returns a multipart/signed message.
+// Supports both file-based keys and YubiKey hardware tokens.
+func signEmailPGP(payload []byte, account *config.Account) ([]byte, error) {
+	// Check if using YubiKey
+	if account.PGPKeySource == "yubikey" {
+		return signEmailPGPWithYubiKey(payload, account)
+	}
+
+	// Default to file-based signing
+	if account.PGPPrivateKey == "" {
+		return nil, errors.New("PGP private key path is missing")
+	}
+
+	// Load private key
+	keyFile, err := os.ReadFile(account.PGPPrivateKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read PGP private key: %w", err)
+	}
+
+	// Try to parse as armored keyring first
+	entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
+	if err != nil {
+		// Try binary format
+		entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse PGP key: %w", err)
+		}
+	}
+
+	if len(entityList) == 0 {
+		return nil, errors.New("no PGP keys found in keyring")
+	}
+
+	// Decrypt the private key if it's encrypted
+	entity := entityList[0]
+	if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
+		passphrase := []byte(account.PGPPIN) // reuse PIN field for passphrase
+		if err := entity.DecryptPrivateKeys(passphrase); err != nil {
+			return nil, fmt.Errorf("failed to decrypt PGP private key: %w", err)
+		}
+	}
+
+	// Split payload into transport headers (From, To, Subject, etc.) and body.
+	// pgpmail.Sign needs the transport headers in its header param so they
+	// appear at the top level of the output, not inside the signed part.
+	// Content headers (Content-Type, etc.) stay with the body as the signed part.
+	var header messagetextproto.Header
+	var bodyPayload []byte
+	if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 {
+		headerBytes := payload[:idx]
+		rawBody := payload[idx+4:]
+
+		var contentHeaders bytes.Buffer
+		for _, line := range bytes.Split(headerBytes, []byte("\r\n")) {
+			if len(line) == 0 {
+				continue
+			}
+			parts := bytes.SplitN(line, []byte(": "), 2)
+			if len(parts) != 2 {
+				continue
+			}
+			key := string(parts[0])
+			val := string(parts[1])
+			upper := strings.ToUpper(key)
+			if strings.HasPrefix(upper, "CONTENT-") || upper == "MIME-VERSION" {
+				// Keep content headers with the body for the signed part
+				contentHeaders.Write(line)
+				contentHeaders.WriteString("\r\n")
+			} else {
+				// Transport headers go to the top-level message
+				header.Set(key, val)
+			}
+		}
+
+		// Reconstruct body payload: content headers + blank line + body
+		contentHeaders.WriteString("\r\n")
+		contentHeaders.Write(rawBody)
+		bodyPayload = contentHeaders.Bytes()
+	} else {
+		bodyPayload = payload
+	}
+
+	// Create multipart/signed message using go-pgpmail
+	var signed bytes.Buffer
+
+	mw, err := pgpmail.Sign(&signed, header, entity, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create PGP signer: %w", err)
+	}
+
+	// Write the body (content headers + body) to be signed
+	if _, err := mw.Write(bodyPayload); err != nil {
+		return nil, fmt.Errorf("failed to write message for signing: %w", err)
+	}
+
+	if err := mw.Close(); err != nil {
+		return nil, fmt.Errorf("failed to finalize PGP signature: %w", err)
+	}
+
+	return signed.Bytes(), nil
+}
+
+// signEmailPGPWithYubiKey signs the message payload using a YubiKey hardware token.
+func signEmailPGPWithYubiKey(payload []byte, account *config.Account) ([]byte, error) {
+	// Get PIN from account (loaded from keyring)
+	pin := account.PGPPIN
+	if pin == "" {
+		return nil, fmt.Errorf("YubiKey PIN not configured - please set it in account settings")
+	}
+
+	if account.PGPPublicKey == "" {
+		return nil, fmt.Errorf("PGP public key path is required for YubiKey signing")
+	}
+
+	// Use the pgp package to sign with YubiKey
+	signed, err := pgp.BuildPGPSignedMessage(payload, pin, account.PGPPublicKey)
+	if err != nil {
+		return nil, fmt.Errorf("YubiKey signing failed: %w", err)
+	}
+	return signed, nil
+}
+
+// encryptEmailPGP encrypts the message payload with PGP and returns a multipart/encrypted message.
+func encryptEmailPGP(payload []byte, recipients []string, account *config.Account) ([]byte, error) {
+	var entityList openpgp.EntityList
+
+	cfgDir, err := config.GetConfigDir()
+	if err != nil {
+		return nil, err
+	}
+	pgpDir := filepath.Join(cfgDir, "pgp")
+
+	// Add recipient keys
+	for _, recipient := range recipients {
+		// Extract email address from "Name <email>" format
+		email := strings.TrimSpace(recipient)
+		if strings.Contains(email, "<") {
+			parts := strings.Split(email, "<")
+			if len(parts) == 2 {
+				email = strings.TrimSuffix(parts[1], ">")
+			}
+		}
+
+		// Try .asc (armored) first, then .gpg (binary)
+		var keyData []byte
+		keyPath := filepath.Join(pgpDir, email+".asc")
+		keyData, err = os.ReadFile(keyPath)
+		if err != nil {
+			keyPath = filepath.Join(pgpDir, email+".gpg")
+			keyData, err = os.ReadFile(keyPath)
+			if err != nil {
+				return nil, fmt.Errorf("missing PGP key for %s (tried .asc and .gpg): %w", email, err)
+			}
+		}
+
+		// Try armored format first
+		entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyData))
+		if err != nil {
+			// Try binary format
+			entities, err = openpgp.ReadKeyRing(bytes.NewReader(keyData))
+			if err != nil {
+				return nil, fmt.Errorf("failed to parse PGP key for %s: %w", email, err)
+			}
+		}
+
+		if len(entities) > 0 {
+			entityList = append(entityList, entities[0])
+		}
+	}
+
+	// Add sender's own key (to read in Sent folder)
+	if account.PGPPublicKey != "" {
+		senderKey, err := os.ReadFile(account.PGPPublicKey)
+		if err == nil {
+			entities, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader(senderKey))
+			if entities == nil {
+				entities, _ = openpgp.ReadKeyRing(bytes.NewReader(senderKey))
+			}
+			if entities != nil && len(entities) > 0 {
+				entityList = append(entityList, entities[0])
+			}
+		}
+	}
+
+	if len(entityList) == 0 {
+		return nil, errors.New("cannot encrypt: no valid PGP public keys found for recipients")
+	}
+
+	// Encrypt using go-pgpmail
+	var encrypted bytes.Buffer
+
+	// Create a minimal header for the encrypted content
+	var header messagetextproto.Header
+
+	mw, err := pgpmail.Encrypt(&encrypted, header, entityList, nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create PGP encryptor: %w", err)
+	}
+
+	if _, err := mw.Write(payload); err != nil {
+		return nil, fmt.Errorf("failed to write message for encryption: %w", err)
+	}
+
+	if err := mw.Close(); err != nil {
+		return nil, fmt.Errorf("failed to finalize PGP encryption: %w", err)
+	}
+
+	return encrypted.Bytes(), nil
+}

tui/composer.go 🔗

@@ -43,6 +43,7 @@ const (
 	focusSignature
 	focusAttachment
 	focusEncryptSMIME
+	focusEncryptPGP
 	focusSend
 )
 
@@ -57,6 +58,7 @@ type Composer struct {
 	signatureInput  textarea.Model
 	attachmentPaths []string
 	encryptSMIME    bool
+	encryptPGP      bool
 	width           int
 	height          int
 	confirmingExit  bool
@@ -402,6 +404,11 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					m.encryptSMIME = !m.encryptSMIME
 				}
 				return m, nil
+			case focusEncryptPGP:
+				if msg.String() == "enter" || msg.String() == " " {
+					m.encryptPGP = !m.encryptPGP
+				}
+				return m, nil
 			case focusSend:
 				if msg.String() == "enter" {
 					acc := m.getSelectedAccount()
@@ -424,6 +431,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 							Signature:       m.signatureInput.Value(),
 							SignSMIME:       acc != nil && acc.SMIMESignByDefault,
 							EncryptSMIME:    m.encryptSMIME,
+							SignPGP:         acc != nil && acc.PGPSignByDefault,
+							EncryptPGP:      m.encryptPGP,
 						}
 					}
 				}
@@ -529,6 +538,15 @@ func (m *Composer) View() tea.View {
 		encField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (S/MIME): %s", encToggle))
 	}
 
+	pgpEncToggle := "[ ]"
+	if m.encryptPGP {
+		pgpEncToggle = "[x]"
+	}
+	pgpEncField := blurredStyle.Render(fmt.Sprintf("  Encrypt Email (PGP): %s", pgpEncToggle))
+	if m.focusIndex == focusEncryptPGP {
+		pgpEncField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (PGP): %s", pgpEncToggle))
+	}
+
 	// Build To field with suggestions
 	toFieldView := m.toInput.View()
 	if m.showSuggestions && len(m.suggestions) > 0 {
@@ -575,6 +593,8 @@ func (m *Composer) View() tea.View {
 		tip = "Enter: add file • backspace/d: remove last attachment"
 	case focusEncryptSMIME:
 		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
+	case focusEncryptPGP:
+		tip = "Press Space or Enter to toggle PGP encryption on or off."
 	case focusSend:
 		tip = "Press Enter to send the email."
 	}
@@ -591,6 +611,7 @@ func (m *Composer) View() tea.View {
 		m.signatureInput.View(),
 		attachmentStyle.Render(attachmentField),
 		smimeToggleStyle.Render(encField),
+		smimeToggleStyle.Render(pgpEncField),
 		button,
 		"",
 	}

tui/composer_test.go 🔗

@@ -71,11 +71,18 @@ func TestComposerUpdate(t *testing.T) {
 			t.Errorf("After seven Tabs, focusIndex should be %d (focusEncryptSMIME), got %d", focusEncryptSMIME, composer.focusIndex)
 		}
 
+		// Simulate pressing Tab again to move to the 'EncryptPGP' toggle.
+		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
+		composer = model.(*Composer)
+		if composer.focusIndex != focusEncryptPGP {
+			t.Errorf("After eight Tabs, focusIndex should be %d (focusEncryptPGP), got %d", focusEncryptPGP, 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 eight Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
+			t.Errorf("After nine Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
 		}
 
 		// Simulate one more Tab to wrap around.
@@ -83,7 +90,7 @@ func TestComposerUpdate(t *testing.T) {
 		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusTo {
-			t.Errorf("After nine Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
+			t.Errorf("After ten Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
 		}
 	})
 
@@ -211,7 +218,9 @@ func TestComposerUpdate(t *testing.T) {
 		multiComposer = model.(*Composer)
 		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> EncryptSMIME
 		multiComposer = model.(*Composer)
-		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> Send
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> EncryptPGP
+		multiComposer = model.(*Composer)
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptPGP -> Send
 		multiComposer = model.(*Composer)
 		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap)
 		multiComposer = model.(*Composer)
@@ -220,7 +229,7 @@ func TestComposerUpdate(t *testing.T) {
 
 		// With multiple accounts, From field should be included in tab order
 		if multiComposer.focusIndex != focusTo {
-			t.Errorf("After nine Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
+			t.Errorf("After ten Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
 		}
 	})
 }

tui/email_view.go 🔗

@@ -39,6 +39,9 @@ type EmailView struct {
 	isSMIME            bool
 	smimeTrusted       bool
 	isEncrypted        bool
+	isPGP              bool
+	pgpTrusted         bool
+	isPGPEncrypted     bool
 	imagePlacements    []view.ImagePlacement
 	pluginStatus       string
 }
@@ -47,6 +50,9 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
 	isSMIME := false
 	smimeTrusted := false
 	isEncrypted := false
+	isPGP := false
+	pgpTrusted := false
+	isPGPEncrypted := false
 	var filteredAtts []fetcher.Attachment
 
 	for _, att := range email.Attachments {
@@ -61,6 +67,17 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
 				smimeTrusted = att.SMIMEVerified
 			}
 			// Skip UI rendering
+		} else if att.Filename == "pgp-status.internal" {
+			isPGP = att.IsPGPSignature || att.IsPGPEncrypted
+			pgpTrusted = att.PGPVerified
+			isPGPEncrypted = att.IsPGPEncrypted
+		} else if att.IsPGPSignature || att.Filename == "signature.asc" || att.MIMEType == "application/pgp-signature" || att.MIMEType == "application/pgp-encrypted" {
+			// Extract PGP status from detached signature attachments
+			if att.IsPGPSignature && !isPGP {
+				isPGP = true
+				pgpTrusted = att.PGPVerified
+			}
+			// Skip UI rendering
 		} else {
 			filteredAtts = append(filteredAtts, att)
 		}
@@ -105,6 +122,9 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
 		isSMIME:         isSMIME,
 		smimeTrusted:    smimeTrusted,
 		isEncrypted:     isEncrypted,
+		isPGP:           isPGP,
+		pgpTrusted:      pgpTrusted,
+		isPGPEncrypted:  isPGPEncrypted,
 		imagePlacements: placements,
 	}
 }
@@ -243,18 +263,27 @@ func (m *EmailView) View() tea.View {
 	os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\")
 	os.Stdout.Sync()
 
-	smimeStatus := ""
+	cryptoStatus := ""
 	if m.isEncrypted {
-		smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]")
+		cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]")
 	} else if m.isSMIME {
 		if m.smimeTrusted {
-			smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]")
+			cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]")
+		} else {
+			cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]")
+		}
+	}
+	if m.isPGPEncrypted {
+		cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: 🔒 Encrypted]")
+	} else if m.isPGP {
+		if m.pgpTrusted {
+			cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: ✅ Verified]")
 		} else {
-			smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]")
+			cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [PGP: ⚠️ Unverified]")
 		}
 	}
 
-	header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, smimeStatus)
+	header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, cryptoStatus)
 	styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
 
 	var help string

tui/messages.go 🔗

@@ -35,6 +35,8 @@ type SendEmailMsg struct {
 	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
+	SignPGP         bool   // Whether to sign the email using PGP
+	EncryptPGP      bool   // Whether to encrypt the email using PGP
 }
 
 type Credentials struct {

tui/settings.go 🔗

@@ -45,6 +45,12 @@ type Settings struct {
 	focusIndex        int
 	smimeCertInput    textinput.Model
 	smimeKeyInput     textinput.Model
+
+	// PGP Config fields
+	pgpPublicKeyInput  textinput.Model
+	pgpPrivateKeyInput textinput.Model
+	pgpKeySource       string // "file" or "yubikey"
+	pgpPINInput        textinput.Model
 }
 
 // NewSettings creates a new settings model.
@@ -67,12 +73,36 @@ func NewSettings(cfg *config.Config) *Settings {
 	keyInput.CharLimit = 256
 	keyInput.SetStyles(tiStyles)
 
+	pgpPubInput := textinput.New()
+	pgpPubInput.Placeholder = "/path/to/public_key.asc"
+	pgpPubInput.Prompt = "> "
+	pgpPubInput.CharLimit = 256
+	pgpPubInput.SetStyles(tiStyles)
+
+	pgpPrivInput := textinput.New()
+	pgpPrivInput.Placeholder = "/path/to/private_key.asc"
+	pgpPrivInput.Prompt = "> "
+	pgpPrivInput.CharLimit = 256
+	pgpPrivInput.SetStyles(tiStyles)
+
+	pgpPINInput := textinput.New()
+	pgpPINInput.Placeholder = "YubiKey PIN (6-8 digits)"
+	pgpPINInput.Prompt = "> "
+	pgpPINInput.CharLimit = 16
+	pgpPINInput.EchoMode = textinput.EchoPassword
+	pgpPINInput.EchoCharacter = '*'
+	pgpPINInput.SetStyles(tiStyles)
+
 	return &Settings{
-		cfg:            cfg,
-		state:          SettingsMain,
-		cursor:         0,
-		smimeCertInput: certInput,
-		smimeKeyInput:  keyInput,
+		cfg:                cfg,
+		state:              SettingsMain,
+		cursor:             0,
+		smimeCertInput:     certInput,
+		smimeKeyInput:      keyInput,
+		pgpPublicKeyInput:  pgpPubInput,
+		pgpPrivateKeyInput: pgpPrivInput,
+		pgpKeySource:       "file", // Default to file-based keys
+		pgpPINInput:        pgpPINInput,
 	}
 }
 
@@ -92,6 +122,9 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.height = msg.Height
 		m.smimeCertInput.SetWidth(m.width - 6)
 		m.smimeKeyInput.SetWidth(m.width - 6)
+		m.pgpPublicKeyInput.SetWidth(m.width - 6)
+		m.pgpPrivateKeyInput.SetWidth(m.width - 6)
+		m.pgpPINInput.SetWidth(m.width - 6)
 		return m, nil
 
 	case tea.KeyPressMsg:
@@ -116,6 +149,12 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, cmd)
 		m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
 		cmds = append(cmds, cmd)
+		m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
+		cmds = append(cmds, cmd)
+		m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
+		cmds = append(cmds, cmd)
+		m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
+		cmds = append(cmds, cmd)
 	}
 
 	return m, tea.Batch(cmds...)
@@ -239,9 +278,21 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 			m.state = SettingsSMIMEConfig
 			m.smimeCertInput.SetValue(m.cfg.Accounts[m.cursor].SMIMECert)
 			m.smimeKeyInput.SetValue(m.cfg.Accounts[m.cursor].SMIMEKey)
+			m.pgpPublicKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPublicKey)
+			m.pgpPrivateKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPrivateKey)
+			// Initialize PGP key source
+			if m.cfg.Accounts[m.cursor].PGPKeySource == "" {
+				m.pgpKeySource = "file"
+			} else {
+				m.pgpKeySource = m.cfg.Accounts[m.cursor].PGPKeySource
+			}
+			m.pgpPINInput.SetValue(m.cfg.Accounts[m.cursor].PGPPIN)
 			m.focusIndex = 0
 			m.smimeCertInput.Focus()
 			m.smimeKeyInput.Blur()
+			m.pgpPublicKeyInput.Blur()
+			m.pgpPrivateKeyInput.Blur()
+			m.pgpPINInput.Blur()
 			return m, textinput.Blink
 		}
 	case "esc":
@@ -252,6 +303,20 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
+// Focus indices for the crypto config screen:
+// 0: S/MIME Certificate Path
+// 1: S/MIME Private Key Path
+// 2: S/MIME Sign By Default toggle
+// 3: PGP Public Key Path
+// 4: PGP Private Key Path
+// 5: PGP Key Source toggle (file/yubikey)
+// 6: PGP PIN input (only shown if yubikey)
+// 7: PGP Sign By Default toggle
+// 8: PGP Encrypt By Default toggle
+// 9: Save button
+// 10: Cancel button
+const cryptoConfigMaxFocus = 10
+
 func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
@@ -264,57 +329,128 @@ func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
 		if msg.String() == "shift+tab" || msg.String() == "up" {
 			m.focusIndex--
 			if m.focusIndex < 0 {
-				m.focusIndex = 4
+				m.focusIndex = cryptoConfigMaxFocus
 			}
 		} else {
 			m.focusIndex++
-			if m.focusIndex > 4 {
+			if m.focusIndex > cryptoConfigMaxFocus {
 				m.focusIndex = 0
 			}
 		}
 
 		m.smimeCertInput.Blur()
 		m.smimeKeyInput.Blur()
+		m.pgpPublicKeyInput.Blur()
+		m.pgpPrivateKeyInput.Blur()
+		m.pgpPINInput.Blur()
 
-		if m.focusIndex == 0 {
+		switch m.focusIndex {
+		case 0:
 			cmds = append(cmds, m.smimeCertInput.Focus())
-		} else if m.focusIndex == 1 {
+		case 1:
 			cmds = append(cmds, m.smimeKeyInput.Focus())
+		case 3:
+			cmds = append(cmds, m.pgpPublicKeyInput.Focus())
+		case 4:
+			cmds = append(cmds, m.pgpPrivateKeyInput.Focus())
+		case 6:
+			cmds = append(cmds, m.pgpPINInput.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 {
+		switch m.focusIndex {
+		case 0: // S/MIME cert - enter advances to next field
+			if msg.String() == "enter" {
+				m.focusIndex = 1
+				m.smimeCertInput.Blur()
+				cmds = append(cmds, m.smimeKeyInput.Focus())
+				return m, tea.Batch(cmds...)
+			}
+		case 1: // S/MIME key - enter advances
+			if msg.String() == "enter" {
+				m.focusIndex = 2
+				m.smimeKeyInput.Blur()
+				return m, nil
+			}
+		case 2: // S/MIME sign toggle
 			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
+		case 3: // PGP public key - enter advances
+			if msg.String() == "enter" {
+				m.focusIndex = 4
+				m.pgpPublicKeyInput.Blur()
+				cmds = append(cmds, m.pgpPrivateKeyInput.Focus())
+				return m, tea.Batch(cmds...)
+			}
+		case 4: // PGP private key - enter advances
+			if msg.String() == "enter" {
+				m.focusIndex = 5
+				m.pgpPrivateKeyInput.Blur()
+				return m, nil
+			}
+		case 5: // PGP key source toggle (file/yubikey)
+			if msg.String() == "enter" || msg.String() == " " {
+				if m.pgpKeySource == "file" {
+					m.pgpKeySource = "yubikey"
+				} else {
+					m.pgpKeySource = "file"
+				}
+			}
 			return m, nil
-		} else if m.focusIndex == 4 && msg.String() == "enter" {
-			m.state = SettingsAccounts
+		case 6: // PGP PIN input - enter advances
+			if msg.String() == "enter" {
+				m.focusIndex = 7
+				m.pgpPINInput.Blur()
+				return m, nil
+			}
+		case 7: // PGP sign toggle
+			if msg.String() == "enter" || msg.String() == " " {
+				m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault
+			}
+			return m, nil
+		case 8: // PGP encrypt toggle
+			if msg.String() == "enter" || msg.String() == " " {
+				m.cfg.Accounts[m.editingAccountIdx].PGPEncryptByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPEncryptByDefault
+			}
 			return m, nil
+		case 9: // Save
+			if msg.String() == "enter" {
+				m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value()
+				m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value()
+				m.cfg.Accounts[m.editingAccountIdx].PGPPublicKey = m.pgpPublicKeyInput.Value()
+				m.cfg.Accounts[m.editingAccountIdx].PGPPrivateKey = m.pgpPrivateKeyInput.Value()
+				m.cfg.Accounts[m.editingAccountIdx].PGPKeySource = m.pgpKeySource
+				m.cfg.Accounts[m.editingAccountIdx].PGPPIN = m.pgpPINInput.Value()
+				_ = config.SaveConfig(m.cfg)
+				m.state = SettingsAccounts
+				return m, nil
+			}
+		case 10: // Cancel
+			if msg.String() == "enter" {
+				m.state = SettingsAccounts
+				return m, nil
+			}
 		}
 	}
 
-	if m.focusIndex == 0 {
+	switch m.focusIndex {
+	case 0:
 		m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
 		cmds = append(cmds, cmd)
-	} else if m.focusIndex == 1 {
+	case 1:
 		m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
 		cmds = append(cmds, cmd)
+	case 3:
+		m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
+		cmds = append(cmds, cmd)
+	case 4:
+		m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
+		cmds = append(cmds, cmd)
+	case 6:
+		m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
+		cmds = append(cmds, cmd)
 	}
 
 	return m, tea.Batch(cmds...)
@@ -537,6 +673,9 @@ func (m *Settings) viewAccounts() string {
 		if account.SMIMECert != "" && account.SMIMEKey != "" {
 			providerInfo += " [S/MIME Configured]"
 		}
+		if account.PGPPublicKey != "" && account.PGPPrivateKey != "" {
+			providerInfo += " [PGP Configured]"
+		}
 
 		line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))
 
@@ -589,9 +728,12 @@ 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.FetchEmail)))
+	b.WriteString(titleStyle.Render(fmt.Sprintf("Crypto Configuration for %s", account.FetchEmail)))
 	b.WriteString("\n\n")
 
+	// --- S/MIME Section ---
+	b.WriteString(settingsFocusedStyle.Render("S/MIME") + "\n")
+
 	if m.focusIndex == 0 {
 		b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n"))
 	} else {
@@ -606,26 +748,85 @@ func (m *Settings) viewSMIMEConfig() string {
 	}
 	b.WriteString(m.smimeKeyInput.View() + "\n\n")
 
-	signStatus := "OFF"
+	smimeSignStatus := "OFF"
 	if account.SMIMESignByDefault {
-		signStatus = "ON"
+		smimeSignStatus = "ON"
 	}
 	if m.focusIndex == 2 {
-		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", signStatus)))
+		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", smimeSignStatus)))
+	} else {
+		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Sign By Default: %s\n\n", smimeSignStatus)))
+	}
+
+	// --- PGP Section ---
+	b.WriteString(settingsFocusedStyle.Render("PGP") + "\n")
+
+	if m.focusIndex == 3 {
+		b.WriteString(settingsFocusedStyle.Render("Public Key Path:\n"))
+	} else {
+		b.WriteString(settingsBlurredStyle.Render("Public Key Path:\n"))
+	}
+	b.WriteString(m.pgpPublicKeyInput.View() + "\n\n")
+
+	if m.focusIndex == 4 {
+		b.WriteString(settingsFocusedStyle.Render("Private Key Path:\n"))
+	} else {
+		b.WriteString(settingsBlurredStyle.Render("Private Key Path:\n"))
+	}
+	b.WriteString(m.pgpPrivateKeyInput.View() + "\n\n")
+
+	// Key source toggle
+	keySourceDisplay := "File"
+	if m.pgpKeySource == "yubikey" {
+		keySourceDisplay = "YubiKey"
+	}
+	if m.focusIndex == 5 {
+		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Key Source: %s\n\n", keySourceDisplay)))
 	} else {
-		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Sign By Default: %s\n\n", signStatus)))
+		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Key Source: %s\n\n", keySourceDisplay)))
 	}
 
+	// PIN input (only shown if YubiKey is selected)
+	if m.pgpKeySource == "yubikey" {
+		if m.focusIndex == 6 {
+			b.WriteString(settingsFocusedStyle.Render("YubiKey PIN:\n"))
+		} else {
+			b.WriteString(settingsBlurredStyle.Render("YubiKey PIN:\n"))
+		}
+		b.WriteString(m.pgpPINInput.View() + "\n\n")
+	}
+
+	pgpSignStatus := "OFF"
+	if account.PGPSignByDefault {
+		pgpSignStatus = "ON"
+	}
+	if m.focusIndex == 7 {
+		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", pgpSignStatus)))
+	} else {
+		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Sign By Default: %s\n\n", pgpSignStatus)))
+	}
+
+	pgpEncryptStatus := "OFF"
+	if account.PGPEncryptByDefault {
+		pgpEncryptStatus = "ON"
+	}
+	if m.focusIndex == 8 {
+		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Encrypt By Default: %s\n\n", pgpEncryptStatus)))
+	} else {
+		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Encrypt By Default: %s\n\n", pgpEncryptStatus)))
+	}
+
+	// --- Buttons ---
 	saveBtn := "[ Save ]"
 	cancelBtn := "[ Cancel ]"
 
-	if m.focusIndex == 3 {
+	if m.focusIndex == 9 {
 		saveBtn = settingsFocusedStyle.Render(saveBtn)
 	} else {
 		saveBtn = settingsBlurredStyle.Render(saveBtn)
 	}
 
-	if m.focusIndex == 4 {
+	if m.focusIndex == 10 {
 		cancelBtn = settingsFocusedStyle.Render(cancelBtn)
 	} else {
 		cancelBtn = settingsBlurredStyle.Render(cancelBtn)
@@ -634,7 +835,7 @@ func (m *Settings) viewSMIMEConfig() string {
 	b.WriteString(saveBtn + "  " + cancelBtn + "\n\n")
 
 	mainContent := b.String()
-	helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • esc: back")
+	helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • space: toggle • esc: back")
 
 	if m.height > 0 {
 		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))