Skip to content

Commit 34b47aa

Browse files
committed
fix(mailtrap): validate nested payloads and unbalanced address delimiters
Response payloads (utils.ts + read transforms): - add expectRecord / expectArray / expectContactList / expectSendingMessage - get/create/update_contact require the `data` object; the contact-list transforms require a numeric `id`; get_email_log requires a `message_id`; list_email_logs requires a `messages` array - a well-formed 2xx body that is missing its defining slice ({} on get_contact, { "messages": "bad" } on list_email_logs) now fails instead of producing a fabricated empty result - the list transforms keep the lenient per-row mappers, so one bad row does not drop the whole page Recipient tokenizer (utils.ts): - splitAddressEntries throws on an unbalanced quote or angle bracket instead of running to the end of the string and absorbing every following recipient into one entry - each address is checked against a bare-address pattern (/^[^\s"<>,@]+@[^\s"<>,@]+$/) rather than a bare "contains @" test, so a value carrying spaces, commas, or brackets is rejected
1 parent f454c2f commit 34b47aa

10 files changed

Lines changed: 128 additions & 19 deletions

apps/sim/tools/mailtrap/create_contact.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import {
44
type MailtrapCreateContactParams,
55
type MailtrapCreateContactResult,
66
} from '@/tools/mailtrap/types'
7-
import { mapContact, parseIdList, parseJsonRecord, readJsonBody } from '@/tools/mailtrap/utils'
7+
import {
8+
expectRecord,
9+
mapContact,
10+
parseIdList,
11+
parseJsonRecord,
12+
readJsonBody,
13+
} from '@/tools/mailtrap/utils'
814
import type { ToolConfig } from '@/tools/types'
915

1016
export const mailtrapCreateContactTool: ToolConfig<
@@ -70,7 +76,7 @@ export const mailtrapCreateContactTool: ToolConfig<
7076
return {
7177
success: true,
7278
output: {
73-
contact: mapContact(data.data),
79+
contact: mapContact(expectRecord(data.data, 'the contact payload')),
7480
},
7581
}
7682
},

apps/sim/tools/mailtrap/create_contact_list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type MailtrapContactListResult,
55
type MailtrapCreateContactListParams,
66
} from '@/tools/mailtrap/types'
7-
import { mapContactList, readJsonBody } from '@/tools/mailtrap/utils'
7+
import { expectContactList, readJsonBody } from '@/tools/mailtrap/utils'
88
import type { ToolConfig } from '@/tools/types'
99

1010
export const mailtrapCreateContactListTool: ToolConfig<
@@ -46,7 +46,7 @@ export const mailtrapCreateContactListTool: ToolConfig<
4646
const data = await readJsonBody(response)
4747
return {
4848
success: true,
49-
output: { list: mapContactList(data) },
49+
output: { list: expectContactList(data) },
5050
}
5151
},
5252

apps/sim/tools/mailtrap/get_contact.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type MailtrapGetContactParams,
55
type MailtrapGetContactResult,
66
} from '@/tools/mailtrap/types'
7-
import { mapContact, readJsonBody } from '@/tools/mailtrap/utils'
7+
import { expectRecord, mapContact, readJsonBody } from '@/tools/mailtrap/utils'
88
import type { ToolConfig } from '@/tools/types'
99

1010
export const mailtrapGetContactTool: ToolConfig<
@@ -46,7 +46,7 @@ export const mailtrapGetContactTool: ToolConfig<
4646
return {
4747
success: true,
4848
output: {
49-
contact: mapContact(data.data),
49+
contact: mapContact(expectRecord(data.data, 'the contact payload')),
5050
},
5151
}
5252
},

apps/sim/tools/mailtrap/get_contact_list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type MailtrapContactListResult,
55
type MailtrapGetContactListParams,
66
} from '@/tools/mailtrap/types'
7-
import { mapContactList, readJsonBody } from '@/tools/mailtrap/utils'
7+
import { expectContactList, readJsonBody } from '@/tools/mailtrap/utils'
88
import type { ToolConfig } from '@/tools/types'
99

1010
export const mailtrapGetContactListTool: ToolConfig<
@@ -45,7 +45,7 @@ export const mailtrapGetContactListTool: ToolConfig<
4545
const data = await readJsonBody(response)
4646
return {
4747
success: true,
48-
output: { list: mapContactList(data) },
48+
output: { list: expectContactList(data) },
4949
}
5050
},
5151

apps/sim/tools/mailtrap/get_email_log.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type MailtrapGetEmailLogParams,
55
type MailtrapGetEmailLogResult,
66
} from '@/tools/mailtrap/types'
7-
import { mapSendingMessage, readJsonBody } from '@/tools/mailtrap/utils'
7+
import { expectSendingMessage, readJsonBody } from '@/tools/mailtrap/utils'
88
import type { ToolConfig } from '@/tools/types'
99

1010
export const mailtrapGetEmailLogTool: ToolConfig<
@@ -48,7 +48,7 @@ export const mailtrapGetEmailLogTool: ToolConfig<
4848
success: true,
4949
output: {
5050
message: {
51-
...mapSendingMessage(data),
51+
...expectSendingMessage(data),
5252
rawMessageUrl: typeof data.raw_message_url === 'string' ? data.raw_message_url : null,
5353
events: Array.isArray(data.events) ? (data.events as Array<Record<string, unknown>>) : [],
5454
},

apps/sim/tools/mailtrap/list_email_logs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type MailtrapListEmailLogsParams,
55
type MailtrapListEmailLogsResult,
66
} from '@/tools/mailtrap/types'
7-
import { mapSendingMessage, readJsonBody } from '@/tools/mailtrap/utils'
7+
import { expectArray, mapSendingMessage, readJsonBody } from '@/tools/mailtrap/utils'
88
import type { ToolConfig } from '@/tools/types'
99

1010
/** Splits a comma-separated filter value into trimmed, non-empty entries. */
@@ -171,7 +171,7 @@ export const mailtrapListEmailLogsTool: ToolConfig<
171171

172172
transformResponse: async (response): Promise<MailtrapListEmailLogsResult> => {
173173
const data = await readJsonBody(response)
174-
const messages = Array.isArray(data.messages) ? data.messages.map(mapSendingMessage) : []
174+
const messages = expectArray(data.messages, 'a messages array').map(mapSendingMessage)
175175

176176
return {
177177
success: true,

apps/sim/tools/mailtrap/mailtrap.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { describe, expect, it, vi } from 'vitest'
55
import { extractErrorMessage } from '@/tools/error-extractors'
66
import { mailtrapCreateContactTool } from '@/tools/mailtrap/create_contact'
77
import { mailtrapDeleteContactTool } from '@/tools/mailtrap/delete_contact'
8+
import { mailtrapGetContactTool } from '@/tools/mailtrap/get_contact'
9+
import { mailtrapGetContactListTool } from '@/tools/mailtrap/get_contact_list'
810
import { mailtrapGetEmailLogTool } from '@/tools/mailtrap/get_email_log'
911
import { mailtrapListContactListsTool } from '@/tools/mailtrap/list_contact_lists'
1012
import { mailtrapListEmailLogsTool } from '@/tools/mailtrap/list_email_logs'
@@ -58,6 +60,15 @@ describe('mailtrap utils', () => {
5860
)
5961
})
6062

63+
it('rejects an unbalanced quote or angle bracket instead of absorbing recipients', () => {
64+
expect(() => parseAddressList('Bad <bad@example.com, good@example.com')).toThrow(
65+
/unbalanced quote or angle bracket/
66+
)
67+
expect(() => parseAddressList('"still open, a@example.com')).toThrow(
68+
/unbalanced quote or angle bracket/
69+
)
70+
})
71+
6172
it('keeps commas that sit inside a quoted display name', () => {
6273
expect(
6374
parseAddressList(
@@ -274,6 +285,27 @@ describe('mailtrap contact tools', () => {
274285
})
275286
})
276287

288+
it('fails a well-formed body that is missing the contact payload', async () => {
289+
await expect(
290+
mailtrapGetContactTool.transformResponse?.(new Response('{}'), {
291+
apiToken: 't',
292+
contactIdentifier: 'c@example.com',
293+
})
294+
).rejects.toThrow(/did not include the contact payload/)
295+
})
296+
297+
it('fails a contact list response with no numeric id', async () => {
298+
await expect(
299+
mailtrapGetContactListTool.transformResponse?.(
300+
new Response(JSON.stringify({ name: 'News' })),
301+
{
302+
apiToken: 't',
303+
listId: '1',
304+
}
305+
)
306+
).rejects.toThrow(/did not include a contact list id/)
307+
})
308+
277309
it('sends include/exclude list ids and the unsubscribe flag on update', () => {
278310
const body = buildBody(mailtrapUpdateContactTool, {
279311
apiToken: 't',
@@ -529,6 +561,24 @@ describe('mailtrap email logs tools', () => {
529561
'https://mailtrap.io/api/email_logs/a%20b%2Fc'
530562
)
531563
})
564+
565+
it('fails when the list response has no messages array', async () => {
566+
await expect(
567+
mailtrapListEmailLogsTool.transformResponse?.(
568+
new Response(JSON.stringify({ messages: 'bad', total_count: 0 })),
569+
{ apiToken: 't' }
570+
)
571+
).rejects.toThrow(/did not include a messages array/)
572+
})
573+
574+
it('fails a single message response with no message id', async () => {
575+
await expect(
576+
mailtrapGetEmailLogTool.transformResponse?.(
577+
new Response(JSON.stringify({ status: 'delivered' })),
578+
{ apiToken: 't', messageId: 'm-9' }
579+
)
580+
).rejects.toThrow(/did not include a message id/)
581+
})
532582
})
533583

534584
describe('mailtrap transformResponse invariants', () => {

apps/sim/tools/mailtrap/update_contact.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type MailtrapUpdateContactResult,
66
} from '@/tools/mailtrap/types'
77
import {
8+
expectRecord,
89
mapContact,
910
parseIdList,
1011
parseJsonRecord,
@@ -102,7 +103,7 @@ export const mailtrapUpdateContactTool: ToolConfig<
102103
success: true,
103104
output: {
104105
action: typeof data.action === 'string' ? data.action : '',
105-
contact: mapContact(data.data),
106+
contact: mapContact(expectRecord(data.data, 'the contact payload')),
106107
},
107108
}
108109
},

apps/sim/tools/mailtrap/update_contact_list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type MailtrapContactListResult,
55
type MailtrapUpdateContactListParams,
66
} from '@/tools/mailtrap/types'
7-
import { mapContactList, readJsonBody } from '@/tools/mailtrap/utils'
7+
import { expectContactList, readJsonBody } from '@/tools/mailtrap/utils'
88
import type { ToolConfig } from '@/tools/types'
99

1010
export const mailtrapUpdateContactListTool: ToolConfig<
@@ -53,7 +53,7 @@ export const mailtrapUpdateContactListTool: ToolConfig<
5353
const data = await readJsonBody(response)
5454
return {
5555
success: true,
56-
output: { list: mapContactList(data) },
56+
output: { list: expectContactList(data) },
5757
}
5858
},
5959

apps/sim/tools/mailtrap/utils.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@ export function parseAddress(value: string | undefined): MailtrapAddress | undef
1919
return parseAddressList(value)[0]
2020
}
2121

22+
/** A bare address: one `@`, no whitespace or list/quote/bracket delimiters on either side. */
23+
const BARE_EMAIL = /^[^\s"<>,@]+@[^\s"<>,@]+$/
24+
2225
/**
23-
* Splits a recipient string on top-level commas only.
26+
* Splits a recipient string on top-level commas only. Throws when a quote or angle
27+
* bracket is left open.
2428
*/
2529
function splitAddressEntries(value: string): string[] {
2630
const entries: string[] = []
@@ -54,6 +58,10 @@ function splitAddressEntries(value: string): string[] {
5458
}
5559
entries.push(current)
5660

61+
if (inQuotes || inAngle) {
62+
throw new Error('Recipient list has an unbalanced quote or angle bracket')
63+
}
64+
5765
return entries
5866
}
5967

@@ -72,7 +80,7 @@ export function parseAddressList(value: string | undefined): MailtrapAddress[] {
7280

7381
const named = entry.match(/^\s*(.*?)\s*<\s*([^<>]+?)\s*>\s*$/)
7482
const email = (named ? named[2] : entry).trim()
75-
if (!email.includes('@')) {
83+
if (!BARE_EMAIL.test(email)) {
7684
throw new Error(`"${entry}" is not a valid email address`)
7785
}
7886

@@ -210,8 +218,9 @@ function parseJsonResponse(text: string): unknown {
210218
/**
211219
* Reads a Mailtrap 2xx JSON object body. Every endpoint that calls this returns
212220
* a JSON object on success, so an empty, unparseable, or wrongly-shaped body
213-
* means the payload did not come from Mailtrap and is surfaced as a failure instead of being mapped to an empty
214-
* record. Delete endpoints return `204` and never call this.
221+
* means the payload did not come from Mailtrap and is surfaced as a failure
222+
* rather than mapped into a fabricated empty record. Delete endpoints return
223+
* `204` and never call this.
215224
*/
216225
export async function readJsonBody(response: Response): Promise<Record<string, unknown>> {
217226
const parsed = parseJsonResponse(await response.text())
@@ -233,3 +242,46 @@ export async function readJsonArray(response: Response): Promise<unknown[]> {
233242
}
234243
return parsed
235244
}
245+
246+
/**
247+
* Asserts that a nested payload the caller depends on is present and is a plain
248+
* object. A well-formed JSON body that is missing its defining slice fails here instead of
249+
* being mapped into a fabricated empty record.
250+
*/
251+
export function expectRecord(value: unknown, description: string): Record<string, unknown> {
252+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
253+
throw new Error(`Mailtrap response did not include ${description}`)
254+
}
255+
return value as Record<string, unknown>
256+
}
257+
258+
/** Asserts that a nested payload the caller iterates over is present and is an array. */
259+
export function expectArray(value: unknown, description: string): unknown[] {
260+
if (!Array.isArray(value)) {
261+
throw new Error(`Mailtrap response did not include ${description}`)
262+
}
263+
return value
264+
}
265+
266+
/**
267+
* Validates a Contacts Lists API payload (`{ id, name }`) and normalizes it.
268+
* The single-list endpoints require a numeric `id`; the list endpoint keeps the
269+
* lenient {@link mapContactList} for individual rows.
270+
*/
271+
export function expectContactList(data: Record<string, unknown>): MailtrapContactList {
272+
if (typeof data.id !== 'number') {
273+
throw new Error('Mailtrap response did not include a contact list id')
274+
}
275+
return mapContactList(data)
276+
}
277+
278+
/**
279+
* Validates an Email Logs message payload and normalizes it. Requires a
280+
* `message_id` so an empty object is not mapped into a blank message.
281+
*/
282+
export function expectSendingMessage(data: Record<string, unknown>): MailtrapSendingMessage {
283+
if (typeof data.message_id !== 'string' || data.message_id.length === 0) {
284+
throw new Error('Mailtrap response did not include a message id')
285+
}
286+
return mapSendingMessage(data)
287+
}

0 commit comments

Comments
 (0)