Skip to content

Commit 7970ce4

Browse files
committed
chore: wip
1 parent c3d6cdb commit 7970ce4

File tree

1 file changed

+189
-16
lines changed

1 file changed

+189
-16
lines changed

storage/framework/core/mail-server/server.ts

Lines changed: 189 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Stacks Mail Server - IMAP/SMTP server with S3 backend
33
*/
44
import * 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';
66
import{SESv2Client,SendEmailCommand}from'@aws-sdk/client-sesv2';
77
import{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(/FROM:<([^>]+)>/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(/TO:<([^>]+)>/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(/FROM:<([^>]+)>/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(/TO:<([^>]+)>/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(/FROM:<([^>]+)>/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(/TO:<([^>]+)>/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+
145317
async 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

Comments
 (0)