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