@@ -4,7 +4,7 @@ import * as Utils from '@utils/index.ts'
44
55/**
66 * Send email through SMTP.
7- * @description Manages connection, auth, and message delivery flow.
7+ * @description Manages connection auth and delivery flow.
88 */
99export class SmtpClient {
1010 /** Raw TCP connection */
@@ -41,7 +41,7 @@ export class SmtpClient {
4141
4242 /**
4343 * Connect to SMTP server.
44- * @description Opens socket, upgrades TLS, then authenticates.
44+ * @description Opens socket upgrades TLS then authenticates.
4545 * @throws {Error } When connection fails or authentication is rejected
4646 */
4747 async connect ( ) : Promise < void > {
@@ -53,18 +53,23 @@ export class SmtpClient {
5353 } )
5454 this . connectionState . tlsConn = this . tlsConn
5555 await this . commands . readResponse ( )
56- await this . commands . sendCommand ( `HELO ${ this . config . host } ` )
56+ await this . commands . sendCommand ( `EHLO ${ this . config . host } ` )
5757 } else {
5858 this . conn = await Deno . connect ( {
5959 hostname : this . config . host ,
6060 port : this . config . port
6161 } )
6262 this . connectionState . conn = this . conn
6363 await this . commands . readResponse ( )
64- await this . commands . sendCommand ( `HELO ${ this . config . host } ` )
65- if ( this . config . port === 587 ) {
66- await this . commands . sendCommand ( 'startTLS' )
64+ const ehloResponse = await this . commands . sendCommand ( `EHLO ${ this . config . host } ` )
65+ const hasStartTlsSupport = / \b S T A R T T L S \b / i. test ( ehloResponse )
66+ if ( this . config . port === 587 && ! hasStartTlsSupport ) {
67+ throw new Error ( 'STARTTLS is required on port 587 but server does not advertise support' )
68+ }
69+ if ( hasStartTlsSupport ) {
70+ await this . commands . sendCommand ( 'STARTTLS' )
6771 await this . upgradeToTLS ( )
72+ await this . commands . sendCommand ( `EHLO ${ this . config . host } ` )
6873 }
6974 }
7075 if ( this . config . auth ) {
@@ -105,7 +110,7 @@ export class SmtpClient {
105110
106111 /**
107112 * Report connection availability.
108- * @description Returns true when TCP or TLS channel is active.
113+ * @description Returns true when TCP or TLS is active.
109114 * @returns True when connection is active
110115 */
111116 get isConnected ( ) : boolean {
@@ -114,7 +119,7 @@ export class SmtpClient {
114119
115120 /**
116121 * Send SMTP message.
117- * @description Validates recipients, sends envelope, then DATA.
122+ * @description Validates recipients sends envelope then DATA.
118123 * @param message - The email message to send
119124 * @returns Structured SMTP delivery result
120125 * @throws {Error } When message validation fails or transmission is unsuccessful
@@ -165,18 +170,20 @@ export class SmtpClient {
165170 throw new Error ( 'All recipients were rejected by SMTP server' )
166171 }
167172 await this . commands . sendCommand ( 'DATA' )
168- const messageContent = this . messageFormatter . formatMessage ( message )
169- const signedMessageContent = await this . applyDkimSignatureIfEnabled ( messageContent )
170- await this . commands . sendData ( signedMessageContent )
173+ const formattedMessage = this . messageFormatter . formatMessage ( message )
174+ const dkimSignedMessage = await this . applyDkimSignatureIfEnabled ( formattedMessage )
175+ const smtpSafeMessage = this . toSmtpDataStream ( dkimSignedMessage )
176+ await this . commands . sendData ( smtpSafeMessage )
171177 const dataResponse = await this . commands . sendCommand ( '.' )
172- const messageIdMatch = signedMessageContent . match ( / \r \n M e s s a g e - I D : \s * ( < [ ^ > \r \n ] + > ) / i) ||
173- signedMessageContent . match ( / ^ M e s s a g e - I D : \s * ( < [ ^ > \r \n ] + > ) / i)
178+ const messageIdMatch =
179+ dkimSignedMessage . match ( / \r \n M e s s a g e - I D : \s * ( < [ ^ > \r \n ] + > ) / i) ||
180+ dkimSignedMessage . match ( / ^ M e s s a g e - I D : \s * ( < [ ^ > \r \n ] + > ) / i)
174181 const messageId = messageIdMatch ? ( messageIdMatch [ 1 ] ?? '' ) : ''
175182 return {
176183 acceptedRecipients,
177184 envelope : {
178185 from : senderEmail ,
179- to : allRecipients . map ( ( recipient ) => recipient . email )
186+ to : allRecipients . map ( recipient => recipient . email )
180187 } ,
181188 messageId,
182189 rejectedRecipients,
@@ -190,8 +197,8 @@ export class SmtpClient {
190197 }
191198
192199 /**
193- * Apply DKIM signature when enabled .
194- * @description Signs formatted message and prepends DKIM-Signature header.
200+ * Apply DKIM signature.
201+ * @description Signs message and prepends DKIM-Signature header.
195202 * @param messageContent - Fully formatted SMTP message content
196203 * @returns DKIM-signed message content or original input
197204 * @throws {Error } When DKIM configuration or key import is invalid
@@ -209,21 +216,21 @@ export class SmtpClient {
209216 const rawHeaderLines = rawHeaderSection . split ( '\r\n' )
210217 const selectedHeaderNames =
211218 this . config . dkim . headerFieldNames && this . config . dkim . headerFieldNames . length > 0
212- ? this . config . dkim . headerFieldNames . map ( ( headerName ) => headerName . toLowerCase ( ) )
219+ ? this . config . dkim . headerFieldNames . map ( headerName => headerName . toLowerCase ( ) )
213220 : [ 'from' , 'to' , 'subject' , 'date' , 'message-id' , 'mime-version' , 'content-type' ]
214- const selectedHeaderLines = rawHeaderLines . filter ( ( headerLine ) => {
221+ const selectedHeaderLines = rawHeaderLines . filter ( headerLine => {
215222 const separatorIndex = headerLine . indexOf ( ':' )
216223 if ( separatorIndex < 1 ) {
217224 return false
218225 }
219226 const headerName = headerLine . slice ( 0 , separatorIndex ) . trim ( ) . toLowerCase ( )
220227 return selectedHeaderNames . includes ( headerName )
221228 } )
222- const signedHeaderNames = selectedHeaderLines . map ( ( headerLine ) => {
229+ const signedHeaderNames = selectedHeaderLines . map ( headerLine => {
223230 const separatorIndex = headerLine . indexOf ( ':' )
224231 return headerLine . slice ( 0 , separatorIndex ) . trim ( ) . toLowerCase ( )
225232 } )
226- const canonicalizedHeaderLines = selectedHeaderLines . map ( ( headerLine ) => {
233+ const canonicalizedHeaderLines = selectedHeaderLines . map ( headerLine => {
227234 const separatorIndex = headerLine . indexOf ( ':' )
228235 const headerName = headerLine . slice ( 0 , separatorIndex ) . trim ( ) . toLowerCase ( )
229236 const headerValue = headerLine
@@ -242,15 +249,14 @@ export class SmtpClient {
242249 const domainName = this . config . dkim . domainName
243250 const keySelector = this . config . dkim . keySelector
244251 const signedHeaderList = signedHeaderNames . join ( ':' )
245- const dkimHeaderPrefix =
246- `v=1; a=rsa-sha256; c=relaxed/relaxed; d=${ domainName } ; s=${ keySelector } ; h=${ signedHeaderList } ; bh=${ bodyHashBase64 } ; b=`
252+ const dkimHeaderPrefix = `v=1; a=rsa-sha256; c=relaxed/relaxed; d=${ domainName } ; s=${ keySelector } ; h=${ signedHeaderList } ; bh=${ bodyHashBase64 } ; b=`
247253 const dkimSigningLine = `dkim-signature:${ dkimHeaderPrefix } `
248254 const signingPayload = [ ...canonicalizedHeaderLines , dkimSigningLine ] . join ( '\r\n' )
249255 const pemBody = this . config . dkim . privateKey
250256 . replace ( '-----BEGIN PRIVATE KEY-----' , '' )
251257 . replace ( '-----END PRIVATE KEY-----' , '' )
252258 . replace ( / \s + / g, '' )
253- const binaryDer = Uint8Array . from ( atob ( pemBody ) , ( char ) => char . charCodeAt ( 0 ) )
259+ const binaryDer = Uint8Array . from ( atob ( pemBody ) , char => char . charCodeAt ( 0 ) )
254260 const cryptoKey = await crypto . subtle . importKey (
255261 'pkcs8' ,
256262 binaryDer ,
@@ -271,6 +277,17 @@ export class SmtpClient {
271277 return `${ dkimHeader } \r\n${ rawHeaderSection } \r\n\r\n${ rawBodySection } `
272278 }
273279
280+ /**
281+ * Prepare SMTP DATA payload.
282+ * @description Applies CRLF normalization and dot-stuffing.
283+ * @param messageContent - Raw MIME message content
284+ * @returns SMTP-safe DATA payload
285+ */
286+ private toSmtpDataStream ( messageContent : string ) : string {
287+ const normalizedContent = messageContent . replace ( / \r ? \n / g, '\r\n' )
288+ return normalizedContent . replace ( / ( ^ | \r \n ) \. / g, '$1..' )
289+ }
290+
274291 /**
275292 * Upgrade transport to TLS.
276293 * @description Starts TLS over existing plain connection.
0 commit comments