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
125 changes: 115 additions & 10 deletions internal/cli/email/read_decrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package email
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"mime"
Expand All @@ -14,22 +15,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
Expand Down Expand Up @@ -57,6 +67,101 @@ 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
}

// 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)
if endIdx != -1 {
pgpContent := content[beginIdx : beginIdx+endIdx+len(pgpEnd)]
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
}

// 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"
Expand Down
232 changes: 232 additions & 0 deletions internal/cli/email/read_decrypt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,235 @@ 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")
}
}

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)
}
}
Loading