@softvisio/core
Version:
Softisio core
658 lines (492 loc) • 17.5 kB
JavaScript
import "#lib/result";
import tls from "node:tls";
import { resolveMx } from "#lib/dns";
import File from "#lib/file";
import { connect, getDefaultPort } from "#lib/net";
import ProxyClient from "#lib/net/proxy";
import Sasl from "#lib/sasl";
import stream from "#lib/stream";
import * as base64Stream from "#lib/stream/base64";
import StreamMultipart from "#lib/stream/multipart";
import ThreadsPool from "#lib/threads/pool";
const DEFAULT_VERIFY_PORT = 25;
const DEFAULT_MAX_RUNNING_THREADS = 10;
const STATUS_REASON = {
"200": "(nonstandard success response, see rfc876)",
"211": "System status, or system help reply",
"214": "Help message",
"220": "Service ready",
"221": "Service closing transmission channel",
"235": "Authentication successful",
"250": "Requested mail action okay, completed",
"251": "User not local; will forward to <forward-path>",
"252": "Cannot VRFY user, but will accept message and attempt delivery",
"334": "Continue request",
"354": "Start mail input; end with <CRLF>.<CRLF>",
"421": "Service not available, closing transmission channel",
"450": "Requested mail action not taken: mailbox unavailable",
"451": "Requested action aborted: local error in processing",
"452": "Requested action not taken: insufficient system storage",
"500": "Syntax error, command unrecognised",
"501": "Syntax error in parameters or arguments",
"502": "Command not implemented",
"503": "Bad sequence of commands",
"504": "Command parameter not implemented",
"521": "Does not accept mail (see rfc1846)",
"530": "Access denied",
"534": "Please log in via your web browser",
"535": "AUTH failed with the remote server",
"550": "Requested action not taken: mailbox unavailable",
"551": "User not local; try <forward-path>",
"552": "Requested mail action aborted: exceeded storage allocation",
"553": "Requested action not taken: mailbox name not allowed",
"554": "Transaction failed",
"555": "Syntax error",
};
class SmtpConnection {
#smtp;
#socket;
#isTls;
#extensions = {};
constructor ( smtp ) {
this.#smtp = smtp;
}
// public
async connect () {
try {
// proxy connection
if ( this.#smtp.proxy ) {
this.#socket = await this.#smtp.proxy.connect( {
"protocol": "smtp:",
"hostname": this.#smtp.hostname,
"port": this.#smtp.port,
} );
}
// direct connection
else {
this.#socket = await connect( {
"host": this.#smtp.hostname,
"port": this.#smtp.port,
} );
}
}
catch ( e ) {
return result.catch( e );
}
// upgrade socket
if ( this.#smtp.isTls ) {
const res = await this.#startTls();
if ( !res.ok ) return res;
}
// read initial message
var res = await this.#read();
if ( !res.ok ) return res;
// ehlo
const ehlo = await this.#ehlo();
if ( !ehlo.ok ) return ehlo;
// start tls
if ( this.#smtp.isStartTls ) {
res = await this.startTls();
if ( !res.ok ) return res;
}
return ehlo;
}
destroy ( res ) {
if ( this.#socket ) {
this.#socket.destroy();
this.#socket = null;
}
return res;
}
// commands
async startTls () {
if ( this.#isTls ) return result( [ 200, "Connection is already TLS" ] );
if ( !this.#extensions.STARTTLS ) return result( [ 200, "TLS is not supported" ] );
this.#write( "STARTTLS" );
var res = await this.#read();
if ( !res.ok ) return res;
res = await this.#startTls();
if ( !res.ok ) return res;
// ehlo
return this.#ehlo();
}
// supported methods in priority order: CRAM-MD5, PLAIN, LOGIN
async auth () {
if ( !this.#extensions.AUTH ) return this.destroy( result( [ 400, "Authentication is not supported" ] ) );
const sasl = await Sasl.new( this.#extensions.AUTH.split( /\s+/ ), this.#smtp.username, this.#smtp.password );
if ( !sasl ) return this.destroy( result( [ 535, "No authentication method supported" ] ) );
var res;
this.#write( "AUTH " + sasl.type );
res = await this.#read();
if ( res.status !== 334 ) return res;
while ( true ) {
const response = sasl.continue( res.data[ 0 ] );
if ( !response ) return result( [ 535, "No authentication failed" ] );
this.#write( response );
res = await this.#read();
if ( res.status !== 334 ) return res;
}
}
async mailFrom ( value ) {
this.#write( `MAIL FROM:<${ value || this.#smtp.username }>` );
return this.#read();
}
async rcptTo ( to, { cc, bcc } = {} ) {
var res;
// to
for ( const address of to ) {
this.#write( `RCPT TO:<${ address }>` );
res = await this.#read();
if ( !res.ok ) return res;
}
// cc
for ( const address of cc ) {
this.#write( `RCPT TO:<${ address }>` );
res = await this.#read();
if ( !res.ok ) return res;
}
if ( !bcc ) return res;
// bcc
for ( const address of bcc ) {
this.#write( `RCPT TO:<${ address }>` );
res = await this.#read();
if ( !res.ok ) return res;
}
return res;
}
async data ( { to, from, replyTo, cc, bcc, subject, text, html, attachments } ) {
this.#write( "DATA" );
var res = await this.#read();
if ( res.status !== 354 ) return res;
var header = "",
body;
from ||= this.#smtp.from;
if ( !from ) from = `<${ this.#smtp.username }>`;
else if ( !from.endsWith( ">" ) ) from += `<${ this.#smtp.username }>`;
// headers
header += `From: ${ from }\r\n`;
if ( replyTo ) {
if ( !replyTo.endsWith( ">" ) ) replyTo += `<${ this.#smtp.username }>`;
header += `Reply-To: ${ replyTo }\r\n`;
}
if ( to ) header += `To: ${ to.join( "," ) }\r\n`;
if ( cc && cc.length ) header += `Cc: ${ cc.join( "," ) }\r\n`;
if ( subject ) header += `Subject: ${ subject }\r\n`;
header += "MIME-Version: 1.0\r\n";
header += `Date: ${ new Date().toDateString() }\r\n`;
// has body
if ( text || html || attachments ) {
body = new StreamMultipart( "mixed" );
if ( text || html ) {
const content = new StreamMultipart( "alternative" );
if ( text ) {
content.append( text instanceof File
? text.stream()
: text, {
"type": "text/plain; charset=utf-8",
"headers": { "content-transfer-encoding": "base64" },
"transform": new base64Stream.Encode(),
} );
}
if ( html ) {
content.append( html instanceof File
? html.stream()
: html, {
"type": "text/html; charset=utf-8",
"headers": { "content-transfer-encoding": "base64" },
"transform": new base64Stream.Encode(),
} );
}
body.append( content );
}
if ( attachments ) {
for ( const attachment of attachments ) {
body.append( attachment, {
"headers": { "content-transfer-encoding": "base64" },
"transform": new base64Stream.Encode(),
} );
}
}
header += `Content-Type: ${ body.type }\r\n`;
}
header += "\r\n";
this.#write( header );
if ( body ) {
const res = await stream.promises
.pipeline( body, this.#socket, {
"end": false,
} )
.then( () => result( 200 ) )
.catch( e => result.catch( e, { "log": false } ) );
if ( !res.ok ) return this.destroy( res );
}
this.#write( "." );
return this.#read();
}
async rset () {
this.#write( "RSET" );
return this.#read();
}
async vrfy ( address ) {
this.#write( `VRFY: <${ address }>` );
return this.#read();
}
async noop () {
this.#write( "NOOP" );
return this.#read();
}
quit ( res ) {
this.#write( "QUIT" );
// do not read QUIT response
return this.destroy( res || result( [ 221, STATUS_REASON[ 221 ] ] ) );
}
// private
#write ( data ) {
this.#socket.write( data + "\r\n" );
}
async #read () {
var status;
const lines = [];
while ( true ) {
const line = await this.#socket.readLine( { "eol": "\r\n", "encoding": "utf8" } ).catch( e => null );
// protocol error or disconnected
if ( line == null ) return this.destroy( result( [ 500, "SMTP server closed connection" ] ) );
status = +line.slice( 0, 3 );
lines.push( line.slice( 4 ) );
// end of the response
if ( line.charAt( 3 ) !== "-" ) break;
}
return result( [ status, STATUS_REASON[ status ] ], lines );
}
async #ehlo ( hostname ) {
this.#write( `EHLO ${ hostname || "localhost.localdomain" }` );
const res = await this.#read();
if ( !res.ok ) return res;
this.#extensions = {};
for ( const line of res.data ) {
const match = line.match( /^([\dA-Z]+)\s?(.*)/ );
if ( match ) this.#extensions[ match[ 1 ] ] = match[ 2 ] || true;
}
return res;
}
async #startTls () {
return new Promise( ( resolve, reject ) => {
const socket = tls.connect( {
"socket": this.#socket,
"host": this.#smtp.hostname,
"servername": this.#smtp.hostname,
} );
socket.once( "error", e => resolve( result( [ 500, e.message ] ) ) );
socket.once( "secureConnect", () => {
this.#socket = socket;
this.#isTls = true;
this.#socket.removeAllListeners( "error" );
resolve( result( 200 ) );
} );
} );
}
}
export default class Smtp {
#url;
#isTls;
#isStartTls;
#port;
#username;
#password;
#from;
#replyTo;
#proxy;
#threadsPool;
constructor ( url, { proxy, maxRunningThreads } = {} ) {
this.#url = new URL( url );
this.proxy = proxy;
if ( +this.#url.port === getDefaultPort( this.#url.protocol ) ) this.#url.port = "";
this.#username = decodeURIComponent( this.#url.username );
this.#password = decodeURIComponent( this.#url.password );
this.#from = this.#url.searchParams.get( "from" );
this.#replyTo = this.#url.searchParams.get( "replyTo" );
this.#threadsPool = new ThreadsPool( { "maxRunningThreads": maxRunningThreads || DEFAULT_MAX_RUNNING_THREADS } );
}
// static
// XXX
static async verifyEmail ( email, { proxy } = {} ) {
try {
email = new URL( "smtp://" + email );
}
catch {
return result( [ 400, "Email is invalid" ] );
}
const mx = await resolveMx( email.hostname );
if ( !mx ) return result( [ 500, "Unable to resolve hostname" ] );
var smtpHostname;
for ( const row of mx ) {
if ( row.exchange ) {
smtpHostname = row.exchange;
break;
}
}
if ( !smtpHostname ) return result( [ 500, "Unable to find MX record" ] );
const smtp = new this( `smtp://${ smtpHostname }:${ email.port || DEFAULT_VERIFY_PORT }`, { proxy } ),
connection = new SmtpConnection( smtp );
var res;
TRANSACTION: {
// connect
res = await connection.connect();
if ( !res.ok ) break TRANSACTION;
// socket.write( "VRFY:" + email.username + "@" + email.hostname + "\r\n" );
// res = await read( socket );
// console.log( res + "" );
res = await connection.mailFrom( "test@vasyns.com" );
if ( !res.ok ) break TRANSACTION;
res = connection.rcptTo( [ email.username + "@" + email.hostname ] );
if ( !res.ok ) break TRANSACTION;
}
return connection.destroy( res );
}
// properties
get url () {
return this.#url.href;
}
get isTls () {
this.#isTls ??= this.#url.protocol === "smtp+tls:";
return this.#isTls;
}
get isStartTls () {
this.#isStartTls ??= this.#url.protocol === "smtp+starttls:";
return this.#isStartTls;
}
get hostname () {
return this.#url.hostname;
}
get port () {
if ( !this.#port ) {
this.#port = +this.#url.port || getDefaultPort( this.#url.protocol );
}
return this.#port;
}
get username () {
return this.#username;
}
get password () {
return this.#password;
}
get from () {
return this.#from;
}
get replyTo () {
return this.#replyTo;
}
get proxy () {
return this.#proxy;
}
set proxy ( value ) {
this.#proxy = ProxyClient.new( value );
}
// public
toString () {
return this.#url.href;
}
toJSON () {
return this.#url.href;
}
async destroy () {
return this.#threadsPool.destroy();
}
async testSmtp () {
return this.#threadsPool.runThread( this.#testSmtp.bind( this ), {
"highPriority": true,
} );
}
async sendEmail ( { to, from, replyTo, cc, bcc, subject, textBody, htmlBody, attachments } = {} ) {
return this.#threadsPool.runThread( this.#sendEmail.bind( this, {
to,
from,
replyTo,
cc,
bcc,
subject,
"text": textBody,
"html": htmlBody,
attachments,
} ) );
}
// private
async #testSmtp () {
const connection = new SmtpConnection( this );
var res;
TRANSACTION: {
// connect
res = await connection.connect();
if ( !res.ok ) break TRANSACTION;
// auth
res = await connection.auth();
if ( !res.ok ) break TRANSACTION;
}
connection.destroy();
return res;
}
async #sendEmail ( { to, from, replyTo, cc, bcc, subject, text, html, attachments } = {} ) {
const connection = new SmtpConnection( this );
var res;
TRANSACTION: {
// connect
res = await connection.connect();
if ( !res.ok ) break TRANSACTION;
// auth
res = await connection.auth();
if ( !res.ok ) break TRANSACTION;
// mailFrom
res = await connection.mailFrom();
if ( !res.ok ) break TRANSACTION;
// prepare addresses
const options = {
"from": from || this.#from,
"replyTo": replyTo || this.#replyTo,
"to": [],
"cc": [],
"bcc": [],
subject,
text,
html,
attachments,
};
const addresses = new Set();
// to
if ( to ) {
if ( !Array.isArray( to ) ) to = [ to ];
for ( const address of to ) {
if ( addresses.has( address ) ) continue;
addresses.add( address );
options.to.push( address );
}
}
// cc
if ( cc ) {
if ( !Array.isArray( cc ) ) cc = [ cc ];
for ( const address of cc ) {
if ( addresses.has( address ) ) continue;
addresses.add( address );
options.cc.push( address );
}
}
// bcc
if ( bcc ) {
if ( !Array.isArray( bcc ) ) bcc = [ bcc ];
for ( const address of bcc ) {
if ( addresses.has( address ) ) continue;
addresses.add( address );
options.bcc.push( address );
}
}
// rcptTo
res = await connection.rcptTo( options.to, options );
if ( !res.ok ) break TRANSACTION;
// data
res = await connection.data( options );
if ( !res.ok ) break TRANSACTION;
res = connection.quit();
}
connection.destroy();
return res;
}
}