From e507396ac9b09085b70f0b6dbec34584978ce875 Mon Sep 17 00:00:00 2001 From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:27:33 +1000 Subject: [PATCH 1/5] fix(gmail): include Cc, Bcc and Reply-To in gmail.get responses gmail.get silently dropped the Cc, Bcc and Reply-To headers in both "metadata" and "full" formats, so callers could not see who an email was copied to without falling back to format "raw" and decoding the RFC 822 message themselves. Also make header-name matching case-insensitive per RFC 5322. --- .../__tests__/services/GmailService.test.ts | 98 +++++++++++++++++++ workspace-server/src/services/GmailService.ts | 10 +- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/workspace-server/src/__tests__/services/GmailService.test.ts b/workspace-server/src/__tests__/services/GmailService.test.ts index 6c8aded..b938427 100644 --- a/workspace-server/src/__tests__/services/GmailService.test.ts +++ b/workspace-server/src/__tests__/services/GmailService.test.ts @@ -250,6 +250,104 @@ describe('GmailService', () => { expect(response.attachments).toEqual([]); }); + it('should extract Cc, Bcc and Reply-To headers when present', async () => { + const mockMessage = { + id: 'msg1', + threadId: 'thread1', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'recipient@example.com' }, + { name: 'Cc', value: 'cc-recipient@example.com' }, + { name: 'Bcc', value: 'archive@example.com' }, + { name: 'Reply-To', value: 'replies@example.com' }, + { name: 'Subject', value: 'Test Email' }, + ], + body: { + data: 'SGVsbG8gV29ybGQh', // Base64 for "Hello World!" + }, + }, + }; + + mockGmailAPI.users.messages.get.mockResolvedValue({ + data: mockMessage, + }); + + const result = await gmailService.get({ + messageId: 'msg1', + format: 'full', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.cc).toBe('cc-recipient@example.com'); + expect(response.bcc).toBe('archive@example.com'); + expect(response.replyTo).toBe('replies@example.com'); + }); + + it('should omit Cc, Bcc and Reply-To when absent', async () => { + const mockMessage = { + id: 'msg1', + threadId: 'thread1', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'recipient@example.com' }, + { name: 'Subject', value: 'Test Email' }, + ], + body: { + data: 'SGVsbG8gV29ybGQh', // Base64 for "Hello World!" + }, + }, + }; + + mockGmailAPI.users.messages.get.mockResolvedValue({ + data: mockMessage, + }); + + const result = await gmailService.get({ + messageId: 'msg1', + format: 'full', + }); + + const response = JSON.parse(result.content[0].text); + expect(response).not.toHaveProperty('cc'); + expect(response).not.toHaveProperty('bcc'); + expect(response).not.toHaveProperty('replyTo'); + }); + + it('should match header names case-insensitively', async () => { + const mockMessage = { + id: 'msg1', + threadId: 'thread1', + payload: { + headers: [ + { name: 'from', value: 'sender@example.com' }, + { name: 'TO', value: 'recipient@example.com' }, + { name: 'CC', value: 'cc-recipient@example.com' }, + { name: 'subject', value: 'Test Email' }, + ], + body: { + data: 'SGVsbG8gV29ybGQh', // Base64 for "Hello World!" + }, + }, + }; + + mockGmailAPI.users.messages.get.mockResolvedValue({ + data: mockMessage, + }); + + const result = await gmailService.get({ + messageId: 'msg1', + format: 'full', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.from).toBe('sender@example.com'); + expect(response.to).toBe('recipient@example.com'); + expect(response.cc).toBe('cc-recipient@example.com'); + expect(response.subject).toBe('Test Email'); + }); + it('should extract attachments in full format', async () => { const mockMessage = { id: 'msg_with_attach', diff --git a/workspace-server/src/services/GmailService.ts b/workspace-server/src/services/GmailService.ts index a088535..b1c9cfb 100644 --- a/workspace-server/src/services/GmailService.ts +++ b/workspace-server/src/services/GmailService.ts @@ -198,12 +198,17 @@ export class GmailService { // Extract useful information based on format if (format === 'metadata' || format === 'full') { const headers = message.payload?.headers || []; + // Header names are case-insensitive per RFC 5322. const getHeader = (name: string) => - headers.find((h) => h.name === name)?.value; + headers.find((h) => h.name?.toLowerCase() === name.toLowerCase()) + ?.value; const subject = getHeader('Subject'); const from = getHeader('From'); const to = getHeader('To'); + const cc = getHeader('Cc'); + const bcc = getHeader('Bcc'); + const replyTo = getHeader('Reply-To'); const date = getHeader('Date'); // Extract body and attachments for full format @@ -228,6 +233,9 @@ export class GmailService { subject, from, to, + ...(cc !== undefined && { cc }), + ...(bcc !== undefined && { bcc }), + ...(replyTo !== undefined && { replyTo }), date, body: body || message.snippet, attachments: attachments, From 6ef7481a7d8948032369ada29200a584f513d2bd Mon Sep 17 00:00:00 2001 From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:48:25 +1000 Subject: [PATCH 2/5] refactor(gmail): hoist lowercased header name out of find callback --- workspace-server/src/services/GmailService.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/workspace-server/src/services/GmailService.ts b/workspace-server/src/services/GmailService.ts index b1c9cfb..ff97697 100644 --- a/workspace-server/src/services/GmailService.ts +++ b/workspace-server/src/services/GmailService.ts @@ -199,9 +199,11 @@ export class GmailService { if (format === 'metadata' || format === 'full') { const headers = message.payload?.headers || []; // Header names are case-insensitive per RFC 5322. - const getHeader = (name: string) => - headers.find((h) => h.name?.toLowerCase() === name.toLowerCase()) + const getHeader = (name: string) => { + const searchName = name.toLowerCase(); + return headers.find((h) => h.name?.toLowerCase() === searchName) ?.value; + }; const subject = getHeader('Subject'); const from = getHeader('From'); From 1960f325f0fd04f0945b3aa47e0df4aabf5ae345 Mon Sep 17 00:00:00 2001 From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:53:04 +1000 Subject: [PATCH 3/5] refactor(gmail): build header lookup map; rely on JSON.stringify undefined omission Addresses review feedback: O(N+M) header lookups via a lowercased Map instead of repeated linear scans, and plain property passing instead of conditional spreads since JSON.stringify already omits undefined values. --- workspace-server/src/services/GmailService.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/workspace-server/src/services/GmailService.ts b/workspace-server/src/services/GmailService.ts index ff97697..e5be2ea 100644 --- a/workspace-server/src/services/GmailService.ts +++ b/workspace-server/src/services/GmailService.ts @@ -199,11 +199,13 @@ export class GmailService { if (format === 'metadata' || format === 'full') { const headers = message.payload?.headers || []; // Header names are case-insensitive per RFC 5322. - const getHeader = (name: string) => { - const searchName = name.toLowerCase(); - return headers.find((h) => h.name?.toLowerCase() === searchName) - ?.value; - }; + const headerMap = new Map(); + for (const h of headers) { + if (h.name && h.value !== undefined) { + headerMap.set(h.name.toLowerCase(), h.value); + } + } + const getHeader = (name: string) => headerMap.get(name.toLowerCase()); const subject = getHeader('Subject'); const from = getHeader('From'); @@ -235,9 +237,9 @@ export class GmailService { subject, from, to, - ...(cc !== undefined && { cc }), - ...(bcc !== undefined && { bcc }), - ...(replyTo !== undefined && { replyTo }), + cc, + bcc, + replyTo, date, body: body || message.snippet, attachments: attachments, From 3575115592aa13b63b18d44fe8aaab4ef42089d0 Mon Sep 17 00:00:00 2001 From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:57:52 +1000 Subject: [PATCH 4/5] fix(gmail): exclude null header values from the header map gmail_v1.Schema$MessagePartHeader.value is string | null | undefined; filtering with != null keeps null-valued headers out of the map so they are omitted from the JSON response rather than serialised as null. Tightens the map type to Map. --- .../__tests__/services/GmailService.test.ts | 30 +++++++++++++++++++ workspace-server/src/services/GmailService.ts | 4 +-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/workspace-server/src/__tests__/services/GmailService.test.ts b/workspace-server/src/__tests__/services/GmailService.test.ts index b938427..770bbbc 100644 --- a/workspace-server/src/__tests__/services/GmailService.test.ts +++ b/workspace-server/src/__tests__/services/GmailService.test.ts @@ -315,6 +315,36 @@ describe('GmailService', () => { expect(response).not.toHaveProperty('replyTo'); }); + it('should omit headers whose value is null', async () => { + const mockMessage = { + id: 'msg1', + threadId: 'thread1', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'recipient@example.com' }, + { name: 'Cc', value: null }, + { name: 'Subject', value: 'Test Email' }, + ], + body: { + data: 'SGVsbG8gV29ybGQh', // Base64 for "Hello World!" + }, + }, + }; + + mockGmailAPI.users.messages.get.mockResolvedValue({ + data: mockMessage, + }); + + const result = await gmailService.get({ + messageId: 'msg1', + format: 'full', + }); + + const response = JSON.parse(result.content[0].text); + expect(response).not.toHaveProperty('cc'); + }); + it('should match header names case-insensitively', async () => { const mockMessage = { id: 'msg1', diff --git a/workspace-server/src/services/GmailService.ts b/workspace-server/src/services/GmailService.ts index e5be2ea..1ca62f5 100644 --- a/workspace-server/src/services/GmailService.ts +++ b/workspace-server/src/services/GmailService.ts @@ -199,9 +199,9 @@ export class GmailService { if (format === 'metadata' || format === 'full') { const headers = message.payload?.headers || []; // Header names are case-insensitive per RFC 5322. - const headerMap = new Map(); + const headerMap = new Map(); for (const h of headers) { - if (h.name && h.value !== undefined) { + if (h.name && h.value != null) { headerMap.set(h.name.toLowerCase(), h.value); } } From faf01269c3273af5038407dff4927c4c56b58ad9 Mon Sep 17 00:00:00 2001 From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:00:29 +1000 Subject: [PATCH 5/5] fix(gmail): preserve first-match-wins for duplicate headers headers.find() returned the first matching header; the lookup map was overwriting on duplicates, silently switching to last-match-wins. Skip set() when the key already exists, with a test pinning the behaviour. --- .../__tests__/services/GmailService.test.ts | 30 +++++++++++++++++++ workspace-server/src/services/GmailService.ts | 5 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/workspace-server/src/__tests__/services/GmailService.test.ts b/workspace-server/src/__tests__/services/GmailService.test.ts index 770bbbc..547941e 100644 --- a/workspace-server/src/__tests__/services/GmailService.test.ts +++ b/workspace-server/src/__tests__/services/GmailService.test.ts @@ -345,6 +345,36 @@ describe('GmailService', () => { expect(response).not.toHaveProperty('cc'); }); + it('should return the first value when duplicate headers exist', async () => { + const mockMessage = { + id: 'msg1', + threadId: 'thread1', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'recipient@example.com' }, + { name: 'Subject', value: 'First Subject' }, + { name: 'Subject', value: 'Second Subject' }, + ], + body: { + data: 'SGVsbG8gV29ybGQh', // Base64 for "Hello World!" + }, + }, + }; + + mockGmailAPI.users.messages.get.mockResolvedValue({ + data: mockMessage, + }); + + const result = await gmailService.get({ + messageId: 'msg1', + format: 'full', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.subject).toBe('First Subject'); + }); + it('should match header names case-insensitively', async () => { const mockMessage = { id: 'msg1', diff --git a/workspace-server/src/services/GmailService.ts b/workspace-server/src/services/GmailService.ts index 1ca62f5..a5586ca 100644 --- a/workspace-server/src/services/GmailService.ts +++ b/workspace-server/src/services/GmailService.ts @@ -202,7 +202,10 @@ export class GmailService { const headerMap = new Map(); for (const h of headers) { if (h.name && h.value != null) { - headerMap.set(h.name.toLowerCase(), h.value); + const key = h.name.toLowerCase(); + if (!headerMap.has(key)) { + headerMap.set(key, h.value); + } } } const getHeader = (name: string) => headerMap.get(name.toLowerCase());