Skip to content

Commit ad51d04

Browse files
Digidaiclaude
andcommitted
feat: competitive parity improvements (CC, inReplyTo, suppression, rate limits, pause, scope, CLI, SSE, domains)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7d8042c commit ad51d04

24 files changed

Lines changed: 1847 additions & 28 deletions

src/cli/commands/help.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Commands:
1010
send Send an email
1111
inbox List received emails
1212
code Wait for a verification code
13+
thread List and view email threads
14+
webhook Manage webhook configuration
1315
config View or modify configuration
1416
help Show this help message
1517
version Show version
@@ -41,6 +43,14 @@ Code:
4143
mails code --to <address> Wait for a verification code
4244
mails code --to <address> --timeout 60
4345
46+
Thread:
47+
mails thread list List email threads
48+
mails thread <id> View thread details
49+
50+
Webhook:
51+
mails webhook list Show current webhook configuration
52+
mails webhook set <url> Set webhook URL for current mailbox
53+
4454
Config:
4555
mails config Show current config
4656
mails config set <key> <value> Set a config value

src/cli/commands/thread.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { loadConfig } from '../../core/config.js'
2+
3+
function getApiDetails(config: ReturnType<typeof loadConfig>) {
4+
const apiUrl = process.env.MAILS_API_URL || config.worker_url || 'https://mails-worker.genedai.workers.dev'
5+
const token = config.api_key || config.worker_token
6+
const isV1 = !!config.api_key
7+
return { apiUrl, token, isV1 }
8+
}
9+
10+
export async function threadCommand(args: string[]) {
11+
const config = loadConfig()
12+
const { apiUrl, token, isV1 } = getApiDetails(config)
13+
14+
if (!token && !config.worker_url) {
15+
console.error('No API key or worker URL configured. Run: mails claim <name>')
16+
process.exit(1)
17+
}
18+
19+
const headers: Record<string, string> = {}
20+
if (token) headers['Authorization'] = `Bearer ${token}`
21+
22+
const subcommand = args[0]
23+
24+
if (subcommand === 'list' || !subcommand) {
25+
// mails thread list — GET /api/threads
26+
const path = isV1 ? '/v1/threads' : '/api/threads'
27+
const url = new URL(path, apiUrl)
28+
if (!isV1 && config.mailbox) url.searchParams.set('to', config.mailbox)
29+
30+
let res: Response
31+
try {
32+
res = await fetch(url.toString(), { headers })
33+
} catch (err) {
34+
console.error(`Cannot connect to ${apiUrl}: ${err instanceof Error ? err.message : err}`)
35+
process.exit(1)
36+
}
37+
38+
if (!res.ok) {
39+
const data = await res.json().catch(() => ({})) as { error?: string }
40+
console.error(`API error: ${data.error ?? `HTTP ${res.status}`}`)
41+
process.exit(1)
42+
}
43+
44+
const data = await res.json() as { threads?: Array<{ thread_id: string; subject: string; message_count: number; from_address: string; from_name: string; received_at: string }> }
45+
const threads = data.threads ?? []
46+
47+
if (threads.length === 0) {
48+
console.log('No threads found.')
49+
return
50+
}
51+
52+
for (const thread of threads) {
53+
const from = thread.from_name || thread.from_address
54+
console.log(`${thread.thread_id.slice(0, 8)} [${thread.message_count}] ${thread.received_at.slice(0, 16)} ${from.padEnd(24).slice(0, 24)} ${thread.subject.slice(0, 40)}`)
55+
}
56+
return
57+
}
58+
59+
// mails thread <id> — GET /api/thread?id=<id>
60+
const threadId = subcommand
61+
const path = isV1 ? '/v1/thread' : '/api/thread'
62+
const url = new URL(path, apiUrl)
63+
url.searchParams.set('id', threadId)
64+
65+
let res: Response
66+
try {
67+
res = await fetch(url.toString(), { headers })
68+
} catch (err) {
69+
console.error(`Cannot connect to ${apiUrl}: ${err instanceof Error ? err.message : err}`)
70+
process.exit(1)
71+
}
72+
73+
if (!res.ok) {
74+
const data = await res.json().catch(() => ({})) as { error?: string }
75+
console.error(`API error: ${data.error ?? `HTTP ${res.status}`}`)
76+
process.exit(1)
77+
}
78+
79+
const data = await res.json() as { thread_id?: string; emails?: Array<{ id: string; from_address: string; from_name: string; subject: string; received_at: string; body_text: string }> }
80+
const emails = data.emails ?? []
81+
82+
if (emails.length === 0) {
83+
console.log('Thread not found or empty.')
84+
return
85+
}
86+
87+
console.log(`Thread: ${data.thread_id}`)
88+
console.log(`Messages: ${emails.length}`)
89+
console.log('---')
90+
91+
for (const email of emails) {
92+
const from = email.from_name ? `${email.from_name} <${email.from_address}>` : email.from_address
93+
console.log(` From: ${from}`)
94+
console.log(` Date: ${email.received_at}`)
95+
console.log(` Subject: ${email.subject}`)
96+
console.log(` ${(email.body_text || '').slice(0, 200)}`)
97+
console.log(' ---')
98+
}
99+
}

src/cli/commands/webhook.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { loadConfig } from '../../core/config.js'
2+
3+
function getApiDetails(config: ReturnType<typeof loadConfig>) {
4+
const apiUrl = process.env.MAILS_API_URL || config.worker_url || 'https://mails-worker.genedai.workers.dev'
5+
const token = config.api_key || config.worker_token
6+
const isV1 = !!config.api_key
7+
return { apiUrl, token, isV1 }
8+
}
9+
10+
export async function webhookCommand(args: string[]) {
11+
const config = loadConfig()
12+
const { apiUrl, token, isV1 } = getApiDetails(config)
13+
14+
if (!token && !config.worker_url) {
15+
console.error('No API key or worker URL configured. Run: mails claim <name>')
16+
process.exit(1)
17+
}
18+
19+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
20+
if (token) headers['Authorization'] = `Bearer ${token}`
21+
22+
const subcommand = args[0]
23+
24+
if (subcommand === 'list' || !subcommand) {
25+
// mails webhook list — GET /api/mailbox to see webhook_url
26+
const path = isV1 ? '/v1/mailbox' : '/api/mailbox'
27+
const url = new URL(path, apiUrl)
28+
29+
let res: Response
30+
try {
31+
res = await fetch(url.toString(), { headers })
32+
} catch (err) {
33+
console.error(`Cannot connect to ${apiUrl}: ${err instanceof Error ? err.message : err}`)
34+
process.exit(1)
35+
}
36+
37+
if (!res.ok) {
38+
const data = await res.json().catch(() => ({})) as { error?: string }
39+
console.error(`API error: ${data.error ?? `HTTP ${res.status}`}`)
40+
process.exit(1)
41+
}
42+
43+
const data = await res.json() as { mailbox?: string; webhook_url?: string | null; status?: string }
44+
console.log(`Mailbox: ${data.mailbox ?? 'unknown'}`)
45+
console.log(`Webhook: ${data.webhook_url ?? '(none)'}`)
46+
console.log(`Status: ${data.status ?? 'active'}`)
47+
return
48+
}
49+
50+
if (subcommand === 'set') {
51+
const webhookUrl = args[1]
52+
if (!webhookUrl) {
53+
console.error('Usage: mails webhook set <url>')
54+
process.exit(1)
55+
}
56+
57+
// Update webhook URL via the mailbox endpoint
58+
// We need a way to set webhook_url — use a generic PATCH approach
59+
// For now, directly update via the API
60+
const path = isV1 ? '/v1/mailbox' : '/api/mailbox'
61+
const url = new URL(path, apiUrl)
62+
63+
let res: Response
64+
try {
65+
res = await fetch(url.toString(), {
66+
method: 'PATCH',
67+
headers,
68+
body: JSON.stringify({ webhook_url: webhookUrl }),
69+
})
70+
} catch (err) {
71+
console.error(`Cannot connect to ${apiUrl}: ${err instanceof Error ? err.message : err}`)
72+
process.exit(1)
73+
}
74+
75+
if (!res.ok) {
76+
const data = await res.json().catch(() => ({})) as { error?: string }
77+
console.error(`API error: ${data.error ?? `HTTP ${res.status}`}`)
78+
process.exit(1)
79+
}
80+
81+
console.log(`Webhook URL set to: ${webhookUrl}`)
82+
return
83+
}
84+
85+
console.error(`Unknown webhook subcommand: ${subcommand}`)
86+
console.error('Usage:')
87+
console.error(' mails webhook list List configured webhooks')
88+
console.error(' mails webhook set <url> Set webhook URL')
89+
process.exit(1)
90+
}

src/cli/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { doctorCommand } from './commands/doctor.js'
88
import { demoCommand } from './commands/demo.js'
99
import { statsCommand } from './commands/stats.js'
1010
import { helpCommand } from './commands/help.js'
11+
import { threadCommand } from './commands/thread.js'
12+
import { webhookCommand } from './commands/webhook.js'
1113

1214
const args = process.argv.slice(2)
1315
const command = args[0]
@@ -38,6 +40,12 @@ async function main() {
3840
case 'stats':
3941
await statsCommand()
4042
break
43+
case 'thread':
44+
await threadCommand(args.slice(1))
45+
break
46+
case 'webhook':
47+
await webhookCommand(args.slice(1))
48+
break
4149
case 'help':
4250
case '--help':
4351
case '-h':

test/unit/claim-auto.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import { handleClaimAuto, RESERVED_NAMES } from '../../worker/src/handlers/claim'
3+
4+
function mockDB(existingMailboxes: string[] = []) {
5+
return {
6+
prepare: (sql: string) => ({
7+
bind: (...args: unknown[]) => ({
8+
first: async () => {
9+
if (sql.includes('SELECT mailbox FROM auth_tokens WHERE mailbox')) {
10+
const mb = args[0] as string
11+
return existingMailboxes.includes(mb) ? { mailbox: mb } : null
12+
}
13+
return null
14+
},
15+
run: async () => ({ meta: { changes: 1 } }),
16+
}),
17+
}),
18+
} as unknown as D1Database
19+
}
20+
21+
function mockEnv(db = mockDB()) {
22+
return { DB: db } as any
23+
}
24+
25+
function makeRequest(body: Record<string, unknown>) {
26+
return new Request('http://localhost/v1/claim/auto', {
27+
method: 'POST',
28+
headers: { 'Content-Type': 'application/json' },
29+
body: JSON.stringify(body),
30+
})
31+
}
32+
33+
describe('Headless Claim (POST /v1/claim/auto)', () => {
34+
test('creates mailbox successfully', async () => {
35+
const auth = { mailbox: 'existing@mails0.com', scope: 'full' as const }
36+
const res = await handleClaimAuto(makeRequest({ name: 'newagent' }), mockEnv(), auth)
37+
38+
expect(res.status).toBe(201)
39+
const data = await res.json() as { mailbox: string; api_key: string }
40+
expect(data.mailbox).toBe('newagent@mails0.com')
41+
expect(data.api_key).toMatch(/^mk_/)
42+
})
43+
44+
test('rejects missing name', async () => {
45+
const auth = { mailbox: 'a@mails0.com', scope: 'full' as const }
46+
const res = await handleClaimAuto(makeRequest({}), mockEnv(), auth)
47+
expect(res.status).toBe(400)
48+
})
49+
50+
test('rejects reserved names', async () => {
51+
const auth = { mailbox: 'a@mails0.com', scope: 'full' as const }
52+
const res = await handleClaimAuto(makeRequest({ name: 'admin' }), mockEnv(), auth)
53+
expect(res.status).toBe(400)
54+
const data = await res.json() as { error: string }
55+
expect(data.error).toContain('reserved')
56+
})
57+
58+
test('rejects invalid name format', async () => {
59+
const auth = { mailbox: 'a@mails0.com', scope: 'full' as const }
60+
61+
const res2 = await handleClaimAuto(makeRequest({ name: 'has spaces' }), mockEnv(), auth)
62+
expect(res2.status).toBe(400)
63+
64+
const res3 = await handleClaimAuto(makeRequest({ name: '-start-dash' }), mockEnv(), auth)
65+
expect(res3.status).toBe(400)
66+
67+
const res4 = await handleClaimAuto(makeRequest({ name: 'a'.repeat(50) }), mockEnv(), auth)
68+
expect(res4.status).toBe(400)
69+
})
70+
71+
test('rejects duplicate mailbox', async () => {
72+
const auth = { mailbox: 'a@mails0.com', scope: 'full' as const }
73+
const db = mockDB(['taken@mails0.com'])
74+
const res = await handleClaimAuto(makeRequest({ name: 'taken' }), mockEnv(db), auth)
75+
expect(res.status).toBe(409)
76+
})
77+
78+
test('rejects non-POST method', async () => {
79+
const auth = { mailbox: 'a@mails0.com', scope: 'full' as const }
80+
const req = new Request('http://localhost/v1/claim/auto', { method: 'GET' })
81+
const res = await handleClaimAuto(req, mockEnv(), auth)
82+
expect(res.status).toBe(405)
83+
})
84+
85+
test('RESERVED_NAMES includes common system names', () => {
86+
expect(RESERVED_NAMES.has('admin')).toBe(true)
87+
expect(RESERVED_NAMES.has('postmaster')).toBe(true)
88+
expect(RESERVED_NAMES.has('abuse')).toBe(true)
89+
expect(RESERVED_NAMES.has('support')).toBe(true)
90+
expect(RESERVED_NAMES.has('noreply')).toBe(true)
91+
expect(RESERVED_NAMES.has('test')).toBe(true)
92+
expect(RESERVED_NAMES.has('api')).toBe(true)
93+
})
94+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import { readFileSync } from 'fs'
3+
import { join } from 'path'
4+
5+
describe('CLI thread and webhook commands', () => {
6+
test('thread command module exports threadCommand', async () => {
7+
const mod = await import('../../src/cli/commands/thread')
8+
expect(typeof mod.threadCommand).toBe('function')
9+
})
10+
11+
test('webhook command module exports webhookCommand', async () => {
12+
const mod = await import('../../src/cli/commands/webhook')
13+
expect(typeof mod.webhookCommand).toBe('function')
14+
})
15+
16+
test('cli index imports thread and webhook commands', () => {
17+
const indexPath = join(import.meta.dir, '..', '..', 'src', 'cli', 'index.ts')
18+
const content = readFileSync(indexPath, 'utf-8')
19+
20+
expect(content).toContain("import { threadCommand }")
21+
expect(content).toContain("import { webhookCommand }")
22+
expect(content).toContain("case 'thread':")
23+
expect(content).toContain("case 'webhook':")
24+
})
25+
26+
test('help command lists thread and webhook', () => {
27+
const helpPath = join(import.meta.dir, '..', '..', 'src', 'cli', 'commands', 'help.ts')
28+
const content = readFileSync(helpPath, 'utf-8')
29+
30+
expect(content).toContain('thread')
31+
expect(content).toContain('webhook')
32+
expect(content).toContain('mails thread list')
33+
expect(content).toContain('mails webhook set')
34+
})
35+
})

0 commit comments

Comments
 (0)