Skip to content

Commit 2d6726d

Browse files
feat(email): support inline PGP decryption for Outlook emails (#19)
Co-authored-by: Qasim <qasim.m@nylas.com>
1 parent f6f0408 commit 2d6726d

3 files changed

Lines changed: 368 additions & 30 deletions

File tree

internal/cli/email/read_decrypt.go

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package email
33
import (
44
"bytes"
55
"context"
6+
"encoding/base64"
67
"fmt"
78
"io"
89
"mime"
@@ -14,22 +15,31 @@ import (
1415
"github.com/nylas/cli/internal/domain"
1516
)
1617

17-
// decryptGPGEmail decrypts a PGP/MIME encrypted message.
18+
// decryptGPGEmail decrypts a PGP-encrypted message.
19+
// Supports both PGP/MIME (RFC 3156) and inline PGP formats.
20+
// Some email providers (e.g., Microsoft/Outlook) transform PGP/MIME into inline PGP.
1821
func decryptGPGEmail(ctx context.Context, msg *domain.Message) (*gpg.DecryptResult, error) {
1922
if msg.RawMIME == "" {
2023
return nil, fmt.Errorf("no raw MIME data available for decryption")
2124
}
2225

23-
// Check if this is an encrypted message
24-
contentType := extractFullContentType(msg.RawMIME)
25-
if !isEncryptedMessage(contentType) {
26-
return nil, fmt.Errorf("message is not PGP/MIME encrypted (Content-Type: %s)", contentType)
27-
}
26+
var ciphertext []byte
27+
var err error
2828

29-
// Parse the multipart message to extract encrypted content
30-
ciphertext, err := parseEncryptedMIME(msg.RawMIME)
31-
if err != nil {
32-
return nil, fmt.Errorf("failed to parse PGP/MIME encrypted message: %w", err)
29+
// Check if this is a PGP/MIME encrypted message
30+
contentType := extractFullContentType(msg.RawMIME)
31+
if isEncryptedMessage(contentType) {
32+
// Parse the multipart message to extract encrypted content
33+
ciphertext, err = parseEncryptedMIME(msg.RawMIME)
34+
if err != nil {
35+
return nil, fmt.Errorf("failed to parse PGP/MIME encrypted message: %w", err)
36+
}
37+
} else {
38+
// Try to find inline PGP content (some providers like Outlook transform PGP/MIME)
39+
ciphertext = extractInlinePGP(msg.RawMIME)
40+
if ciphertext == nil {
41+
return nil, fmt.Errorf("message does not contain PGP encrypted content")
42+
}
3343
}
3444

3545
// Initialize GPG service
@@ -57,6 +67,101 @@ func isEncryptedMessage(contentType string) bool {
5767
strings.Contains(contentType, "application/pgp-encrypted")
5868
}
5969

70+
// extractInlinePGP extracts inline PGP encrypted content from a message.
71+
// This handles emails where providers (e.g., Microsoft/Outlook) have transformed
72+
// PGP/MIME into a multipart/mixed with the PGP block as inline text.
73+
func extractInlinePGP(rawMIME string) []byte {
74+
const pgpBegin = "-----BEGIN PGP MESSAGE-----"
75+
const pgpEnd = "-----END PGP MESSAGE-----"
76+
77+
// First, try to find PGP content directly in the raw MIME
78+
beginIdx := strings.Index(rawMIME, pgpBegin)
79+
if beginIdx != -1 {
80+
endIdx := strings.Index(rawMIME[beginIdx:], pgpEnd)
81+
if endIdx != -1 {
82+
// Include the end marker
83+
pgpContent := rawMIME[beginIdx : beginIdx+endIdx+len(pgpEnd)]
84+
return []byte(strings.TrimSpace(pgpContent))
85+
}
86+
}
87+
88+
// If not found directly, try parsing multipart and checking each part
89+
contentType := extractFullContentType(rawMIME)
90+
if !strings.Contains(contentType, "multipart/") {
91+
return nil
92+
}
93+
94+
_, params, err := mime.ParseMediaType(contentType)
95+
if err != nil {
96+
return nil
97+
}
98+
99+
boundary := params["boundary"]
100+
if boundary == "" {
101+
return nil
102+
}
103+
104+
headerEnd := findHeaderEnd(rawMIME)
105+
if headerEnd == -1 {
106+
return nil
107+
}
108+
109+
bodySection := rawMIME[headerEnd:]
110+
mr := multipart.NewReader(strings.NewReader(bodySection), boundary)
111+
112+
for {
113+
part, err := mr.NextPart()
114+
if err == io.EOF {
115+
break
116+
}
117+
if err != nil {
118+
return nil
119+
}
120+
121+
partContent, err := io.ReadAll(part)
122+
if err != nil {
123+
continue
124+
}
125+
126+
// Check if content needs base64 decoding
127+
// Outlook transforms PGP/MIME into attachments with base64 encoding
128+
transferEncoding := strings.ToLower(part.Header.Get("Content-Transfer-Encoding"))
129+
partContentType := strings.ToLower(part.Header.Get("Content-Type"))
130+
131+
// Decode base64 if needed (common for application/octet-stream or pgp-encrypted attachments)
132+
if transferEncoding == "base64" {
133+
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(partContent)))
134+
if err == nil {
135+
partContent = decoded
136+
}
137+
}
138+
139+
content := string(partContent)
140+
141+
// Check for PGP content in this part
142+
beginIdx := strings.Index(content, pgpBegin)
143+
if beginIdx != -1 {
144+
endIdx := strings.Index(content[beginIdx:], pgpEnd)
145+
if endIdx != -1 {
146+
pgpContent := content[beginIdx : beginIdx+endIdx+len(pgpEnd)]
147+
return []byte(strings.TrimSpace(pgpContent))
148+
}
149+
}
150+
151+
// Also check for application/octet-stream or encrypted.asc which might contain PGP data
152+
if strings.Contains(partContentType, "application/octet-stream") ||
153+
strings.Contains(partContentType, "application/pgp-encrypted") {
154+
// The whole part might be the PGP message (without explicit markers in some edge cases)
155+
if strings.Contains(content, pgpBegin) {
156+
// Already handled above
157+
continue
158+
}
159+
}
160+
}
161+
162+
return nil
163+
}
164+
60165
// parseEncryptedMIME parses a PGP/MIME encrypted message and extracts the ciphertext.
61166
// RFC 3156 Section 4 defines the structure:
62167
// Part 1: application/pgp-encrypted with "Version: 1"

internal/cli/email/read_decrypt_test.go

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,235 @@ func TestIsEncryptedMessage_CaseInsensitive(t *testing.T) {
360360
})
361361
}
362362
}
363+
364+
func TestExtractInlinePGP(t *testing.T) {
365+
tests := []struct {
366+
name string
367+
rawMIME string
368+
wantContains string
369+
wantNil bool
370+
}{
371+
{
372+
name: "inline PGP in plain text body",
373+
rawMIME: `From: sender@example.com
374+
To: recipient@example.com
375+
Content-Type: text/plain
376+
377+
-----BEGIN PGP MESSAGE-----
378+
379+
hQEMAxxxxxxxxx
380+
=xxxx
381+
-----END PGP MESSAGE-----`,
382+
wantContains: "-----BEGIN PGP MESSAGE-----",
383+
wantNil: false,
384+
},
385+
{
386+
name: "inline PGP in multipart/mixed (Outlook style)",
387+
rawMIME: `From: sender@example.com
388+
Content-Type: multipart/mixed; boundary="boundary123"
389+
390+
--boundary123
391+
Content-Type: text/plain; charset="UTF-8"
392+
393+
-----BEGIN PGP MESSAGE-----
394+
395+
hQIMAwfdV3YDsnmWARAAs8jMMsaoLnlg
396+
=xxxx
397+
-----END PGP MESSAGE-----
398+
--boundary123--`,
399+
wantContains: "-----BEGIN PGP MESSAGE-----",
400+
wantNil: false,
401+
},
402+
{
403+
name: "inline PGP with surrounding text",
404+
rawMIME: `Content-Type: text/plain
405+
406+
Some text before
407+
408+
-----BEGIN PGP MESSAGE-----
409+
encrypted_data_here
410+
-----END PGP MESSAGE-----
411+
412+
Some text after`,
413+
wantContains: "-----BEGIN PGP MESSAGE-----",
414+
wantNil: false,
415+
},
416+
{
417+
name: "no PGP content",
418+
rawMIME: `From: sender@example.com
419+
Content-Type: text/plain
420+
421+
Just a regular email with no encryption.`,
422+
wantNil: true,
423+
},
424+
{
425+
name: "incomplete PGP block - missing end marker",
426+
rawMIME: `Content-Type: text/plain
427+
428+
-----BEGIN PGP MESSAGE-----
429+
encrypted_data_here
430+
No end marker`,
431+
wantNil: true,
432+
},
433+
{
434+
name: "PGP in second part of multipart",
435+
rawMIME: `Content-Type: multipart/mixed; boundary="mixed"
436+
437+
--mixed
438+
Content-Type: text/plain
439+
440+
Regular text part
441+
--mixed
442+
Content-Type: text/plain
443+
444+
-----BEGIN PGP MESSAGE-----
445+
encrypted_in_second_part
446+
-----END PGP MESSAGE-----
447+
--mixed--`,
448+
wantContains: "-----BEGIN PGP MESSAGE-----",
449+
wantNil: false,
450+
},
451+
{
452+
name: "PGP with CRLF line endings",
453+
rawMIME: "Content-Type: text/plain\r\n\r\n-----BEGIN PGP MESSAGE-----\r\nencrypted\r\n-----END PGP MESSAGE-----",
454+
wantContains: "-----BEGIN PGP MESSAGE-----",
455+
wantNil: false,
456+
},
457+
{
458+
name: "empty input",
459+
rawMIME: "",
460+
wantNil: true,
461+
},
462+
{
463+
name: "multipart without PGP",
464+
rawMIME: `Content-Type: multipart/mixed; boundary="b"
465+
466+
--b
467+
Content-Type: text/plain
468+
469+
No encryption here
470+
--b--`,
471+
wantNil: true,
472+
},
473+
}
474+
475+
for _, tt := range tests {
476+
t.Run(tt.name, func(t *testing.T) {
477+
got := extractInlinePGP(tt.rawMIME)
478+
if tt.wantNil {
479+
if got != nil {
480+
t.Errorf("extractInlinePGP() = %q, want nil", string(got))
481+
}
482+
return
483+
}
484+
if got == nil {
485+
t.Error("extractInlinePGP() = nil, want non-nil")
486+
return
487+
}
488+
if !strings.Contains(string(got), tt.wantContains) {
489+
t.Errorf("extractInlinePGP() = %q, want to contain %q", string(got), tt.wantContains)
490+
}
491+
// Verify we extract the complete PGP block
492+
if !strings.HasPrefix(string(got), "-----BEGIN PGP MESSAGE-----") {
493+
t.Errorf("extractInlinePGP() should start with PGP header, got: %q", string(got))
494+
}
495+
if !strings.HasSuffix(string(got), "-----END PGP MESSAGE-----") {
496+
t.Errorf("extractInlinePGP() should end with PGP footer, got: %q", string(got))
497+
}
498+
})
499+
}
500+
}
501+
502+
func TestExtractInlinePGP_ExtractsCompletePGPBlock(t *testing.T) {
503+
// Verify the extracted content is exactly the PGP block
504+
rawMIME := `Content-Type: text/plain
505+
506+
Preamble text here.
507+
508+
-----BEGIN PGP MESSAGE-----
509+
510+
hQEMAxxxxxxxxx
511+
line2
512+
line3
513+
=xxxx
514+
-----END PGP MESSAGE-----
515+
516+
Postamble text here.`
517+
518+
got := extractInlinePGP(rawMIME)
519+
if got == nil {
520+
t.Fatal("extractInlinePGP() returned nil")
521+
}
522+
523+
gotStr := string(got)
524+
525+
// Should not contain preamble or postamble
526+
if strings.Contains(gotStr, "Preamble") {
527+
t.Error("extractInlinePGP() should not include preamble text")
528+
}
529+
if strings.Contains(gotStr, "Postamble") {
530+
t.Error("extractInlinePGP() should not include postamble text")
531+
}
532+
533+
// Should contain the full PGP message
534+
if !strings.Contains(gotStr, "hQEMAxxxxxxxxx") {
535+
t.Error("extractInlinePGP() should contain the encrypted data")
536+
}
537+
if !strings.Contains(gotStr, "line2") {
538+
t.Error("extractInlinePGP() should contain all lines of encrypted data")
539+
}
540+
}
541+
542+
func TestExtractInlinePGP_Base64EncodedAttachment(t *testing.T) {
543+
// Test Outlook-style email where PGP content is base64-encoded in an attachment
544+
// This is the actual format Microsoft/Outlook uses for PGP/MIME emails
545+
// The base64 decodes to: "-----BEGIN PGP MESSAGE-----\n\nhQEMAtest\n=xxxx\n-----END PGP MESSAGE-----\n"
546+
base64PGP := "LS0tLS1CRUdJTiBQR1AgTUVTU0FHRS0tLS0tCgpoUUVNQXRlc3QKPXh4eHgKLS0tLS1FTkQgUEdQIE1FU1NBR0UtLS0tLQo="
547+
548+
rawMIME := `From: sender@outlook.com
549+
To: recipient@example.com
550+
Content-Type: multipart/mixed;
551+
boundary="_003_OutlookBoundary_"
552+
MIME-Version: 1.0
553+
554+
--_003_OutlookBoundary_
555+
Content-Type: text/plain; charset="us-ascii"
556+
Content-Transfer-Encoding: quoted-printable
557+
558+
559+
--_003_OutlookBoundary_
560+
Content-Type: application/pgp-encrypted; name="PGPMIME version identification"
561+
Content-Description: PGP/MIME version identification
562+
Content-Disposition: attachment; filename="PGPMIME version identification"
563+
Content-Transfer-Encoding: base64
564+
565+
VmVyc2lvbjogMQ0K
566+
567+
--_003_OutlookBoundary_
568+
Content-Type: application/octet-stream; name="encrypted.asc"
569+
Content-Description: OpenPGP encrypted message.asc
570+
Content-Disposition: attachment; filename="encrypted.asc"
571+
Content-Transfer-Encoding: base64
572+
573+
` + base64PGP + `
574+
575+
--_003_OutlookBoundary_--`
576+
577+
got := extractInlinePGP(rawMIME)
578+
if got == nil {
579+
t.Fatal("extractInlinePGP() returned nil for base64-encoded Outlook attachment")
580+
}
581+
582+
gotStr := string(got)
583+
584+
// Should extract the decoded PGP message
585+
if !strings.HasPrefix(gotStr, "-----BEGIN PGP MESSAGE-----") {
586+
t.Errorf("extractInlinePGP() should start with PGP header, got: %q", gotStr)
587+
}
588+
if !strings.HasSuffix(gotStr, "-----END PGP MESSAGE-----") {
589+
t.Errorf("extractInlinePGP() should end with PGP footer, got: %q", gotStr)
590+
}
591+
if !strings.Contains(gotStr, "hQEMAtest") {
592+
t.Errorf("extractInlinePGP() should contain the encrypted data, got: %q", gotStr)
593+
}
594+
}

0 commit comments

Comments
 (0)