From 1698df3a53b6f0459295ada3810e9d4a73c75e57 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 25 Oct 2025 15:05:11 +0000 Subject: [PATCH] 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. --- .serena/.gitignore | 1 + .serena/project.yml | 71 ++++ test/fixtures/test-cert.pem | 21 ++ test/fixtures/test-key.pem | 27 ++ test/helpers/server.loader.ts | 326 ++++++++++++++++ test/helpers/smtp.client.ts | 236 ++++++++++++ test/helpers/utils.ts | 350 ++++++++++++++++++ test/readme.md | 309 ++++++++++++++++ .../test.cmd-01.ehlo-command.test.ts | 154 ++++++++ .../test.cmd-02.mail-from.test.ts | 169 +++++++++ ts/plugins.ts | 5 +- ts/security/classes.ipreputationchecker.ts | 2 +- 12 files changed, 1668 insertions(+), 3 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 test/fixtures/test-cert.pem create mode 100644 test/fixtures/test-key.pem create mode 100644 test/helpers/server.loader.ts create mode 100644 test/helpers/smtp.client.ts create mode 100644 test/helpers/utils.ts create mode 100644 test/readme.md create mode 100644 test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts create mode 100644 test/suite/smtpserver_commands/test.cmd-02.mail-from.test.ts diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..49e92e1 --- /dev/null +++ b/.serena/project.yml @@ -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" diff --git a/test/fixtures/test-cert.pem b/test/fixtures/test-cert.pem new file mode 100644 index 0000000..05b4cc0 --- /dev/null +++ b/test/fixtures/test-cert.pem @@ -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----- diff --git a/test/fixtures/test-key.pem b/test/fixtures/test-key.pem new file mode 100644 index 0000000..4a1c8c4 --- /dev/null +++ b/test/fixtures/test-key.pem @@ -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----- diff --git a/test/helpers/server.loader.ts b/test/helpers/server.loader.ts new file mode 100644 index 0000000..47399ef --- /dev/null +++ b/test/helpers/server.loader.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 || '

This is a test email

', + attachments: options.attachments || [], + date: new Date(), + messageId: `<${Date.now()}@test.example.com>`, + }; +} diff --git a/test/helpers/smtp.client.ts b/test/helpers/smtp.client.ts new file mode 100644 index 0000000..96c5252 --- /dev/null +++ b/test/helpers/smtp.client.ts @@ -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 = {}): 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 { + 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 { + 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 { + 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 { + 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 = {} +): SmtpClient { + return createTestSmtpClient({ + ...options, + pool: true, + maxConnections: options.maxConnections || 5, + maxMessages: options.maxMessages || 100, + }); +} diff --git a/test/helpers/utils.ts b/test/helpers/utils.ts new file mode 100644 index 0000000..f2ab8ae --- /dev/null +++ b/test/helpers/utils.ts @@ -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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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( + operation: () => Promise +): 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( + operation: () => Promise, + maxRetries: number = 3, + initialDelay: number = 1000 +): Promise { + 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!; +} diff --git a/test/readme.md b/test/readme.md new file mode 100644 index 0000000..e167cca --- /dev/null +++ b/test/readme.md @@ -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...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) diff --git a/test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts b/test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts new file mode 100644 index 0000000..cd6a6cf --- /dev/null +++ b/test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts @@ -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:', '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, +}); diff --git a/test/suite/smtpserver_commands/test.cmd-02.mail-from.test.ts b/test/suite/smtpserver_commands/test.cmd-02.mail-from.test.ts new file mode 100644 index 0000000..18bed4f --- /dev/null +++ b/test/suite/smtpserver_commands/test.cmd-02.mail-from.test.ts @@ -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: 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:'); + 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, +}); diff --git a/ts/plugins.ts b/ts/plugins.ts index 7bf3884..4f983fb 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -8,7 +8,7 @@ export * as path from '@std/path'; export * as colors from '@std/fmt/colors'; export * as cli from '@std/cli'; 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) import { EventEmitter } from 'node:events'; @@ -20,8 +20,9 @@ import * as os from 'node:os'; import * as process from 'node:process'; import * as buffer from 'node:buffer'; 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; // Cloudflare API client diff --git a/ts/security/classes.ipreputationchecker.ts b/ts/security/classes.ipreputationchecker.ts index 3154fe9..dd20035 100644 --- a/ts/security/classes.ipreputationchecker.ts +++ b/ts/security/classes.ipreputationchecker.ts @@ -10,7 +10,7 @@ export interface IIpReputationResult { sources: string[]; } -export class IpReputationChecker { +export class IPReputationChecker { public async checkReputation(ip: string): Promise { // Placeholder implementation return {