Skip to content

Commit 03cfa4f

Browse files
guo-yuclaude
andcommitted
v1.3.0: add attachment download support
Add getAttachment to remote provider — calls /v1/attachment (hosted) or /api/attachment (self-hosted) to download binary attachments. CLI inbox detail now shows attachment IDs and supports --save flag to download all attachments to disk. Inbox list shows +Natt indicator for emails with attachments. New e2e tests hit live APIs: OSS worker (test.mails.dev) for attachment metadata, hosted worker (mails.dev) for binary download via R2. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0b7854f commit 03cfa4f

12 files changed

Lines changed: 921 additions & 9 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mails",
3-
"version": "1.2.4",
3+
"version": "1.3.0",
44
"description": "Email infrastructure for AI agents",
55
"type": "module",
66
"main": "dist/index.js",

src/cli/commands/help.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Inbox:
3232
mails inbox --query "reset password" Search emails in a mailbox
3333
mails inbox --query "invoice" --direction inbound --limit 10
3434
mails inbox <id> Show email details
35+
mails inbox <id> --save Download attachments to current directory
36+
mails inbox <id> --save ./downloads Download attachments to a directory
3537
3638
Code:
3739
mails code --to <address> Wait for a verification code

src/cli/commands/inbox.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { getInbox, searchInbox, getEmail } from '../../core/receive.js'
1+
import { writeFile, mkdir } from 'node:fs/promises'
2+
import { join } from 'node:path'
3+
import { getInbox, searchInbox, getEmail, downloadAttachment } from '../../core/receive.js'
24
import { loadConfig } from '../../core/config.js'
35

46
function parseArgs(args: string[]): Record<string, string> {
@@ -11,6 +13,8 @@ function parseArgs(args: string[]): Record<string, string> {
1113
if (value && !value.startsWith('--')) {
1214
result[key] = value
1315
i++
16+
} else {
17+
result[key] = ''
1418
}
1519
} else if (!result._positional) {
1620
result._positional = arg
@@ -38,12 +42,32 @@ export async function inboxCommand(args: string[]) {
3842
if (email.attachments?.length) {
3943
console.log('Attachments:')
4044
for (const attachment of email.attachments) {
41-
const size = attachment.size_bytes ?? 0
42-
console.log(`- ${attachment.filename} (${attachment.content_type}, ${size} bytes)`)
45+
const size = attachment.size_bytes ?? (attachment as unknown as Record<string, unknown>).size ?? 0
46+
console.log(` ${attachment.id} ${attachment.filename} (${attachment.content_type}, ${size} bytes)`)
4347
}
4448
}
4549
console.log('---')
4650
console.log(email.body_text || '(no text body)')
51+
52+
// --save: download all attachments to disk
53+
if (opts.save !== undefined && email.attachments?.length) {
54+
const dir = opts.save || '.'
55+
await mkdir(dir, { recursive: true })
56+
for (const att of email.attachments) {
57+
try {
58+
const download = await downloadAttachment(att.id)
59+
if (!download) {
60+
console.error(`Attachment not found: ${att.id}`)
61+
continue
62+
}
63+
const dest = join(dir, download.filename)
64+
await writeFile(dest, Buffer.from(download.data))
65+
console.log(`Saved: ${dest}`)
66+
} catch (err) {
67+
console.error(`Failed to download ${att.filename}: ${(err as Error).message}`)
68+
}
69+
}
70+
}
4771
return
4872
}
4973

@@ -72,7 +96,8 @@ export async function inboxCommand(args: string[]) {
7296

7397
for (const email of emails) {
7498
const code = email.code ? ` [${email.code}]` : ''
99+
const clip = email.attachment_count ? ` +${email.attachment_count}att` : ''
75100
const from = email.from_name || email.from_address
76-
console.log(`${email.id.slice(0, 8)} ${email.received_at.slice(0, 16)} ${from.padEnd(24).slice(0, 24)} ${email.subject.slice(0, 40)}${code}`)
101+
console.log(`${email.id.slice(0, 8)} ${email.received_at.slice(0, 16)} ${from.padEnd(24).slice(0, 24)} ${email.subject.slice(0, 40)}${code}${clip}`)
77102
}
78103
}

src/cli/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async function main() {
3535
case 'version':
3636
case '--version':
3737
case '-v':
38-
console.log('mails v1.2.4')
38+
console.log('mails v1.3.0')
3939
break
4040
default:
4141
console.error(`Unknown command: ${command}`)

src/core/receive.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Email, EmailQueryOptions, EmailSearchOptions } from './types.js'
1+
import type { AttachmentDownload, Email, EmailQueryOptions, EmailSearchOptions } from './types.js'
22
import { getStorage } from './storage.js'
33

44
export async function getInbox(mailbox: string, options?: EmailQueryOptions): Promise<Email[]> {
@@ -19,6 +19,14 @@ export async function getEmail(id: string): Promise<Email | null> {
1919
return storage.getEmail(id)
2020
}
2121

22+
export async function downloadAttachment(id: string): Promise<AttachmentDownload | null> {
23+
const storage = await getStorage()
24+
if (!storage.getAttachment) {
25+
throw new Error(`Storage provider "${storage.name}" does not support attachment downloads`)
26+
}
27+
return storage.getAttachment(id)
28+
}
29+
2230
export async function waitForCode(mailbox: string, options?: {
2331
timeout?: number
2432
since?: string

src/core/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ export interface EmailSearchOptions extends EmailQueryOptions {
118118
query: string
119119
}
120120

121+
export interface AttachmentDownload {
122+
data: ArrayBuffer
123+
filename: string
124+
contentType: string
125+
}
126+
121127
export interface StorageProvider {
122128
name: string
123129
init(): Promise<void>
@@ -129,4 +135,5 @@ export interface StorageProvider {
129135
timeout?: number
130136
since?: string
131137
}): Promise<{ code: string; from: string; subject: string } | null>
138+
getAttachment?(id: string): Promise<AttachmentDownload | null>
132139
}

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { send } from './core/send.js'
2-
export { getInbox, searchInbox, getEmail, waitForCode } from './core/receive.js'
2+
export { getInbox, searchInbox, getEmail, waitForCode, downloadAttachment } from './core/receive.js'
33
export { getStorage } from './core/storage.js'
44
export { loadConfig, saveConfig, getConfigValue, setConfigValue } from './core/config.js'
55
export { createResendProvider } from './providers/send/resend.js'
@@ -10,6 +10,7 @@ export { createRemoteProvider } from './providers/storage/remote.js'
1010

1111
export type {
1212
Attachment,
13+
AttachmentDownload,
1314
AttachmentTextExtractionStatus,
1415
Email,
1516
PreparedAttachment,

src/providers/storage/remote.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Email, EmailQueryOptions, EmailSearchOptions, StorageProvider } from '../../core/types.js'
1+
import type { AttachmentDownload, Email, EmailQueryOptions, EmailSearchOptions, StorageProvider } from '../../core/types.js'
22

33
interface RemoteProviderOptions {
44
/** Worker API base URL */
@@ -34,6 +34,7 @@ export function createRemoteProvider(options: RemoteProviderOptions): StoragePro
3434
function inboxPath() { return useAuthApi ? '/v1/inbox' : '/api/inbox' }
3535
function codePath() { return useAuthApi ? '/v1/code' : '/api/code' }
3636
function emailPath() { return useAuthApi ? '/v1/email' : '/api/email' }
37+
function attachmentPath() { return useAuthApi ? '/v1/attachment' : '/api/attachment' }
3738

3839
function withMailbox(params: Record<string, string | number>): Record<string, string | number> {
3940
if (!useAuthApi) {
@@ -90,6 +91,22 @@ export function createRemoteProvider(options: RemoteProviderOptions): StoragePro
9091
return await res.json() as Email
9192
},
9293

94+
async getAttachment(id): Promise<AttachmentDownload | null> {
95+
const res = await apiFetch(attachmentPath(), { id })
96+
if (!res.ok) {
97+
if (res.status === 404) return null
98+
const data = await res.json() as { error?: string }
99+
throw new Error(`API error: ${data.error ?? res.statusText}`)
100+
}
101+
const contentDisposition = res.headers.get('Content-Disposition') ?? ''
102+
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/)
103+
return {
104+
data: await res.arrayBuffer(),
105+
filename: filenameMatch?.[1] ?? 'download',
106+
contentType: res.headers.get('Content-Type') ?? 'application/octet-stream',
107+
}
108+
},
109+
93110
async getCode(_mailbox, options) {
94111
const res = await apiFetch(codePath(), withMailbox({
95112
timeout: options?.timeout ?? 30,

0 commit comments

Comments
 (0)