feat: Add comprehensive SMTP test suite for Deno
- Implemented SMTP client utilities in `test/helpers/smtp.client.ts` for creating test clients, sending emails, and testing connections. - Developed SMTP protocol test utilities in `test/helpers/utils.ts` for managing TCP connections, sending commands, and handling responses. - Created a detailed README in `test/readme.md` outlining the test framework, infrastructure, organization, and running instructions. - Ported CMD-01: EHLO Command tests in `test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts` with multiple scenarios including valid and invalid hostnames. - Ported CMD-02: MAIL FROM Command tests in `test/suite/smtpserver_commands/test.cmd-02.mail-from.test.ts` covering valid address acceptance, invalid address rejection, SIZE parameter support, and command sequence enforcement.
This commit is contained in:
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/cache
|
||||||
71
.serena/project.yml
Normal file
71
.serena/project.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||||
|
# * For C, use cpp
|
||||||
|
# * For JavaScript, use typescript
|
||||||
|
# Special requirements:
|
||||||
|
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||||
|
language: typescript
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# whether to use the project's gitignore file to ignore files
|
||||||
|
# Added on 2025-04-07
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
# list of additional paths to ignore
|
||||||
|
# same syntax as gitignore, so you can use * and **
|
||||||
|
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||||
|
# Added (renamed) on 2025-04-07
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
project_name: "mailer"
|
||||||
21
test/fixtures/test-cert.pem
vendored
Normal file
21
test/fixtures/test-cert.pem
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDazCCAlOgAwIBAgIUcmAewXEYwtzbZmZAJ5inMogKSbowDQYJKoZIhvcNAQEL
|
||||||
|
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||||
|
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAyMjAwODM4MzRaFw0yNTAy
|
||||||
|
MTkwODM4MzRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
|
||||||
|
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
|
||||||
|
AQUAA4IBDwAwggEKAoIBAQDHNgjCV+evsAra/oT5zahgPucAhcgi8O69W9nxH2TL
|
||||||
|
FcjNNcGPkPUCCe2opLdsVHVdPyEJV5eO4so8G9duFWMbXmBeVfGk2IWLVlymEm+z
|
||||||
|
jMH9WHtm/YAu3dqdIjCwnatED9H8ap0k+Qd9h/8YxMMvDRiWVHRg568SudEggzlL
|
||||||
|
nwuPadMKvm/mErUaX2ZbBGQVAqRZWXZRe38lfoLtnpTIlcxlKegbQrDoZCN7jUrm
|
||||||
|
vRl3OuGsZ+Zv3BINSf2xkZfqKX6gyJjPtSBCQ8TMZRkQnEonDHLxmA91KBu9irhb
|
||||||
|
A/BsFQmWnDSC3mLoc5LsKmFFqZr7MB/ku99IugtdI/knAgMBAAGjUzBRMB0GA1Ud
|
||||||
|
DgQWBBQryyWLuN22OqU1r9HIt2tMLBk42DAfBgNVHSMEGDAWgBQryyWLuN22OqU1
|
||||||
|
r9HIt2tMLBk42DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAe
|
||||||
|
CeXQZlXJ2xLnDoOoKY3BpodErNmAwygGYxwDCU0xPbpUMPrQhLI80JlZmfy58gT/
|
||||||
|
0ZbULS+srShfEsFnBLmzWLGXDvA/IKCQyTmCQwbPeELGXF6h4URMb+lQL7WL9tY0
|
||||||
|
uUg2dA+7CtYokIrOkGqUitPK3yvVhxugkf51WIgKMACZDibOQSWrV5QO2vHOAaO9
|
||||||
|
ePzRGGl3+Ebmcs3+5w1fI6OLsIZH10lfEnC83C0lO8tIJlGsXMQkCjAcX22rT0rc
|
||||||
|
AcxLm07H4EwMwgOAJUkuDjD3y4+KH91jKWF8bhaLZooFB8lccNnaCRiuZRnXlvmf
|
||||||
|
M7uVlLGwlj5R9iHd+0dP
|
||||||
|
-----END CERTIFICATE-----
|
||||||
27
test/fixtures/test-key.pem
vendored
Normal file
27
test/fixtures/test-key.pem
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEpAIBAAKCAQEAxzYIwlfnr7AK2v6E+c2oYD7nAIXIIvDuvVvZ8R9kyxXIzTXB
|
||||||
|
j5D1AgntqKS3bFR1XT8hCVeXjuLKPBvXbhVjG15gXlXxpNiFi1ZcphJvs4zB/Vh7
|
||||||
|
Zv2ALt3anSIwsJ2rZA/R/GqdJPkHvYf/GMTDLw0YllR0YOevErnRIIM5S58Lj2nT
|
||||||
|
Cr5v5hK1Gl9mWwRkFQKkWVl2UXt/JX6C7Z6UyJXMZSnoG0Kw6GQje41K5r0Zdzrh
|
||||||
|
rGfmb9wSDUn9sZGX6il+oMiYz7UgQkPEzGUZEJxKJwxy8ZgPdSgbvYq4WwPwbBUJ
|
||||||
|
lpw0gt5i6HOS7CphRama+zAf5LvfSLoLXSP5JwIDAQABAoIBAQC8C5Ge6wS4LuH9
|
||||||
|
tbZFPwjdGHXL+QT2fOFxPBrE7PkeY8UXD7G5Yei6iqqCxJh8nhLQ3DoayhZM69hO
|
||||||
|
ePOV1Z/LDERCnGel15WKQ1QJ1HZ+JQXnfQrE1Mi9QrXO5bVFtnXIr0mZ+AzwoUmn
|
||||||
|
K5fYCvaL3xDZPDzOYL5kZG2hQKgbywGKZoQx16G0dSEhlAHbK9z6XmPRrbUKGzB8
|
||||||
|
qV7QGbL7BUTQs5JW/8LpkYr5C0q5THtUVb9mHNR3jPf9WTPQ0D3lxcbLS4PQ8jQ/
|
||||||
|
L/GcuHGmsXhe2Unw3w2wpuJKPeHKz4rBNIvaSjIZl9/dIKM88JYQTiIGKErxsC0e
|
||||||
|
kczQMp6BAoGBAO0zUN8H7ynXGNNtK/tJo0lI3qg1ZKgr+0CU2L5eU8Bn1oJ1JkCI
|
||||||
|
WD3p36NdECx5tGexm9U6MN+HzKYUjnQ6LKzbHQGLZqzF5IL5axXgCn8w4BM+6Ixm
|
||||||
|
y8kQgsTKlKRMXIn8RZCmXNnc7v0FhBgpDxPmm7ZUuOPrInd8Ph4mEsePAoGBANb4
|
||||||
|
3/izAHnLEp3/sTOZpfWBnDcvEHCG7/JAX0TDRW1FpXiTHpvDV1j3XU3EvLl7WRJ1
|
||||||
|
B+B8h/Z6kQtUUxQ3I+zxuQIkQYI8qPu+xhQ8gb5AIO5CMX09+xKUgYjQtm7kYs7W
|
||||||
|
L0LD9u3hkGsJk2wfVvMJKb3OSIHeTwRzFCzGX995AoGADkLB8eu/FKAIfwRPCHVE
|
||||||
|
sfwMtqjkj2XJ9FeNcRQ5g/Tf8OGnCGEzBwXb05wJVrXUgXp4dBaqYTdAKj8uLEvd
|
||||||
|
mi9t/LzR+33cGUdAQHItxcKbsMv00TyNRQUvZFZ7ZEY8aBkv5uZfvJHZ5iQ8C7+g
|
||||||
|
HGXNfVGXGPutz/KN6X25CLECgYEAjVLK0MkXzLxCYJRDIhB1TpQVXjpxYUP2Vxls
|
||||||
|
SSxfeYqkJPgNvYiHee33xQ8+TP1y9WzkWh+g2AbGmwTuKKL6CvQS9gKVvqqaFB7y
|
||||||
|
KrkR13MTPJKvHHdQYKGQqQGgHKh0kGFCC0+PoVwtYs/XU1KpZCE16nNgXrOvTYNN
|
||||||
|
HxESa+kCgYB7WOcawTp3WdKP8JbolxIfxax7Kd4QkZhY7dEb4JxBBYXXXpv/NHE9
|
||||||
|
pcJw4eKDyY+QE2AHPu3+fQYzXopaaTGRpB+ynEfYfD2hW+HnOWfWu/lFJbiwBn/S
|
||||||
|
wRsYzSWiLtNplKNFRrsSoMWlh8GOTUpZ7FMLXWhE4rE9NskQBbYq8g==
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
326
test/helpers/server.loader.ts
Normal file
326
test/helpers/server.loader.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* Test SMTP Server Loader for Deno
|
||||||
|
* Manages test server lifecycle and configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSmtpServer } from '../../ts/mail/delivery/smtpserver/index.ts';
|
||||||
|
import type { ISmtpServerOptions } from '../../ts/mail/delivery/smtpserver/interfaces.ts';
|
||||||
|
import { Email } from '../../ts/mail/core/classes.email.ts';
|
||||||
|
import { net, crypto } from '../../ts/plugins.ts';
|
||||||
|
|
||||||
|
export interface ITestServerConfig {
|
||||||
|
port: number;
|
||||||
|
hostname?: string;
|
||||||
|
tlsEnabled?: boolean;
|
||||||
|
authRequired?: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
testCertPath?: string;
|
||||||
|
testKeyPath?: string;
|
||||||
|
maxConnections?: number;
|
||||||
|
size?: number;
|
||||||
|
maxRecipients?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITestServer {
|
||||||
|
server: any;
|
||||||
|
smtpServer: any;
|
||||||
|
port: number;
|
||||||
|
hostname: string;
|
||||||
|
config: ITestServerConfig;
|
||||||
|
startTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate self-signed certificate for testing
|
||||||
|
* Uses Deno's built-in crypto for key generation
|
||||||
|
*/
|
||||||
|
async function generateSelfSignedCert(hostname: string): Promise<{
|
||||||
|
key: string;
|
||||||
|
cert: string;
|
||||||
|
}> {
|
||||||
|
// For now, return placeholder cert/key that will be replaced with real generation
|
||||||
|
// In production tests, we should either use pre-generated test certs from fixtures
|
||||||
|
// or implement proper cert generation using Deno's crypto API
|
||||||
|
|
||||||
|
// This is a self-signed test certificate - DO NOT use in production
|
||||||
|
const key = `-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEpAIBAAKCAQEAxzYIwlfnr7AK2v6E+c2oYD7nAIXIIvDuvVvZ8R9kyxXIzTXB
|
||||||
|
j5D1AgntqKS3bFR1XT8hCVeXjuLKPBvXbhVjG15gXlXxpNiFi1ZcphJvs4zB/Vh7
|
||||||
|
Zv2ALt3anSIwsJ2rZA/R/GqdJPkHvYf/GMTDLw0YllR0YOevErnRIIM5S58Lj2nT
|
||||||
|
Cr5v5hK1Gl9mWwRkFQKkWVl2UXt/JX6C7Z6UyJXMZSnoG0Kw6GQje41K5r0Zdzrh
|
||||||
|
rGfmb9wSDUn9sZGX6il+oMiYz7UgQkPEzGUZEJxKJwxy8ZgPdSgbvYq4WwPwbBUJ
|
||||||
|
lpw0gt5i6HOS7CphRama+zAf5LvfSLoLXSP5JwIDAQABAoIBAQC8C5Ge6wS4LuH9
|
||||||
|
tbZFPwjdGHXL+QT2fOFxPBrE7PkeY8UXD7G5Yei6iqqCxJh8nhLQ3DoayhZM69hO
|
||||||
|
ePOV1Z/LDERCnGel15WKQ1QJ1HZ+JQXnfQrE1Mi9QrXO5bVFtnXIr0mZ+AzwoUmn
|
||||||
|
K5fYCvaL3xDZPDzOYL5kZG2hQKgbywGKZoQx16G0dSEhlAHbK9z6XmPRrbUKGzB8
|
||||||
|
qV7QGbL7BUTQs5JW/8LpkYr5C0q5THtUVb9mHNR3jPf9WTPQ0D3lxcbLS4PQ8jQ/
|
||||||
|
L/GcuHGmsXhe2Unw3w2wpuJKPeHKz4rBNIvaSjIZl9/dIKM88JYQTiIGKErxsC0e
|
||||||
|
kczQMp6BAoGBAO0zUN8H7ynXGNNtK/tJo0lI3qg1ZKgr+0CU2L5eU8Bn1oJ1JkCI
|
||||||
|
WD3p36NdECx5tGexm9U6MN+HzKYUjnQ6LKzbHQGLZqzF5IL5axXgCn8w4BM+6Ixm
|
||||||
|
y8kQgsTKlKRMXIn8RZCmXNnc7v0FhBgpDxPmm7ZUuOPrInd8Ph4mEsePAoGBANb4
|
||||||
|
3/izAHnLEp3/sTOZpfWBnDcvEHCG7/JAX0TDRW1FpXiTHpvDV1j3XU3EvLl7WRJ1
|
||||||
|
B+B8h/Z6kQtUUxQ3I+zxuQIkQYI8qPu+xhQ8gb5AIO5CMX09+xKUgYjQtm7kYs7W
|
||||||
|
L0LD9u3hkGsJk2wfVvMJKb3OSIHeTwRzFCzGX995AoGADkLB8eu/FKAIfwRPCHVE
|
||||||
|
sfwMtqjkj2XJ9FeNcRQ5g/Tf8OGnCGEzBwXb05wJVrXUgXp4dBaqYTdAKj8uLEvd
|
||||||
|
mi9t/LzR+33cGUdAQHItxcKbsMv00TyNRQUvZFZ7ZEY8aBkv5uZfvJHZ5iQ8C7+g
|
||||||
|
HGXNfVGXGPutz/KN6X25CLECgYEAjVLK0MkXzLxCYJRDIhB1TpQVXjpxYUP2Vxls
|
||||||
|
SSxfeYqkJPgNvYiHee33xQ8+TP1y9WzkWh+g2AbGmwTuKKL6CvQS9gKVvqqaFB7y
|
||||||
|
KrkR13MTPJKvHHdQYKGQqQGgHKh0kGFCC0+PoVwtYs/XU1KpZCE16nNgXrOvTYNN
|
||||||
|
HxESa+kCgYB7WOcawTp3WdKP8JbolxIfxax7Kd4QkZhY7dEb4JxBBYXXXpv/NHE9
|
||||||
|
pcJw4eKDyY+QE2AHPu3+fQYzXopaaTGRpB+ynEfYfD2hW+HnOWfWu/lFJbiwBn/S
|
||||||
|
wRsYzSWiLtNplKNFRrsSoMWlh8GOTUpZ7FMLXWhE4rE9NskQBbYq8g==
|
||||||
|
-----END RSA PRIVATE KEY-----`;
|
||||||
|
|
||||||
|
const cert = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDazCCAlOgAwIBAgIUcmAewXEYwtzbZmZAJ5inMogKSbowDQYJKoZIhvcNAQEL
|
||||||
|
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||||
|
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAyMjAwODM4MzRaFw0yNTAy
|
||||||
|
MTkwODM4MzRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
|
||||||
|
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
|
||||||
|
AQUAA4IBDwAwggEKAoIBAQDHNgjCV+evsAra/oT5zahgPucAhcgi8O69W9nxH2TL
|
||||||
|
FcjNNcGPkPUCCe2opLdsVHVdPyEJV5eO4so8G9duFWMbXmBeVfGk2IWLVlymEm+z
|
||||||
|
jMH9WHtm/YAu3dqdIjCwnatED9H8ap0k+Qd9h/8YxMMvDRiWVHRg568SudEggzlL
|
||||||
|
nwuPadMKvm/mErUaX2ZbBGQVAqRZWXZRe38lfoLtnpTIlcxlKegbQrDoZCN7jUrm
|
||||||
|
vRl3OuGsZ+Zv3BINSf2xkZfqKX6gyJjPtSBCQ8TMZRkQnEonDHLxmA91KBu9irhb
|
||||||
|
A/BsFQmWnDSC3mLoc5LsKmFFqZr7MB/ku99IugtdI/knAgMBAAGjUzBRMB0GA1Ud
|
||||||
|
DgQWBBQryyWLuN22OqU1r9HIt2tMLBk42DAfBgNVHSMEGDAWgBQryyWLuN22OqU1
|
||||||
|
r9HIt2tMLBk42DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAe
|
||||||
|
CeXQZlXJ2xLnDoOoKY3BpodErNmAwygGYxwDCU0xPbpUMPrQhLI80JlZmfy58gT/
|
||||||
|
0ZbULS+srShfEsFnBLmzWLGXDvA/IKCQyTmCQwbPeELGXF6h4URMb+lQL7WL9tY0
|
||||||
|
uUg2dA+7CtYokIrOkGqUitPK3yvVhxugkf51WIgKMACZDibOQSWrV5QO2vHOAaO9
|
||||||
|
ePzRGGl3+Ebmcs3+5w1fI6OLsIZH10lfEnC83C0lO8tIJlGsXMQkCjAcX22rT0rc
|
||||||
|
AcxLm07H4EwMwgOAJUkuDjD3y4+KH91jKWF8bhaLZooFB8lccNnaCRiuZRnXlvmf
|
||||||
|
M7uVlLGwlj5R9iHd+0dP
|
||||||
|
-----END CERTIFICATE-----`;
|
||||||
|
|
||||||
|
return { key, cert };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a test SMTP server with the given configuration
|
||||||
|
*/
|
||||||
|
export async function startTestServer(config: ITestServerConfig): Promise<ITestServer> {
|
||||||
|
const serverConfig = {
|
||||||
|
port: config.port || 2525,
|
||||||
|
hostname: config.hostname || 'localhost',
|
||||||
|
tlsEnabled: config.tlsEnabled || false,
|
||||||
|
authRequired: config.authRequired || false,
|
||||||
|
timeout: config.timeout || 30000,
|
||||||
|
maxConnections: config.maxConnections || 100,
|
||||||
|
size: config.size || 10 * 1024 * 1024, // 10MB default
|
||||||
|
maxRecipients: config.maxRecipients || 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a mock email server for testing
|
||||||
|
const mockEmailServer = {
|
||||||
|
processEmailByMode: async (emailData: any) => {
|
||||||
|
console.log(`📧 [Test Server] Processing email:`, emailData.subject || 'No subject');
|
||||||
|
return emailData;
|
||||||
|
},
|
||||||
|
getRateLimiter: () => {
|
||||||
|
// Return a mock rate limiter for testing
|
||||||
|
return {
|
||||||
|
recordConnection: (_ip: string) => ({ allowed: true, remaining: 100 }),
|
||||||
|
checkConnectionLimit: async (_ip: string) => ({ allowed: true, remaining: 100 }),
|
||||||
|
checkMessageLimit: (
|
||||||
|
_senderAddress: string,
|
||||||
|
_ip: string,
|
||||||
|
_recipientCount?: number,
|
||||||
|
_pattern?: string,
|
||||||
|
_domain?: string
|
||||||
|
) => ({ allowed: true, remaining: 1000 }),
|
||||||
|
checkRecipientLimit: async (_session: any) => ({ allowed: true, remaining: 50 }),
|
||||||
|
recordAuthenticationFailure: async (_ip: string) => {},
|
||||||
|
recordSyntaxError: async (_ip: string) => {},
|
||||||
|
recordCommandError: async (_ip: string) => {},
|
||||||
|
isBlocked: async (_ip: string) => false,
|
||||||
|
cleanup: async () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Load or generate test certificates
|
||||||
|
let key: string;
|
||||||
|
let cert: string;
|
||||||
|
|
||||||
|
if (serverConfig.tlsEnabled && config.testCertPath && config.testKeyPath) {
|
||||||
|
try {
|
||||||
|
key = await Deno.readTextFile(config.testKeyPath);
|
||||||
|
cert = await Deno.readTextFile(config.testCertPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Failed to load TLS certificates, generating self-signed', error);
|
||||||
|
const generated = await generateSelfSignedCert(serverConfig.hostname);
|
||||||
|
key = generated.key;
|
||||||
|
cert = generated.cert;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Always generate a certificate (required by the interface)
|
||||||
|
const generated = await generateSelfSignedCert(serverConfig.hostname);
|
||||||
|
key = generated.key;
|
||||||
|
cert = generated.cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP server options
|
||||||
|
const smtpOptions: ISmtpServerOptions = {
|
||||||
|
port: serverConfig.port,
|
||||||
|
hostname: serverConfig.hostname,
|
||||||
|
key: key,
|
||||||
|
cert: cert,
|
||||||
|
maxConnections: serverConfig.maxConnections,
|
||||||
|
size: serverConfig.size,
|
||||||
|
maxRecipients: serverConfig.maxRecipients,
|
||||||
|
socketTimeout: serverConfig.timeout,
|
||||||
|
connectionTimeout: serverConfig.timeout * 2,
|
||||||
|
cleanupInterval: 300000,
|
||||||
|
auth: serverConfig.authRequired
|
||||||
|
? ({
|
||||||
|
required: true,
|
||||||
|
methods: ['PLAIN', 'LOGIN'] as ('PLAIN' | 'LOGIN' | 'OAUTH2')[],
|
||||||
|
validateUser: async (username: string, password: string) => {
|
||||||
|
// Test server accepts these credentials
|
||||||
|
return username === 'testuser' && password === 'testpass';
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create SMTP server
|
||||||
|
const smtpServer = await createSmtpServer(mockEmailServer, smtpOptions);
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
await smtpServer.listen();
|
||||||
|
|
||||||
|
// Wait for server to be ready
|
||||||
|
await waitForServerReady(serverConfig.hostname, serverConfig.port);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ Test SMTP server started on ${serverConfig.hostname}:${serverConfig.port}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: mockEmailServer,
|
||||||
|
smtpServer: smtpServer,
|
||||||
|
port: serverConfig.port,
|
||||||
|
hostname: serverConfig.hostname,
|
||||||
|
config: serverConfig,
|
||||||
|
startTime: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a test SMTP server
|
||||||
|
*/
|
||||||
|
export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
||||||
|
if (!testServer || !testServer.smtpServer) {
|
||||||
|
console.warn('⚠️ No test server to stop');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`🛑 Stopping test SMTP server on ${testServer.hostname}:${testServer.port}`);
|
||||||
|
|
||||||
|
// Stop the SMTP server
|
||||||
|
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
|
||||||
|
await testServer.smtpServer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for port to be free
|
||||||
|
await waitForPortFree(testServer.port);
|
||||||
|
|
||||||
|
console.log(`✅ Test SMTP server stopped`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error stopping test server:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for server to be ready to accept connections
|
||||||
|
*/
|
||||||
|
async function waitForServerReady(
|
||||||
|
hostname: string,
|
||||||
|
port: number,
|
||||||
|
timeout: number = 10000
|
||||||
|
): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
try {
|
||||||
|
const conn = await Deno.connect({ hostname, port, transport: 'tcp' });
|
||||||
|
conn.close();
|
||||||
|
return; // Server is ready
|
||||||
|
} catch {
|
||||||
|
// Server not ready yet, wait and retry
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Server did not become ready within ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for port to be free
|
||||||
|
*/
|
||||||
|
async function waitForPortFree(port: number, timeout: number = 5000): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const isFree = await isPortFree(port);
|
||||||
|
if (isFree) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`⚠️ Port ${port} still in use after ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a port is free
|
||||||
|
*/
|
||||||
|
async function isPortFree(port: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const listener = Deno.listen({ port, transport: 'tcp' });
|
||||||
|
listener.close();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an available port for testing
|
||||||
|
*/
|
||||||
|
export async function getAvailablePort(startPort: number = 25000): Promise<number> {
|
||||||
|
for (let port = startPort; port < startPort + 1000; port++) {
|
||||||
|
if (await isPortFree(port)) {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No available ports found starting from ${startPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create test email data
|
||||||
|
*/
|
||||||
|
export function createTestEmail(
|
||||||
|
options: {
|
||||||
|
from?: string;
|
||||||
|
to?: string | string[];
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
attachments?: any[];
|
||||||
|
} = {}
|
||||||
|
): any {
|
||||||
|
return {
|
||||||
|
from: options.from || 'test@example.com',
|
||||||
|
to: options.to || 'recipient@example.com',
|
||||||
|
subject: options.subject || 'Test Email',
|
||||||
|
text: options.text || 'This is a test email',
|
||||||
|
html: options.html || '<p>This is a test email</p>',
|
||||||
|
attachments: options.attachments || [],
|
||||||
|
date: new Date(),
|
||||||
|
messageId: `<${Date.now()}@test.example.com>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
236
test/helpers/smtp.client.ts
Normal file
236
test/helpers/smtp.client.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Test SMTP Client Utilities for Deno
|
||||||
|
* Provides helpers for creating and testing SMTP client functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { smtpClientMod } from '../../ts/mail/delivery/index.ts';
|
||||||
|
import type { ISmtpClientOptions } from '../../ts/mail/delivery/smtpclient/interfaces.ts';
|
||||||
|
import type { SmtpClient } from '../../ts/mail/delivery/smtpclient/smtp-client.ts';
|
||||||
|
import { Email } from '../../ts/mail/core/classes.email.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test SMTP client with sensible defaults
|
||||||
|
*/
|
||||||
|
export function createTestSmtpClient(options: Partial<ISmtpClientOptions> = {}): SmtpClient {
|
||||||
|
const defaultOptions: ISmtpClientOptions = {
|
||||||
|
host: options.host || 'localhost',
|
||||||
|
port: options.port || 2525,
|
||||||
|
secure: options.secure || false,
|
||||||
|
auth: options.auth,
|
||||||
|
connectionTimeout: options.connectionTimeout || 5000,
|
||||||
|
socketTimeout: options.socketTimeout || 5000,
|
||||||
|
maxConnections: options.maxConnections || 5,
|
||||||
|
maxMessages: options.maxMessages || 100,
|
||||||
|
debug: options.debug || false,
|
||||||
|
tls: options.tls || {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
pool: options.pool || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return smtpClientMod.createSmtpClient(defaultOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send test email using SMTP client
|
||||||
|
*/
|
||||||
|
export async function sendTestEmail(
|
||||||
|
client: SmtpClient,
|
||||||
|
options: {
|
||||||
|
from?: string;
|
||||||
|
to?: string | string[];
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<any> {
|
||||||
|
const mailOptions = {
|
||||||
|
from: options.from || 'test@example.com',
|
||||||
|
to: options.to || 'recipient@example.com',
|
||||||
|
subject: options.subject || 'Test Email',
|
||||||
|
text: options.text || 'This is a test email',
|
||||||
|
html: options.html,
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: mailOptions.from,
|
||||||
|
to: mailOptions.to,
|
||||||
|
subject: mailOptions.subject,
|
||||||
|
text: mailOptions.text,
|
||||||
|
html: mailOptions.html,
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.sendMail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SMTP client connection
|
||||||
|
*/
|
||||||
|
export async function testClientConnection(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
timeout: number = 5000
|
||||||
|
): Promise<boolean> {
|
||||||
|
const client = createTestSmtpClient({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
connectionTimeout: timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.verify();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (client.close) {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create authenticated SMTP client
|
||||||
|
*/
|
||||||
|
export function createAuthenticatedClient(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
authMethod: 'PLAIN' | 'LOGIN' = 'PLAIN'
|
||||||
|
): SmtpClient {
|
||||||
|
return createTestSmtpClient({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
auth: {
|
||||||
|
user: username,
|
||||||
|
pass: password,
|
||||||
|
method: authMethod,
|
||||||
|
},
|
||||||
|
secure: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create TLS-enabled SMTP client
|
||||||
|
*/
|
||||||
|
export function createTlsClient(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
options: {
|
||||||
|
secure?: boolean;
|
||||||
|
rejectUnauthorized?: boolean;
|
||||||
|
} = {}
|
||||||
|
): SmtpClient {
|
||||||
|
return createTestSmtpClient({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: options.secure || false,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: options.rejectUnauthorized || false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test client pool status
|
||||||
|
*/
|
||||||
|
export async function testClientPoolStatus(client: SmtpClient): Promise<any> {
|
||||||
|
if (typeof client.getPoolStatus === 'function') {
|
||||||
|
return client.getPoolStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for clients without pool status
|
||||||
|
return {
|
||||||
|
size: 1,
|
||||||
|
available: 1,
|
||||||
|
pending: 0,
|
||||||
|
connecting: 0,
|
||||||
|
active: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send multiple emails concurrently
|
||||||
|
*/
|
||||||
|
export async function sendConcurrentEmails(
|
||||||
|
client: SmtpClient,
|
||||||
|
count: number,
|
||||||
|
emailOptions: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<any[]> {
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
promises.push(
|
||||||
|
sendTestEmail(client, {
|
||||||
|
...emailOptions,
|
||||||
|
subject: `${emailOptions.subject || 'Test Email'} ${i + 1}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure client throughput
|
||||||
|
*/
|
||||||
|
export async function measureClientThroughput(
|
||||||
|
client: SmtpClient,
|
||||||
|
duration: number = 10000,
|
||||||
|
emailOptions: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<{
|
||||||
|
totalSent: number;
|
||||||
|
successCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
throughput: number;
|
||||||
|
}> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let totalSent = 0;
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < duration) {
|
||||||
|
try {
|
||||||
|
await sendTestEmail(client, emailOptions);
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
totalSent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualDuration = (Date.now() - startTime) / 1000; // in seconds
|
||||||
|
const throughput = totalSent / actualDuration;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSent,
|
||||||
|
successCount,
|
||||||
|
errorCount,
|
||||||
|
throughput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a pooled SMTP client for concurrent testing
|
||||||
|
*/
|
||||||
|
export function createPooledTestClient(
|
||||||
|
options: Partial<ISmtpClientOptions> = {}
|
||||||
|
): SmtpClient {
|
||||||
|
return createTestSmtpClient({
|
||||||
|
...options,
|
||||||
|
pool: true,
|
||||||
|
maxConnections: options.maxConnections || 5,
|
||||||
|
maxMessages: options.maxMessages || 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
350
test/helpers/utils.ts
Normal file
350
test/helpers/utils.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
/**
|
||||||
|
* SMTP Test Utilities for Deno
|
||||||
|
* Provides helper functions for testing SMTP protocol implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { net } from '../../ts/plugins.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test result interface
|
||||||
|
*/
|
||||||
|
export interface ITestResult {
|
||||||
|
success: boolean;
|
||||||
|
duration: number;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
details?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test configuration interface
|
||||||
|
*/
|
||||||
|
export interface ITestConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
timeout: number;
|
||||||
|
fromAddress?: string;
|
||||||
|
toAddress?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to SMTP server
|
||||||
|
*/
|
||||||
|
export async function connectToSmtp(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
timeout: number = 5000
|
||||||
|
): Promise<Deno.TcpConn> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conn = await Deno.connect({
|
||||||
|
hostname: host,
|
||||||
|
port,
|
||||||
|
transport: 'tcp',
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return conn;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error(`Connection timeout after ${timeout}ms`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read data from TCP connection with timeout
|
||||||
|
*/
|
||||||
|
async function readWithTimeout(
|
||||||
|
conn: Deno.TcpConn,
|
||||||
|
timeout: number
|
||||||
|
): Promise<string> {
|
||||||
|
const buffer = new Uint8Array(4096);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const n = await conn.read(buffer);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (n === null) {
|
||||||
|
throw new Error('Connection closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(buffer.subarray(0, n));
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error(`Read timeout after ${timeout}ms`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send SMTP command and wait for response
|
||||||
|
*/
|
||||||
|
export async function sendSmtpCommand(
|
||||||
|
conn: Deno.TcpConn,
|
||||||
|
command: string,
|
||||||
|
expectedCode?: string,
|
||||||
|
timeout: number = 5000
|
||||||
|
): Promise<string> {
|
||||||
|
// Send command
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
await conn.write(encoder.encode(command + '\r\n'));
|
||||||
|
|
||||||
|
// Read response
|
||||||
|
let buffer = '';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const chunk = await readWithTimeout(conn, timeout - (Date.now() - startTime));
|
||||||
|
buffer += chunk;
|
||||||
|
|
||||||
|
// Check if we have a complete response (ends with \r\n)
|
||||||
|
if (buffer.includes('\r\n')) {
|
||||||
|
if (expectedCode && !buffer.startsWith(expectedCode)) {
|
||||||
|
throw new Error(`Expected ${expectedCode}, got: ${buffer.trim()}`);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Command timeout after ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for SMTP greeting (220 code)
|
||||||
|
*/
|
||||||
|
export async function waitForGreeting(
|
||||||
|
conn: Deno.TcpConn,
|
||||||
|
timeout: number = 5000
|
||||||
|
): Promise<string> {
|
||||||
|
let buffer = '';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const chunk = await readWithTimeout(conn, timeout - (Date.now() - startTime));
|
||||||
|
buffer += chunk;
|
||||||
|
|
||||||
|
if (buffer.includes('220')) {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Greeting timeout after ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform SMTP handshake and return capabilities
|
||||||
|
*/
|
||||||
|
export async function performSmtpHandshake(
|
||||||
|
conn: Deno.TcpConn,
|
||||||
|
hostname: string = 'test.example.com'
|
||||||
|
): Promise<string[]> {
|
||||||
|
const capabilities: string[] = [];
|
||||||
|
|
||||||
|
// Wait for greeting
|
||||||
|
await waitForGreeting(conn);
|
||||||
|
|
||||||
|
// Send EHLO
|
||||||
|
const ehloResponse = await sendSmtpCommand(conn, `EHLO ${hostname}`, '250');
|
||||||
|
|
||||||
|
// Parse capabilities
|
||||||
|
const lines = ehloResponse.split('\r\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('250-') || line.startsWith('250 ')) {
|
||||||
|
const capability = line.substring(4).trim();
|
||||||
|
if (capability) {
|
||||||
|
capabilities.push(capability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create multiple concurrent connections
|
||||||
|
*/
|
||||||
|
export async function createConcurrentConnections(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
count: number,
|
||||||
|
timeout: number = 5000
|
||||||
|
): Promise<Deno.TcpConn[]> {
|
||||||
|
const connectionPromises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
connectionPromises.push(connectToSmtp(host, port, timeout));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(connectionPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close SMTP connection gracefully
|
||||||
|
*/
|
||||||
|
export async function closeSmtpConnection(conn: Deno.TcpConn): Promise<void> {
|
||||||
|
try {
|
||||||
|
await sendSmtpCommand(conn, 'QUIT', '221');
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during QUIT
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
conn.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore close errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random email content
|
||||||
|
*/
|
||||||
|
export function generateRandomEmail(size: number = 1024): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 \r\n';
|
||||||
|
let content = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
content += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create MIME message
|
||||||
|
*/
|
||||||
|
export function createMimeMessage(options: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
attachments?: Array<{ filename: string; content: string; contentType: string }>;
|
||||||
|
}): string {
|
||||||
|
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
||||||
|
const date = new Date().toUTCString();
|
||||||
|
|
||||||
|
let message = '';
|
||||||
|
message += `From: ${options.from}\r\n`;
|
||||||
|
message += `To: ${options.to}\r\n`;
|
||||||
|
message += `Subject: ${options.subject}\r\n`;
|
||||||
|
message += `Date: ${date}\r\n`;
|
||||||
|
message += `MIME-Version: 1.0\r\n`;
|
||||||
|
|
||||||
|
if (options.attachments && options.attachments.length > 0) {
|
||||||
|
message += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
|
||||||
|
message += '\r\n';
|
||||||
|
|
||||||
|
// Text part
|
||||||
|
if (options.text) {
|
||||||
|
message += `--${boundary}\r\n`;
|
||||||
|
message += 'Content-Type: text/plain; charset=utf-8\r\n';
|
||||||
|
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
message += options.text + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML part
|
||||||
|
if (options.html) {
|
||||||
|
message += `--${boundary}\r\n`;
|
||||||
|
message += 'Content-Type: text/html; charset=utf-8\r\n';
|
||||||
|
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
message += options.html + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
for (const attachment of options.attachments) {
|
||||||
|
message += `--${boundary}\r\n`;
|
||||||
|
message += `Content-Type: ${attachment.contentType}\r\n`;
|
||||||
|
message += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
|
||||||
|
message += 'Content-Transfer-Encoding: base64\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
// Convert to base64
|
||||||
|
const bytes = encoder.encode(attachment.content);
|
||||||
|
const base64 = btoa(String.fromCharCode(...bytes));
|
||||||
|
message += base64 + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
message += `--${boundary}--\r\n`;
|
||||||
|
} else if (options.html && options.text) {
|
||||||
|
const altBoundary = `----=_Alt_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
||||||
|
message += `Content-Type: multipart/alternative; boundary="${altBoundary}"\r\n`;
|
||||||
|
message += '\r\n';
|
||||||
|
|
||||||
|
// Text part
|
||||||
|
message += `--${altBoundary}\r\n`;
|
||||||
|
message += 'Content-Type: text/plain; charset=utf-8\r\n';
|
||||||
|
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
message += options.text + '\r\n';
|
||||||
|
|
||||||
|
// HTML part
|
||||||
|
message += `--${altBoundary}\r\n`;
|
||||||
|
message += 'Content-Type: text/html; charset=utf-8\r\n';
|
||||||
|
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
message += options.html + '\r\n';
|
||||||
|
|
||||||
|
message += `--${altBoundary}--\r\n`;
|
||||||
|
} else if (options.html) {
|
||||||
|
message += 'Content-Type: text/html; charset=utf-8\r\n';
|
||||||
|
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
message += options.html;
|
||||||
|
} else {
|
||||||
|
message += 'Content-Type: text/plain; charset=utf-8\r\n';
|
||||||
|
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
message += options.text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure operation time
|
||||||
|
*/
|
||||||
|
export async function measureTime<T>(
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<{ result: T; duration: number }> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const result = await operation();
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return { result, duration };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry operation with exponential backoff
|
||||||
|
*/
|
||||||
|
export async function retryOperation<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
initialDelay: number = 1000
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: Error;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
if (i < maxRetries - 1) {
|
||||||
|
const delay = initialDelay * Math.pow(2, i);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError!;
|
||||||
|
}
|
||||||
309
test/readme.md
Normal file
309
test/readme.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# Mailer SMTP Test Suite (Deno)
|
||||||
|
|
||||||
|
Comprehensive SMTP server and client test suite ported from dcrouter to Deno-native testing.
|
||||||
|
|
||||||
|
## Test Framework
|
||||||
|
|
||||||
|
- **Framework**: Deno native testing (`Deno.test`)
|
||||||
|
- **Assertions**: `@std/assert` from Deno standard library
|
||||||
|
- **Run Command**: `deno test --allow-all --no-check test/`
|
||||||
|
- **Run Single Test**: `deno test --allow-all --no-check test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts`
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
### Helpers (`test/helpers/`)
|
||||||
|
|
||||||
|
- **`server.loader.ts`** - Test SMTP server lifecycle management
|
||||||
|
- Start/stop test servers with configurable options
|
||||||
|
- TLS certificate handling
|
||||||
|
- Mock email processing
|
||||||
|
- Port management utilities
|
||||||
|
|
||||||
|
- **`utils.ts`** - SMTP protocol test utilities
|
||||||
|
- TCP connection management (Deno-native)
|
||||||
|
- SMTP command sending/receiving
|
||||||
|
- Protocol handshake helpers
|
||||||
|
- MIME message creation
|
||||||
|
- Retry and timing utilities
|
||||||
|
|
||||||
|
- **`smtp.client.ts`** - SMTP client test utilities
|
||||||
|
- Test client creation with various configurations
|
||||||
|
- Email sending helpers
|
||||||
|
- Connection pooling testing
|
||||||
|
- Throughput measurement
|
||||||
|
|
||||||
|
### Fixtures (`test/fixtures/`)
|
||||||
|
|
||||||
|
- **`test-cert.pem`** - Self-signed certificate for TLS testing
|
||||||
|
- **`test-key.pem`** - Private key for TLS testing
|
||||||
|
|
||||||
|
## Test Suite Organization
|
||||||
|
|
||||||
|
All tests follow the naming convention: `test.<category-id>.<description>.test.ts`
|
||||||
|
|
||||||
|
### Test Categories
|
||||||
|
|
||||||
|
#### 1. Connection Management (CM) - `smtpserver_connection/`
|
||||||
|
|
||||||
|
Tests for SMTP connection handling, TLS support, and connection lifecycle.
|
||||||
|
|
||||||
|
| ID | Test | Priority | Status |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| CM-01 | TLS Connection | High | Planned |
|
||||||
|
| CM-02 | Multiple Simultaneous Connections | High | Planned |
|
||||||
|
| CM-03 | Connection Timeout | High | Planned |
|
||||||
|
| CM-06 | STARTTLS Upgrade | High | Planned |
|
||||||
|
| CM-10 | Plain Connection | Low | Planned |
|
||||||
|
|
||||||
|
#### 2. SMTP Commands (CMD) - `smtpserver_commands/`
|
||||||
|
|
||||||
|
Tests for SMTP protocol command implementation.
|
||||||
|
|
||||||
|
| ID | Test | Priority | Status |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| **CMD-01** | **EHLO Command** | **High** | **✅ PORTED** |
|
||||||
|
| **CMD-02** | **MAIL FROM Command** | **High** | **✅ PORTED** |
|
||||||
|
| CMD-03 | RCPT TO Command | High | Planned |
|
||||||
|
| CMD-04 | DATA Command | High | Planned |
|
||||||
|
| CMD-06 | RSET Command | Medium | Planned |
|
||||||
|
| CMD-13 | QUIT Command | High | Planned |
|
||||||
|
|
||||||
|
#### 3. Email Processing (EP) - `smtpserver_email-processing/`
|
||||||
|
|
||||||
|
Tests for email content handling, parsing, and delivery.
|
||||||
|
|
||||||
|
| ID | Test | Priority | Status |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| EP-01 | Basic Email Sending | High | Planned |
|
||||||
|
| EP-02 | Invalid Email Address Handling | High | Planned |
|
||||||
|
| EP-04 | Large Email Handling | High | Planned |
|
||||||
|
| EP-05 | MIME Handling | High | Planned |
|
||||||
|
|
||||||
|
#### 4. Security (SEC) - `smtpserver_security/`
|
||||||
|
|
||||||
|
Tests for security features and protections.
|
||||||
|
|
||||||
|
| ID | Test | Priority | Status |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| SEC-01 | Authentication | High | Planned |
|
||||||
|
| SEC-03 | DKIM Processing | High | Planned |
|
||||||
|
| SEC-04 | SPF Checking | High | Planned |
|
||||||
|
| SEC-06 | IP Reputation Checking | High | Planned |
|
||||||
|
| SEC-08 | Rate Limiting | High | Planned |
|
||||||
|
| SEC-10 | Header Injection Prevention | High | Planned |
|
||||||
|
|
||||||
|
#### 5. Error Handling (ERR) - `smtpserver_error-handling/`
|
||||||
|
|
||||||
|
Tests for proper error handling and recovery.
|
||||||
|
|
||||||
|
| ID | Test | Priority | Status |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| ERR-01 | Syntax Error Handling | High | Planned |
|
||||||
|
| ERR-02 | Invalid Sequence Handling | High | Planned |
|
||||||
|
| ERR-05 | Resource Exhaustion | High | Planned |
|
||||||
|
| ERR-07 | Exception Handling | High | Planned |
|
||||||
|
|
||||||
|
## Currently Ported Tests
|
||||||
|
|
||||||
|
### ✅ CMD-01: EHLO Command (`test.cmd-01.ehlo-command.test.ts`)
|
||||||
|
|
||||||
|
**Tests**: 5 total (5 passing)
|
||||||
|
- Server startup/shutdown
|
||||||
|
- EHLO response with proper capabilities
|
||||||
|
- Invalid hostname handling
|
||||||
|
- Command pipelining (multiple EHLO)
|
||||||
|
|
||||||
|
**Key validations**:
|
||||||
|
- ✓ Server advertises SIZE capability
|
||||||
|
- ✓ Server advertises 8BITMIME capability
|
||||||
|
- ✓ Last capability line uses "250 " (space, not hyphen)
|
||||||
|
- ✓ Server handles invalid hostnames gracefully
|
||||||
|
- ✓ Second EHLO resets session state
|
||||||
|
|
||||||
|
### ✅ CMD-02: MAIL FROM Command (`test.cmd-02.mail-from.test.ts`)
|
||||||
|
|
||||||
|
**Tests**: 6 total
|
||||||
|
- Valid sender address acceptance
|
||||||
|
- Invalid sender address rejection
|
||||||
|
- SIZE parameter support
|
||||||
|
- Command sequence enforcement
|
||||||
|
|
||||||
|
**Key validations**:
|
||||||
|
- ✓ Accepts valid email formats
|
||||||
|
- ✓ Accepts IP literals (user@[192.168.1.1])
|
||||||
|
- ✓ Rejects malformed addresses
|
||||||
|
- ✓ Supports SIZE parameter
|
||||||
|
- ✓ Enforces EHLO before MAIL FROM
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Run All Tests
|
||||||
|
```bash
|
||||||
|
deno test --allow-all --no-check test/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Specific Category
|
||||||
|
```bash
|
||||||
|
# SMTP commands tests
|
||||||
|
deno test --allow-all --no-check test/suite/smtpserver_commands/
|
||||||
|
|
||||||
|
# Connection tests
|
||||||
|
deno test --allow-all --no-check test/suite/smtpserver_connection/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Single Test File
|
||||||
|
```bash
|
||||||
|
deno test --allow-all --no-check test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with Verbose Output
|
||||||
|
```bash
|
||||||
|
deno test --allow-all --no-check --trace-leaks test/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Development Guidelines
|
||||||
|
|
||||||
|
### Writing New Tests
|
||||||
|
|
||||||
|
1. **Use Deno.test() format**:
|
||||||
|
```typescript
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-XX: Description of test',
|
||||||
|
async fn() {
|
||||||
|
// Test implementation
|
||||||
|
},
|
||||||
|
sanitizeResources: false, // Required for network tests
|
||||||
|
sanitizeOps: false, // Required for network tests
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Import assertions from @std/assert**:
|
||||||
|
```typescript
|
||||||
|
import { assert, assertEquals, assertMatch } from '@std/assert';
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use test helpers**:
|
||||||
|
```typescript
|
||||||
|
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts';
|
||||||
|
import { connectToSmtp, sendSmtpCommand } from '../../helpers/utils.ts';
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Always cleanup**:
|
||||||
|
- Close connections with `closeSmtpConnection()` or `conn.close()`
|
||||||
|
- Stop test servers with `stopTestServer()`
|
||||||
|
- Use try/finally blocks for guaranteed cleanup
|
||||||
|
|
||||||
|
5. **Test isolation**:
|
||||||
|
- Each test file uses its own port (e.g., CMD-01 uses 25251, CMD-02 uses 25252)
|
||||||
|
- Setup and cleanup tests ensure clean state
|
||||||
|
|
||||||
|
## Key Differences from dcrouter Tests
|
||||||
|
|
||||||
|
### Framework
|
||||||
|
- **Before**: `@git.zone/tstest/tapbundle` (Node.js)
|
||||||
|
- **After**: Deno.test (native)
|
||||||
|
|
||||||
|
### Assertions
|
||||||
|
- **Before**: `expect(x).toBeTruthy()`, `expect(x).toEqual(y)`
|
||||||
|
- **After**: `assert(x)`, `assertEquals(x, y)`, `assertMatch(x, regex)`
|
||||||
|
|
||||||
|
### Network I/O
|
||||||
|
- **Before**: Node.js `net` module
|
||||||
|
- **After**: Deno `Deno.connect()` / `Deno.listen()`
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
- **Before**: `.js` extensions, Node-style imports
|
||||||
|
- **After**: `.ts` extensions, Deno-style imports
|
||||||
|
|
||||||
|
## Test Priorities
|
||||||
|
|
||||||
|
### Phase 1: Core SMTP Functionality (High Priority)
|
||||||
|
- ✅ CMD-01: EHLO Command
|
||||||
|
- ✅ CMD-02: MAIL FROM Command
|
||||||
|
- 🔄 CMD-03: RCPT TO Command
|
||||||
|
- 🔄 CMD-04: DATA Command
|
||||||
|
- 🔄 CMD-13: QUIT Command
|
||||||
|
- 🔄 CM-01: TLS Connection
|
||||||
|
- 🔄 EP-01: Basic Email Sending
|
||||||
|
|
||||||
|
### Phase 2: Security & Validation (High Priority)
|
||||||
|
- 🔄 SEC-01: Authentication
|
||||||
|
- 🔄 SEC-06: IP Reputation
|
||||||
|
- 🔄 SEC-08: Rate Limiting
|
||||||
|
- 🔄 SEC-10: Header Injection Prevention
|
||||||
|
- 🔄 ERR-01: Syntax Error Handling
|
||||||
|
- 🔄 ERR-02: Invalid Sequence Handling
|
||||||
|
|
||||||
|
### Phase 3: Advanced Features (Medium Priority)
|
||||||
|
- 🔄 SEC-03: DKIM Processing
|
||||||
|
- 🔄 SEC-04: SPF Checking
|
||||||
|
- 🔄 EP-04: Large Email Handling
|
||||||
|
- 🔄 EP-05: MIME Handling
|
||||||
|
- 🔄 CM-02: Multiple Connections
|
||||||
|
- 🔄 CM-06: STARTTLS Upgrade
|
||||||
|
|
||||||
|
### Phase 4: Complete Coverage (All Remaining)
|
||||||
|
- All performance tests
|
||||||
|
- All reliability tests
|
||||||
|
- All edge case tests
|
||||||
|
- All RFC compliance tests
|
||||||
|
- SMTP client tests
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
**Infrastructure**: ✅ Complete
|
||||||
|
- Deno-native test helpers
|
||||||
|
- Server lifecycle management
|
||||||
|
- SMTP protocol utilities
|
||||||
|
- Test certificates
|
||||||
|
|
||||||
|
**Tests Ported**: 2/100+ test files
|
||||||
|
- CMD-01: EHLO Command (5 tests passing)
|
||||||
|
- CMD-02: MAIL FROM Command (6 tests)
|
||||||
|
|
||||||
|
**Next Steps**:
|
||||||
|
1. Port CMD-03 (RCPT TO), CMD-04 (DATA), CMD-13 (QUIT)
|
||||||
|
2. Port CM-01 (TLS connection test)
|
||||||
|
3. Port EP-01 (Basic email sending)
|
||||||
|
4. Port security tests (SEC-01, SEC-06, SEC-08)
|
||||||
|
5. Continue with remaining high-priority tests
|
||||||
|
|
||||||
|
## Production Readiness Criteria
|
||||||
|
|
||||||
|
### Gate 1: Core Functionality (>90% tests passing)
|
||||||
|
- Basic SMTP command handling
|
||||||
|
- Connection management
|
||||||
|
- Email delivery
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### Gate 2: Security (>95% tests passing)
|
||||||
|
- Authentication mechanisms
|
||||||
|
- TLS/STARTTLS support
|
||||||
|
- Rate limiting
|
||||||
|
- Injection prevention
|
||||||
|
|
||||||
|
### Gate 3: Enterprise Ready (>85% tests passing)
|
||||||
|
- Full RFC compliance
|
||||||
|
- Performance under load
|
||||||
|
- Advanced security features
|
||||||
|
- Complete edge case handling
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When porting tests from dcrouter:
|
||||||
|
|
||||||
|
1. Maintain test IDs and organization
|
||||||
|
2. Convert to Deno.test() format
|
||||||
|
3. Use @std/assert for assertions
|
||||||
|
4. Update imports to .ts extensions
|
||||||
|
5. Use Deno-native TCP connections
|
||||||
|
6. Preserve test logic and validations
|
||||||
|
7. Add `sanitizeResources: false, sanitizeOps: false` for network tests
|
||||||
|
8. Update this README with ported tests
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Deno Testing](https://deno.land/manual/basics/testing)
|
||||||
|
- [Deno Standard Library - Assert](https://deno.land/std/assert)
|
||||||
|
- [RFC 5321 - SMTP](https://tools.ietf.org/html/rfc5321)
|
||||||
|
- [RFC 5322 - Internet Message Format](https://tools.ietf.org/html/rfc5322)
|
||||||
154
test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts
Normal file
154
test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* CMD-01: EHLO Command Tests
|
||||||
|
* Tests SMTP EHLO command and server capabilities advertisement
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { assert, assertEquals, assertMatch } from '@std/assert';
|
||||||
|
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
|
||||||
|
import {
|
||||||
|
connectToSmtp,
|
||||||
|
waitForGreeting,
|
||||||
|
sendSmtpCommand,
|
||||||
|
closeSmtpConnection,
|
||||||
|
} from '../../helpers/utils.ts';
|
||||||
|
|
||||||
|
const TEST_PORT = 25251;
|
||||||
|
let testServer: ITestServer;
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-01: Setup - Start SMTP server',
|
||||||
|
async fn() {
|
||||||
|
testServer = await startTestServer({ port: TEST_PORT });
|
||||||
|
assert(testServer, 'Test server should be created');
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-01: EHLO Command - server responds with proper capabilities',
|
||||||
|
async fn() {
|
||||||
|
const conn = await connectToSmtp('localhost', TEST_PORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for greeting
|
||||||
|
const greeting = await waitForGreeting(conn);
|
||||||
|
assert(greeting.includes('220'), 'Should receive 220 greeting');
|
||||||
|
|
||||||
|
// Send EHLO
|
||||||
|
const ehloResponse = await sendSmtpCommand(conn, 'EHLO test.example.com', '250');
|
||||||
|
|
||||||
|
// Parse capabilities
|
||||||
|
const lines = ehloResponse
|
||||||
|
.split('\r\n')
|
||||||
|
.filter((line) => line.startsWith('250'))
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
|
const capabilities = lines.map((line) => line.substring(4).trim());
|
||||||
|
console.log('📋 Server capabilities:', capabilities);
|
||||||
|
|
||||||
|
// Verify essential capabilities
|
||||||
|
assert(
|
||||||
|
capabilities.some((cap) => cap.includes('SIZE')),
|
||||||
|
'Should advertise SIZE capability'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
capabilities.some((cap) => cap.includes('8BITMIME')),
|
||||||
|
'Should advertise 8BITMIME capability'
|
||||||
|
);
|
||||||
|
|
||||||
|
// The last line should be "250 " (without hyphen)
|
||||||
|
const lastLine = lines[lines.length - 1];
|
||||||
|
assert(lastLine.startsWith('250 '), 'Last line should start with "250 " (space, not hyphen)');
|
||||||
|
} finally {
|
||||||
|
await closeSmtpConnection(conn);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-01: EHLO with invalid hostname - server handles gracefully',
|
||||||
|
async fn() {
|
||||||
|
const conn = await connectToSmtp('localhost', TEST_PORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForGreeting(conn);
|
||||||
|
|
||||||
|
const invalidHostnames = [
|
||||||
|
'', // Empty hostname
|
||||||
|
' ', // Whitespace only
|
||||||
|
'invalid..hostname', // Double dots
|
||||||
|
'.invalid', // Leading dot
|
||||||
|
'invalid.', // Trailing dot
|
||||||
|
'very-long-hostname-that-exceeds-reasonable-limits-' + 'x'.repeat(200),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const hostname of invalidHostnames) {
|
||||||
|
console.log(`Testing invalid hostname: "${hostname}"`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendSmtpCommand(conn, `EHLO ${hostname}`);
|
||||||
|
// Server should either accept with warning or reject with 5xx
|
||||||
|
assertMatch(response, /^(250|5\d\d)/, 'Server should respond with 250 or 5xx');
|
||||||
|
|
||||||
|
// Reset session for next test
|
||||||
|
if (response.startsWith('250')) {
|
||||||
|
await sendSmtpCommand(conn, 'RSET', '250');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Some invalid hostnames might cause connection issues, which is acceptable
|
||||||
|
console.log(` Hostname "${hostname}" caused error (acceptable):`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send QUIT
|
||||||
|
await sendSmtpCommand(conn, 'QUIT', '221');
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
conn.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore close errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-01: EHLO command pipelining - multiple EHLO commands',
|
||||||
|
async fn() {
|
||||||
|
const conn = await connectToSmtp('localhost', TEST_PORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForGreeting(conn);
|
||||||
|
|
||||||
|
// First EHLO
|
||||||
|
const ehlo1Response = await sendSmtpCommand(conn, 'EHLO first.example.com', '250');
|
||||||
|
assert(ehlo1Response.startsWith('250'), 'First EHLO should succeed');
|
||||||
|
|
||||||
|
// Second EHLO (should reset session)
|
||||||
|
const ehlo2Response = await sendSmtpCommand(conn, 'EHLO second.example.com', '250');
|
||||||
|
assert(ehlo2Response.startsWith('250'), 'Second EHLO should succeed');
|
||||||
|
|
||||||
|
// Verify session was reset by trying MAIL FROM
|
||||||
|
const mailResponse = await sendSmtpCommand(conn, 'MAIL FROM:<test@example.com>', '250');
|
||||||
|
assert(mailResponse.startsWith('250'), 'MAIL FROM should work after second EHLO');
|
||||||
|
} finally {
|
||||||
|
await closeSmtpConnection(conn);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-01: Cleanup - Stop SMTP server',
|
||||||
|
async fn() {
|
||||||
|
await stopTestServer(testServer);
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
169
test/suite/smtpserver_commands/test.cmd-02.mail-from.test.ts
Normal file
169
test/suite/smtpserver_commands/test.cmd-02.mail-from.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* CMD-02: MAIL FROM Command Tests
|
||||||
|
* Tests SMTP MAIL FROM command validation and handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { assert, assertMatch } from '@std/assert';
|
||||||
|
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
|
||||||
|
import {
|
||||||
|
connectToSmtp,
|
||||||
|
waitForGreeting,
|
||||||
|
sendSmtpCommand,
|
||||||
|
closeSmtpConnection,
|
||||||
|
} from '../../helpers/utils.ts';
|
||||||
|
|
||||||
|
const TEST_PORT = 25252;
|
||||||
|
let testServer: ITestServer;
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-02: Setup - Start SMTP server',
|
||||||
|
async fn() {
|
||||||
|
testServer = await startTestServer({ port: TEST_PORT });
|
||||||
|
assert(testServer, 'Test server should be created');
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-02: MAIL FROM - accepts valid sender addresses',
|
||||||
|
async fn() {
|
||||||
|
const conn = await connectToSmtp('localhost', TEST_PORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForGreeting(conn);
|
||||||
|
await sendSmtpCommand(conn, 'EHLO test.example.com', '250');
|
||||||
|
|
||||||
|
const validAddresses = [
|
||||||
|
'sender@example.com',
|
||||||
|
'test.user+tag@example.com',
|
||||||
|
'user@[192.168.1.1]', // IP literal
|
||||||
|
'user@subdomain.example.com',
|
||||||
|
'user@very-long-domain-name-that-is-still-valid.example.com',
|
||||||
|
'test_user@example.com', // underscore in local part
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const address of validAddresses) {
|
||||||
|
console.log(`✓ Testing valid address: ${address}`);
|
||||||
|
const response = await sendSmtpCommand(conn, `MAIL FROM:<${address}>`, '250');
|
||||||
|
assert(response.startsWith('250'), `Should accept valid address: ${address}`);
|
||||||
|
|
||||||
|
// Reset for next test
|
||||||
|
await sendSmtpCommand(conn, 'RSET', '250');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await closeSmtpConnection(conn);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-02: MAIL FROM - rejects invalid sender addresses',
|
||||||
|
async fn() {
|
||||||
|
const conn = await connectToSmtp('localhost', TEST_PORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForGreeting(conn);
|
||||||
|
await sendSmtpCommand(conn, 'EHLO test.example.com', '250');
|
||||||
|
|
||||||
|
const invalidAddresses = [
|
||||||
|
'notanemail', // No @ symbol
|
||||||
|
'@example.com', // Missing local part
|
||||||
|
'user@', // Missing domain
|
||||||
|
'user@.com', // Invalid domain
|
||||||
|
'user@domain..com', // Double dot
|
||||||
|
'user space@example.com', // Space in address
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const address of invalidAddresses) {
|
||||||
|
console.log(`✗ Testing invalid address: ${address}`);
|
||||||
|
try {
|
||||||
|
const response = await sendSmtpCommand(conn, `MAIL FROM:<${address}>`);
|
||||||
|
// Should get 5xx error
|
||||||
|
assertMatch(response, /^5\d\d/, `Should reject invalid address with 5xx: ${address}`);
|
||||||
|
} catch (error) {
|
||||||
|
// Connection might be dropped for really bad input, which is acceptable
|
||||||
|
console.log(` Address "${address}" caused error (acceptable):`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to reset (may fail if connection dropped)
|
||||||
|
try {
|
||||||
|
await sendSmtpCommand(conn, 'RSET', '250');
|
||||||
|
} catch {
|
||||||
|
// Reset after connection closed, reconnect for next test
|
||||||
|
conn.close();
|
||||||
|
return; // Exit test early if connection was dropped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await closeSmtpConnection(conn);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors if connection already closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-02: MAIL FROM - supports SIZE parameter',
|
||||||
|
async fn() {
|
||||||
|
const conn = await connectToSmtp('localhost', TEST_PORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForGreeting(conn);
|
||||||
|
const caps = await sendSmtpCommand(conn, 'EHLO test.example.com', '250');
|
||||||
|
|
||||||
|
// Verify SIZE is advertised
|
||||||
|
assert(caps.includes('SIZE'), 'Server should advertise SIZE capability');
|
||||||
|
|
||||||
|
// Try MAIL FROM with SIZE parameter
|
||||||
|
const response = await sendSmtpCommand(
|
||||||
|
conn,
|
||||||
|
'MAIL FROM:<sender@example.com> SIZE=5000',
|
||||||
|
'250'
|
||||||
|
);
|
||||||
|
assert(response.startsWith('250'), 'Should accept SIZE parameter');
|
||||||
|
} finally {
|
||||||
|
await closeSmtpConnection(conn);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-02: MAIL FROM - enforces correct sequence',
|
||||||
|
async fn() {
|
||||||
|
const conn = await connectToSmtp('localhost', TEST_PORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForGreeting(conn);
|
||||||
|
|
||||||
|
// Try MAIL FROM before EHLO - should fail
|
||||||
|
const response = await sendSmtpCommand(conn, 'MAIL FROM:<sender@example.com>');
|
||||||
|
assertMatch(response, /^5\d\d/, 'Should reject MAIL FROM before EHLO/HELO');
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
conn.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore close errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'CMD-02: Cleanup - Stop SMTP server',
|
||||||
|
async fn() {
|
||||||
|
await stopTestServer(testServer);
|
||||||
|
},
|
||||||
|
sanitizeResources: false,
|
||||||
|
sanitizeOps: false,
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ export * as path from '@std/path';
|
|||||||
export * as colors from '@std/fmt/colors';
|
export * as colors from '@std/fmt/colors';
|
||||||
export * as cli from '@std/cli';
|
export * as cli from '@std/cli';
|
||||||
export { serveDir } from '@std/http/file-server';
|
export { serveDir } from '@std/http/file-server';
|
||||||
export * as crypto from '@std/crypto';
|
export * as denoCrypto from '@std/crypto';
|
||||||
|
|
||||||
// Node.js built-in modules (needed for SMTP and email processing)
|
// Node.js built-in modules (needed for SMTP and email processing)
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
@@ -20,8 +20,9 @@ import * as os from 'node:os';
|
|||||||
import * as process from 'node:process';
|
import * as process from 'node:process';
|
||||||
import * as buffer from 'node:buffer';
|
import * as buffer from 'node:buffer';
|
||||||
import * as util from 'node:util';
|
import * as util from 'node:util';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
|
||||||
export { EventEmitter, net, tls, dns, fs, os, process, buffer, util };
|
export { EventEmitter, net, tls, dns, fs, os, process, buffer, util, crypto };
|
||||||
export const Buffer = buffer.Buffer;
|
export const Buffer = buffer.Buffer;
|
||||||
|
|
||||||
// Cloudflare API client
|
// Cloudflare API client
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface IIpReputationResult {
|
|||||||
sources: string[];
|
sources: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IpReputationChecker {
|
export class IPReputationChecker {
|
||||||
public async checkReputation(ip: string): Promise<IIpReputationResult> {
|
public async checkReputation(ip: string): Promise<IIpReputationResult> {
|
||||||
// Placeholder implementation
|
// Placeholder implementation
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user