22 * Stacks Mail Server - IMAP/SMTP server with S3 backend
33 */
44import * as tls from 'tls' ; import * as net from 'net' ; import * as crypto from 'crypto' ; import * as fs from 'fs' ;
5- import { S3Client , GetObjectCommand , ListObjectsV2Command } from '@aws-sdk/client-s3' ;
5+ import { S3Client , GetObjectCommand , ListObjectsV2Command , PutObjectCommand } from '@aws-sdk/client-s3' ;
66import { SESv2Client , SendEmailCommand } from '@aws-sdk/client-sesv2' ;
77import { DynamoDBClient , GetItemCommand } from '@aws-sdk/client-dynamodb' ;
88
@@ -70,7 +70,20 @@ function startIMAP(tls:boolean){
7070 const p = l . split ( ' ' ) , tag = p [ 0 ] , cmd = ( p [ 1 ] || '' ) . toUpperCase ( ) , args = p . slice ( 2 ) ;
7171 console . log ( 'IMAP:' , l ) ;
7272 switch ( cmd ) {
73- case 'CAPABILITY' :s . write ( '* CAPABILITY IMAP4rev1 AUTH=PLAIN\r\n' + tag + ' OK\r\n' ) ; break ;
73+ case 'CAPABILITY' :s . write ( '* CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=LOGIN ID NAMESPACE\r\n' + tag + ' OK\r\n' ) ; break ;
74+ case 'AUTHENTICATE' :
75+ if ( args [ 0 ] ?. toUpperCase ( ) === 'PLAIN' ) {
76+ s . write ( '+ \r\n' ) ;
77+ s . once ( 'data' , async ( authData ) => {
78+ const dec = Buffer . from ( authData . toString ( ) . trim ( ) , 'base64' ) . toString ( ) ;
79+ const parts = dec . split ( '\0' ) ;
80+ const u = ( parts [ 1 ] || '' ) . trim ( ) , p = ( parts [ 2 ] || '' ) . trim ( ) ;
81+ console . log ( 'IMAP AUTHENTICATE user:' , u ) ;
82+ if ( await authenticate ( u , p ) ) { auth = true ; user = u ; s . write ( tag + ' OK\r\n' ) ; }
83+ else s . write ( tag + ' NO\r\n' ) ;
84+ } ) ;
85+ } else { s . write ( tag + ' NO Unsupported\r\n' ) ; }
86+ break ;
7487 case 'LOGIN' :
7588 const loginUser = ( args [ 0 ] || '' ) . replace ( / " / g, '' ) . trim ( ) ;
7689 const loginPass = ( args [ 1 ] || '' ) . replace ( / " / g, '' ) . trim ( ) ;
@@ -96,7 +109,45 @@ function startIMAP(tls:boolean){
96109 s . write ( tag + ' OK\r\n' ) ; break ;
97110 case 'NOOP' :s . write ( tag + ' OK\r\n' ) ; break ;
98111 case 'LOGOUT' :s . write ( '* BYE\r\n' + tag + ' OK\r\n' ) ; s . end ( ) ; break ;
99- default :s . write ( tag + ' BAD\r\n' ) ;
112+ case 'EXAMINE' :if ( ! auth ) { s . write ( tag + ' NO\r\n' ) ; break ; }
113+ mbox = ( args [ 0 ] || '' ) . replace ( / " / g, '' ) || 'INBOX' ; msgs = await listM ( user , mbox ) ;
114+ s . write ( `* ${ msgs . length } EXISTS\r\n* 0 RECENT\r\n* FLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)\r\n* OK [UIDVALIDITY 1]\r\n${ tag } OK [READ-ONLY]\r\n` ) ; break ;
115+ case 'STATUS' :if ( ! auth ) { s . write ( tag + ' NO\r\n' ) ; break ; }
116+ const stBox = ( args [ 0 ] || '' ) . replace ( / " / g, '' ) || 'INBOX' ;
117+ s . write ( `* STATUS "${ stBox } " (MESSAGES 0 RECENT 0 UNSEEN 0 UIDNEXT 1 UIDVALIDITY 1)\r\n${ tag } OK\r\n` ) ; break ;
118+ case 'CREATE' :case 'DELETE' :case 'RENAME' :case 'SUBSCRIBE' :case 'UNSUBSCRIBE' :
119+ s . write ( tag + ' OK\r\n' ) ; break ;
120+ case 'LSUB' :if ( ! auth ) { s . write ( tag + ' NO\r\n' ) ; break ; }
121+ s . write ( '* LSUB () "/" "INBOX"\r\n* LSUB (\\Sent) "/" "Sent"\r\n* LSUB (\\Drafts) "/" "Drafts"\r\n* LSUB (\\Trash) "/" "Trash"\r\n' + tag + ' OK\r\n' ) ; break ;
122+ case 'APPEND' :if ( ! auth ) { s . write ( tag + ' NO\r\n' ) ; break ; }
123+ const appBox = ( args [ 0 ] || '' ) . replace ( / " / g, '' ) . toLowerCase ( ) ;
124+ console . log ( 'APPEND to:' , appBox ) ;
125+ const litMatch = l . match ( / \{ ( \d + ) \} / ) ;
126+ if ( litMatch ) {
127+ const litSize = parseInt ( litMatch [ 1 ] ) ;
128+ s . write ( '+ Ready\r\n' ) ;
129+ let appData = '' ;
130+ const appHandler = ( chunk :Buffer ) => {
131+ appData += chunk . toString ( ) ;
132+ if ( appData . length >= litSize ) {
133+ s . removeListener ( 'data' , appHandler ) ;
134+ const key = `${ appBox } /${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } .eml` ;
135+ s3 . send ( new PutObjectCommand ( { Bucket :B , Key :key , Body :appData . slice ( 0 , litSize ) , ContentType :'message/rfc822' } ) )
136+ . then ( ( ) => { console . log ( 'Saved:' , key ) ; s . write ( tag + ' OK APPEND completed\r\n' ) ; } )
137+ . catch ( ( e :any ) => { console . error ( 'APPEND err:' , e ) ; s . write ( tag + ' NO\r\n' ) ; } ) ;
138+ }
139+ } ;
140+ s . on ( 'data' , appHandler ) ;
141+ } else { s . write ( tag + ' OK\r\n' ) ; }
142+ break ;
143+ case 'STORE' :case 'COPY' :case 'MOVE' :case 'EXPUNGE' :case 'CLOSE' :case 'UID' :
144+ if ( ! auth ) { s . write ( tag + ' NO\r\n' ) ; break ; }
145+ s . write ( tag + ' OK\r\n' ) ; break ;
146+ case 'NAMESPACE' :
147+ s . write ( '* NAMESPACE (("" "/")) NIL NIL\r\n' + tag + ' OK\r\n' ) ; break ;
148+ case 'ID' :
149+ s . write ( '* ID ("name" "Stacks Mail" "version" "1.0")\r\n' + tag + ' OK\r\n' ) ; break ;
150+ default :console . log ( 'Unknown IMAP cmd:' , cmd ) ; s . write ( tag + ' BAD Unknown command\r\n' ) ;
100151 }
101152 }
102153 } ) ;
@@ -111,25 +162,73 @@ function startSMTP(tls:boolean){
111162 s . write ( `220 ${ SD } .${ D } ESMTP\r\n` ) ;
112163 s . on ( 'data' , async ( d ) => {
113164 const inp = d . toString ( ) ;
114- if ( inD ) { if ( inp . trim ( ) === '.' ) { inD = false ;
115- try { await ses . send ( new SendEmailCommand ( { FromEmailAddress :from , Destination :{ ToAddresses :to } , Content :{ Raw :{ Data :new TextEncoder ( ) . encode ( data ) } } } ) ) ; s . write ( '250 OK\r\n' ) ; }
116- catch ( e :any ) { s . write ( '550 ' + e . message + '\r\n' ) ; }
117- data = '' ; to = [ ] ;
118- } else data += inp ; return ; }
165+ if ( inD ) {
166+ data += inp ;
167+ // Check for end of data: \r\n.\r\n
168+ if ( data . endsWith ( '\r\n.\r\n' ) || data . endsWith ( '\n.\n' ) || inp . trim ( ) === '.' ) {
169+ inD = false ;
170+ // Remove the trailing dot
171+ const emailData = data . replace ( / \r ? \n \. \r ? \n $ / , '' ) ;
172+ console . log ( 'SMTP DATA complete, sending via SES, size:' , emailData . length ) ;
173+ try {
174+ await ses . send ( new SendEmailCommand ( { FromEmailAddress :from , Destination :{ ToAddresses :to } , Content :{ Raw :{ Data :new TextEncoder ( ) . encode ( emailData ) } } } ) ) ;
175+ console . log ( 'SES send success' ) ;
176+ s . write ( '250 2.0.0 OK Message queued\r\n' ) ;
177+ } catch ( e :any ) {
178+ console . error ( 'SES error:' , e ) ;
179+ s . write ( '550 5.7.1 ' + e . message + '\r\n' ) ;
180+ }
181+ data = '' ; to = [ ] ;
182+ }
183+ return ;
184+ }
119185 for ( const l of inp . split ( '\r\n' ) . filter ( ( x :string ) => x ) ) {
120186 const cmd = l . split ( ' ' ) [ 0 ] . toUpperCase ( ) , args = l . substring ( cmd . length + 1 ) ;
121187 console . log ( 'SMTP:' , l ) ;
122188 switch ( cmd ) {
123- case 'EHLO' :case 'HELO' :s . write ( `250-${ SD } .${ D } \r\n250-AUTH PLAIN\r\n250 OK\r\n` ) ; break ;
189+ case 'EHLO' :case 'HELO' :
190+ s . write ( `250-${ SD } .${ D } \r\n250-AUTH PLAIN LOGIN\r\n250-AUTH=PLAIN LOGIN\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-SIZE 52428800\r\n250 OK\r\n` ) ; break ;
124191 case 'AUTH' :
125- if ( args . startsWith ( 'PLAIN ' ) ) { const dec = Buffer . from ( args . substring ( 6 ) , 'base64' ) . toString ( ) , [ , u , p ] = dec . split ( '\0' ) ;
126- if ( await module . exports . auth ( u , p ) ) { auth = true ; s . write ( '235 OK\r\n' ) ; } else s . write ( '535 NO\r\n' ) ; }
127- else if ( args === 'PLAIN' ) { s . write ( '334\r\n' ) ; s . once ( 'data' , async ( ad ) => {
128- const dec = Buffer . from ( ad . toString ( ) . trim ( ) , 'base64' ) . toString ( ) , [ , u , p ] = dec . split ( '\0' ) ;
129- if ( await module . exports . auth ( u , p ) ) { auth = true ; s . write ( '235 OK\r\n' ) ; } else s . write ( '535 NO\r\n' ) ; } ) ; }
192+ console . log ( 'SMTP AUTH:' , args ) ;
193+ if ( args . startsWith ( 'PLAIN ' ) ) {
194+ const dec = Buffer . from ( args . substring ( 6 ) , 'base64' ) . toString ( ) ;
195+ const parts = dec . split ( '\0' ) ;
196+ const u = ( parts [ 1 ] || '' ) . trim ( ) , p = ( parts [ 2 ] || '' ) . trim ( ) ;
197+ console . log ( 'AUTH PLAIN inline user:' , u ) ;
198+ if ( await authenticate ( u , p ) ) { auth = true ; from = u ; s . write ( '235 2.7.0 Authentication successful\r\n' ) ; }
199+ else s . write ( '535 5.7.8 Authentication failed\r\n' ) ;
200+ } else if ( args === 'PLAIN' || args . startsWith ( 'PLAIN' ) ) {
201+ s . write ( '334 \r\n' ) ;
202+ s . once ( 'data' , async ( ad ) => {
203+ const dec = Buffer . from ( ad . toString ( ) . trim ( ) , 'base64' ) . toString ( ) ;
204+ const parts = dec . split ( '\0' ) ;
205+ const u = ( parts [ 1 ] || '' ) . trim ( ) , p = ( parts [ 2 ] || '' ) . trim ( ) ;
206+ console . log ( 'AUTH PLAIN challenge user:' , u ) ;
207+ if ( await authenticate ( u , p ) ) { auth = true ; from = u ; s . write ( '235 2.7.0 Authentication successful\r\n' ) ; }
208+ else s . write ( '535 5.7.8 Authentication failed\r\n' ) ;
209+ } ) ;
210+ } else if ( args === 'LOGIN' || args . startsWith ( 'LOGIN' ) ) {
211+ let authUser = '' ;
212+ s . write ( '334 VXNlcm5hbWU6\r\n' ) ; // "Username:" base64
213+ s . once ( 'data' , async ( ud ) => {
214+ authUser = Buffer . from ( ud . toString ( ) . trim ( ) , 'base64' ) . toString ( ) . trim ( ) ;
215+ console . log ( 'AUTH LOGIN user:' , authUser ) ;
216+ s . write ( '334 UGFzc3dvcmQ6\r\n' ) ; // "Password:" base64
217+ s . once ( 'data' , async ( pd ) => {
218+ const authPass = Buffer . from ( pd . toString ( ) . trim ( ) , 'base64' ) . toString ( ) . trim ( ) ;
219+ if ( await authenticate ( authUser , authPass ) ) { auth = true ; from = authUser ; s . write ( '235 2.7.0 Authentication successful\r\n' ) ; }
220+ else s . write ( '535 5.7.8 Authentication failed\r\n' ) ;
221+ } ) ;
222+ } ) ;
223+ } else { s . write ( '504 5.5.4 Unrecognized authentication type\r\n' ) ; }
130224 break ;
131- case 'MAIL' :if ( ! auth ) { s . write ( '530\r\n' ) ; break ; } const fm = args . match ( / F R O M : < ( [ ^ > ] + ) > / i) ; if ( fm ) { from = fm [ 1 ] ; s . write ( '250 OK\r\n' ) ; } else s . write ( '501\r\n' ) ; break ;
132- case 'RCPT' :if ( ! auth ) { s . write ( '530\r\n' ) ; break ; } const tm = args . match ( / T O : < ( [ ^ > ] + ) > / i) ; if ( tm ) { to . push ( tm [ 1 ] ) ; s . write ( '250 OK\r\n' ) ; } else s . write ( '501\r\n' ) ; break ;
225+ case 'MAIL' :
226+ console . log ( 'SMTP MAIL auth:' , auth , 'from:' , args ) ;
227+ if ( ! auth ) { s . write ( '530 5.7.0 Authentication required\r\n' ) ; break ; }
228+ const fm = args . match ( / F R O M : < ( [ ^ > ] + ) > / i) ; if ( fm ) { from = fm [ 1 ] ; s . write ( '250 2.1.0 OK\r\n' ) ; } else s . write ( '501 5.1.7 Bad sender address\r\n' ) ; break ;
229+ case 'RCPT' :
230+ if ( ! auth ) { s . write ( '530 5.7.0 Authentication required\r\n' ) ; break ; }
231+ const tm = args . match ( / T O : < ( [ ^ > ] + ) > / i) ; if ( tm ) { to . push ( tm [ 1 ] ) ; s . write ( '250 2.1.5 OK\r\n' ) ; } else s . write ( '501 5.1.3 Bad recipient address\r\n' ) ; break ;
133232 case 'DATA' :if ( ! auth || ! from || ! to . length ) { s . write ( '503\r\n' ) ; break ; } inD = true ; s . write ( '354\r\n' ) ; break ;
134233 case 'QUIT' :s . write ( '221 Bye\r\n' ) ; s . end ( ) ; break ;
135234 case 'NOOP' :case 'RSET' :s . write ( '250 OK\r\n' ) ; if ( cmd === 'RSET' ) { from = '' ; to = [ ] ; data = '' ; } break ;
@@ -142,11 +241,85 @@ function startSMTP(tls:boolean){
142241 srv . listen ( SP , ( ) => console . log ( 'SMTP on' , SP ) ) ;
143242}
144243
244+ function startSMTP587 ( ) {
245+ // Port 587 with STARTTLS - starts plain, upgrades to TLS
246+ const srv = net . createServer ( ( s ) => {
247+ let auth = false , from = '' , to :string [ ] = [ ] , inD = false , data = '' , upgraded = false ;
248+ s . write ( `220 ${ SD } .${ D } ESMTP\r\n` ) ;
249+ s . on ( 'data' , async ( d ) => {
250+ const inp = d . toString ( ) ;
251+ if ( inD ) { if ( inp . trim ( ) === '.' ) { inD = false ;
252+ try { await ses . send ( new SendEmailCommand ( { FromEmailAddress :from , Destination :{ ToAddresses :to } , Content :{ Raw :{ Data :new TextEncoder ( ) . encode ( data ) } } } ) ) ; s . write ( '250 OK\r\n' ) ; }
253+ catch ( e :any ) { console . error ( 'SES error:' , e ) ; s . write ( '550 ' + e . message + '\r\n' ) ; }
254+ data = '' ; to = [ ] ;
255+ } else data += inp ; return ; }
256+ for ( const l of inp . split ( '\r\n' ) . filter ( ( x :string ) => x ) ) {
257+ const cmd = l . split ( ' ' ) [ 0 ] . toUpperCase ( ) , args = l . substring ( cmd . length + 1 ) ;
258+ console . log ( 'SMTP587:' , l ) ;
259+ switch ( cmd ) {
260+ case 'EHLO' :case 'HELO' :
261+ s . write ( `250-${ SD } .${ D } \r\n250-AUTH PLAIN LOGIN\r\n250-STARTTLS\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 52428800\r\n` ) ; break ;
262+ case 'STARTTLS' :
263+ if ( tO . key ) {
264+ s . write ( '220 Ready to start TLS\r\n' ) ;
265+ const tlsSock = new tls . TLSSocket ( s , { ...tO , isServer :true } ) ;
266+ upgraded = true ;
267+ tlsSock . on ( 'secure' , ( ) => console . log ( 'STARTTLS upgrade complete' ) ) ;
268+ } else { s . write ( '454 TLS not available\r\n' ) ; }
269+ break ;
270+ case 'AUTH' :
271+ console . log ( 'SMTP587 AUTH:' , args ) ;
272+ if ( args . startsWith ( 'PLAIN ' ) ) {
273+ const dec = Buffer . from ( args . substring ( 6 ) , 'base64' ) . toString ( ) ;
274+ const parts = dec . split ( '\0' ) ;
275+ const u = ( parts [ 1 ] || '' ) . trim ( ) , p = ( parts [ 2 ] || '' ) . trim ( ) ;
276+ if ( await authenticate ( u , p ) ) { auth = true ; from = u ; s . write ( '235 2.7.0 Authentication successful\r\n' ) ; }
277+ else s . write ( '535 5.7.8 Authentication failed\r\n' ) ;
278+ } else if ( args === 'PLAIN' ) {
279+ s . write ( '334 \r\n' ) ;
280+ s . once ( 'data' , async ( ad ) => {
281+ const dec = Buffer . from ( ad . toString ( ) . trim ( ) , 'base64' ) . toString ( ) ;
282+ const parts = dec . split ( '\0' ) ;
283+ const u = ( parts [ 1 ] || '' ) . trim ( ) , p = ( parts [ 2 ] || '' ) . trim ( ) ;
284+ if ( await authenticate ( u , p ) ) { auth = true ; from = u ; s . write ( '235 2.7.0 Authentication successful\r\n' ) ; }
285+ else s . write ( '535 5.7.8 Authentication failed\r\n' ) ;
286+ } ) ;
287+ } else if ( args === 'LOGIN' || args . startsWith ( 'LOGIN' ) ) {
288+ let authUser = '' ;
289+ s . write ( '334 VXNlcm5hbWU6\r\n' ) ;
290+ s . once ( 'data' , async ( ud ) => {
291+ authUser = Buffer . from ( ud . toString ( ) . trim ( ) , 'base64' ) . toString ( ) . trim ( ) ;
292+ s . write ( '334 UGFzc3dvcmQ6\r\n' ) ;
293+ s . once ( 'data' , async ( pd ) => {
294+ const authPass = Buffer . from ( pd . toString ( ) . trim ( ) , 'base64' ) . toString ( ) . trim ( ) ;
295+ if ( await authenticate ( authUser , authPass ) ) { auth = true ; from = authUser ; s . write ( '235 2.7.0 Authentication successful\r\n' ) ; }
296+ else s . write ( '535 5.7.8 Authentication failed\r\n' ) ;
297+ } ) ;
298+ } ) ;
299+ } else { s . write ( '504 Unrecognized auth type\r\n' ) ; }
300+ break ;
301+ case 'MAIL' :if ( ! auth ) { s . write ( '530 5.7.0 Authentication required\r\n' ) ; break ; }
302+ const fm = args . match ( / F R O M : < ( [ ^ > ] + ) > / i) ; if ( fm ) { from = fm [ 1 ] ; s . write ( '250 OK\r\n' ) ; } else s . write ( '501\r\n' ) ; break ;
303+ case 'RCPT' :if ( ! auth ) { s . write ( '530 5.7.0 Authentication required\r\n' ) ; break ; }
304+ const tm = args . match ( / T O : < ( [ ^ > ] + ) > / i) ; if ( tm ) { to . push ( tm [ 1 ] ) ; s . write ( '250 OK\r\n' ) ; } else s . write ( '501\r\n' ) ; break ;
305+ case 'DATA' :if ( ! auth || ! from || ! to . length ) { s . write ( '503\r\n' ) ; break ; } inD = true ; s . write ( '354 Start mail input\r\n' ) ; break ;
306+ case 'QUIT' :s . write ( '221 Bye\r\n' ) ; s . end ( ) ; break ;
307+ case 'NOOP' :case 'RSET' :s . write ( '250 OK\r\n' ) ; if ( cmd === 'RSET' ) { from = '' ; to = [ ] ; data = '' ; } break ;
308+ default :s . write ( '502 Command not implemented\r\n' ) ;
309+ }
310+ }
311+ } ) ;
312+ s . on ( 'error' , e => console . error ( 'SMTP587 err:' , e ) ) ;
313+ } ) ;
314+ srv . listen ( 587 , ( ) => console . log ( 'SMTP on 587 (STARTTLS)' ) ) ;
315+ }
316+
145317async function main ( ) {
146318 console . log ( 'Mail server for' , D ) ;
147319 const hasTls = await loadCert ( ) ;
148320 startIMAP ( hasTls ) ;
149321 startSMTP ( hasTls ) ;
322+ startSMTP587 ( ) ; // Also listen on 587 with STARTTLS
150323}
151324
152325// Export for testing
0 commit comments