Skip to content

Commit a6cf72b

Browse files
committed
chore: wip
1 parent 2a8eb4d commit a6cf72b

6 files changed

Lines changed: 148 additions & 64 deletions

File tree

packages/devtools/pages/app.stx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1136,7 +1136,7 @@
11361136

11371137
switch (tab) {
11381138
case 'html':
1139-
body.innerHTML = `<iframe src="${API}/messages/${selectedId}/html" sandbox="allow-same-origin" style="width:100%;height:100%;border:none;"></iframe>`
1139+
body.innerHTML = `<iframe src="${API}/messages/${selectedId}/html" sandbox="" style="width:100%;height:100%;border:none;"></iframe>`
11401140
break
11411141
case 'text': {
11421142
const msg = await api(`/messages/${selectedId}`)

packages/devtools/server.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,14 @@ const appHtml = await Bun.file(new URL('./pages/app.stx', import.meta.url).pathn
139139

140140
// JSON helper
141141
function json(data: any, status = 200) {
142-
return Response.json(data, { status })
142+
return Response.json(data, {
143+
status,
144+
headers: {
145+
'Access-Control-Allow-Origin': '*',
146+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
147+
'Access-Control-Allow-Headers': 'Content-Type',
148+
},
149+
})
143150
}
144151

145152
// Parse URL params

packages/devtools/src/pop3.ts

Lines changed: 76 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,42 @@ export function createPop3Server(opts: {
99
const hostname = opts.hostname || '0.0.0.0'
1010
const store = opts.store
1111

12+
interface Session {
13+
user: string
14+
authed: boolean
15+
deleted: Set<string>
16+
messages: Message[] | null // cached after auth, per RFC 1939
17+
}
18+
19+
// Per RFC 1939, message numbers are fixed for the session and never change.
20+
// Deleted messages remain in the array but are checked via the deleted set.
21+
function getSnapshot(session: Session): Message[] {
22+
if (!session.messages) {
23+
session.messages = store.getMessages({ limit: 10000 }).messages
24+
}
25+
return session.messages
26+
}
27+
28+
function isDeleted(session: Session, idx: number): boolean {
29+
const msgs = getSnapshot(session)
30+
return idx >= 0 && idx < msgs.length && session.deleted.has(msgs[idx].id)
31+
}
32+
1233
const server = Bun.listen({
1334
hostname,
1435
port,
1536
socket: {
1637
open(socket) {
17-
socket.data = { state: 'auth', user: '', authed: false, deleted: new Set<string>() }
38+
socket.data = { user: '', authed: false, deleted: new Set<string>(), messages: null } as Session
1839
socket.write('+OK mail-dev POP3 server ready\r\n')
1940
},
2041
data(socket, buffer) {
21-
const input = Buffer.from(buffer).toString().trim()
22-
const session = socket.data as any
23-
const parts = input.split(' ')
42+
const input = Buffer.from(buffer).toString()
43+
const session = socket.data as Session
44+
// Handle multiple commands in a single chunk
45+
const commandLines = input.split(/\r?\n/).filter(l => l.trim())
46+
for (const commandLine of commandLines) {
47+
const parts = commandLine.trim().split(' ')
2448
const cmd = parts[0].toUpperCase()
2549

2650
if (cmd === 'CAPA') {
@@ -36,79 +60,94 @@ export function createPop3Server(opts: {
3660
socket.write('+OK\r\n')
3761
} else if (cmd === 'PASS') {
3862
session.authed = true
63+
// Snapshot messages at login time per RFC 1939
64+
session.messages = store.getMessages({ limit: 10000 }).messages
3965
socket.write('+OK Logged in\r\n')
4066
} else if (!session.authed && cmd !== 'QUIT') {
4167
socket.write('-ERR Not authenticated\r\n')
4268
} else if (cmd === 'STAT') {
43-
const { messages } = store.getMessages({ limit: 10000 })
44-
const active = messages.filter(m => !session.deleted.has(m.id))
45-
const totalSize = active.reduce((s, m) => s + m.size, 0)
46-
socket.write(`+OK ${active.length} ${totalSize}\r\n`)
69+
const msgs = getSnapshot(session)
70+
let count = 0, totalSize = 0
71+
for (let i = 0; i < msgs.length; i++) {
72+
if (!session.deleted.has(msgs[i].id)) {
73+
count++
74+
totalSize += msgs[i].size
75+
}
76+
}
77+
socket.write(`+OK ${count} ${totalSize}\r\n`)
4778
} else if (cmd === 'LIST') {
48-
const { messages } = store.getMessages({ limit: 10000 })
49-
const active = messages.filter(m => !session.deleted.has(m.id))
79+
const msgs = getSnapshot(session)
5080
if (parts[1]) {
5181
const idx = parseInt(parts[1]) - 1
52-
if (idx >= 0 && idx < active.length) {
53-
socket.write(`+OK ${idx + 1} ${active[idx].size}\r\n`)
82+
if (idx >= 0 && idx < msgs.length && !isDeleted(session, idx)) {
83+
socket.write(`+OK ${idx + 1} ${msgs[idx].size}\r\n`)
5484
} else {
5585
socket.write('-ERR No such message\r\n')
5686
}
5787
} else {
58-
let response = `+OK ${active.length} messages\r\n`
59-
active.forEach((m, i) => { response += `${i + 1} ${m.size}\r\n` })
88+
let count = 0
89+
for (const m of msgs) { if (!session.deleted.has(m.id)) count++ }
90+
let response = `+OK ${count} messages\r\n`
91+
msgs.forEach((m, i) => {
92+
if (!session.deleted.has(m.id)) response += `${i + 1} ${m.size}\r\n`
93+
})
6094
response += '.\r\n'
6195
socket.write(response)
6296
}
6397
} else if (cmd === 'UIDL') {
64-
const { messages } = store.getMessages({ limit: 10000 })
65-
const active = messages.filter(m => !session.deleted.has(m.id))
98+
const msgs = getSnapshot(session)
6699
if (parts[1]) {
67100
const idx = parseInt(parts[1]) - 1
68-
if (idx >= 0 && idx < active.length) {
69-
socket.write(`+OK ${idx + 1} ${active[idx].id}\r\n`)
101+
if (idx >= 0 && idx < msgs.length && !isDeleted(session, idx)) {
102+
socket.write(`+OK ${idx + 1} ${msgs[idx].id}\r\n`)
70103
} else {
71104
socket.write('-ERR No such message\r\n')
72105
}
73106
} else {
74107
let response = `+OK\r\n`
75-
active.forEach((m, i) => { response += `${i + 1} ${m.id}\r\n` })
108+
msgs.forEach((m, i) => {
109+
if (!session.deleted.has(m.id)) response += `${i + 1} ${m.id}\r\n`
110+
})
76111
response += '.\r\n'
77112
socket.write(response)
78113
}
79114
} else if (cmd === 'RETR') {
80-
const { messages } = store.getMessages({ limit: 10000 })
81-
const active = messages.filter(m => !session.deleted.has(m.id))
115+
const msgs = getSnapshot(session)
82116
const idx = parseInt(parts[1]) - 1
83-
if (idx >= 0 && idx < active.length) {
84-
const msg = active[idx]
117+
if (idx >= 0 && idx < msgs.length && !isDeleted(session, idx)) {
118+
const msg = msgs[idx]
85119
const raw = msg.raw || `From: ${msg.from_addr}\r\nTo: ${JSON.parse(msg.to_addrs).join(', ')}\r\nSubject: ${msg.subject}\r\n\r\n${msg.text_body || msg.html_body}`
86120
socket.write(`+OK ${raw.length} octets\r\n${raw}\r\n.\r\n`)
87121
store.updateMessage(msg.id, { read: 1 })
88122
} else {
89123
socket.write('-ERR No such message\r\n')
90124
}
91125
} else if (cmd === 'TOP') {
92-
const { messages } = store.getMessages({ limit: 10000 })
93-
const active = messages.filter(m => !session.deleted.has(m.id))
126+
const msgs = getSnapshot(session)
94127
const idx = parseInt(parts[1]) - 1
95128
const lines = parseInt(parts[2]) || 0
96-
if (idx >= 0 && idx < active.length) {
97-
const msg = active[idx]
129+
if (idx >= 0 && idx < msgs.length && !isDeleted(session, idx)) {
130+
const msg = msgs[idx]
98131
const raw = msg.raw || `From: ${msg.from_addr}\r\nSubject: ${msg.subject}\r\n\r\n${msg.text_body}`
99-
const headerEnd = raw.indexOf('\r\n\r\n')
100-
const headers = raw.slice(0, headerEnd + 4)
101-
const body = raw.slice(headerEnd + 4).split('\r\n').slice(0, lines).join('\r\n')
102-
socket.write(`+OK\r\n${headers}${body}\r\n.\r\n`)
132+
// Handle both \r\n and \n in raw content
133+
const sep = raw.includes('\r\n\r\n') ? '\r\n\r\n' : '\n\n'
134+
const headerEnd = raw.indexOf(sep)
135+
if (headerEnd >= 0) {
136+
const headers = raw.slice(0, headerEnd + sep.length)
137+
const lineSep = raw.includes('\r\n') ? '\r\n' : '\n'
138+
const body = raw.slice(headerEnd + sep.length).split(lineSep).slice(0, lines).join('\r\n')
139+
socket.write(`+OK\r\n${headers}${body}\r\n.\r\n`)
140+
} else {
141+
socket.write(`+OK\r\n${raw}\r\n.\r\n`)
142+
}
103143
} else {
104144
socket.write('-ERR No such message\r\n')
105145
}
106146
} else if (cmd === 'DELE') {
107-
const { messages } = store.getMessages({ limit: 10000 })
108-
const active = messages.filter(m => !session.deleted.has(m.id))
147+
const msgs = getSnapshot(session)
109148
const idx = parseInt(parts[1]) - 1
110-
if (idx >= 0 && idx < active.length) {
111-
session.deleted.add(active[idx].id)
149+
if (idx >= 0 && idx < msgs.length && !isDeleted(session, idx)) {
150+
session.deleted.add(msgs[idx].id)
112151
socket.write('+OK Deleted\r\n')
113152
} else {
114153
socket.write('-ERR No such message\r\n')
@@ -119,7 +158,7 @@ export function createPop3Server(opts: {
119158
} else if (cmd === 'NOOP') {
120159
socket.write('+OK\r\n')
121160
} else if (cmd === 'QUIT') {
122-
// Actually delete marked messages
161+
// Actually delete marked messages on QUIT
123162
for (const id of session.deleted) {
124163
store.deleteMessage(id)
125164
}
@@ -128,6 +167,7 @@ export function createPop3Server(opts: {
128167
} else {
129168
socket.write('-ERR Unknown command\r\n')
130169
}
170+
} // end for commandLines
131171
},
132172
close() {},
133173
error(socket, error) {

packages/devtools/src/smtp.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,16 @@ export function createSmtpServer(opts: {
7070

7171
const respond = (response: string) => {
7272
if (delay > 0) {
73-
setTimeout(() => socket.write(response), delay)
73+
setTimeout(() => {
74+
try { socket.write(response) } catch {}
75+
}, delay)
7476
} else {
7577
socket.write(response)
7678
}
7779
}
7880

79-
if (session.collecting) {
80-
session.data += input
81+
// Helper to process collected data and deliver message
82+
const tryDeliverData = () => {
8183
// Handle both \r\n.\r\n and \n.\n line endings
8284
if (session.data.includes('\r\n.\r\n') || session.data.includes('\n.\n')) {
8385
const raw = session.data.replace(/\r?\n\.\r?\n[\s\S]*$/, '')
@@ -99,11 +101,18 @@ export function createSmtpServer(opts: {
99101
session.from = ''
100102
session.to = []
101103
}
104+
}
105+
106+
if (session.collecting) {
107+
session.data += input
108+
tryDeliverData()
102109
return
103110
}
104111

105-
const lines = input.split(/\r?\n/).filter(Boolean)
106-
for (const line of lines) {
112+
const lines = input.split(/\r?\n/)
113+
for (let i = 0; i < lines.length; i++) {
114+
const line = lines[i]
115+
if (!line) continue // skip empty lines between commands
107116
const cmd = line.toUpperCase()
108117

109118
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
@@ -128,8 +137,29 @@ export function createSmtpServer(opts: {
128137
respond('250 2.1.5 Ok\r\n')
129138
} else if (cmd === 'DATA') {
130139
session.collecting = true
131-
session.data = ''
140+
// Capture everything after the DATA line from the raw input (preserving blank lines)
141+
// Reconstruct the byte offset by joining the lines we've already processed
142+
const processedLen = lines.slice(0, i + 1).join('\n').length
143+
// Find the actual position in the original input accounting for \r\n vs \n
144+
let pos = 0
145+
let lineCount = 0
146+
while (lineCount <= i && pos < input.length) {
147+
const nextCr = input.indexOf('\r\n', pos)
148+
const nextLf = input.indexOf('\n', pos)
149+
if (nextCr >= 0 && nextCr <= nextLf) {
150+
pos = nextCr + 2
151+
} else if (nextLf >= 0) {
152+
pos = nextLf + 1
153+
} else {
154+
pos = input.length
155+
break
156+
}
157+
lineCount++
158+
}
159+
session.data = input.slice(pos)
132160
respond('354 End data with <CR><LF>.<CR><LF>\r\n')
161+
tryDeliverData()
162+
return // Stop processing - rest is data, not commands
133163
} else if (cmd === 'RSET') {
134164
session.from = ''
135165
session.to = []
@@ -139,6 +169,7 @@ export function createSmtpServer(opts: {
139169
} else if (cmd === 'QUIT') {
140170
respond('221 2.0.0 Bye\r\n')
141171
socket.end()
172+
return
142173
} else if (cmd === 'NOOP') {
143174
respond('250 2.0.0 Ok\r\n')
144175
} else if (cmd === 'VRFY') {
@@ -281,14 +312,16 @@ function parseMimeParts(body: string, boundary: string): { headers: Record<strin
281312
const section = sections[i]
282313
if (section.startsWith('--')) break // closing boundary
283314

284-
const headerEnd = section.indexOf('\r\n\r\n')
315+
// Handle both \r\n and \n line endings
316+
const normalizedSection = section.replace(/\r\n/g, '\n')
317+
const headerEnd = normalizedSection.indexOf('\n\n')
285318
if (headerEnd < 0) continue
286319

287-
const headerSection = section.slice(0, headerEnd).replace(/^\r\n/, '')
288-
const bodyContent = section.slice(headerEnd + 4).replace(/\r\n$/, '')
320+
const headerSection = normalizedSection.slice(0, headerEnd).replace(/^\n/, '')
321+
const bodyContent = normalizedSection.slice(headerEnd + 2).replace(/\n$/, '')
289322

290323
const headers: Record<string, string> = {}
291-
const headerLines = headerSection.replace(/\r\n([ \t])/g, ' ').split('\r\n')
324+
const headerLines = headerSection.replace(/\n([ \t])/g, ' ').split('\n')
292325
for (const line of headerLines) {
293326
const colonIdx = line.indexOf(':')
294327
if (colonIdx > 0) {

packages/devtools/src/store.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,10 +213,14 @@ export class MessageStore {
213213
}
214214

215215
getStats() {
216-
const total = (this.db.prepare('SELECT COUNT(*) as c FROM messages').get() as any).c
217-
const unread = (this.db.prepare('SELECT COUNT(*) as c FROM messages WHERE read = 0').get() as any).c
218-
const starred = (this.db.prepare('SELECT COUNT(*) as c FROM messages WHERE starred = 1').get() as any).c
219-
return { total, unread, starred }
216+
const row = this.db.prepare(`
217+
SELECT
218+
COUNT(*) as total,
219+
COALESCE(SUM(CASE WHEN read = 0 THEN 1 ELSE 0 END), 0) as unread,
220+
COALESCE(SUM(CASE WHEN starred = 1 THEN 1 ELSE 0 END), 0) as starred
221+
FROM messages
222+
`).get() as { total: number, unread: number, starred: number }
223+
return row
220224
}
221225

222226
// Tags

0 commit comments

Comments
 (0)