Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 33 additions & 20 deletions daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,26 +556,39 @@ func (d *Daemon) sendOutboxEntry(entry *OutboxEntry) {
return
}

rawMsg, err := sender.SendEmail(
acct,
entry.Params.To,
entry.Params.Cc,
entry.Params.Bcc,
entry.Params.Subject,
entry.Params.Body,
entry.Params.HTMLBody,
entry.Params.Images,
entry.Params.Attachments,
entry.Params.InReplyTo,
entry.Params.References,
entry.Params.SignSMIME,
entry.Params.EncryptSMIME,
entry.Params.SignPGP,
entry.Params.EncryptPGP,
)
if err != nil {
log.Printf("daemon: outbox send failed for %s: %v", entry.ID, err)
return
var rawMsg []byte

if len(entry.Params.PrebuiltRaw) > 0 {
// Message was pre-built (e.g. PGP-signed) in the client process; just deliver.
allRecipients := append(append([]string{}, entry.Params.To...), append(entry.Params.Cc, entry.Params.Bcc...)...)
if err := sender.DeliverRaw(acct, allRecipients, entry.Params.PrebuiltRaw); err != nil {
log.Printf("daemon: outbox deliver failed for %s: %v", entry.ID, err)
return
}
rawMsg = entry.Params.PrebuiltRaw
} else {
var err error
rawMsg, err = sender.SendEmail(
acct,
entry.Params.To,
entry.Params.Cc,
entry.Params.Bcc,
entry.Params.Subject,
entry.Params.Body,
entry.Params.HTMLBody,
entry.Params.Images,
entry.Params.Attachments,
entry.Params.InReplyTo,
entry.Params.References,
entry.Params.SignSMIME,
entry.Params.EncryptSMIME,
entry.Params.SignPGP,
entry.Params.EncryptPGP,
)
if err != nil {
log.Printf("daemon: outbox send failed for %s: %v", entry.ID, err)
return
}
}

if acct.ServiceProvider != "gmail" {
Expand Down
55 changes: 33 additions & 22 deletions daemonclient/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type Service interface {
MoveEmails(accountID string, uids []uint32, src, dst string) error
MarkRead(accountID, folder string, uids []uint32) error
MarkUnread(accountID, folder string, uids []uint32) error
QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int) (string, error)
QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int, prebuiltRaw []byte) (string, error)
CancelEmail(jobID string) error
FetchFolders(accountID string) ([]backend.Folder, error)
RefreshFolder(accountID, folder string) error
Expand Down Expand Up @@ -183,7 +183,7 @@ func (s *daemonService) MarkUnread(accountID, folder string, uids []uint32) erro
}, nil)
}

func (s *daemonService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int) (string, error) {
func (s *daemonService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int, prebuiltRaw []byte) (string, error) {
var result daemonrpc.QueueEmailResult
err := s.client.Call(daemonrpc.MethodQueueEmail, daemonrpc.QueueEmailParams{
Email: daemonrpc.SendEmailParams{
Expand All @@ -202,6 +202,7 @@ func (s *daemonService) QueueEmail(accountID string, to, cc, bcc []string, subje
EncryptSMIME: encryptSMIME,
SignPGP: signPGP,
EncryptPGP: encryptPGP,
PrebuiltRaw: prebuiltRaw,
},
DelaySeconds: delaySeconds,
}, &result)
Expand Down Expand Up @@ -410,31 +411,41 @@ func (s *directService) Close() error {
return nil
}

func (s *directService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, _ int) (string, error) {
func (s *directService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, _ int, prebuiltRaw []byte) (string, error) {
acct := s.cfg.GetAccountByID(accountID)
if acct == nil {
return "", fmt.Errorf("no account for %s", accountID)
}

rawMsg, err := sender.SendEmail(
acct,
to,
cc,
bcc,
subject,
body,
htmlBody,
images,
attachments,
inReplyTo,
references,
signSMIME,
encryptSMIME,
signPGP,
encryptPGP,
)
if err != nil {
return "", err
var rawMsg []byte
if len(prebuiltRaw) > 0 {
allRecipients := append(append([]string{}, to...), append(cc, bcc...)...)
if err := sender.DeliverRaw(acct, allRecipients, prebuiltRaw); err != nil {
return "", err
}
rawMsg = prebuiltRaw
} else {
var err error
rawMsg, err = sender.SendEmail(
acct,
to,
cc,
bcc,
subject,
body,
htmlBody,
images,
attachments,
inReplyTo,
references,
signSMIME,
encryptSMIME,
signPGP,
encryptPGP,
)
if err != nil {
return "", err
}
}

if acct.ServiceProvider != "gmail" {
Expand Down
1 change: 1 addition & 0 deletions daemonrpc/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ type SendEmailParams struct {
EncryptSMIME bool `json:"encrypt_smime,omitempty"`
SignPGP bool `json:"sign_pgp,omitempty"`
EncryptPGP bool `json:"encrypt_pgp,omitempty"`
PrebuiltRaw []byte `json:"prebuilt_raw,omitempty"`
}

type DeleteEmailsParams struct {
Expand Down
59 changes: 59 additions & 0 deletions docs/docs/Features/PGP.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,33 @@ gpg/card> admin
gpg/card> generate
```

### Decrypting Email with a YubiKey

Matcha can also decrypt incoming PGP email using the decryption key on your
YubiKey — the private key never leaves the device. No extra configuration is
needed beyond the YubiKey setup above; decryption uses the same PIN.

How it works under the hood:

- **RSA keys** are decrypted over a direct PC/SC session. Matcha first runs
`gpgconf --kill scdaemon` to release the card, because GnuPG's `scdaemon`
otherwise holds it inside a PC/SC transaction and any direct access would
block indefinitely (the YubiKey LED glows steadily rather than flashing).
- **ECDH / Curve25519 keys** are not supported over the direct PC/SC path, so
Matcha falls back to the local `gpg` binary (which talks to `gpg-agent`). If
the direct session hangs for more than 20 seconds it also falls back to this
path automatically.

To suppress the `gpg-agent` PIN prompt — a `pinentry` dialog clashes with the
TUI — Matcha pre-caches your PIN with the agent via `PRESET_PASSPHRASE`. This
requires `allow-preset-passphrase` in `~/.gnupg/gpg-agent.conf`, so Matcha adds
that line automatically (and reloads the agent) the first time it decrypts. No
manual edit is needed.

> [!NOTE]
> Decryption debug output (card open, fallback decisions, PIN caching) is only
> emitted when Matcha runs at debug log level — start it with `--debug --logs`.

## Status Indicators

When viewing an email, Matcha shows the PGP status in the header:
Expand Down Expand Up @@ -297,6 +324,38 @@ 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.

### Decryption hangs and the YubiKey LED glows steadily

A steady (non-flashing) LED means the card is held in a session, not waiting for
a touch. GnuPG's `scdaemon` is holding the card in a PC/SC transaction, so
Matcha's direct decryption session blocks.

Matcha now runs `gpgconf --kill scdaemon` before each decryption to release the
card, and times out after 20 seconds to fall back to the `gpg-agent` path. If it
still hangs, free the card manually:

```bash
gpgconf --kill scdaemon
```

You can also let GnuPG and Matcha share the reader via `pcscd` (see
[LIBUSB_ERROR_BUSY](#libusb_error_busy-in-pcscd-logs) above).

### A pinentry dialog appears and breaks the TUI

When decrypting via the `gpg-agent` fallback, the agent asks for the card PIN
with a `pinentry` program, whose dialog corrupts the terminal UI.

Matcha avoids this by pre-caching your PIN in `gpg-agent`, which requires
`allow-preset-passphrase` in `~/.gnupg/gpg-agent.conf`. Matcha adds this line
automatically, but if you have a restricted or read-only GnuPG home, add it
yourself and reload the agent:

```bash
echo "allow-preset-passphrase" >> ~/.gnupg/gpg-agent.conf
gpg-connect-agent reloadagent /bye
```

### "PIN verification failed"

- The default YubiKey PIN is `123456` (change it with `gpg --card-edit` then `passwd`).
Expand Down
106 changes: 44 additions & 62 deletions fetcher/fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ import (
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-pgpmail"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/internal/loglevel"
"github.com/floatpane/matcha/pgp"
"go.mozilla.org/pkcs7"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/ianaindex"
Expand Down Expand Up @@ -1033,26 +1035,44 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint
// We handle decryption when we find the encrypted data part (application/octet-stream).
// Skip the version info part (application/pgp-encrypted) and continue processing.

// Detect encrypted data part of PGP message
if strings.Contains(filename, ".asc") || (mimeType == "application/octet-stream" && part.Encoding == "7bit") {
// Detect encrypted data part of PGP message.
// Match: filename ends with .asc, OR anonymous application/octet-stream (RFC 3156
// encrypted data part has no filename). The previous "7bit" encoding check was
// dropped because IMAP servers return "7BIT" (uppercase) while the default for
// nil encoding in go-imap is "7bit" (lowercase) — the mismatch silently blocked
// all decryption attempts.
isPossiblePGPData := strings.Contains(strings.ToLower(filename), ".asc") ||
(mimeType == "application/octet-stream" && filename == "")
if isPossiblePGPData {
// This might be PGP encrypted data
loglevel.Debugf("pgp: fetching part %s (encoding=%s, mime=%s)", partID, part.Encoding, mimeType)
data, err := fetchInlinePart(partID, part.Encoding)
loglevel.Debugf("pgp: fetchInlinePart returned len=%d err=%v", len(data), err)
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 {
pgpProvider, provErr := pgp.NewProvider(account)
loglevel.Debugf("pgp: NewProvider returned provErr=%v", provErr)
if provErr == nil {
loglevel.Debugf("pgp: calling DecryptBare")
decrypted, decErr := pgpProvider.DecryptBare(data)
loglevel.Debugf("pgp: DecryptBare returned len=%d decErr=%v", len(decrypted), decErr)
if decErr == nil {
// Parse the decrypted MIME content.
// mail.CreateReader can return (reader, non-nil-error) for
// unknown charsets — accept the reader in that case too.
mr, parseErr := mail.CreateReader(bytes.NewReader(decrypted))
if mr != nil && (parseErr == nil || message.IsUnknownCharset(parseErr)) {
for {
p, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
if err != nil && !message.IsUnknownCharset(err) {
break
}
if p == nil {
continue
}

if h, ok := p.Header.(*mail.InlineHeader); ok {
ct, _, _ := h.ContentType()
Expand All @@ -1069,21 +1089,28 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint
}
}
}
}

// Add status marker
attachments = append(attachments, Attachment{
Filename: "pgp-status.internal",
IsPGPEncrypted: true,
PGPVerified: true, // Decryption succeeded
})
// Fallback: if MIME parsing failed or yielded no body, treat
// the raw decrypted bytes as plain text.
if extractedBody == "" {
extractedBody = strings.TrimSpace(string(decrypted))
extractedBodyMIMEType = mimeTextPlain
plainPartID = "decrypted"
}

attachments = append(attachments, Attachment{
Filename: "pgp-status.internal",
IsPGPEncrypted: true,
PGPVerified: true,
})
} else {
extractedBody = fmt.Sprintf("**PGP Decryption Failed:** %s\n", err)
extractedBody = fmt.Sprintf("**PGP Decryption Failed:** %s\n", decErr)
extractedBodyMIMEType = mimeTextPlain
htmlPartID = partExtracted
}
} else {
extractedBody = "**PGP Encrypted:** Private key not configured\n"
extractedBody = "**PGP Encrypted:** Key not configured\n"
extractedBodyMIMEType = mimeTextPlain
htmlPartID = partExtracted
}
Expand Down Expand Up @@ -2019,51 +2046,6 @@ func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) erro
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 {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ require (
github.com/floatpane/go-icalendar v0.0.1
github.com/floatpane/go-keybind v0.0.1
github.com/floatpane/go-mailpatch v0.0.2
github.com/floatpane/go-openpgp-card-hl v0.0.1
github.com/floatpane/go-openpgp-card-hl v0.1.2
github.com/floatpane/go-patchapply v0.0.1
github.com/floatpane/go-secretbox v0.1.0
github.com/floatpane/go-uds-jsonrpc v0.0.1
Expand Down
Loading
Loading