From bf4097d94d90abb9889c0d6c14821214000e1415 Mon Sep 17 00:00:00 2001 From: Qasim Date: Wed, 4 Feb 2026 10:02:58 -0500 Subject: [PATCH 1/2] feat(email): support inline PGP decryption for Outlook emails Some email providers (e.g., Microsoft/Outlook) transform PGP/MIME encrypted messages into multipart/mixed with inline PGP blocks. This adds fallback detection for inline PGP content when the message is not in standard PGP/MIME format. Also improves error propagation in GPG signing/encryption functions to provide meaningful error messages instead of generic failures. --- internal/cli/email/read_decrypt.go | 99 +++++++++++-- internal/cli/email/read_decrypt_test.go | 178 ++++++++++++++++++++++++ internal/cli/email/send_gpg.go | 41 +++--- 3 files changed, 288 insertions(+), 30 deletions(-) diff --git a/internal/cli/email/read_decrypt.go b/internal/cli/email/read_decrypt.go index b2889f4..46e68bb 100644 --- a/internal/cli/email/read_decrypt.go +++ b/internal/cli/email/read_decrypt.go @@ -14,22 +14,31 @@ import ( "github.com/nylas/cli/internal/domain" ) -// decryptGPGEmail decrypts a PGP/MIME encrypted message. +// decryptGPGEmail decrypts a PGP-encrypted message. +// Supports both PGP/MIME (RFC 3156) and inline PGP formats. +// Some email providers (e.g., Microsoft/Outlook) transform PGP/MIME into inline PGP. func decryptGPGEmail(ctx context.Context, msg *domain.Message) (*gpg.DecryptResult, error) { if msg.RawMIME == "" { return nil, fmt.Errorf("no raw MIME data available for decryption") } - // Check if this is an encrypted message - contentType := extractFullContentType(msg.RawMIME) - if !isEncryptedMessage(contentType) { - return nil, fmt.Errorf("message is not PGP/MIME encrypted (Content-Type: %s)", contentType) - } + var ciphertext []byte + var err error - // Parse the multipart message to extract encrypted content - ciphertext, err := parseEncryptedMIME(msg.RawMIME) - if err != nil { - return nil, fmt.Errorf("failed to parse PGP/MIME encrypted message: %w", err) + // Check if this is a PGP/MIME encrypted message + contentType := extractFullContentType(msg.RawMIME) + if isEncryptedMessage(contentType) { + // Parse the multipart message to extract encrypted content + ciphertext, err = parseEncryptedMIME(msg.RawMIME) + if err != nil { + return nil, fmt.Errorf("failed to parse PGP/MIME encrypted message: %w", err) + } + } else { + // Try to find inline PGP content (some providers like Outlook transform PGP/MIME) + ciphertext = extractInlinePGP(msg.RawMIME) + if ciphertext == nil { + return nil, fmt.Errorf("message does not contain PGP encrypted content") + } } // Initialize GPG service @@ -57,6 +66,76 @@ func isEncryptedMessage(contentType string) bool { strings.Contains(contentType, "application/pgp-encrypted") } +// extractInlinePGP extracts inline PGP encrypted content from a message. +// This handles emails where providers (e.g., Microsoft/Outlook) have transformed +// PGP/MIME into a multipart/mixed with the PGP block as inline text. +func extractInlinePGP(rawMIME string) []byte { + const pgpBegin = "-----BEGIN PGP MESSAGE-----" + const pgpEnd = "-----END PGP MESSAGE-----" + + // First, try to find PGP content directly in the raw MIME + beginIdx := strings.Index(rawMIME, pgpBegin) + if beginIdx != -1 { + endIdx := strings.Index(rawMIME[beginIdx:], pgpEnd) + if endIdx != -1 { + // Include the end marker + pgpContent := rawMIME[beginIdx : beginIdx+endIdx+len(pgpEnd)] + return []byte(strings.TrimSpace(pgpContent)) + } + } + + // If not found directly, try parsing multipart and checking each part + contentType := extractFullContentType(rawMIME) + if !strings.Contains(contentType, "multipart/") { + return nil + } + + _, params, err := mime.ParseMediaType(contentType) + if err != nil { + return nil + } + + boundary := params["boundary"] + if boundary == "" { + return nil + } + + headerEnd := findHeaderEnd(rawMIME) + if headerEnd == -1 { + return nil + } + + bodySection := rawMIME[headerEnd:] + mr := multipart.NewReader(strings.NewReader(bodySection), boundary) + + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil + } + + partContent, err := io.ReadAll(part) + if err != nil { + continue + } + + content := string(partContent) + beginIdx := strings.Index(content, pgpBegin) + if beginIdx != -1 { + endIdx := strings.Index(content[beginIdx:], pgpEnd) + if endIdx != -1 { + pgpContent := content[beginIdx : beginIdx+endIdx+len(pgpEnd)] + return []byte(strings.TrimSpace(pgpContent)) + } + } + } + + return nil +} + // parseEncryptedMIME parses a PGP/MIME encrypted message and extracts the ciphertext. // RFC 3156 Section 4 defines the structure: // Part 1: application/pgp-encrypted with "Version: 1" diff --git a/internal/cli/email/read_decrypt_test.go b/internal/cli/email/read_decrypt_test.go index f90d229..1b60762 100644 --- a/internal/cli/email/read_decrypt_test.go +++ b/internal/cli/email/read_decrypt_test.go @@ -360,3 +360,181 @@ func TestIsEncryptedMessage_CaseInsensitive(t *testing.T) { }) } } + +func TestExtractInlinePGP(t *testing.T) { + tests := []struct { + name string + rawMIME string + wantContains string + wantNil bool + }{ + { + name: "inline PGP in plain text body", + rawMIME: `From: sender@example.com +To: recipient@example.com +Content-Type: text/plain + +-----BEGIN PGP MESSAGE----- + +hQEMAxxxxxxxxx +=xxxx +-----END PGP MESSAGE-----`, + wantContains: "-----BEGIN PGP MESSAGE-----", + wantNil: false, + }, + { + name: "inline PGP in multipart/mixed (Outlook style)", + rawMIME: `From: sender@example.com +Content-Type: multipart/mixed; boundary="boundary123" + +--boundary123 +Content-Type: text/plain; charset="UTF-8" + +-----BEGIN PGP MESSAGE----- + +hQIMAwfdV3YDsnmWARAAs8jMMsaoLnlg +=xxxx +-----END PGP MESSAGE----- +--boundary123--`, + wantContains: "-----BEGIN PGP MESSAGE-----", + wantNil: false, + }, + { + name: "inline PGP with surrounding text", + rawMIME: `Content-Type: text/plain + +Some text before + +-----BEGIN PGP MESSAGE----- +encrypted_data_here +-----END PGP MESSAGE----- + +Some text after`, + wantContains: "-----BEGIN PGP MESSAGE-----", + wantNil: false, + }, + { + name: "no PGP content", + rawMIME: `From: sender@example.com +Content-Type: text/plain + +Just a regular email with no encryption.`, + wantNil: true, + }, + { + name: "incomplete PGP block - missing end marker", + rawMIME: `Content-Type: text/plain + +-----BEGIN PGP MESSAGE----- +encrypted_data_here +No end marker`, + wantNil: true, + }, + { + name: "PGP in second part of multipart", + rawMIME: `Content-Type: multipart/mixed; boundary="mixed" + +--mixed +Content-Type: text/plain + +Regular text part +--mixed +Content-Type: text/plain + +-----BEGIN PGP MESSAGE----- +encrypted_in_second_part +-----END PGP MESSAGE----- +--mixed--`, + wantContains: "-----BEGIN PGP MESSAGE-----", + wantNil: false, + }, + { + name: "PGP with CRLF line endings", + rawMIME: "Content-Type: text/plain\r\n\r\n-----BEGIN PGP MESSAGE-----\r\nencrypted\r\n-----END PGP MESSAGE-----", + wantContains: "-----BEGIN PGP MESSAGE-----", + wantNil: false, + }, + { + name: "empty input", + rawMIME: "", + wantNil: true, + }, + { + name: "multipart without PGP", + rawMIME: `Content-Type: multipart/mixed; boundary="b" + +--b +Content-Type: text/plain + +No encryption here +--b--`, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractInlinePGP(tt.rawMIME) + if tt.wantNil { + if got != nil { + t.Errorf("extractInlinePGP() = %q, want nil", string(got)) + } + return + } + if got == nil { + t.Error("extractInlinePGP() = nil, want non-nil") + return + } + if !strings.Contains(string(got), tt.wantContains) { + t.Errorf("extractInlinePGP() = %q, want to contain %q", string(got), tt.wantContains) + } + // Verify we extract the complete PGP block + if !strings.HasPrefix(string(got), "-----BEGIN PGP MESSAGE-----") { + t.Errorf("extractInlinePGP() should start with PGP header, got: %q", string(got)) + } + if !strings.HasSuffix(string(got), "-----END PGP MESSAGE-----") { + t.Errorf("extractInlinePGP() should end with PGP footer, got: %q", string(got)) + } + }) + } +} + +func TestExtractInlinePGP_ExtractsCompletePGPBlock(t *testing.T) { + // Verify the extracted content is exactly the PGP block + rawMIME := `Content-Type: text/plain + +Preamble text here. + +-----BEGIN PGP MESSAGE----- + +hQEMAxxxxxxxxx +line2 +line3 +=xxxx +-----END PGP MESSAGE----- + +Postamble text here.` + + got := extractInlinePGP(rawMIME) + if got == nil { + t.Fatal("extractInlinePGP() returned nil") + } + + gotStr := string(got) + + // Should not contain preamble or postamble + if strings.Contains(gotStr, "Preamble") { + t.Error("extractInlinePGP() should not include preamble text") + } + if strings.Contains(gotStr, "Postamble") { + t.Error("extractInlinePGP() should not include postamble text") + } + + // Should contain the full PGP message + if !strings.Contains(gotStr, "hQEMAxxxxxxxxx") { + t.Error("extractInlinePGP() should contain the encrypted data") + } + if !strings.Contains(gotStr, "line2") { + t.Error("extractInlinePGP() should contain all lines of encrypted data") + } +} diff --git a/internal/cli/email/send_gpg.go b/internal/cli/email/send_gpg.go index c88f4ee..7648eab 100644 --- a/internal/cli/email/send_gpg.go +++ b/internal/cli/email/send_gpg.go @@ -120,21 +120,22 @@ func sendSecureEmail(ctx context.Context, client ports.NylasClient, grantID stri mimeBuilder := mime.NewBuilder() var rawMIME []byte + var buildErr error // Step 3: Build and send based on mode if doSign && doEncrypt { // Sign+Encrypt: Maximum security - rawMIME = buildSignedEncryptedMessage(ctx, gpgSvc, mimeBuilder, req, toContacts, subject, body, contentType, signerKeyID, recipientKeyIDs) + rawMIME, buildErr = buildSignedEncryptedMessage(ctx, gpgSvc, mimeBuilder, req, toContacts, subject, body, contentType, signerKeyID, recipientKeyIDs) } else if doEncrypt { // Encrypt only - rawMIME = buildEncryptedMessage(ctx, gpgSvc, mimeBuilder, req, toContacts, subject, body, contentType, recipientKeyIDs) + rawMIME, buildErr = buildEncryptedMessage(ctx, gpgSvc, mimeBuilder, req, toContacts, subject, body, contentType, recipientKeyIDs) } else if doSign { // Sign only (original behavior) - rawMIME = buildSignedMessage(ctx, gpgSvc, mimeBuilder, req, toContacts, subject, body, contentType, signerKeyID, signingIdentity) + rawMIME, buildErr = buildSignedMessage(ctx, gpgSvc, mimeBuilder, req, toContacts, subject, body, contentType, signerKeyID, signingIdentity) } - if rawMIME == nil { - return nil, fmt.Errorf("failed to build secure message") + if buildErr != nil { + return nil, buildErr } // Step 4: Send raw MIME message @@ -236,7 +237,7 @@ func resolveRecipientKeys(ctx context.Context, gpgSvc gpg.Service, explicitKeyID } // buildSignedMessage builds a signed-only PGP/MIME message. -func buildSignedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mime.Builder, req *domain.SendMessageRequest, toContacts []domain.EmailParticipant, subject, body, contentType, signerKeyID, signingIdentity string) []byte { +func buildSignedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mime.Builder, req *domain.SendMessageRequest, toContacts []domain.EmailParticipant, subject, body, contentType, signerKeyID, signingIdentity string) ([]byte, error) { spinner := common.NewSpinner(fmt.Sprintf("Signing email with GPG identity: %s...", signingIdentity)) spinner.Start() defer spinner.Stop() @@ -244,7 +245,7 @@ func buildSignedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mim // Prepare the MIME content part to be signed dataToSign, err := mimeBuilder.PrepareContentToSign(body, contentType, req.Attachments) if err != nil { - return nil + return nil, fmt.Errorf("failed to prepare content for signing: %w", err) } // Extract sender email for the Signer's User ID subpacket @@ -256,7 +257,7 @@ func buildSignedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mim // Sign the MIME content part signResult, err := gpgSvc.SignData(ctx, signerKeyID, dataToSign, senderEmail) if err != nil { - return nil + return nil, fmt.Errorf("GPG signing failed with key %s: %w", signerKeyID, err) } // Build PGP/MIME signed message @@ -277,14 +278,14 @@ func buildSignedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mim rawMIME, err := mimeBuilder.BuildSignedMessage(mimeReq) if err != nil { - return nil + return nil, fmt.Errorf("failed to build MIME message: %w", err) } - return rawMIME + return rawMIME, nil } // buildEncryptedMessage builds an encrypted-only PGP/MIME message. -func buildEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mime.Builder, req *domain.SendMessageRequest, toContacts []domain.EmailParticipant, subject, body, contentType string, recipientKeyIDs []string) []byte { +func buildEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mime.Builder, req *domain.SendMessageRequest, toContacts []domain.EmailParticipant, subject, body, contentType string, recipientKeyIDs []string) ([]byte, error) { spinner := common.NewSpinner("Encrypting email...") spinner.Start() defer spinner.Stop() @@ -292,13 +293,13 @@ func buildEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder // Prepare the content to encrypt dataToEncrypt, err := mimeBuilder.PrepareContentToEncrypt(body, contentType, req.Attachments) if err != nil { - return nil + return nil, fmt.Errorf("failed to prepare content for encryption: %w", err) } // Encrypt the content encryptResult, err := gpgSvc.EncryptData(ctx, recipientKeyIDs, dataToEncrypt) if err != nil { - return nil + return nil, fmt.Errorf("GPG encryption failed: %w", err) } // Build PGP/MIME encrypted message @@ -314,15 +315,15 @@ func buildEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder rawMIME, err := mimeBuilder.BuildEncryptedMessage(mimeReq) if err != nil { - return nil + return nil, fmt.Errorf("failed to build encrypted MIME message: %w", err) } - return rawMIME + return rawMIME, nil } // buildSignedEncryptedMessage builds a signed AND encrypted PGP/MIME message. // Order: Sign first, then encrypt (per OpenPGP best practice). -func buildSignedEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mime.Builder, req *domain.SendMessageRequest, toContacts []domain.EmailParticipant, subject, body, contentType, signerKeyID string, recipientKeyIDs []string) []byte { +func buildSignedEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mime.Builder, req *domain.SendMessageRequest, toContacts []domain.EmailParticipant, subject, body, contentType, signerKeyID string, recipientKeyIDs []string) ([]byte, error) { spinner := common.NewSpinner("Signing and encrypting email...") spinner.Start() defer spinner.Stop() @@ -330,7 +331,7 @@ func buildSignedEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBu // Prepare the content dataToProcess, err := mimeBuilder.PrepareContentToEncrypt(body, contentType, req.Attachments) if err != nil { - return nil + return nil, fmt.Errorf("failed to prepare content: %w", err) } // Extract sender email for signing @@ -342,7 +343,7 @@ func buildSignedEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBu // Sign AND encrypt in one GPG operation encryptResult, err := gpgSvc.SignAndEncryptData(ctx, signerKeyID, recipientKeyIDs, dataToProcess, senderEmail) if err != nil { - return nil + return nil, fmt.Errorf("GPG sign and encrypt failed with key %s: %w", signerKeyID, err) } // Build PGP/MIME encrypted message (signature is inside the encrypted payload) @@ -358,10 +359,10 @@ func buildSignedEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBu rawMIME, err := mimeBuilder.BuildEncryptedMessage(mimeReq) if err != nil { - return nil + return nil, fmt.Errorf("failed to build encrypted MIME message: %w", err) } - return rawMIME + return rawMIME, nil } // sendSignedEmail signs an email with GPG and sends it as raw MIME. From 8013c22874a576bb123a52e2d4ef75ff695a26bc Mon Sep 17 00:00:00 2001 From: Qasim Date: Wed, 4 Feb 2026 10:13:38 -0500 Subject: [PATCH 2/2] fix(email): decode base64-encoded PGP attachments from Outlook Outlook/Microsoft transforms PGP/MIME emails into multipart/mixed with base64-encoded attachments. The extractInlinePGP function now checks Content-Transfer-Encoding and decodes base64 content before searching for PGP markers. - Add base64 decoding for MIME parts with Content-Transfer-Encoding: base64 - Add test for Outlook-style base64-encoded PGP attachments --- internal/cli/email/read_decrypt.go | 26 ++++++++++++ internal/cli/email/read_decrypt_test.go | 54 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/internal/cli/email/read_decrypt.go b/internal/cli/email/read_decrypt.go index 46e68bb..9f0d812 100644 --- a/internal/cli/email/read_decrypt.go +++ b/internal/cli/email/read_decrypt.go @@ -3,6 +3,7 @@ package email import ( "bytes" "context" + "encoding/base64" "fmt" "io" "mime" @@ -122,7 +123,22 @@ func extractInlinePGP(rawMIME string) []byte { continue } + // Check if content needs base64 decoding + // Outlook transforms PGP/MIME into attachments with base64 encoding + transferEncoding := strings.ToLower(part.Header.Get("Content-Transfer-Encoding")) + partContentType := strings.ToLower(part.Header.Get("Content-Type")) + + // Decode base64 if needed (common for application/octet-stream or pgp-encrypted attachments) + if transferEncoding == "base64" { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(partContent))) + if err == nil { + partContent = decoded + } + } + content := string(partContent) + + // Check for PGP content in this part beginIdx := strings.Index(content, pgpBegin) if beginIdx != -1 { endIdx := strings.Index(content[beginIdx:], pgpEnd) @@ -131,6 +147,16 @@ func extractInlinePGP(rawMIME string) []byte { return []byte(strings.TrimSpace(pgpContent)) } } + + // Also check for application/octet-stream or encrypted.asc which might contain PGP data + if strings.Contains(partContentType, "application/octet-stream") || + strings.Contains(partContentType, "application/pgp-encrypted") { + // The whole part might be the PGP message (without explicit markers in some edge cases) + if strings.Contains(content, pgpBegin) { + // Already handled above + continue + } + } } return nil diff --git a/internal/cli/email/read_decrypt_test.go b/internal/cli/email/read_decrypt_test.go index 1b60762..3b95428 100644 --- a/internal/cli/email/read_decrypt_test.go +++ b/internal/cli/email/read_decrypt_test.go @@ -538,3 +538,57 @@ Postamble text here.` t.Error("extractInlinePGP() should contain all lines of encrypted data") } } + +func TestExtractInlinePGP_Base64EncodedAttachment(t *testing.T) { + // Test Outlook-style email where PGP content is base64-encoded in an attachment + // This is the actual format Microsoft/Outlook uses for PGP/MIME emails + // The base64 decodes to: "-----BEGIN PGP MESSAGE-----\n\nhQEMAtest\n=xxxx\n-----END PGP MESSAGE-----\n" + base64PGP := "LS0tLS1CRUdJTiBQR1AgTUVTU0FHRS0tLS0tCgpoUUVNQXRlc3QKPXh4eHgKLS0tLS1FTkQgUEdQIE1FU1NBR0UtLS0tLQo=" + + rawMIME := `From: sender@outlook.com +To: recipient@example.com +Content-Type: multipart/mixed; + boundary="_003_OutlookBoundary_" +MIME-Version: 1.0 + +--_003_OutlookBoundary_ +Content-Type: text/plain; charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + + +--_003_OutlookBoundary_ +Content-Type: application/pgp-encrypted; name="PGPMIME version identification" +Content-Description: PGP/MIME version identification +Content-Disposition: attachment; filename="PGPMIME version identification" +Content-Transfer-Encoding: base64 + +VmVyc2lvbjogMQ0K + +--_003_OutlookBoundary_ +Content-Type: application/octet-stream; name="encrypted.asc" +Content-Description: OpenPGP encrypted message.asc +Content-Disposition: attachment; filename="encrypted.asc" +Content-Transfer-Encoding: base64 + +` + base64PGP + ` + +--_003_OutlookBoundary_--` + + got := extractInlinePGP(rawMIME) + if got == nil { + t.Fatal("extractInlinePGP() returned nil for base64-encoded Outlook attachment") + } + + gotStr := string(got) + + // Should extract the decoded PGP message + if !strings.HasPrefix(gotStr, "-----BEGIN PGP MESSAGE-----") { + t.Errorf("extractInlinePGP() should start with PGP header, got: %q", gotStr) + } + if !strings.HasSuffix(gotStr, "-----END PGP MESSAGE-----") { + t.Errorf("extractInlinePGP() should end with PGP footer, got: %q", gotStr) + } + if !strings.Contains(gotStr, "hQEMAtest") { + t.Errorf("extractInlinePGP() should contain the encrypted data, got: %q", gotStr) + } +}