@@ -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 ) {
0 commit comments