Skip to content

Commit effee3f

Browse files
committed
feat(smtp): harden SMTP protocol and MIME encoding 🛡️
- Apply CRLF normalization and dot-stuffing before DATA send - Encode attachment and embedded parts per transfer-encoding choice - Parse SMTP replies until final multiline response line completes - Replace HELO with EHLO and re-EHLO after TLS upgrade completes - Require advertised STARTTLS on submission transport when using port 587
1 parent bb1efc1 commit effee3f

3 files changed

Lines changed: 207 additions & 123 deletions

File tree

src/smtp/Client.ts

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
99
export 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 = /\bSTARTTLS\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\nMessage-ID:\s*(<[^>\r\n]+>)/i) ||
173-
signedMessageContent.match(/^Message-ID:\s*(<[^>\r\n]+>)/i)
178+
const messageIdMatch =
179+
dkimSignedMessage.match(/\r\nMessage-ID:\s*(<[^>\r\n]+>)/i) ||
180+
dkimSignedMessage.match(/^Message-ID:\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.

src/smtp/Command.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@ import type * as Types from '@app/Types.ts'
22

33
/**
44
* Execute SMTP wire commands.
5-
* @description Sends commands and validates protocol responses.
5+
* @description Sends commands and validates SMTP responses.
66
*/
77
export class SmtpCommand {
88
/**
99
* Create command handler.
10-
* @description Stores shared state for SMTP transport I/O.
10+
* @description Stores shared SMTP transport state.
1111
* @param state - Shared SMTP connection state
1212
*/
1313
constructor(private state: Types.SmtpConnectionState) {}
1414

1515
/**
1616
* Read server response.
17-
* @description Reads response and validates SMTP status class.
17+
* @description Reads server reply until final status line.
1818
* @returns Server response string
1919
* @throws {Error} When connection is closed or server returns error code
2020
*/
@@ -33,28 +33,34 @@ export class SmtpCommand {
3333
throw new Error('Connection closed')
3434
}
3535
}
36-
const readUntilComplete = async (response: string): Promise<string> => {
37-
const n = await readChunk()
38-
if (n === null) {
36+
let response = ''
37+
while (true) {
38+
const bytesRead = await readChunk()
39+
if (bytesRead === null) {
3940
throw new Error('Connection closed')
4041
}
41-
const newResponse = response + decoder.decode(buffer.subarray(0, n))
42-
if (newResponse.endsWith('\r\n')) {
43-
return newResponse
42+
response += decoder.decode(buffer.subarray(0, bytesRead))
43+
const completeLines = response.split('\r\n').filter(line => line.length > 0)
44+
const lastLine = completeLines[completeLines.length - 1]
45+
if (!lastLine) {
46+
continue
47+
}
48+
if (/^\d{3}\s/.test(lastLine)) {
49+
break
4450
}
45-
return await readUntilComplete(newResponse)
4651
}
47-
const response = await readUntilComplete('')
48-
const code = response.substring(0, 3)
49-
if (!response.startsWith('2') && !response.startsWith('3')) {
50-
throw new Error(`SMTP Error ${code}: ${response}`)
52+
const finalLines = response.split('\r\n').filter(line => line.length > 0)
53+
const finalLine = finalLines[finalLines.length - 1] ?? response
54+
const statusCode = finalLine.substring(0, 3)
55+
if (!finalLine.startsWith('2') && !finalLine.startsWith('3')) {
56+
throw new Error(`SMTP Error ${statusCode}: ${response}`)
5157
}
5258
return response
5359
}
5460

5561
/**
5662
* Send SMTP command.
57-
* @description Writes command and waits for server reply.
63+
* @description Writes command and waits for response.
5864
* @param command - SMTP command to send
5965
* @returns Server response string
6066
* @throws {Error} When command times out or server returns error
@@ -64,11 +70,11 @@ export class SmtpCommand {
6470
throw new Error('Not connected')
6571
}
6672
const encoder = new TextEncoder()
67-
const data = encoder.encode(`${command}\r\n`)
73+
const commandPayload = encoder.encode(`${command}\r\n`)
6874
if (this.state.tlsConn) {
69-
await this.state.tlsConn.write(data)
75+
await this.state.tlsConn.write(commandPayload)
7076
} else if (this.state.conn) {
71-
await this.state.conn.write(data)
77+
await this.state.conn.write(commandPayload)
7278
}
7379
return await this.readResponse()
7480
}

0 commit comments

Comments
 (0)