├── .devcontainer └── devcontainer.json ├── .github ├── dependabot.yml ├── initial_migration.py └── workflows │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── alembic.ini ├── commit_message.txt ├── docker-compose.yml ├── mailoney.py ├── mailoney ├── __init__.py ├── config.py ├── core.py ├── db.py └── migrations │ ├── __init__.py │ ├── env.py │ └── versions │ └── 1a5b9822e49c_initial_migration.py ├── main.py ├── main └── main_guts_stuff.py ├── pyproject.toml ├── requirements.txt ├── src └── mailoney │ ├── __init__.py │ ├── config.py │ ├── db.py │ ├── migrations │ └── env.py │ └── server.py └── tests ├── __init__.py ├── conftest.py ├── test_config.py ├── test_core.py └── test_db.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile 3 | { 4 | "name": "Existing Dockerfile", 5 | "build": { 6 | // Sets the run context to one level up instead of the .devcontainer folder. 7 | "context": "..", 8 | // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. 9 | "dockerfile": "../Dockerfile" 10 | }, 11 | "features": { 12 | "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} 13 | } 14 | 15 | // Features to add to the dev container. More info: https://containers.dev/features. 16 | // "features": {}, 17 | 18 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 19 | // "forwardPorts": [], 20 | 21 | // Uncomment the next line to run commands after the container is created. 22 | // "postCreateCommand": "cat /etc/os-release", 23 | 24 | // Configure tool-specific properties. 25 | // "customizations": {}, 26 | 27 | // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. 28 | // "remoteUser": "devcontainer" 29 | } 30 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /.github/initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: 1a5b9822e49c 4 | Revises: 5 | Create Date: 2025-04-08 23:00:00.000000 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '1a5b9822e49c' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # Create smtp_sessions table 21 | op.create_table('smtp_sessions', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('timestamp', sa.DateTime(), nullable=True), 24 | sa.Column('ip_address', sa.String(length=255), nullable=False), 25 | sa.Column('port', sa.Integer(), nullable=False), 26 | sa.Column('server_name', sa.String(length=255), nullable=True), 27 | sa.Column('session_data', sa.Text(), nullable=True), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | 31 | # Create credentials table 32 | op.create_table('credentials', 33 | sa.Column('id', sa.Integer(), nullable=False), 34 | sa.Column('timestamp', sa.DateTime(), nullable=True), 35 | sa.Column('session_id', sa.Integer(), nullable=True), 36 | sa.Column('auth_string', sa.String(length=255), nullable=True), 37 | sa.ForeignKeyConstraint(['session_id'], ['smtp_sessions.id'], ), 38 | sa.PrimaryKeyConstraint('id') 39 | ) 40 | 41 | 42 | def downgrade(): 43 | op.drop_table('credentials') 44 | op.drop_table('smtp_sessions') 45 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI/CD 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ 'v*.*.*' ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: "3.11" 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 23 | pip install pytest pytest-cov alembic 24 | - name: Run tests with SQLite 25 | run: | 26 | # Set up environment for testing 27 | export PYTHONPATH=$PYTHONPATH:$(pwd) 28 | 29 | # Run tests with verbose output for debugging 30 | PYTHONPATH=$(pwd) pytest -vvv --log-cli-level=DEBUG 31 | 32 | build-and-push: 33 | needs: test 34 | runs-on: ubuntu-latest 35 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) 36 | permissions: 37 | contents: read 38 | packages: write 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v3 42 | 43 | - name: Set up Docker Buildx 44 | uses: docker/setup-buildx-action@v2 45 | with: 46 | platforms: linux/amd64,linux/arm64 47 | 48 | - name: Log in to GitHub Container Registry 49 | uses: docker/login-action@v2 50 | with: 51 | registry: ghcr.io 52 | username: ${{ github.actor }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | - name: Extract metadata for Docker 56 | id: meta 57 | uses: docker/metadata-action@v4 58 | with: 59 | images: ghcr.io/${{ github.repository_owner }}/mailoney 60 | tags: | 61 | type=ref,event=branch 62 | type=semver,pattern={{version}} 63 | type=sha,format=short 64 | latest 65 | 66 | - name: Build and push 67 | uses: docker/build-push-action@v4 68 | with: 69 | context: . 70 | platforms: linux/amd64,linux/arm64 71 | push: true 72 | tags: ${{ steps.meta.outputs.tags }} 73 | labels: ${{ steps.meta.outputs.labels }} 74 | cache-from: type=gha 75 | cache-to: type=gha,mode=max 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Virtual Environment 24 | venv/ 25 | env/ 26 | ENV/ 27 | 28 | # IDE 29 | .idea/ 30 | .vscode/ 31 | *.swp 32 | *.swo 33 | 34 | # Distribution 35 | dist/ 36 | 37 | # Database 38 | *.db 39 | *.sqlite3 40 | 41 | # Logs 42 | logs/ 43 | *.log 44 | 45 | # Environment variables 46 | .env 47 | .env.* 48 | 49 | # Docker 50 | .docker/ 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /app 4 | 5 | # Set environment variables 6 | ENV PYTHONDONTWRITEBYTECODE=1 \ 7 | PYTHONUNBUFFERED=1 \ 8 | MAILONEY_DB_URL=sqlite:///mailoney.db 9 | 10 | # Copy requirements and install dependencies 11 | COPY requirements.txt . 12 | RUN pip install --no-cache-dir -r requirements.txt 13 | 14 | # Copy application code 15 | COPY . . 16 | 17 | # Run as non-root user for better security 18 | RUN groupadd -r mailoney && \ 19 | useradd -r -g mailoney mailoney && \ 20 | chown -R mailoney:mailoney /app 21 | 22 | USER mailoney 23 | 24 | # Expose port 25 | EXPOSE 25 26 | 27 | # Run the application 28 | ENTRYPOINT ["python", "main.py"] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Brandon Edmunds 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mailoney 2 | 3 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/phin3has/mailoney) 4 | ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/phin3has/mailoney/Docker%20Image%20CI/CD) 5 | ![GitHub](https://img.shields.io/github/license/phin3has/mailoney) 6 | 7 | A modern SMTP honeypot designed to capture and log email-based attacks with database integration. 8 | 9 | ## About 10 | 11 | Mailoney is a low-interaction SMTP honeypot that simulates a vulnerable mail server to detect and log unauthorized access attempts, credential harvesting, and other SMTP-based attacks. This version (2.0.0) is a complete rewrite with modern Python packaging practices and database logging. 12 | 13 | ### Features 14 | 15 | - 📧 Simulates an SMTP server accepting connections on port 25 16 | - 🔐 Captures authentication attempts and credentials 17 | - 💾 Stores all session data in a database (PostgreSQL recommended) 18 | - 🐳 Containerized for easy deployment via Docker 19 | - 🛠️ Modern, maintainable Python code base 20 | - 📊 Structured data for easy analysis and integration 21 | 22 | ## Quick Start with Docker 23 | 24 | Pull and run the container with a single command: 25 | 26 | ```bash 27 | docker run -p 25:25 ghcr.io/phin3has/mailoney:latest 28 | ``` 29 | 30 | ## Installation Options 31 | 32 | ### Option 1: Docker Compose (Recommended) 33 | 34 | The most convenient way to run Mailoney with proper database persistence: 35 | 36 | 1. Create a `docker-compose.yml` file: 37 | 38 | ```yaml 39 | version: '3.8' 40 | 41 | services: 42 | mailoney: 43 | image: ghcr.io/phin3has/mailoney:latest 44 | restart: unless-stopped 45 | ports: 46 | - "25:25" 47 | environment: 48 | - MAILONEY_BIND_IP=0.0.0.0 49 | - MAILONEY_BIND_PORT=25 50 | - MAILONEY_SERVER_NAME=mail.example.com 51 | - MAILONEY_LOG_LEVEL=INFO 52 | - MAILONEY_DB_URL=postgresql://postgres:postgres@db:5432/mailoney 53 | depends_on: 54 | - db 55 | 56 | db: 57 | image: postgres:15 58 | restart: unless-stopped 59 | environment: 60 | - POSTGRES_USER=postgres 61 | - POSTGRES_PASSWORD=postgres 62 | - POSTGRES_DB=mailoney 63 | volumes: 64 | - postgres_data:/var/lib/postgresql/data 65 | healthcheck: 66 | test: ["CMD-SHELL", "pg_isready -U postgres"] 67 | interval: 5s 68 | timeout: 5s 69 | retries: 5 70 | 71 | volumes: 72 | postgres_data: 73 | ``` 74 | 75 | 2. Start the services: 76 | 77 | ```bash 78 | docker-compose up -d 79 | ``` 80 | 81 | 3. View logs: 82 | 83 | ```bash 84 | docker-compose logs -f mailoney 85 | ``` 86 | 87 | ### Option 2: Local Installation 88 | 89 | For development or customization: 90 | 91 | ```bash 92 | # Clone the repository 93 | git clone https://github.com/phin3has/mailoney.git 94 | cd mailoney 95 | 96 | # Create a virtual environment 97 | python -m venv venv 98 | source venv/bin/activate # On Windows: venv\Scripts\activate 99 | 100 | # Install the package in development mode 101 | pip install -e . 102 | 103 | # Run Mailoney 104 | python main.py 105 | ``` 106 | 107 | ## Configuration 108 | 109 | ### Environment Variables 110 | 111 | | Variable | Description | Default | 112 | |----------|-------------|---------| 113 | | `MAILONEY_BIND_IP` | IP address to bind to | 0.0.0.0 | 114 | | `MAILONEY_BIND_PORT` | Port to listen on | 25 | 115 | | `MAILONEY_SERVER_NAME` | SMTP server name | mail.example.com | 116 | | `MAILONEY_DB_URL` | Database connection URL | sqlite:///mailoney.db | 117 | | `MAILONEY_LOG_LEVEL` | Logging level | INFO | 118 | 119 | ### Command-line Arguments 120 | 121 | When running directly: 122 | 123 | ```bash 124 | python main.py --help 125 | ``` 126 | 127 | Available arguments: 128 | - `-i`, `--ip`: IP address to bind to 129 | - `-p`, `--port`: Port to listen on 130 | - `-s`, `--server-name`: Server name to display in SMTP responses 131 | - `-d`, `--db-url`: Database URL 132 | - `--log-level`: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 133 | 134 | ### Database Configuration 135 | 136 | Mailoney can use various SQL databases: 137 | 138 | **SQLite** (simplest, for testing): 139 | ``` 140 | sqlite:///mailoney.db 141 | ``` 142 | 143 | **PostgreSQL** (recommended for production): 144 | ``` 145 | postgresql://username:password@hostname:port/database 146 | ``` 147 | 148 | **MySQL/MariaDB**: 149 | ``` 150 | mysql+pymysql://username:password@hostname:port/database 151 | ``` 152 | 153 | ## Database Schema 154 | 155 | Mailoney creates two main tables: 156 | 157 | 1. `smtp_sessions`: Stores information about each SMTP session 158 | - Session ID, timestamp, IP address, port, server name 159 | - Full JSON log of the entire session 160 | 161 | 2. `credentials`: Stores captured authentication credentials 162 | - Credential ID, timestamp, session ID, auth string 163 | 164 | ## Development 165 | 166 | ### Running Tests 167 | 168 | ```bash 169 | # Install test dependencies 170 | pip install pytest pytest-cov 171 | 172 | # Run tests 173 | pytest 174 | 175 | # Run tests with coverage 176 | pytest --cov=mailoney 177 | ``` 178 | 179 | ### Database Migrations 180 | 181 | ```bash 182 | # Create a new migration 183 | alembic revision --autogenerate -m "Description of changes" 184 | 185 | # Apply migrations 186 | alembic upgrade head 187 | ``` 188 | 189 | ### Building the Package 190 | 191 | ```bash 192 | # Install build tools 193 | pip install build 194 | 195 | # Build the package 196 | python -m build 197 | ``` 198 | 199 | ## Project Structure 200 | 201 | ``` 202 | mailoney/ 203 | ├── mailoney/ # Main package 204 | │ ├── __init__.py # Package initialization 205 | │ ├── core.py # Core server functionality 206 | │ ├── db.py # Database handling 207 | │ ├── config.py # Configuration management 208 | │ └── migrations/ # Database migrations 209 | ├── tests/ # Test suite 210 | ├── main.py # Clean entry point 211 | ├── docker-compose.yml # Docker Compose configuration 212 | ├── Dockerfile # Docker configuration 213 | ├── pyproject.toml # Package configuration 214 | └── ... other files 215 | ``` 216 | 217 | ## Security Considerations 218 | 219 | - Mailoney is a honeypot and should be deployed in a controlled environment 220 | - Consider running with limited privileges 221 | - Firewall appropriately to prevent misuse 222 | - Regularly backup and analyze collected data 223 | 224 | ## Integrating with Other Tools 225 | 226 | ### Forwarding Logs to Security Systems 227 | 228 | Mailoney stores all interaction data in the database. To integrate with SIEM or other security tools: 229 | 230 | 1. **Direct Database Integration**: Connect your security tools to the PostgreSQL database 231 | 2. **Log Forwarding**: Use a separate service to monitor the database and forward events 232 | 3. **API Development**: Extend Mailoney to provide a REST API for data access 233 | 234 | ## License 235 | 236 | MIT License - See LICENSE file for details. 237 | 238 | ## Acknowledgments 239 | 240 | This project is a modernized rewrite of the original Mailoney by @phin3has. 241 | 242 | 243 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = mailoney/migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to mailoney/migrations/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" below. 39 | # version_locations = %(here)s/bar:%(here)s/bat:mailoney/migrations/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 44 | # Valid values for version_path_separator are: 45 | # 46 | # version_path_separator = : 47 | # version_path_separator = ; 48 | # version_path_separator = space 49 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 50 | 51 | # the output encoding used when revision files 52 | # are written from script.py.mako 53 | # output_encoding = utf-8 54 | 55 | sqlalchemy.url = sqlite:///mailoney.db 56 | 57 | 58 | [post_write_hooks] 59 | # post_write_hooks defines scripts or Python functions that are run 60 | # on newly generated revision scripts. See the documentation for further 61 | # detail and examples 62 | 63 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 64 | # hooks = black 65 | # black.type = console_scripts 66 | # black.entrypoint = black 67 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 68 | 69 | # Logging configuration 70 | [loggers] 71 | keys = root,sqlalchemy,alembic 72 | 73 | [handlers] 74 | keys = console 75 | 76 | [formatters] 77 | keys = generic 78 | 79 | [logger_root] 80 | level = WARN 81 | handlers = console 82 | qualname = 83 | 84 | [logger_sqlalchemy] 85 | level = WARN 86 | handlers = 87 | qualname = sqlalchemy.engine 88 | 89 | [logger_alembic] 90 | level = INFO 91 | handlers = 92 | qualname = alembic 93 | 94 | [handler_console] 95 | class = StreamHandler 96 | args = (sys.stderr,) 97 | level = NOTSET 98 | formatter = generic 99 | 100 | [formatter_generic] 101 | format = %(levelname)-5.5s [%(name)s] %(message)s 102 | datefmt = %H:%M:%S 103 | -------------------------------------------------------------------------------- /commit_message.txt: -------------------------------------------------------------------------------- 1 | Add multi-architecture Docker build support 2 | 3 | * Updated GitHub Actions workflow to build for both amd64 and arm64 4 | * Added platform specification to docker/setup-buildx-action 5 | * Added platform specification to docker/build-push-action 6 | * This allows the Docker image to run on both x86_64 and ARM architectures 7 | * Fixes "no matching manifest" error when pulling on ARM devices 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mailoney: 3 | build: . 4 | restart: unless-stopped 5 | ports: 6 | - "25:25" 7 | environment: 8 | - MAILONEY_BIND_IP=0.0.0.0 9 | - MAILONEY_BIND_PORT=25 10 | - MAILONEY_SERVER_NAME=mail.example.com 11 | - MAILONEY_LOG_LEVEL=INFO 12 | - MAILONEY_DB_URL=postgresql://postgres:postgres@db:5432/mailoney 13 | depends_on: 14 | - db 15 | 16 | db: 17 | image: postgres:15 18 | restart: unless-stopped 19 | environment: 20 | - POSTGRES_USER=postgres 21 | - POSTGRES_PASSWORD=postgres 22 | - POSTGRES_DB=mailoney 23 | volumes: 24 | - postgres_data:/var/lib/postgresql/data 25 | healthcheck: 26 | test: ["CMD-SHELL", "pg_isready -U postgres"] 27 | interval: 5s 28 | timeout: 5s 29 | retries: 5 30 | 31 | volumes: 32 | postgres_data: 33 | -------------------------------------------------------------------------------- /mailoney.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Mailoney - A Simple SMTP Honeypot 4 | 5 | This is the entry point script for running the Mailoney honeypot. 6 | """ 7 | 8 | import sys 9 | import os 10 | 11 | # Ensure the package is in the path 12 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 13 | 14 | if __name__ == "__main__": 15 | from main.main_guts_stuff import run_server 16 | run_server() 17 | -------------------------------------------------------------------------------- /mailoney/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mailoney - A Simple SMTP Honeypot with database logging 3 | """ 4 | __author__ = 'awhitehatter' 5 | __version__ = '2.0.0' 6 | 7 | # Import key components to make them available at package level 8 | from .db import init_db 9 | from .core import SMTPHoneypot, run_server 10 | from .config import get_settings, configure_logging 11 | -------------------------------------------------------------------------------- /mailoney/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration handling for Mailoney 3 | """ 4 | import os 5 | import logging 6 | from typing import Dict, Any, Optional 7 | from pydantic import Field 8 | from pydantic_settings import BaseSettings, SettingsConfigDict 9 | 10 | # Configure logging 11 | logging.basicConfig( 12 | level=logging.INFO, 13 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 14 | ) 15 | 16 | class Settings(BaseSettings): 17 | """Application settings""" 18 | # Server settings 19 | bind_ip: str = Field(default="0.0.0.0") 20 | bind_port: int = Field(default=25) 21 | server_name: str = Field(default="mail.example.com") 22 | 23 | # Database settings 24 | db_url: str = Field(default="sqlite:///mailoney.db") 25 | 26 | # Logging settings 27 | log_level: str = Field(default="INFO") 28 | 29 | # Configure the settings to use the MAILONEY_ prefix for environment variables 30 | model_config = SettingsConfigDict( 31 | env_file=".env", 32 | env_prefix="MAILONEY_", 33 | case_sensitive=False, 34 | extra="ignore" 35 | ) 36 | 37 | def get_settings() -> Settings: 38 | """ 39 | Get application settings 40 | 41 | Returns: 42 | Settings object 43 | """ 44 | return Settings() 45 | 46 | def configure_logging(level: Optional[str] = None) -> None: 47 | """ 48 | Configure application logging 49 | 50 | Args: 51 | level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 52 | """ 53 | if level is None: 54 | level = get_settings().log_level 55 | 56 | numeric_level = getattr(logging, level.upper(), None) 57 | if not isinstance(numeric_level, int): 58 | raise ValueError(f"Invalid log level: {level}") 59 | 60 | logging.basicConfig( 61 | level=numeric_level, 62 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 63 | ) 64 | -------------------------------------------------------------------------------- /mailoney/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core functionality for the Mailoney SMTP Honeypot 3 | """ 4 | import socket 5 | import threading 6 | import logging 7 | import json 8 | import sys 9 | import argparse 10 | from time import strftime 11 | from typing import Optional, Tuple, Dict, Any, List 12 | 13 | from .db import create_session, update_session_data, log_credential, init_db 14 | from .config import get_settings, configure_logging 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | class SMTPHoneypot: 19 | """ 20 | SMTP Honeypot Server class 21 | """ 22 | 23 | def __init__( 24 | self, 25 | bind_ip: str = '0.0.0.0', 26 | bind_port: int = 25, 27 | server_name: str = 'mail.example.com' 28 | ): 29 | """ 30 | Initialize the SMTP honeypot server. 31 | 32 | Args: 33 | bind_ip: IP address to bind to 34 | bind_port: Port to listen on 35 | server_name: Server name to display in SMTP responses 36 | """ 37 | self.bind_ip = bind_ip 38 | self.bind_port = bind_port 39 | self.server_name = server_name 40 | self.socket = None 41 | self.ehlo_response = f'''250 {server_name} 42 | 250-PIPELINING 43 | 250-SIZE 10240000 44 | 250-VRFY 45 | 250-ETRN 46 | 250-STARTTLS 47 | 250-AUTH LOGIN PLAIN 48 | 250 8BITMIME\n''' 49 | 50 | def start(self) -> None: 51 | """ 52 | Start the SMTP honeypot server 53 | """ 54 | try: 55 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 56 | self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 57 | self.socket.bind((self.bind_ip, self.bind_port)) 58 | self.socket.listen(10) 59 | 60 | logger.info(f"SMTP Honeypot listening on {self.bind_ip}:{self.bind_port}") 61 | print(f"[*] SMTP Honeypot listening on {self.bind_ip}:{self.bind_port}") 62 | 63 | self._accept_connections() 64 | except Exception as e: 65 | logger.error(f"Error starting server: {e}") 66 | if self.socket: 67 | self.socket.close() 68 | raise 69 | 70 | def _accept_connections(self) -> None: 71 | """ 72 | Accept and handle incoming connections 73 | """ 74 | while True: 75 | try: 76 | client, addr = self.socket.accept() 77 | logger.info(f"Connection from {addr[0]}:{addr[1]}") 78 | print(f"[*] Connection from {addr[0]}:{addr[1]}") 79 | 80 | client_handler = threading.Thread( 81 | target=self._handle_client, 82 | args=(client, addr) 83 | ) 84 | client_handler.daemon = True 85 | client_handler.start() 86 | except Exception as e: 87 | logger.error(f"Error accepting connection: {e}") 88 | 89 | def _handle_client(self, client_socket: socket.socket, addr: Tuple[str, int]) -> None: 90 | """ 91 | Handle client connection 92 | 93 | Args: 94 | client_socket: Client socket 95 | addr: Client address tuple (ip, port) 96 | """ 97 | session_record = create_session(addr[0], addr[1], self.server_name) 98 | session_log = [] 99 | 100 | try: 101 | # Send banner 102 | banner = f"220 {self.server_name} ESMTP Service Ready\n" 103 | client_socket.send(banner.encode()) 104 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": banner}) 105 | 106 | # Handle client commands 107 | error_count = 0 108 | while error_count < 10: 109 | try: 110 | request = client_socket.recv(4096).decode().strip().lower() 111 | if not request: 112 | break 113 | 114 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "in", "data": request}) 115 | logger.debug(f"Client: {request}") 116 | 117 | # Handle EHLO/HELO 118 | if request.startswith('ehlo') or request.startswith('helo'): 119 | client_socket.send(self.ehlo_response.encode()) 120 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": self.ehlo_response}) 121 | error_count = 0 # Reset error count after successful command 122 | 123 | # Handle AUTH 124 | elif request.startswith('auth plain'): 125 | # Extract auth string 126 | parts = request.split() 127 | if len(parts) >= 3: 128 | auth_string = parts[2] 129 | log_credential(session_record.id, auth_string) 130 | logger.info(f"Captured credential: {auth_string}") 131 | 132 | response = "235 2.7.0 Authentication failed\n" 133 | client_socket.send(response.encode()) 134 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": response}) 135 | 136 | # Handle QUIT 137 | elif request.startswith('quit'): 138 | response = "221 2.0.0 Goodbye\n" 139 | client_socket.send(response.encode()) 140 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": response}) 141 | break 142 | 143 | # Handle other SMTP commands (simplistic simulation) 144 | elif request.startswith(('mail from:', 'rcpt to:', 'data')): 145 | response = "250 2.1.0 OK\n" 146 | client_socket.send(response.encode()) 147 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": response}) 148 | error_count = 0 149 | 150 | # Unknown command 151 | else: 152 | response = "502 5.5.2 Error: command not recognized\n" 153 | client_socket.send(response.encode()) 154 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": response}) 155 | error_count += 1 156 | 157 | except Exception as e: 158 | logger.error(f"Error handling client request: {e}") 159 | error_count += 1 160 | 161 | # Store the session log 162 | update_session_data(session_record.id, json.dumps(session_log)) 163 | 164 | except Exception as e: 165 | logger.error(f"Error in client handler: {e}") 166 | finally: 167 | client_socket.close() 168 | logger.info(f"Connection closed for {addr[0]}:{addr[1]}") 169 | 170 | def stop(self) -> None: 171 | """ 172 | Stop the SMTP honeypot server 173 | """ 174 | if self.socket: 175 | self.socket.close() 176 | logger.info("SMTP Honeypot server stopped") 177 | 178 | 179 | def parse_args() -> argparse.Namespace: 180 | """ 181 | Parse command-line arguments. 182 | 183 | Returns: 184 | Parsed arguments 185 | """ 186 | parser = argparse.ArgumentParser(description="Mailoney - A Simple SMTP Honeypot") 187 | 188 | parser.add_argument( 189 | '-i', '--ip', 190 | default=get_settings().bind_ip, 191 | help='IP address to bind to (default: 0.0.0.0)' 192 | ) 193 | 194 | parser.add_argument( 195 | '-p', '--port', 196 | type=int, 197 | default=get_settings().bind_port, 198 | help='Port to listen on (default: 25)' 199 | ) 200 | 201 | parser.add_argument( 202 | '-s', '--server-name', 203 | default=get_settings().server_name, 204 | help='Server name to display in SMTP responses' 205 | ) 206 | 207 | parser.add_argument( 208 | '-d', '--db-url', 209 | default=get_settings().db_url, 210 | help='Database URL (default: sqlite:///mailoney.db)' 211 | ) 212 | 213 | parser.add_argument( 214 | '--log-level', 215 | choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], 216 | default=get_settings().log_level, 217 | help='Log level' 218 | ) 219 | 220 | return parser.parse_args() 221 | 222 | 223 | def display_banner() -> None: 224 | """Display the Mailoney banner""" 225 | from . import __version__ 226 | 227 | banner = f""" 228 | **************************************************************** 229 | * Mailoney - A Simple SMTP Honeypot - Version: {__version__} * 230 | **************************************************************** 231 | """ 232 | print(banner) 233 | 234 | 235 | def run_server() -> None: 236 | """ 237 | Run the SMTP Honeypot server. 238 | This is the main entry point for the application. 239 | """ 240 | # Parse command-line arguments 241 | args = parse_args() 242 | 243 | # Configure logging 244 | configure_logging(args.log_level) 245 | 246 | # Display banner 247 | display_banner() 248 | 249 | # Initialize database 250 | logger.info(f"Initializing database with URL: {args.db_url}") 251 | init_db(args.db_url) 252 | 253 | # Create and start server 254 | try: 255 | server = SMTPHoneypot( 256 | bind_ip=args.ip, 257 | bind_port=args.port, 258 | server_name=args.server_name 259 | ) 260 | 261 | logger.info(f"Starting SMTP Honeypot on {args.ip}:{args.port}") 262 | server.start() 263 | except KeyboardInterrupt: 264 | logger.info("Server stopped by user") 265 | except Exception as e: 266 | logger.error(f"Error running server: {e}") 267 | sys.exit(1) 268 | -------------------------------------------------------------------------------- /mailoney/db.py: -------------------------------------------------------------------------------- 1 | """ 2 | Database handling module for Mailoney 3 | """ 4 | import os 5 | import logging 6 | import sqlalchemy as sa 7 | from datetime import datetime 8 | from typing import Dict, Any, Optional 9 | 10 | from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, ForeignKey, inspect 11 | from sqlalchemy.ext.declarative import declarative_base 12 | from sqlalchemy.orm import sessionmaker, relationship 13 | from sqlalchemy.pool import NullPool 14 | 15 | Base = declarative_base() 16 | engine = None 17 | Session = None 18 | logger = logging.getLogger(__name__) 19 | 20 | class SMTPSession(Base): 21 | """Model for SMTP session data""" 22 | __tablename__ = "smtp_sessions" 23 | 24 | id = Column(Integer, primary_key=True) 25 | timestamp = Column(DateTime, default=datetime.utcnow) 26 | ip_address = Column(String(255), nullable=False) 27 | port = Column(Integer, nullable=False) 28 | server_name = Column(String(255)) 29 | session_data = Column(Text) 30 | 31 | class Credential(Base): 32 | """Model for captured credentials""" 33 | __tablename__ = "credentials" 34 | 35 | id = Column(Integer, primary_key=True) 36 | timestamp = Column(DateTime, default=datetime.utcnow) 37 | session_id = Column(Integer, ForeignKey("smtp_sessions.id")) 38 | auth_string = Column(String(255)) 39 | 40 | session = relationship("SMTPSession", back_populates="credentials") 41 | 42 | # Add relationship to SMTPSession 43 | SMTPSession.credentials = relationship("Credential", order_by=Credential.id, back_populates="session") 44 | 45 | def init_db(db_url: Optional[str] = None) -> None: 46 | """ 47 | Initialize the database connection. 48 | 49 | Args: 50 | db_url: Database connection URL. If None, uses MAILONEY_DB_URL environment variable. 51 | """ 52 | global engine, Session 53 | 54 | if db_url is None: 55 | db_url = os.environ.get("MAILONEY_DB_URL") 56 | 57 | if not db_url: 58 | # Default to SQLite for development 59 | db_url = "sqlite:///mailoney.db" 60 | logger.warning(f"No database URL provided, using default: {db_url}") 61 | 62 | logger.info(f"Initializing database with URL: {db_url}") 63 | 64 | # For in-memory SQLite, we need to use specific settings 65 | is_memory_db = db_url == "sqlite:///:memory:" 66 | connect_args = {} 67 | if is_memory_db: 68 | # Use a shared connection for in-memory SQLite 69 | connect_args = {"check_same_thread": False} 70 | logger.info("Using in-memory SQLite database with shared connection") 71 | 72 | # Create the engine with appropriate settings 73 | engine = create_engine(db_url, poolclass=NullPool, connect_args=connect_args, future=True) 74 | Session = sessionmaker(bind=engine, future=True) 75 | 76 | # Always ensure tables exist 77 | Base.metadata.create_all(engine) 78 | logger.info("Tables created successfully") 79 | 80 | # Verify tables were created (for debugging) 81 | try: 82 | inspector = inspect(engine) 83 | tables = inspector.get_table_names() 84 | logger.info(f"Tables in database: {tables}") 85 | if 'smtp_sessions' not in tables or 'credentials' not in tables: 86 | logger.warning("Expected tables not found after create_all!") 87 | except Exception as e: 88 | logger.error(f"Error inspecting tables: {e}") 89 | 90 | # Run migrations if not using in-memory DB 91 | if not is_memory_db: 92 | try: 93 | import os 94 | from alembic import command 95 | from alembic.config import Config 96 | 97 | # Find the alembic.ini file 98 | alembic_ini = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'alembic.ini') 99 | if os.path.exists(alembic_ini): 100 | alembic_cfg = Config(alembic_ini) 101 | alembic_cfg.set_main_option('sqlalchemy.url', db_url) 102 | command.upgrade(alembic_cfg, 'head') 103 | logger.info("Database migrations applied successfully") 104 | except ImportError: 105 | logger.warning("Alembic not installed, skipping database migrations") 106 | except Exception as e: 107 | logger.warning(f"Error applying migrations: {e}") 108 | 109 | def create_session(ip_address: str, port: int, server_name: str) -> SMTPSession: 110 | """ 111 | Create a new session record in the database. 112 | 113 | Args: 114 | ip_address: Client IP address 115 | port: Client port 116 | server_name: Server name used 117 | 118 | Returns: 119 | The created SMTPSession instance 120 | """ 121 | global engine, Session 122 | 123 | if Session is None: 124 | init_db() 125 | 126 | # First verify that tables exist 127 | insp = inspect(engine) 128 | tables = insp.get_table_names() 129 | if 'smtp_sessions' not in tables: 130 | # Recreate tables if they don't exist 131 | logger.warning("Tables missing, recreating...") 132 | Base.metadata.create_all(engine) 133 | 134 | db_session = Session() 135 | try: 136 | smtp_session = SMTPSession( 137 | ip_address=ip_address, 138 | port=port, 139 | server_name=server_name 140 | ) 141 | db_session.add(smtp_session) 142 | db_session.commit() 143 | 144 | # This is key: Make a copy of all the attributes we need before closing session 145 | session_copy = SMTPSession( 146 | id=smtp_session.id, 147 | timestamp=smtp_session.timestamp, 148 | ip_address=smtp_session.ip_address, 149 | port=smtp_session.port, 150 | server_name=smtp_session.server_name, 151 | session_data=smtp_session.session_data 152 | ) 153 | 154 | return session_copy 155 | except Exception as e: 156 | db_session.rollback() 157 | logger.error(f"Error creating session: {e}") 158 | raise 159 | finally: 160 | db_session.close() 161 | 162 | def update_session_data(session_id: int, session_data: str) -> None: 163 | """ 164 | Update session data for an existing session. 165 | 166 | Args: 167 | session_id: The ID of the session to update 168 | session_data: Session data to store 169 | """ 170 | if Session is None: 171 | init_db() 172 | 173 | session = Session() 174 | try: 175 | # SQLAlchemy 2.0 style 176 | stmt = sa.select(SMTPSession).filter_by(id=session_id) 177 | smtp_session = session.execute(stmt).scalar_one_or_none() 178 | if smtp_session: 179 | smtp_session.session_data = session_data 180 | session.commit() 181 | else: 182 | logger.warning(f"Session ID {session_id} not found") 183 | except Exception as e: 184 | session.rollback() 185 | logger.error(f"Error updating session: {e}") 186 | raise 187 | finally: 188 | session.close() 189 | 190 | def log_credential(session_id: int, auth_string: str) -> None: 191 | """ 192 | Log a credential attempt. 193 | 194 | Args: 195 | session_id: The ID of the session 196 | auth_string: The authentication string 197 | """ 198 | if Session is None: 199 | init_db() 200 | 201 | session = Session() 202 | try: 203 | credential = Credential( 204 | session_id=session_id, 205 | auth_string=auth_string 206 | ) 207 | session.add(credential) 208 | session.commit() 209 | except Exception as e: 210 | session.rollback() 211 | logger.error(f"Error logging credential: {e}") 212 | raise 213 | finally: 214 | session.close() 215 | -------------------------------------------------------------------------------- /mailoney/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | # Migrations package 2 | -------------------------------------------------------------------------------- /mailoney/migrations/env.py: -------------------------------------------------------------------------------- 1 | """ 2 | Alembic environment script for database migrations 3 | """ 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | if config.config_file_name is not None: 18 | fileConfig(config.config_file_name) 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | from mailoney.db import Base 23 | target_metadata = Base.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | # Override with environment variable if set 31 | import os 32 | url = os.getenv("MAILONEY_DB_URL") 33 | if url: 34 | config.set_main_option("sqlalchemy.url", url) 35 | 36 | def run_migrations_offline() -> None: 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, 51 | target_metadata=target_metadata, 52 | literal_binds=True, 53 | dialect_opts={"paramstyle": "named"}, 54 | ) 55 | 56 | with context.begin_transaction(): 57 | context.run_migrations() 58 | 59 | 60 | def run_migrations_online() -> None: 61 | """Run migrations in 'online' mode. 62 | 63 | In this scenario we need to create an Engine 64 | and associate a connection with the context. 65 | 66 | """ 67 | connectable = engine_from_config( 68 | config.get_section(config.config_ini_section, {}), 69 | prefix="sqlalchemy.", 70 | poolclass=pool.NullPool, 71 | ) 72 | 73 | with connectable.connect() as connection: 74 | context.configure( 75 | connection=connection, target_metadata=target_metadata 76 | ) 77 | 78 | with context.begin_transaction(): 79 | context.run_migrations() 80 | 81 | 82 | if context.is_offline_mode(): 83 | run_migrations_offline() 84 | else: 85 | run_migrations_online() 86 | -------------------------------------------------------------------------------- /mailoney/migrations/versions/1a5b9822e49c_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: 1a5b9822e49c 4 | Revises: 5 | Create Date: 2025-04-08 23:00:00.000000 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '1a5b9822e49c' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # Create smtp_sessions table 21 | op.create_table('smtp_sessions', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('timestamp', sa.DateTime(), nullable=True), 24 | sa.Column('ip_address', sa.String(length=255), nullable=False), 25 | sa.Column('port', sa.Integer(), nullable=False), 26 | sa.Column('server_name', sa.String(length=255), nullable=True), 27 | sa.Column('session_data', sa.Text(), nullable=True), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | 31 | # Create credentials table 32 | op.create_table('credentials', 33 | sa.Column('id', sa.Integer(), nullable=False), 34 | sa.Column('timestamp', sa.DateTime(), nullable=True), 35 | sa.Column('session_id', sa.Integer(), nullable=True), 36 | sa.Column('auth_string', sa.String(length=255), nullable=True), 37 | sa.ForeignKeyConstraint(['session_id'], ['smtp_sessions.id'], ), 38 | sa.PrimaryKeyConstraint('id') 39 | ) 40 | 41 | 42 | def downgrade(): 43 | op.drop_table('credentials') 44 | op.drop_table('smtp_sessions') 45 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Mailoney - A Simple SMTP Honeypot 4 | 5 | Clean entry point for the Mailoney application. 6 | """ 7 | from mailoney import run_server 8 | 9 | if __name__ == "__main__": 10 | run_server() 11 | -------------------------------------------------------------------------------- /main/main_guts_stuff.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main functionality for the Mailoney SMTP Honeypot. 3 | This module contains the main server running logic. 4 | """ 5 | import sys 6 | import argparse 7 | import logging 8 | from typing import Dict, Any, List, Optional 9 | 10 | from src.mailoney.config import get_settings, configure_logging 11 | from src.mailoney.db import init_db 12 | from src.mailoney.server import SMTPHoneypot 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | def parse_args() -> argparse.Namespace: 17 | """ 18 | Parse command-line arguments. 19 | 20 | Returns: 21 | Parsed arguments 22 | """ 23 | parser = argparse.ArgumentParser(description="Mailoney - A Simple SMTP Honeypot") 24 | 25 | parser.add_argument( 26 | '-i', '--ip', 27 | default=get_settings().bind_ip, 28 | help='IP address to bind to (default: 0.0.0.0)' 29 | ) 30 | 31 | parser.add_argument( 32 | '-p', '--port', 33 | type=int, 34 | default=get_settings().bind_port, 35 | help='Port to listen on (default: 25)' 36 | ) 37 | 38 | parser.add_argument( 39 | '-s', '--server-name', 40 | default=get_settings().server_name, 41 | help='Server name to display in SMTP responses' 42 | ) 43 | 44 | parser.add_argument( 45 | '-d', '--db-url', 46 | default=get_settings().db_url, 47 | help='Database URL (default: sqlite:///mailoney.db)' 48 | ) 49 | 50 | parser.add_argument( 51 | '--log-level', 52 | choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], 53 | default=get_settings().log_level, 54 | help='Log level' 55 | ) 56 | 57 | return parser.parse_args() 58 | 59 | def display_banner() -> None: 60 | """Display the Mailoney banner""" 61 | from src.mailoney import __version__ 62 | 63 | banner = f""" 64 | **************************************************************** 65 | * Mailoney - A Simple SMTP Honeypot - Version: {__version__} * 66 | **************************************************************** 67 | """ 68 | print(banner) 69 | 70 | def run_server() -> None: 71 | """ 72 | Run the SMTP Honeypot server. 73 | This is the main entry point for the application. 74 | """ 75 | # Parse command-line arguments 76 | args = parse_args() 77 | 78 | # Configure logging 79 | configure_logging(args.log_level) 80 | 81 | # Display banner 82 | display_banner() 83 | 84 | # Initialize database 85 | logger.info(f"Initializing database with URL: {args.db_url}") 86 | init_db(args.db_url) 87 | 88 | # Create and start server 89 | try: 90 | server = SMTPHoneypot( 91 | bind_ip=args.ip, 92 | bind_port=args.port, 93 | server_name=args.server_name 94 | ) 95 | 96 | logger.info(f"Starting SMTP Honeypot on {args.ip}:{args.port}") 97 | server.start() 98 | except KeyboardInterrupt: 99 | logger.info("Server stopped by user") 100 | except Exception as e: 101 | logger.error(f"Error running server: {e}") 102 | sys.exit(1) 103 | 104 | if __name__ == "__main__": 105 | run_server() 106 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "mailoney" 7 | version = "2.0.0" 8 | authors = [ 9 | { name = "phin3has", email = "phin3has@protonmail.com" } 10 | ] 11 | description = "A Simple SMTP Honeypot with database logging" 12 | readme = "README.md" 13 | license = { file = "LICENSE" } 14 | requires-python = ">=3.8" 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | ] 20 | dependencies = [ 21 | "sqlalchemy>=2.0.0", 22 | "psycopg2-binary>=2.9.0", 23 | "pydantic>=1.10.0", 24 | "alembic>=1.7.0", 25 | "python-dotenv>=0.19.0", 26 | ] 27 | 28 | [project.urls] 29 | "Homepage" = "https://github.com/phin3has/mailoney" 30 | "Bug Tracker" = "https://github.com/phin3has/mailoney/issues" 31 | 32 | [project.scripts] 33 | mailoney = "main:main" 34 | 35 | [tool.pytest.ini_options] 36 | addopts = "-ra -q" 37 | testpaths = [ 38 | "tests", 39 | ] 40 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | sqlalchemy>=2.0.0 2 | psycopg2-binary>=2.9.0 3 | pydantic>=2.0.0 4 | pydantic-settings>=2.0.0 5 | alembic>=1.7.0 6 | python-dotenv>=0.19.0 7 | pytest>=7.0.0 8 | pytest-cov>=3.0.0 9 | black>=22.3.0 10 | isort>=5.10.0 11 | mypy>=0.950 12 | -------------------------------------------------------------------------------- /src/mailoney/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mailoney - A Simple SMTP Honeypot 3 | """ 4 | __author__ = 'awhitehatter' 5 | __version__ = '0.2.0' 6 | 7 | from .db import init_db 8 | from .server import SMTPHoneypot 9 | 10 | def main(): 11 | """Main entry point for the application.""" 12 | from main.main_guts_stuff import run_server 13 | run_server() 14 | -------------------------------------------------------------------------------- /src/mailoney/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration handling for Mailoney 3 | """ 4 | import os 5 | import logging 6 | from typing import Dict, Any, Optional 7 | from pydantic import BaseSettings, Field 8 | 9 | # Configure logging 10 | logging.basicConfig( 11 | level=logging.INFO, 12 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 13 | ) 14 | 15 | class Settings(BaseSettings): 16 | """Application settings""" 17 | # Server settings 18 | bind_ip: str = Field(default="0.0.0.0", env="MAILONEY_BIND_IP") 19 | bind_port: int = Field(default=25, env="MAILONEY_BIND_PORT") 20 | server_name: str = Field(default="mail.example.com", env="MAILONEY_SERVER_NAME") 21 | 22 | # Database settings 23 | db_url: str = Field(default="sqlite:///mailoney.db", env="MAILONEY_DB_URL") 24 | 25 | # Logging settings 26 | log_level: str = Field(default="INFO", env="MAILONEY_LOG_LEVEL") 27 | 28 | class Config: 29 | """Pydantic config""" 30 | env_file = ".env" 31 | case_sensitive = False 32 | 33 | def get_settings() -> Settings: 34 | """ 35 | Get application settings 36 | 37 | Returns: 38 | Settings object 39 | """ 40 | return Settings() 41 | 42 | def configure_logging(level: Optional[str] = None) -> None: 43 | """ 44 | Configure application logging 45 | 46 | Args: 47 | level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 48 | """ 49 | if level is None: 50 | level = get_settings().log_level 51 | 52 | numeric_level = getattr(logging, level.upper(), None) 53 | if not isinstance(numeric_level, int): 54 | raise ValueError(f"Invalid log level: {level}") 55 | 56 | logging.basicConfig( 57 | level=numeric_level, 58 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 59 | ) 60 | -------------------------------------------------------------------------------- /src/mailoney/db.py: -------------------------------------------------------------------------------- 1 | """ 2 | Database handling module for Mailoney 3 | """ 4 | import os 5 | import logging 6 | from datetime import datetime 7 | from typing import Dict, Any, Optional 8 | 9 | from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, ForeignKey 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm import sessionmaker, relationship 12 | from sqlalchemy.pool import NullPool 13 | 14 | Base = declarative_base() 15 | engine = None 16 | Session = None 17 | logger = logging.getLogger(__name__) 18 | 19 | class SMTPSession(Base): 20 | """Model for SMTP session data""" 21 | __tablename__ = "smtp_sessions" 22 | 23 | id = Column(Integer, primary_key=True) 24 | timestamp = Column(DateTime, default=datetime.utcnow) 25 | ip_address = Column(String(255), nullable=False) 26 | port = Column(Integer, nullable=False) 27 | server_name = Column(String(255)) 28 | session_data = Column(Text) 29 | 30 | class Credential(Base): 31 | """Model for captured credentials""" 32 | __tablename__ = "credentials" 33 | 34 | id = Column(Integer, primary_key=True) 35 | timestamp = Column(DateTime, default=datetime.utcnow) 36 | session_id = Column(Integer, ForeignKey("smtp_sessions.id")) 37 | auth_string = Column(String(255)) 38 | 39 | session = relationship("SMTPSession", back_populates="credentials") 40 | 41 | # Add relationship to SMTPSession 42 | SMTPSession.credentials = relationship("Credential", order_by=Credential.id, back_populates="session") 43 | 44 | def init_db(db_url: Optional[str] = None) -> None: 45 | """ 46 | Initialize the database connection. 47 | 48 | Args: 49 | db_url: Database connection URL. If None, uses MAILONEY_DB_URL environment variable. 50 | """ 51 | global engine, Session 52 | 53 | if db_url is None: 54 | db_url = os.environ.get("MAILONEY_DB_URL") 55 | 56 | if not db_url: 57 | # Default to SQLite for development 58 | db_url = "sqlite:///mailoney.db" 59 | logger.warning(f"No database URL provided, using default: {db_url}") 60 | 61 | engine = create_engine(db_url, poolclass=NullPool) 62 | Session = sessionmaker(bind=engine) 63 | 64 | # Create tables if they don't exist 65 | Base.metadata.create_all(engine) 66 | logger.info(f"Database initialized with URL: {db_url}") 67 | 68 | def create_session(ip_address: str, port: int, server_name: str) -> SMTPSession: 69 | """ 70 | Create a new session record in the database. 71 | 72 | Args: 73 | ip_address: Client IP address 74 | port: Client port 75 | server_name: Server name used 76 | 77 | Returns: 78 | The created SMTPSession instance 79 | """ 80 | if Session is None: 81 | init_db() 82 | 83 | session = Session() 84 | try: 85 | smtp_session = SMTPSession( 86 | ip_address=ip_address, 87 | port=port, 88 | server_name=server_name 89 | ) 90 | session.add(smtp_session) 91 | session.commit() 92 | return smtp_session 93 | except Exception as e: 94 | session.rollback() 95 | logger.error(f"Error creating session: {e}") 96 | raise 97 | finally: 98 | session.close() 99 | 100 | def update_session_data(session_id: int, session_data: str) -> None: 101 | """ 102 | Update session data for an existing session. 103 | 104 | Args: 105 | session_id: The ID of the session to update 106 | session_data: Session data to store 107 | """ 108 | if Session is None: 109 | init_db() 110 | 111 | session = Session() 112 | try: 113 | smtp_session = session.query(SMTPSession).filter_by(id=session_id).first() 114 | if smtp_session: 115 | smtp_session.session_data = session_data 116 | session.commit() 117 | else: 118 | logger.warning(f"Session ID {session_id} not found") 119 | except Exception as e: 120 | session.rollback() 121 | logger.error(f"Error updating session: {e}") 122 | raise 123 | finally: 124 | session.close() 125 | 126 | def log_credential(session_id: int, auth_string: str) -> None: 127 | """ 128 | Log a credential attempt. 129 | 130 | Args: 131 | session_id: The ID of the session 132 | auth_string: The authentication string 133 | """ 134 | if Session is None: 135 | init_db() 136 | 137 | session = Session() 138 | try: 139 | credential = Credential( 140 | session_id=session_id, 141 | auth_string=auth_string 142 | ) 143 | session.add(credential) 144 | session.commit() 145 | except Exception as e: 146 | session.rollback() 147 | logger.error(f"Error logging credential: {e}") 148 | raise 149 | finally: 150 | session.close() 151 | -------------------------------------------------------------------------------- /src/mailoney/migrations/env.py: -------------------------------------------------------------------------------- 1 | """ 2 | Alembic environment script for database migrations 3 | """ 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | if config.config_file_name is not None: 18 | fileConfig(config.config_file_name) 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | from src.mailoney.db import Base 23 | target_metadata = Base.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | # Override with environment variable if set 31 | import os 32 | url = os.getenv("MAILONEY_DB_URL") 33 | if url: 34 | config.set_main_option("sqlalchemy.url", url) 35 | 36 | def run_migrations_offline() -> None: 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, 51 | target_metadata=target_metadata, 52 | literal_binds=True, 53 | dialect_opts={"paramstyle": "named"}, 54 | ) 55 | 56 | with context.begin_transaction(): 57 | context.run_migrations() 58 | 59 | 60 | def run_migrations_online() -> None: 61 | """Run migrations in 'online' mode. 62 | 63 | In this scenario we need to create an Engine 64 | and associate a connection with the context. 65 | 66 | """ 67 | connectable = engine_from_config( 68 | config.get_section(config.config_ini_section, {}), 69 | prefix="sqlalchemy.", 70 | poolclass=pool.NullPool, 71 | ) 72 | 73 | with connectable.connect() as connection: 74 | context.configure( 75 | connection=connection, target_metadata=target_metadata 76 | ) 77 | 78 | with context.begin_transaction(): 79 | context.run_migrations() 80 | 81 | 82 | if context.is_offline_mode(): 83 | run_migrations_offline() 84 | else: 85 | run_migrations_online() 86 | -------------------------------------------------------------------------------- /src/mailoney/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | SMTP Honeypot Server Implementation 3 | """ 4 | import socket 5 | import threading 6 | import logging 7 | import json 8 | from time import strftime 9 | from typing import Optional, Tuple, Dict, Any 10 | 11 | from .db import create_session, update_session_data, log_credential 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | class SMTPHoneypot: 16 | """ 17 | SMTP Honeypot Server class 18 | """ 19 | 20 | def __init__( 21 | self, 22 | bind_ip: str = '0.0.0.0', 23 | bind_port: int = 25, 24 | server_name: str = 'mail.example.com' 25 | ): 26 | """ 27 | Initialize the SMTP honeypot server. 28 | 29 | Args: 30 | bind_ip: IP address to bind to 31 | bind_port: Port to listen on 32 | server_name: Server name to display in SMTP responses 33 | """ 34 | self.bind_ip = bind_ip 35 | self.bind_port = bind_port 36 | self.server_name = server_name 37 | self.socket = None 38 | self.ehlo_response = f'''250 {server_name} 39 | 250-PIPELINING 40 | 250-SIZE 10240000 41 | 250-VRFY 42 | 250-ETRN 43 | 250-STARTTLS 44 | 250-AUTH LOGIN PLAIN 45 | 250 8BITMIME\n''' 46 | 47 | def start(self) -> None: 48 | """ 49 | Start the SMTP honeypot server 50 | """ 51 | try: 52 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 53 | self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 54 | self.socket.bind((self.bind_ip, self.bind_port)) 55 | self.socket.listen(10) 56 | 57 | logger.info(f"SMTP Honeypot listening on {self.bind_ip}:{self.bind_port}") 58 | print(f"[*] SMTP Honeypot listening on {self.bind_ip}:{self.bind_port}") 59 | 60 | self._accept_connections() 61 | except Exception as e: 62 | logger.error(f"Error starting server: {e}") 63 | if self.socket: 64 | self.socket.close() 65 | raise 66 | 67 | def _accept_connections(self) -> None: 68 | """ 69 | Accept and handle incoming connections 70 | """ 71 | while True: 72 | try: 73 | client, addr = self.socket.accept() 74 | logger.info(f"Connection from {addr[0]}:{addr[1]}") 75 | print(f"[*] Connection from {addr[0]}:{addr[1]}") 76 | 77 | client_handler = threading.Thread( 78 | target=self._handle_client, 79 | args=(client, addr) 80 | ) 81 | client_handler.daemon = True 82 | client_handler.start() 83 | except Exception as e: 84 | logger.error(f"Error accepting connection: {e}") 85 | 86 | def _handle_client(self, client_socket: socket.socket, addr: Tuple[str, int]) -> None: 87 | """ 88 | Handle client connection 89 | 90 | Args: 91 | client_socket: Client socket 92 | addr: Client address tuple (ip, port) 93 | """ 94 | session_record = create_session(addr[0], addr[1], self.server_name) 95 | session_log = [] 96 | 97 | try: 98 | # Send banner 99 | banner = f"220 {self.server_name} ESMTP Service Ready\n" 100 | client_socket.send(banner.encode()) 101 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": banner}) 102 | 103 | # Handle client commands 104 | error_count = 0 105 | while error_count < 10: 106 | try: 107 | request = client_socket.recv(4096).decode().strip().lower() 108 | if not request: 109 | break 110 | 111 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "in", "data": request}) 112 | logger.debug(f"Client: {request}") 113 | 114 | # Handle EHLO/HELO 115 | if request.startswith('ehlo') or request.startswith('helo'): 116 | client_socket.send(self.ehlo_response.encode()) 117 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": self.ehlo_response}) 118 | error_count = 0 # Reset error count after successful command 119 | 120 | # Handle AUTH 121 | elif request.startswith('auth plain'): 122 | # Extract auth string 123 | parts = request.split() 124 | if len(parts) >= 3: 125 | auth_string = parts[2] 126 | log_credential(session_record.id, auth_string) 127 | logger.info(f"Captured credential: {auth_string}") 128 | 129 | response = "235 2.7.0 Authentication failed\n" 130 | client_socket.send(response.encode()) 131 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": response}) 132 | 133 | # Handle QUIT 134 | elif request.startswith('quit'): 135 | response = "221 2.0.0 Goodbye\n" 136 | client_socket.send(response.encode()) 137 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": response}) 138 | break 139 | 140 | # Handle other SMTP commands (simplistic simulation) 141 | elif request.startswith(('mail from:', 'rcpt to:', 'data')): 142 | response = "250 2.1.0 OK\n" 143 | client_socket.send(response.encode()) 144 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": response}) 145 | error_count = 0 146 | 147 | # Unknown command 148 | else: 149 | response = "502 5.5.2 Error: command not recognized\n" 150 | client_socket.send(response.encode()) 151 | session_log.append({"timestamp": strftime("%Y-%m-%d %H:%M:%S"), "direction": "out", "data": response}) 152 | error_count += 1 153 | 154 | except Exception as e: 155 | logger.error(f"Error handling client request: {e}") 156 | error_count += 1 157 | 158 | # Store the session log 159 | update_session_data(session_record.id, json.dumps(session_log)) 160 | 161 | except Exception as e: 162 | logger.error(f"Error in client handler: {e}") 163 | finally: 164 | client_socket.close() 165 | logger.info(f"Connection closed for {addr[0]}:{addr[1]}") 166 | 167 | def stop(self) -> None: 168 | """ 169 | Stop the SMTP honeypot server 170 | """ 171 | if self.socket: 172 | self.socket.close() 173 | logger.info("SMTP Honeypot server stopped") 174 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Tests package 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shared test fixtures and configuration 3 | """ 4 | import pytest 5 | import logging 6 | from sqlalchemy import create_engine, inspect 7 | from sqlalchemy.orm import sessionmaker 8 | import sys 9 | 10 | from mailoney.db import Base 11 | 12 | # Configure logging for debugging 13 | logging.basicConfig(level=logging.DEBUG) 14 | 15 | @pytest.fixture(scope="session", autouse=True) 16 | def setup_test_environment(): 17 | """ 18 | Set up the test environment with the correct database 19 | This fixture runs automatically once before all tests 20 | """ 21 | # Ensure we can import our modules 22 | import os 23 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 24 | 25 | # Set up in-memory SQLite for testing 26 | import mailoney.db as db_module 27 | 28 | # Create a persistent in-memory database for all tests 29 | db_url = "sqlite:///:memory:" 30 | test_engine = create_engine(db_url, echo=True) # echo=True for SQL debugging 31 | 32 | # Create all tables 33 | Base.metadata.drop_all(test_engine) # Start clean 34 | Base.metadata.create_all(test_engine) 35 | 36 | # Verify tables were created 37 | inspector = inspect(test_engine) 38 | tables = inspector.get_table_names() 39 | logging.info(f"Tables created in test environment: {tables}") 40 | 41 | # Save original values 42 | original_engine = db_module.engine 43 | original_session = db_module.Session 44 | 45 | # Replace with our test engine/session 46 | db_module.engine = test_engine 47 | db_module.Session = sessionmaker(bind=test_engine) 48 | 49 | yield 50 | 51 | # Restore original values after all tests 52 | db_module.engine = original_engine 53 | db_module.Session = original_session 54 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the configuration module 3 | """ 4 | import os 5 | import pytest 6 | from mailoney.config import get_settings, configure_logging, Settings 7 | 8 | def test_default_settings(): 9 | """Test default settings""" 10 | settings = get_settings() 11 | 12 | assert settings.bind_ip == "0.0.0.0" 13 | assert settings.bind_port == 25 14 | assert settings.server_name == "mail.example.com" 15 | assert settings.db_url == "sqlite:///mailoney.db" 16 | assert settings.log_level == "INFO" 17 | 18 | def test_env_settings(monkeypatch): 19 | """Test settings from environment variables""" 20 | # Set environment variables 21 | monkeypatch.setenv("MAILONEY_BIND_IP", "127.0.0.1") 22 | monkeypatch.setenv("MAILONEY_BIND_PORT", "2525") 23 | monkeypatch.setenv("MAILONEY_SERVER_NAME", "honeypot.local") 24 | monkeypatch.setenv("MAILONEY_DB_URL", "postgresql://user:pass@localhost/honeypot") 25 | monkeypatch.setenv("MAILONEY_LOG_LEVEL", "DEBUG") 26 | 27 | # Recreate settings to pick up environment variables 28 | settings = Settings() 29 | 30 | assert settings.bind_ip == "127.0.0.1" 31 | assert settings.bind_port == 2525 32 | assert settings.server_name == "honeypot.local" 33 | assert settings.db_url == "postgresql://user:pass@localhost/honeypot" 34 | assert settings.log_level == "DEBUG" 35 | 36 | def test_configure_logging(): 37 | """Test logging configuration""" 38 | # This is more of a smoke test since it's hard to test logging configuration 39 | configure_logging("DEBUG") 40 | configure_logging("INFO") 41 | 42 | # Test with invalid level 43 | with pytest.raises(ValueError): 44 | configure_logging("NOT_A_LEVEL") 45 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the core module 3 | """ 4 | import pytest 5 | import socket 6 | from unittest.mock import patch, MagicMock 7 | from mailoney.core import SMTPHoneypot 8 | 9 | @pytest.fixture 10 | def smtp_honeypot(): 11 | """Test SMTP honeypot fixture""" 12 | honeypot = SMTPHoneypot( 13 | bind_ip="127.0.0.1", 14 | bind_port=8025, 15 | server_name="test.example.com" 16 | ) 17 | return honeypot 18 | 19 | def test_smtp_honeypot_init(smtp_honeypot): 20 | """Test SMTP honeypot initialization""" 21 | assert smtp_honeypot.bind_ip == "127.0.0.1" 22 | assert smtp_honeypot.bind_port == 8025 23 | assert smtp_honeypot.server_name == "test.example.com" 24 | assert "test.example.com" in smtp_honeypot.ehlo_response 25 | assert smtp_honeypot.socket is None 26 | 27 | @patch("socket.socket") 28 | def test_smtp_honeypot_start(mock_socket, smtp_honeypot): 29 | """Test SMTP honeypot start method""" 30 | # Setup mock socket 31 | mock_socket_instance = MagicMock() 32 | mock_socket.return_value = mock_socket_instance 33 | 34 | # Create a simple counter to check if the method was called 35 | call_count = [0] 36 | 37 | # Define a replacement function that increments the counter 38 | def accept_connections_mock(): 39 | call_count[0] += 1 40 | return None 41 | 42 | # Replace the method with our mock that increments the counter 43 | original_method = smtp_honeypot._accept_connections 44 | smtp_honeypot._accept_connections = accept_connections_mock 45 | 46 | try: 47 | # Call the start method 48 | smtp_honeypot.start() 49 | 50 | # Verify socket configuration 51 | mock_socket.assert_called_with(socket.AF_INET, socket.SOCK_STREAM) 52 | mock_socket_instance.setsockopt.assert_called_with( 53 | socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 54 | ) 55 | mock_socket_instance.bind.assert_called_with(("127.0.0.1", 8025)) 56 | mock_socket_instance.listen.assert_called_with(10) 57 | 58 | # Verify our function was called 59 | assert call_count[0] > 0, "The _accept_connections method was not called" 60 | finally: 61 | # Restore the original method 62 | smtp_honeypot._accept_connections = original_method 63 | -------------------------------------------------------------------------------- /tests/test_db.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the database module 3 | """ 4 | import pytest 5 | import os 6 | import logging 7 | import sqlalchemy as sa 8 | from sqlalchemy import create_engine, inspect 9 | from sqlalchemy.orm import sessionmaker 10 | 11 | from mailoney.db import Base, init_db, create_session, update_session_data, log_credential 12 | 13 | # Configure logging for debugging 14 | logging.basicConfig(level=logging.DEBUG) 15 | 16 | @pytest.fixture 17 | def test_db(): 18 | """Simple test database fixture that ensures a clean state before each test""" 19 | # The actual setup is done in conftest.py 20 | # This fixture just ensures each test starts with a clean db state 21 | 22 | from mailoney.db import engine 23 | 24 | # Verify tables are there (debugging) 25 | insp = inspect(engine) 26 | tables = insp.get_table_names() 27 | logging.info(f"Available tables for test: {tables}") 28 | 29 | yield 30 | 31 | # No cleanup needed - handled in conftest 32 | 33 | def test_create_session(test_db): 34 | """Test creating a session record""" 35 | session = create_session("192.168.1.1", 12345, "test.example.com") 36 | assert session.id is not None 37 | assert session.ip_address == "192.168.1.1" 38 | assert session.port == 12345 39 | assert session.server_name == "test.example.com" 40 | 41 | # Verify the session was created in the database 42 | from mailoney.db import Session, SMTPSession 43 | db_session = Session() 44 | try: 45 | stmt = sa.select(SMTPSession).filter_by(id=session.id) 46 | db_result = db_session.execute(stmt).scalar_one_or_none() 47 | assert db_result is not None 48 | assert db_result.ip_address == "192.168.1.1" 49 | finally: 50 | db_session.close() 51 | 52 | def test_update_session_data(test_db): 53 | """Test updating session data""" 54 | # Create a new session 55 | session = create_session("192.168.1.1", 12345, "test.example.com") 56 | session_id = session.id 57 | 58 | # Update its data 59 | update_session_data(session_id, '{"test": "data"}') 60 | 61 | # Verify the data was updated by querying the database directly 62 | from mailoney.db import Session, SMTPSession 63 | db_session = Session() 64 | try: 65 | # SQLAlchemy 2.0 syntax 66 | stmt = sa.select(SMTPSession).filter_by(id=session_id) 67 | updated_session = db_session.execute(stmt).scalar_one() 68 | assert updated_session.session_data == '{"test": "data"}' 69 | finally: 70 | db_session.close() 71 | 72 | def test_log_credential(test_db): 73 | """Test logging a credential""" 74 | # Create a session first 75 | session = create_session("192.168.1.1", 12345, "test.example.com") 76 | session_id = session.id 77 | 78 | # Log a credential 79 | log_credential(session_id, "dGVzdDp0ZXN0") # test:test in base64 80 | 81 | # Verify the credential was logged by querying directly 82 | from mailoney.db import Session, Credential 83 | db_session = Session() 84 | try: 85 | # SQLAlchemy 2.0 syntax 86 | stmt = sa.select(Credential).filter_by(session_id=session_id) 87 | credential = db_session.execute(stmt).scalar_one() 88 | assert credential is not None 89 | assert credential.auth_string == "dGVzdDp0ZXN0" 90 | finally: 91 | db_session.close() 92 | --------------------------------------------------------------------------------