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
168 changes: 166 additions & 2 deletions packages/email-core/src/actions/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MockEmailProvider } from '../testing/mock-provider.js';
import { listAttachmentsAction, detectMimeType, validateAttachment, sanitizeFilename } from './attachments.js';
import { listAttachmentsAction, downloadAttachmentAction, detectMimeType, validateAttachment, sanitizeFilename } from './attachments.js';
import { AttachmentNotSupportedError } from '../providers/provider.js';
import type { ActionContext } from './registry.js';

let provider: MockEmailProvider;
Expand Down Expand Up @@ -41,6 +42,169 @@ describe('email-attachments/Download Attachments', () => {
});
});

describe('email-attachments/Download Attachment', () => {
const PDF_BYTES = Buffer.from('%PDF-1.4 fake pdf body for tests');

it('Scenario: Happy path returns sanitized filename, declared mimeType, and round-trippable base64', async () => {
provider.addMessage({
id: 'msg-dl',
hasAttachments: true,
attachments: [
{ id: 'att-1', filename: 'Report (Final).pdf', mimeType: 'application/pdf', size: PDF_BYTES.length, isInline: false },
],
});
provider.addAttachmentData('msg-dl', 'att-1', PDF_BYTES);

const result = await downloadAttachmentAction.run(ctx, {
message_id: 'msg-dl',
attachment_id: 'att-1',
max_size_mb: 5,
});

expect(result.success).toBe(true);
expect(result.mimeType).toBe('application/pdf');
expect(result.size).toBe(PDF_BYTES.length);
expect(result.filename).not.toContain('(');
expect(result.filename).toMatch(/\.pdf$/);
expect(Buffer.from(result.base64!, 'base64').equals(PDF_BYTES)).toBe(true);
});

it('Scenario: Pre-download size rejection — declared size exceeds cap, downloadAttachment is not called', async () => {
provider.addMessage({
id: 'msg-big',
hasAttachments: true,
attachments: [
{ id: 'att-big', filename: 'huge.pdf', mimeType: 'application/pdf', size: 2 * 1024 * 1024, isInline: false },
],
});
provider.addAttachmentData('msg-big', 'att-big', Buffer.alloc(2 * 1024 * 1024));
const downloadSpy = vi.spyOn(provider, 'downloadAttachment');

const result = await downloadAttachmentAction.run(ctx, {
message_id: 'msg-big',
attachment_id: 'att-big',
max_size_mb: 1,
});

expect(result.success).toBe(false);
expect(result.error?.code).toBe('ATTACHMENT_TOO_LARGE');
expect(result.error?.message).toMatch(/exceeds max_size_mb=1/);
expect(downloadSpy).not.toHaveBeenCalled();
});

it('Scenario: Post-download size rejection — provider lies about size, actual buffer overruns the cap', async () => {
const declaredSize = 100;
const actualBuf = Buffer.alloc(2 * 1024 * 1024);
provider.addMessage({
id: 'msg-liar',
hasAttachments: true,
attachments: [
{ id: 'att-liar', filename: 'liar.bin', mimeType: 'application/octet-stream', size: declaredSize, isInline: false },
],
});
provider.addAttachmentData('msg-liar', 'att-liar', actualBuf);

const result = await downloadAttachmentAction.run(ctx, {
message_id: 'msg-liar',
attachment_id: 'att-liar',
max_size_mb: 1,
});

expect(result.success).toBe(false);
expect(result.error?.code).toBe('ATTACHMENT_TOO_LARGE');
expect(result.error?.message).toMatch(new RegExp(`${actualBuf.length} bytes`));
});

it('Scenario: NOT_SUPPORTED when provider lacks downloadAttachment', async () => {
const stubCtx: ActionContext = { provider: { getMessage: async () => ({ attachments: [] }) } as never };

const result = await downloadAttachmentAction.run(stubCtx, {
message_id: 'msg-x',
attachment_id: 'att-x',
max_size_mb: 5,
});

expect(result.success).toBe(false);
expect(result.error?.code).toBe('NOT_SUPPORTED');
});

it('Scenario: ATTACHMENT_NOT_FOUND when attachment id is missing from listAttachments', async () => {
provider.addMessage({
id: 'msg-empty',
hasAttachments: true,
attachments: [
{ id: 'att-other', filename: 'other.pdf', mimeType: 'application/pdf', size: 10, isInline: false },
],
});

const result = await downloadAttachmentAction.run(ctx, {
message_id: 'msg-empty',
attachment_id: 'att-missing',
max_size_mb: 5,
});

expect(result.success).toBe(false);
expect(result.error?.code).toBe('ATTACHMENT_NOT_FOUND');
});

it('Scenario: NOT_SUPPORTED when provider throws AttachmentNotSupportedError during download (e.g. Graph itemAttachment)', async () => {
provider.addMessage({
id: 'msg-item',
hasAttachments: true,
attachments: [
{ id: 'att-item', filename: 'embedded.eml', mimeType: 'message/rfc822', size: 1000, isInline: false },
],
});
vi.spyOn(provider, 'downloadAttachment').mockRejectedValue(
new AttachmentNotSupportedError('Attachment att-item has @odata.type=#microsoft.graph.itemAttachment'),
);

const result = await downloadAttachmentAction.run(ctx, {
message_id: 'msg-item',
attachment_id: 'att-item',
max_size_mb: 5,
});

expect(result.success).toBe(false);
expect(result.error?.code).toBe('NOT_SUPPORTED');
expect(result.error?.message).toMatch(/itemAttachment/);
});

it('Scenario: Network errors are NOT swallowed as ATTACHMENT_NOT_FOUND — must propagate so wrapAction returns PROVIDER_UNAVAILABLE', async () => {
vi.spyOn(provider, 'listAttachments').mockRejectedValue(new Error('Network unreachable'));

await expect(
downloadAttachmentAction.run(ctx, {
message_id: 'msg-any',
attachment_id: 'att-any',
max_size_mb: 5,
}),
).rejects.toThrow('Network unreachable');
});

it('Scenario: Gmail synthetic part:* attachment IDs are passed through unchanged to the provider', async () => {
const PART_ID = 'part:1.0';
provider.addMessage({
id: 'msg-inline',
hasAttachments: true,
attachments: [
{ id: PART_ID, filename: 'logo.png', mimeType: 'image/png', size: 4, isInline: true, contentId: 'image001' },
],
});
provider.addAttachmentData('msg-inline', PART_ID, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const downloadSpy = vi.spyOn(provider, 'downloadAttachment');

const result = await downloadAttachmentAction.run(ctx, {
message_id: 'msg-inline',
attachment_id: PART_ID,
max_size_mb: 5,
});

expect(result.success).toBe(true);
expect(downloadSpy).toHaveBeenCalledWith('msg-inline', PART_ID);
});
});

describe('email-attachments/Inline Image Handling', () => {
it('Scenario: Embedded image in HTML body', async () => {
provider.addMessage({
Expand Down
105 changes: 105 additions & 0 deletions packages/email-core/src/actions/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { z } from 'zod';
import { extname } from 'node:path';
import type { EmailAction } from './registry.js';
import { AttachmentNotSupportedError } from '../providers/provider.js';

const MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024; // 25MB

Expand Down Expand Up @@ -86,6 +87,110 @@ export const listAttachmentsAction: EmailAction<
},
};

// Download a single attachment as inline base64
const DownloadAttachmentInput = z.object({
message_id: z.string(),
attachment_id: z.string(),
mailbox: z.string().optional(),
max_size_mb: z.number().int().positive().max(25).optional().default(5),
});

const DownloadAttachmentOutput = z.object({
success: z.boolean(),
filename: z.string().optional(),
mimeType: z.string().optional(),
size: z.number().optional(),
base64: z.string().optional(),
error: z.object({
code: z.string(),
message: z.string(),
recoverable: z.boolean(),
}).optional(),
});

export const downloadAttachmentAction: EmailAction<
z.infer<typeof DownloadAttachmentInput>,
z.infer<typeof DownloadAttachmentOutput>
> = {
name: 'download_attachment',
description: 'Download a single attachment as inline base64. Default max_size_mb=5 (hard ceiling 25). File attachments only — Microsoft item/reference attachments return NOT_SUPPORTED.',
input: DownloadAttachmentInput,
output: DownloadAttachmentOutput,
annotations: { readOnlyHint: true, destructiveHint: false },
run: async (ctx, input) => {
if (typeof ctx.provider.downloadAttachment !== 'function') {
return {
success: false,
error: {
code: 'NOT_SUPPORTED',
message: 'Provider does not support attachment download',
recoverable: false,
},
};
}

const attachments = ctx.provider.listAttachments
? await ctx.provider.listAttachments(input.message_id)
: ((await ctx.provider.getMessage(input.message_id)).attachments ?? []);

const meta = attachments.find(a => a.id === input.attachment_id);
if (!meta) {
return {
success: false,
error: {
code: 'ATTACHMENT_NOT_FOUND',
message: `Attachment ${input.attachment_id} not found on message ${input.message_id}`,
recoverable: false,
},
};
}

const cap = input.max_size_mb * 1024 * 1024;
if (meta.size > cap) {
return {
success: false,
error: {
code: 'ATTACHMENT_TOO_LARGE',
message: `Attachment is ${meta.size} bytes; exceeds max_size_mb=${input.max_size_mb} (${cap} bytes)`,
recoverable: false,
},
};
}

let buf: Buffer;
try {
buf = await ctx.provider.downloadAttachment(input.message_id, input.attachment_id);
} catch (err) {
if (err instanceof AttachmentNotSupportedError) {
return {
success: false,
error: { code: 'NOT_SUPPORTED', message: err.message, recoverable: false },
};
}
throw err;
}

if (buf.length > cap) {
return {
success: false,
error: {
code: 'ATTACHMENT_TOO_LARGE',
message: `Downloaded attachment is ${buf.length} bytes; exceeds max_size_mb=${input.max_size_mb} (${cap} bytes)`,
recoverable: false,
},
};
}

return {
success: true,
filename: sanitizeFilename(meta.filename),
mimeType: meta.mimeType,
size: buf.length,
base64: buf.toString('base64'),
};
},
};

// Validate attachment for outbound
export function validateAttachment(
content: Buffer,
Expand Down
4 changes: 3 additions & 1 deletion packages/email-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ export type {
EmailSender,
EmailSubscriber,
EmailCategorizer,
EmailAttachmentHandler,
EmailProvider,
AuthManager,
} from './providers/provider.js';
export { AttachmentNotSupportedError } from './providers/provider.js';
export {
isAllowedSender,
loadReceiveAllowlist,
Expand All @@ -38,7 +40,7 @@ export { sendEmailAction } from './actions/send.js';
export { replyToEmailAction } from './actions/reply.js';
export { createDraftAction, sendDraftAction, updateDraftAction } from './actions/draft.js';
export { getThreadAction } from './actions/conversation.js';
export { listAttachmentsAction } from './actions/attachments.js';
export { listAttachmentsAction, downloadAttachmentAction } from './actions/attachments.js';
export { labelEmailAction, flagEmailAction, markReadAction, deleteEmailAction } from './actions/label.js';
export { moveToFolderAction } from './actions/move.js';
export { parseFrontmatter } from './content/frontmatter.js';
Expand Down
12 changes: 12 additions & 0 deletions packages/email-core/src/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ export class ProviderError extends Error {
}
}

// Thrown by providers when an attachment cannot be downloaded by this
// implementation — e.g. Microsoft Graph item/reference attachments, which
// require the /$value raw-bytes path. The download_attachment action remaps
// this to a typed { code: 'NOT_SUPPORTED' } result instead of letting it
// surface as PROVIDER_UNAVAILABLE.
export class AttachmentNotSupportedError extends Error {
constructor(message: string) {
super(message);
this.name = 'AttachmentNotSupportedError';
}
}

// Provider registry for dynamic discovery
const providerRegistry = new Map<string, () => Promise<EmailProvider>>();

Expand Down
Loading
Loading