Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc4877e06b | |||
| 36006191fc | |||
| d43fc15d8e | |||
| 248bfcfe78 | |||
| 1e7c9f6822 | |||
| f3a74a7660 | |||
| 399f5fa418 | |||
| cd4584ec26 | |||
| f601859f8b | |||
| eb2643de93 | |||
| 595634fb0f | |||
| cee8a51081 | |||
| f1c5546186 | |||
| 5220ee0857 | |||
| fc2e6d44f4 | |||
| 15a45089aa | |||
| b82468ab1e | |||
| ffe294643c | |||
| f1071faf3d | |||
| 6b082cee8f | |||
| 9185242530 | |||
| 8293663619 | |||
| 199b9b79d2 | |||
| 91b49182bb | |||
| 564b65c7f2 | |||
| 8bd8c295b0 | |||
| 237dba3bab | |||
| bcde137332 | |||
| 14be3cdb9a | |||
| f262f602a0 | |||
| 0ac598818f | |||
| c7b3206140 | |||
| 17f5661636 | |||
| 6523c55516 | |||
| 9cd15342e0 | |||
| 0018b19164 | |||
| 7ecdd9f1e4 | |||
| 1698df3a53 | |||
| d7f37afc30 | |||
| 27b6bb779e | |||
| d4778d15fc | |||
| 269bafc1bf | |||
| d6a1cf5bf4 | |||
| e49becec95 |
26
.gitea/release-template.md
Normal file
26
.gitea/release-template.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
## MAILER {{VERSION}}
|
||||||
|
|
||||||
|
Pre-compiled binaries for multiple platforms.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
#### Option 1: Via npm (recommended)
|
||||||
|
```bash
|
||||||
|
npm install -g @push.rocks/smartmta
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 2: Direct binary download
|
||||||
|
Download the appropriate binary for your platform from the assets below and make it executable.
|
||||||
|
|
||||||
|
### Supported Platforms
|
||||||
|
- Linux x86_64 (x64)
|
||||||
|
- Linux ARM64 (aarch64)
|
||||||
|
- macOS x86_64 (Intel)
|
||||||
|
- macOS ARM64 (Apple Silicon)
|
||||||
|
- Windows x86_64
|
||||||
|
|
||||||
|
### Checksums
|
||||||
|
SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification.
|
||||||
|
|
||||||
|
### npm Package
|
||||||
|
The npm package includes automatic binary detection and installation for your platform.
|
||||||
84
.gitea/workflows/ci.yml
Normal file
84
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'migration/**'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Type Check & Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Deno
|
||||||
|
uses: denoland/setup-deno@v1
|
||||||
|
with:
|
||||||
|
deno-version: v2.x
|
||||||
|
|
||||||
|
- name: Check TypeScript types
|
||||||
|
run: deno check mod.ts
|
||||||
|
|
||||||
|
- name: Lint code
|
||||||
|
run: deno lint
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: deno fmt --check
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build Test (Current Platform)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Deno
|
||||||
|
uses: denoland/setup-deno@v1
|
||||||
|
with:
|
||||||
|
deno-version: v2.x
|
||||||
|
|
||||||
|
- name: Compile for current platform
|
||||||
|
run: |
|
||||||
|
echo "Testing compilation for Linux x86_64..."
|
||||||
|
deno compile --allow-all --no-check \
|
||||||
|
--output mailer-test \
|
||||||
|
--target x86_64-unknown-linux-gnu mod.ts
|
||||||
|
|
||||||
|
- name: Test binary execution
|
||||||
|
run: |
|
||||||
|
chmod +x mailer-test
|
||||||
|
./mailer-test --version
|
||||||
|
./mailer-test help
|
||||||
|
|
||||||
|
build-all:
|
||||||
|
name: Build All Platforms
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Deno
|
||||||
|
uses: denoland/setup-deno@v1
|
||||||
|
with:
|
||||||
|
deno-version: v2.x
|
||||||
|
|
||||||
|
- name: Compile all platform binaries
|
||||||
|
run: bash scripts/compile-all.sh
|
||||||
|
|
||||||
|
- name: Upload all binaries as artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: mailer-binaries.zip
|
||||||
|
path: dist/binaries/*
|
||||||
|
retention-days: 30
|
||||||
129
.gitea/workflows/npm-publish.yml
Normal file
129
.gitea/workflows/npm-publish.yml
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
name: Publish to npm
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
npm-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Deno
|
||||||
|
uses: denoland/setup-deno@v1
|
||||||
|
with:
|
||||||
|
deno-version: v2.x
|
||||||
|
|
||||||
|
- name: Setup Node.js for npm publishing
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18.x'
|
||||||
|
registry-url: 'https://registry.npmjs.org/'
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Publishing version: $VERSION"
|
||||||
|
|
||||||
|
- name: Verify deno.json version matches tag
|
||||||
|
run: |
|
||||||
|
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
|
||||||
|
TAG_VERSION="${{ steps.version.outputs.version_number }}"
|
||||||
|
echo "deno.json version: $DENO_VERSION"
|
||||||
|
echo "Tag version: $TAG_VERSION"
|
||||||
|
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
|
||||||
|
echo "ERROR: Version mismatch!"
|
||||||
|
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Compile binaries for npm package
|
||||||
|
run: |
|
||||||
|
echo "Compiling binaries for npm package..."
|
||||||
|
deno task compile
|
||||||
|
echo ""
|
||||||
|
echo "Binary sizes:"
|
||||||
|
ls -lh dist/binaries/
|
||||||
|
|
||||||
|
- name: Generate SHA256 checksums
|
||||||
|
run: |
|
||||||
|
cd dist/binaries
|
||||||
|
sha256sum * > SHA256SUMS
|
||||||
|
cat SHA256SUMS
|
||||||
|
cd ../..
|
||||||
|
|
||||||
|
- name: Sync package.json version
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version_number }}"
|
||||||
|
echo "Syncing package.json to version ${VERSION}..."
|
||||||
|
npm version ${VERSION} --no-git-tag-version --allow-same-version
|
||||||
|
echo "package.json version: $(grep '"version"' package.json | head -1)"
|
||||||
|
|
||||||
|
- name: Create npm package
|
||||||
|
run: |
|
||||||
|
echo "Creating npm package..."
|
||||||
|
npm pack
|
||||||
|
echo ""
|
||||||
|
echo "Package created:"
|
||||||
|
ls -lh *.tgz
|
||||||
|
|
||||||
|
- name: Test local installation
|
||||||
|
run: |
|
||||||
|
echo "Testing local package installation..."
|
||||||
|
PACKAGE_FILE=$(ls *.tgz)
|
||||||
|
npm install -g ${PACKAGE_FILE}
|
||||||
|
echo ""
|
||||||
|
echo "Testing mailer command:"
|
||||||
|
mailer --version || echo "Note: Binary execution may fail in CI environment"
|
||||||
|
echo ""
|
||||||
|
echo "Checking installed files:"
|
||||||
|
npm ls -g @push.rocks/smartmta || true
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
run: |
|
||||||
|
echo "Publishing to npm registry..."
|
||||||
|
npm publish --access public
|
||||||
|
echo ""
|
||||||
|
echo "✅ Successfully published @push.rocks/smartmta to npm!"
|
||||||
|
echo ""
|
||||||
|
echo "Package info:"
|
||||||
|
npm view @push.rocks/smartmta
|
||||||
|
|
||||||
|
- name: Verify npm package
|
||||||
|
run: |
|
||||||
|
echo "Waiting for npm propagation..."
|
||||||
|
sleep 30
|
||||||
|
echo ""
|
||||||
|
echo "Verifying published package..."
|
||||||
|
npm view @push.rocks/smartmta
|
||||||
|
echo ""
|
||||||
|
echo "Testing installation from npm:"
|
||||||
|
npm install -g @push.rocks/smartmta
|
||||||
|
echo ""
|
||||||
|
echo "Package installed successfully!"
|
||||||
|
which mailer || echo "Binary location check skipped"
|
||||||
|
|
||||||
|
- name: Publish Summary
|
||||||
|
run: |
|
||||||
|
echo "================================================"
|
||||||
|
echo " npm Publish Complete!"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
echo "✅ Package: @push.rocks/smartmta"
|
||||||
|
echo "✅ Version: ${{ steps.version.outputs.version }}"
|
||||||
|
echo ""
|
||||||
|
echo "Installation:"
|
||||||
|
echo " npm install -g @push.rocks/smartmta"
|
||||||
|
echo ""
|
||||||
|
echo "Registry:"
|
||||||
|
echo " https://www.npmjs.com/package/@push.rocks/smartmta"
|
||||||
|
echo ""
|
||||||
249
.gitea/workflows/release.yml
Normal file
249
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Deno
|
||||||
|
uses: denoland/setup-deno@v1
|
||||||
|
with:
|
||||||
|
deno-version: v2.x
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Building version: $VERSION"
|
||||||
|
|
||||||
|
- name: Verify deno.json version matches tag
|
||||||
|
run: |
|
||||||
|
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
|
||||||
|
TAG_VERSION="${{ steps.version.outputs.version_number }}"
|
||||||
|
echo "deno.json version: $DENO_VERSION"
|
||||||
|
echo "Tag version: $TAG_VERSION"
|
||||||
|
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
|
||||||
|
echo "ERROR: Version mismatch!"
|
||||||
|
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Compile binaries for all platforms
|
||||||
|
run: |
|
||||||
|
echo "================================================"
|
||||||
|
echo " MAILER Release Compilation"
|
||||||
|
echo " Version: ${{ steps.version.outputs.version }}"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Clean up old binaries and create fresh directory
|
||||||
|
rm -rf dist/binaries
|
||||||
|
mkdir -p dist/binaries
|
||||||
|
echo "→ Cleaned old binaries from dist/binaries"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Linux x86_64
|
||||||
|
echo "→ Compiling for Linux x86_64..."
|
||||||
|
deno compile --allow-all --no-check \
|
||||||
|
--output dist/binaries/mailer-linux-x64 \
|
||||||
|
--target x86_64-unknown-linux-gnu mod.ts
|
||||||
|
echo " ✓ Linux x86_64 complete"
|
||||||
|
|
||||||
|
# Linux ARM64
|
||||||
|
echo "→ Compiling for Linux ARM64..."
|
||||||
|
deno compile --allow-all --no-check \
|
||||||
|
--output dist/binaries/mailer-linux-arm64 \
|
||||||
|
--target aarch64-unknown-linux-gnu mod.ts
|
||||||
|
echo " ✓ Linux ARM64 complete"
|
||||||
|
|
||||||
|
# macOS x86_64
|
||||||
|
echo "→ Compiling for macOS x86_64..."
|
||||||
|
deno compile --allow-all --no-check \
|
||||||
|
--output dist/binaries/mailer-macos-x64 \
|
||||||
|
--target x86_64-apple-darwin mod.ts
|
||||||
|
echo " ✓ macOS x86_64 complete"
|
||||||
|
|
||||||
|
# macOS ARM64
|
||||||
|
echo "→ Compiling for macOS ARM64..."
|
||||||
|
deno compile --allow-all --no-check \
|
||||||
|
--output dist/binaries/mailer-macos-arm64 \
|
||||||
|
--target aarch64-apple-darwin mod.ts
|
||||||
|
echo " ✓ macOS ARM64 complete"
|
||||||
|
|
||||||
|
# Windows x86_64
|
||||||
|
echo "→ Compiling for Windows x86_64..."
|
||||||
|
deno compile --allow-all --no-check \
|
||||||
|
--output dist/binaries/mailer-windows-x64.exe \
|
||||||
|
--target x86_64-pc-windows-msvc mod.ts
|
||||||
|
echo " ✓ Windows x86_64 complete"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "All binaries compiled successfully!"
|
||||||
|
ls -lh dist/binaries/
|
||||||
|
|
||||||
|
- name: Generate SHA256 checksums
|
||||||
|
run: |
|
||||||
|
cd dist/binaries
|
||||||
|
sha256sum * > SHA256SUMS.txt
|
||||||
|
cat SHA256SUMS.txt
|
||||||
|
cd ../..
|
||||||
|
|
||||||
|
- name: Extract changelog for this version
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
# Check if CHANGELOG.md exists
|
||||||
|
if [ ! -f CHANGELOG.md ]; then
|
||||||
|
echo "No CHANGELOG.md found, using default release notes"
|
||||||
|
cat > /tmp/release_notes.md << EOF
|
||||||
|
## MAILER $VERSION
|
||||||
|
|
||||||
|
Pre-compiled binaries for multiple platforms.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
Use the installation script:
|
||||||
|
\`\`\`bash
|
||||||
|
curl -sSL https://code.foss.global/serve.zone/mailer/raw/branch/main/install.sh | sudo bash
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Or download the binary for your platform and make it executable.
|
||||||
|
|
||||||
|
### Supported Platforms
|
||||||
|
- Linux x86_64 (x64)
|
||||||
|
- Linux ARM64 (aarch64)
|
||||||
|
- macOS x86_64 (Intel)
|
||||||
|
- macOS ARM64 (Apple Silicon)
|
||||||
|
- Windows x86_64
|
||||||
|
|
||||||
|
### Checksums
|
||||||
|
SHA256 checksums are provided in SHA256SUMS.txt
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
# Try to extract section for this version from CHANGELOG.md
|
||||||
|
# This is a simple extraction - adjust based on your CHANGELOG format
|
||||||
|
awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
|
||||||
|
## MAILER $VERSION
|
||||||
|
|
||||||
|
See CHANGELOG.md for full details.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
Use the installation script:
|
||||||
|
\`\`\`bash
|
||||||
|
curl -sSL https://code.foss.global/serve.zone/mailer/raw/branch/main/install.sh | sudo bash
|
||||||
|
\`\`\`
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Release notes:"
|
||||||
|
cat /tmp/release_notes.md
|
||||||
|
|
||||||
|
- name: Delete existing release if it exists
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
echo "Checking for existing release $VERSION..."
|
||||||
|
|
||||||
|
# Try to get existing release by tag
|
||||||
|
EXISTING_RELEASE_ID=$(curl -s \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases/tags/$VERSION" \
|
||||||
|
| jq -r '.id // empty')
|
||||||
|
|
||||||
|
if [ -n "$EXISTING_RELEASE_ID" ]; then
|
||||||
|
echo "Found existing release (ID: $EXISTING_RELEASE_ID), deleting..."
|
||||||
|
curl -X DELETE -s \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases/$EXISTING_RELEASE_ID"
|
||||||
|
echo "Existing release deleted"
|
||||||
|
sleep 2
|
||||||
|
else
|
||||||
|
echo "No existing release found, proceeding with creation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
RELEASE_NOTES=$(cat /tmp/release_notes.md)
|
||||||
|
|
||||||
|
# Create the release
|
||||||
|
echo "Creating release for $VERSION..."
|
||||||
|
RELEASE_ID=$(curl -X POST -s \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"$VERSION\",
|
||||||
|
\"name\": \"MAILER $VERSION\",
|
||||||
|
\"body\": $(jq -Rs . /tmp/release_notes.md),
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}" | jq -r '.id')
|
||||||
|
|
||||||
|
echo "Release created with ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
# Upload binaries as release assets
|
||||||
|
for binary in dist/binaries/*; do
|
||||||
|
filename=$(basename "$binary")
|
||||||
|
echo "Uploading $filename..."
|
||||||
|
curl -X POST -s \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@$binary" \
|
||||||
|
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases/$RELEASE_ID/assets?name=$filename"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "All assets uploaded successfully"
|
||||||
|
|
||||||
|
- name: Clean up old releases
|
||||||
|
run: |
|
||||||
|
echo "Cleaning up old releases (keeping only last 3)..."
|
||||||
|
|
||||||
|
# Fetch all releases sorted by creation date
|
||||||
|
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases" | \
|
||||||
|
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
|
||||||
|
|
||||||
|
# Delete old releases
|
||||||
|
if [ -n "$RELEASES" ]; then
|
||||||
|
echo "Found releases to delete:"
|
||||||
|
for release_id in $RELEASES; do
|
||||||
|
echo " Deleting release ID: $release_id"
|
||||||
|
curl -X DELETE -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases/$release_id"
|
||||||
|
done
|
||||||
|
echo "Old releases deleted successfully"
|
||||||
|
else
|
||||||
|
echo "No old releases to delete (less than 4 releases total)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
- name: Release Summary
|
||||||
|
run: |
|
||||||
|
echo "================================================"
|
||||||
|
echo " Release ${{ steps.version.outputs.version }} Complete!"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
echo "Binaries published:"
|
||||||
|
ls -lh dist/binaries/
|
||||||
|
echo ""
|
||||||
|
echo "Release URL:"
|
||||||
|
echo "https://code.foss.global/serve.zone/mailer/releases/tag/${{ steps.version.outputs.version }}"
|
||||||
|
echo ""
|
||||||
|
echo "Installation command:"
|
||||||
|
echo "curl -sSL https://code.foss.global/serve.zone/mailer/raw/branch/main/install.sh | sudo bash"
|
||||||
|
echo ""
|
||||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -1,7 +1,24 @@
|
|||||||
node_modules/
|
|
||||||
.nogit/
|
.nogit/
|
||||||
|
|
||||||
|
# artifacts
|
||||||
|
coverage/
|
||||||
|
public/
|
||||||
|
|
||||||
|
# installs
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.yarn/
|
||||||
|
.cache/
|
||||||
|
.rpt2_cache
|
||||||
|
|
||||||
|
# builds
|
||||||
dist/
|
dist/
|
||||||
deno.lock
|
dist_*/
|
||||||
*.log
|
|
||||||
.env
|
# AI
|
||||||
.DS_Store
|
.claude/
|
||||||
|
.serena/
|
||||||
|
|
||||||
|
#------# custom
|
||||||
|
rust/target
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MAILER npm wrapper
|
|
||||||
* This script executes the appropriate pre-compiled binary based on the current platform
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { platform, arch } from 'os';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the binary name for the current platform
|
|
||||||
*/
|
|
||||||
function getBinaryName() {
|
|
||||||
const plat = platform();
|
|
||||||
const architecture = arch();
|
|
||||||
|
|
||||||
// Map Node's platform/arch to our binary naming
|
|
||||||
const platformMap = {
|
|
||||||
'darwin': 'macos',
|
|
||||||
'linux': 'linux',
|
|
||||||
'win32': 'windows'
|
|
||||||
};
|
|
||||||
|
|
||||||
const archMap = {
|
|
||||||
'x64': 'x64',
|
|
||||||
'arm64': 'arm64'
|
|
||||||
};
|
|
||||||
|
|
||||||
const mappedPlatform = platformMap[plat];
|
|
||||||
const mappedArch = archMap[architecture];
|
|
||||||
|
|
||||||
if (!mappedPlatform || !mappedArch) {
|
|
||||||
console.error(`Error: Unsupported platform/architecture: ${plat}/${architecture}`);
|
|
||||||
console.error('Supported platforms: Linux, macOS, Windows');
|
|
||||||
console.error('Supported architectures: x64, arm64');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct binary name
|
|
||||||
let binaryName = `mailer-${mappedPlatform}-${mappedArch}`;
|
|
||||||
if (plat === 'win32') {
|
|
||||||
binaryName += '.exe';
|
|
||||||
}
|
|
||||||
|
|
||||||
return binaryName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the binary
|
|
||||||
*/
|
|
||||||
function executeBinary() {
|
|
||||||
const binaryName = getBinaryName();
|
|
||||||
const binaryPath = join(__dirname, '..', 'dist', 'binaries', binaryName);
|
|
||||||
|
|
||||||
// Check if binary exists
|
|
||||||
if (!existsSync(binaryPath)) {
|
|
||||||
console.error(`Error: Binary not found at ${binaryPath}`);
|
|
||||||
console.error('This might happen if:');
|
|
||||||
console.error('1. The postinstall script failed to run');
|
|
||||||
console.error('2. The platform is not supported');
|
|
||||||
console.error('3. The package was not installed correctly');
|
|
||||||
console.error('');
|
|
||||||
console.error('Try reinstalling the package:');
|
|
||||||
console.error(' npm uninstall -g @serve.zone/mailer');
|
|
||||||
console.error(' npm install -g @serve.zone/mailer');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spawn the binary with all arguments passed through
|
|
||||||
const child = spawn(binaryPath, process.argv.slice(2), {
|
|
||||||
stdio: 'inherit',
|
|
||||||
shell: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle child process events
|
|
||||||
child.on('error', (err) => {
|
|
||||||
console.error(`Error executing mailer: ${err.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('exit', (code, signal) => {
|
|
||||||
if (signal) {
|
|
||||||
process.kill(process.pid, signal);
|
|
||||||
} else {
|
|
||||||
process.exit(code || 0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forward signals to child process
|
|
||||||
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
||||||
signals.forEach(signal => {
|
|
||||||
process.on(signal, () => {
|
|
||||||
if (!child.killed) {
|
|
||||||
child.kill(signal);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute
|
|
||||||
executeBinary();
|
|
||||||
139
changelog.md
139
changelog.md
@@ -1,5 +1,144 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-10 - 3.0.0 - BREAKING CHANGE(security)
|
||||||
|
implement resilience and lifecycle management for RustSecurityBridge (auto-restart, health checks, state machine and eventing); remove legacy TS SMTP test helper and DNSManager; remove deliverability IP-warmup/sender-reputation integrations and related types; drop unused dependencies
|
||||||
|
|
||||||
|
- RustSecurityBridge now extends EventEmitter and includes a BridgeState state machine, IBridgeResilienceConfig with DEFAULT_RESILIENCE_CONFIG, auto-restart with exponential backoff, periodic health checks, restart/restore logic, and descriptive ensureRunning() guards on command methods.
|
||||||
|
- Added static methods: resetInstance() (test-friendly) and configure(...) to tweak resilience settings at runtime.
|
||||||
|
- Added stateChange events and logging for lifecycle transitions; new tests added for resilience: test/test.rustsecuritybridge.resilience.node.ts.
|
||||||
|
- Removed the TypeScript SMTP test helper (test/helpers/server.loader.ts), the DNSManager (ts/mail/routing/classes.dnsmanager.ts), and many deliverability-related interfaces/implementations (IP warmup manager and sender reputation monitor) from unified email server.
|
||||||
|
- Removed public types ISmtpServerOptions and ISmtpTransactionResult from ts/mail/delivery/interfaces.ts, which is a breaking API change for consumers relying on those types.
|
||||||
|
- Removed unused dependencies from package.json: ip and mailauth.
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.4.0 - feat(docs)
|
||||||
|
document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts
|
||||||
|
|
||||||
|
- Clarifies that the Rust SMTP server accepts the full SMTP protocol and runs the security pipeline in-process (DKIM/SPF/DMARC verification, content scanning, IP reputation/DNSBL) to avoid IPC round-trips
|
||||||
|
- Notes that Rust now emits an emailReceived IPC event with pre-computed security results attached for TypeScript to use in routing/delivery decisions
|
||||||
|
- Updates mailer-smtp crate description to include the in-process security pipeline and increments its test count from 72 to 77
|
||||||
|
- Adjusts TypeScript directory comments to reflect removal/relocation of the legacy TS SMTP server and the smtpclient path
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.3.2 - fix(tests)
|
||||||
|
remove large SMTP client test suites and update SmartFile API usage
|
||||||
|
|
||||||
|
- Deleted ~80 test files under test/suite/ (multiple smtpclient command, connection, edge-cases, email-composition, error-handling and performance test suites)
|
||||||
|
- Updated SmartFile usage in test/test.smartmail.ts: replaced plugins.smartfile.SmartFile.fromString(...) with plugins.smartfile.SmartFileFactory.nodeFs().fromString(...)
|
||||||
|
- Removes a large set of tests to reduce test surface / simplify test runtime
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.3.1 - fix(npmextra)
|
||||||
|
update .gitignore and npmextra.json to add ignore patterns, registries, and module metadata
|
||||||
|
|
||||||
|
- .gitignore: expanded ignore list for artifacts, installs, caches, builds, AI dirs, and rust target path
|
||||||
|
- npmextra.json: added npmjs registry alongside internal Verdaccio registry for @git.zone/cli release settings
|
||||||
|
- npmextra.json: added projectType and module metadata (githost, gitscope, gitrepo, description, npmPackagename, license) for @git.zone/cli and added empty @ship.zone/szci entry
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.3.0 - feat(mailer-smtp)
|
||||||
|
add in-process security pipeline for SMTP delivery (DKIM/SPF/DMARC, content scanning, IP reputation)
|
||||||
|
|
||||||
|
- Integrate mailer_security verification (DKIM/SPF/DMARC) and IP reputation checks into the Rust SMTP server; run concurrently and wrapped with a 30s timeout.
|
||||||
|
- Add MIME parsing using mailparse and an extract_mime_parts helper to extract subject, text/html bodies and attachment filenames for content scanning.
|
||||||
|
- Wire MessageAuthenticator and TokioResolver into server and connection startup; pass them into the delivery pipeline and connection handlers.
|
||||||
|
- Run content scanning (mailer_security::content_scanner), combine results (dkim/spf/dmarc, contentScan, ipReputation) into a JSON object and attach as security_results on EmailReceived events.
|
||||||
|
- Update Rust crates (Cargo.toml/Cargo.lock) to include mailparse and resolver usage and add serde::Deserialize where required; add unit tests for MIME extraction.
|
||||||
|
- Remove the TypeScript SMTP server implementation and many TS tests; replace test helper (server.loader.ts) with a stub that points tests to use the Rust SMTP server and provide small utilities (getAvailablePort/isPortFree).
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.2.1 - fix(readme)
|
||||||
|
Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates
|
||||||
|
|
||||||
|
- Emphasizes that the SMTP server is Rust-powered (high-performance) and not a nodemailer-based TS server.
|
||||||
|
- Documents that the Rust binary (mailer-bin) is required — if unavailable UnifiedEmailServer.start() will throw an error.
|
||||||
|
- Adds installation/build note: run `pnpm build` to compile the Rust binary.
|
||||||
|
- Adds a new Rust Acceleration Layer section listing workspace crates and responsibilities (mailer-core, mailer-security, mailer-smtp, mailer-bin, mailer-napi).
|
||||||
|
- Updates project structure: marks legacy TS SMTP server as fallback/legacy, adds dist_rust output, and clarifies which operations run in Rust vs TypeScript.
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.2.0 - feat(mailer-smtp)
|
||||||
|
implement in-process SMTP server and management IPC integration
|
||||||
|
|
||||||
|
- Add full SMTP protocol engine crate (mailer-smtp) with modules: command, config, connection, data, response, session, state, validation, rate_limiter and server
|
||||||
|
- Introduce SmtpServerConfig, DataAccumulator (DATA phase handling, dot-unstuffing, size limits) and SmtpResponse builder with EHLO capability construction
|
||||||
|
- Add in-process RateLimiter using DashMap and runtime-configurable RateLimitConfig
|
||||||
|
- Add TCP/TLS server start/stop API (start_server) with TlsAcceptor building from PEM and SmtpServerHandle for shutdown and status
|
||||||
|
- Integrate callback registry and oneshot-based correlation callbacks in mailer-bin management mode for email processing/auth results and JSON IPC parsing for SmtpServerConfig
|
||||||
|
- TypeScript bridge and routing updates: new IPC commands/types (startSmtpServer, stopSmtpServer, emailProcessingResult, authResult, configureRateLimits) and event handlers (emailReceived, authRequest)
|
||||||
|
- Update Cargo manifests and lockfile to add dependencies (dashmap, regex, rustls, rustls-pemfile, rustls-pki-types, uuid, serde_json, base64, etc.)
|
||||||
|
- Add comprehensive unit tests for new modules (config, data, response, session, state, rate_limiter, validation)
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.1.0 - feat(security)
|
||||||
|
migrate content scanning and bounce detection to Rust security bridge; add scanContent IPC command and Rust content scanner with tests; update TS RustSecurityBridge and callers, and adjust CI package references
|
||||||
|
|
||||||
|
- Add Rust content scanner implementation (rust/crates/mailer-security/src/content_scanner.rs) with pattern-based detection and unit tests (~515 lines)
|
||||||
|
- Expose new IPC command 'scanContent' in mailer-bin and marshal results via JSON for the RustSecurityBridge
|
||||||
|
- Update TypeScript RustSecurityBridge with scanContent typing and method, and replace local JS detection logic (bounce/content) to call Rust bridge
|
||||||
|
- Update tests to start/stop the RustSecurityBridge and rely on Rust-based detection (test updates in test.bouncemanager.ts and test.contentscanner.ts)
|
||||||
|
- Update CI workflow messages and package references from @serve.zone/mailer to @push.rocks/smartmta
|
||||||
|
- Add regex dependency to rust mailer-security workspace (Cargo.toml / Cargo.lock updated)
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.0.1 - fix(docs/readme)
|
||||||
|
update README: clarify APIs, document RustSecurityBridge, update examples and architecture diagram
|
||||||
|
|
||||||
|
- Documented RustSecurityBridge: startup/shutdown, automatic delegation, compound verifyEmail API, and individual operations
|
||||||
|
- Clarified verification APIs: SpfVerifier.verify() and DmarcVerifier.verify() examples now take an Email object as the first argument
|
||||||
|
- Updated example method names/usages: scanEmail, createEmail, evaluateRoutes, checkMessageLimit, isEmailSuppressed, DKIMCreator rotation and output formatting
|
||||||
|
- Reformatted architecture diagram and added Rust Security Bridge and expanded Rust Acceleration details
|
||||||
|
- Rate limiter example updated: renamed/standardized config keys (maxMessagesPerMinute, domains) and added additional limits (maxRecipientsPerMessage, maxConnectionsPerIP, etc.)
|
||||||
|
- DNS management documentation reorganized: UnifiedEmailServer now handles DNS record setup automatically; DNSManager usage clarified for standalone checks
|
||||||
|
- Minor wording/formatting tweaks throughout README (arrow styles, headings, test counts)
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.0.0 - BREAKING CHANGE(smartmta)
|
||||||
|
Rebrand package to @push.rocks/smartmta, add consolidated email security verification and IPC handler
|
||||||
|
|
||||||
|
- Package renamed from @serve.zone/mailer to @push.rocks/smartmta (package.json, commitinfo, README and homepage/bugs/repository URLs updated) — breaking for consumers who import by package name.
|
||||||
|
- Added new compound email security API verify_email_security that runs DKIM, SPF and DMARC in a single call (rust/crates/mailer-security/src/verify.rs) and exposed it from the mailer-security crate.
|
||||||
|
- Added IPC handler "verifyEmail" in mailer-bin to call the new verify_email_security function from the Rust side.
|
||||||
|
- Refactored DKIM and SPF code to convert mail-auth outputs to serializable results (dkim_outputs_to_results and SpfResult::from_output) and wired them into the combined verifier.
|
||||||
|
- Updated TypeScript plugin exports and dependencies: added @push.rocks/smartrust and exported smartrust in ts/plugins.ts.
|
||||||
|
- Large README overhaul to reflect rebranding, install instructions, architecture and legal/company info.
|
||||||
|
|
||||||
|
## 2026-02-10 - 1.3.1 - fix(deps)
|
||||||
|
add workspace dependency entries for multiple crates across mailer-bin, mailer-core, and mailer-security
|
||||||
|
|
||||||
|
- rust/crates/mailer-bin/Cargo.toml: add clap.workspace = true
|
||||||
|
- rust/crates/mailer-core/Cargo.toml: add regex.workspace = true, base64.workspace = true, uuid.workspace = true
|
||||||
|
- rust/crates/mailer-security/Cargo.toml: add serde_json.workspace = true, tokio.workspace = true, hickory-resolver.workspace = true, ipnet.workspace = true, rustls-pki-types.workspace = true, psl.workspace = true
|
||||||
|
- Purpose: align and enable workspace-managed dependencies for the affected crates
|
||||||
|
|
||||||
|
## 2026-02-10 - 1.3.0 - feat(mail/delivery)
|
||||||
|
add error-count based blocking to rate limiter; improve test SMTP server port selection; add tsbuild scripts and devDependency; remove stale backup file
|
||||||
|
|
||||||
|
- Add TokenBucket error tracking (errors, firstErrorTime) and initialize fields for global and per-key buckets
|
||||||
|
- Introduce RateLimiter.recordError(key, window, threshold) to track errors and decide blocking when threshold exceeded
|
||||||
|
- Update test SMTP server loader to dynamically find a free port using smartnetwork and add a recordError stub to the mock server
|
||||||
|
- Add build and check scripts to package.json and add @git.zone/tsbuild to devDependencies
|
||||||
|
- Remove a large backup file (classes.emailsendjob.ts.backup) from the repo; minor whitespace and logging cleanups in SMTP server code
|
||||||
|
|
||||||
|
## 2025-10-24 - 1.2.1 - fix(mail/delivery)
|
||||||
|
Centralize runtime/plugin imports and switch modules to use plugins exports; unify EventEmitter usage; update Deno dependencies and small path/server refactors
|
||||||
|
|
||||||
|
- Centralized Node and third-party imports in ts/plugins.ts and re-exported commonly used utilities (net, tls, dns, fs, smartfile, smartdns, smartmail, mailauth, uuid, ip, LRUCache, etc).
|
||||||
|
- Replaced direct EventEmitter / Node built-in imports with plugins.EventEmitter across delivery, smtpclient, routing and the unified email server to standardize runtime integration.
|
||||||
|
- Updated deno.json dependency map: added @push.rocks/smartfile, @push.rocks/smartdns, @tsclass/tsclass and ip; reordered lru-cache entry.
|
||||||
|
- Stopped exporting ./dns/index.ts from ts/index.ts (DNS is available via mail/routing) to avoid duplicate exports.
|
||||||
|
- Added keysDir alias and dnsRecordsDir in ts/paths.ts and small path-related fixes.
|
||||||
|
- Added a placeholder SmtpServer and other minor delivery/smtpserver refactors and sanitizations.
|
||||||
|
- Added a local .claude/settings.local.json for development permissions (local-only configuration).
|
||||||
|
|
||||||
|
## 2025-10-24 - 1.2.0 - feat(plugins)
|
||||||
|
Add smartmail, mailauth and uuid to Deno dependencies and export them from plugins; include local dev permissions file
|
||||||
|
|
||||||
|
- Add @push.rocks/smartmail, mailauth and uuid to deno.json dependencies so email parsing, DKIM and UUID utilities are available.
|
||||||
|
- Export smartmail, mailauth and uuid from ts/plugins.ts for centralized access across the codebase.
|
||||||
|
- Add .claude/settings.local.json to define local development permissions (tooling/dev environment configuration).
|
||||||
|
|
||||||
|
## 2025-10-24 - 1.1.0 - feat(ci)
|
||||||
|
Add CI, release and npm-publish automation; introduce release template and local settings
|
||||||
|
|
||||||
|
- Add CI workflow (.gitea/workflows/ci.yml) to run TypeScript checks, linting, formatting and platform compilation tests
|
||||||
|
- Add release workflow (.gitea/workflows/release.yml) to compile binaries for multiple platforms, generate checksums, create/update Gitea releases and upload assets
|
||||||
|
- Add npm publish workflow (.gitea/workflows/npm-publish.yml) to build package from a tag and publish precompiled binaries to npm (tag-triggered)
|
||||||
|
- Add a Gitea release template (.gitea/release-template.md) describing platforms, checksums and installation options
|
||||||
|
- Add local tool permission settings (.claude/settings.local.json) used by local tooling
|
||||||
|
- No API or runtime source changes — this commit is focused on CI/CD, release automation and packaging
|
||||||
|
|
||||||
## 2025-10-24 - 1.0.1 - fix(dev)
|
## 2025-10-24 - 1.0.1 - fix(dev)
|
||||||
Add local development settings file to grant tooling permissions
|
Add local development settings file to grant tooling permissions
|
||||||
|
|
||||||
|
|||||||
47
deno.json
47
deno.json
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@serve.zone/mailer",
|
|
||||||
"version": "1.0.1",
|
|
||||||
"exports": "./mod.ts",
|
|
||||||
"nodeModulesDir": "auto",
|
|
||||||
"tasks": {
|
|
||||||
"dev": "deno run --allow-all mod.ts",
|
|
||||||
"compile": "deno task compile:all",
|
|
||||||
"compile:all": "bash scripts/compile-all.sh",
|
|
||||||
"test": "deno test --allow-all test/",
|
|
||||||
"test:watch": "deno test --allow-all --watch test/",
|
|
||||||
"check": "deno check mod.ts",
|
|
||||||
"fmt": "deno fmt",
|
|
||||||
"lint": "deno lint"
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"rules": {
|
|
||||||
"tags": [
|
|
||||||
"recommended"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fmt": {
|
|
||||||
"useTabs": false,
|
|
||||||
"lineWidth": 100,
|
|
||||||
"indentWidth": 2,
|
|
||||||
"semiColons": true,
|
|
||||||
"singleQuote": true
|
|
||||||
},
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": [
|
|
||||||
"deno.window"
|
|
||||||
],
|
|
||||||
"strict": true
|
|
||||||
},
|
|
||||||
"imports": {
|
|
||||||
"@std/cli": "jsr:@std/cli@^1.0.0",
|
|
||||||
"@std/fmt": "jsr:@std/fmt@^1.0.0",
|
|
||||||
"@std/path": "jsr:@std/path@^1.0.0",
|
|
||||||
"@std/http": "jsr:@std/http@^1.0.0",
|
|
||||||
"@std/crypto": "jsr:@std/crypto@^1.0.0",
|
|
||||||
"@std/assert": "jsr:@std/assert@^1.0.0",
|
|
||||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@latest",
|
|
||||||
"lru-cache": "npm:lru-cache@^11.0.0",
|
|
||||||
"mailaddress-validator": "npm:mailaddress-validator@^1.0.11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
license
2
license
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Serve Zone
|
Copyright (c) 2025 Task Venture Capital GmbH
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
13
mod.ts
13
mod.ts
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mailer - Enterprise mail server for serve.zone
|
|
||||||
*
|
|
||||||
* Main entry point for the Deno module
|
|
||||||
*/
|
|
||||||
|
|
||||||
// When run as a script, execute the CLI
|
|
||||||
if (import.meta.main) {
|
|
||||||
await import('./ts/cli.ts');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export public API
|
|
||||||
export * from './ts/index.ts';
|
|
||||||
@@ -1 +1,27 @@
|
|||||||
{}
|
{
|
||||||
|
"@git.zone/tsrust": {
|
||||||
|
"targets": [
|
||||||
|
"linux_amd64",
|
||||||
|
"linux_arm64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@git.zone/cli": {
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
|
},
|
||||||
|
"projectType": "npm",
|
||||||
|
"module": {
|
||||||
|
"githost": "code.foss.global",
|
||||||
|
"gitscope": "push.rocks",
|
||||||
|
"gitrepo": "smartmta",
|
||||||
|
"description": "an mta implementation as TypeScript package, with network side implemented in rust",
|
||||||
|
"npmPackagename": "@push.rocks/smartmta",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@ship.zone/szci": {}
|
||||||
|
}
|
||||||
72
package.json
72
package.json
@@ -1,44 +1,84 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/mailer",
|
"name": "@push.rocks/smartmta",
|
||||||
"version": "1.0.1",
|
"version": "3.0.0",
|
||||||
"description": "Enterprise mail server with SMTP, HTTP API, and DNS management - built for serve.zone infrastructure",
|
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mailer",
|
"mta",
|
||||||
"smtp",
|
"smtp",
|
||||||
"email",
|
"email",
|
||||||
"mail server",
|
"mail server",
|
||||||
"mailgun",
|
"mail transfer agent",
|
||||||
"dkim",
|
"dkim",
|
||||||
"spf",
|
"spf",
|
||||||
|
"dmarc",
|
||||||
"dns",
|
"dns",
|
||||||
"cloudflare",
|
"cloudflare",
|
||||||
"daemon service",
|
"typescript",
|
||||||
"api",
|
"rust"
|
||||||
"serve.zone"
|
|
||||||
],
|
],
|
||||||
"homepage": "https://code.foss.global/serve.zone/mailer",
|
"homepage": "https://code.foss.global/push.rocks/smartmta",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://code.foss.global/serve.zone/mailer/issues"
|
"url": "https://code.foss.global/push.rocks/smartmta/issues"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://code.foss.global/serve.zone/mailer.git"
|
"url": "git+https://code.foss.global/push.rocks/smartmta.git"
|
||||||
},
|
},
|
||||||
"author": "Serve Zone",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"mailer": "./bin/mailer-wrapper.js"
|
"mailer": "./bin/mailer-wrapper.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node scripts/install-binary.js",
|
"test": "tstest test/ --logfile --verbose --timeout 60",
|
||||||
"prepublishOnly": "echo 'Publishing MAILER binaries to npm...'",
|
"build": "tsbuild tsfolders && tsrust",
|
||||||
"test": "echo 'Tests are run with Deno: deno task test'",
|
"check": "tsbuild check"
|
||||||
"build": "echo 'no build needed'"
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsbuild": "^4.1.2",
|
||||||
|
"@git.zone/tsrust": "^1.3.0",
|
||||||
|
"@git.zone/tstest": "^3.1.8",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"tsx": "^4.21.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@api.global/typedrequest": "^3.2.5",
|
||||||
|
"@api.global/typedserver": "^8.3.0",
|
||||||
|
"@api.global/typedsocket": "^4.1.0",
|
||||||
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
|
"@push.rocks/projectinfo": "^5.0.1",
|
||||||
|
"@push.rocks/qenv": "^6.1.0",
|
||||||
|
"@push.rocks/smartacme": "^8.0.0",
|
||||||
|
"@push.rocks/smartdata": "^7.0.15",
|
||||||
|
"@push.rocks/smartdns": "^7.5.0",
|
||||||
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
|
"@push.rocks/smartfs": "^1.3.1",
|
||||||
|
"@push.rocks/smartguard": "^3.1.0",
|
||||||
|
"@push.rocks/smartjwt": "^2.2.1",
|
||||||
|
"@push.rocks/smartlog": "^3.1.8",
|
||||||
|
"@push.rocks/smartmail": "^2.2.0",
|
||||||
|
"@push.rocks/smartmetrics": "^2.0.10",
|
||||||
|
"@push.rocks/smartnetwork": "^4.0.2",
|
||||||
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
|
"@push.rocks/smartpromise": "^4.0.3",
|
||||||
|
"@push.rocks/smartproxy": "^23.1.0",
|
||||||
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
|
"@push.rocks/smartrule": "^2.0.1",
|
||||||
|
"@push.rocks/smartrust": "^1.1.1",
|
||||||
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
|
"@serve.zone/interfaces": "^5.0.4",
|
||||||
|
"@tsclass/tsclass": "^9.2.0",
|
||||||
|
"lru-cache": "^11.2.5",
|
||||||
|
"mailparser": "^3.9.3",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"bin/",
|
"bin/",
|
||||||
"scripts/install-binary.js",
|
"scripts/install-binary.js",
|
||||||
|
"dist_rust/**/*",
|
||||||
"readme.md",
|
"readme.md",
|
||||||
"license",
|
"license",
|
||||||
"changelog.md"
|
"changelog.md"
|
||||||
|
|||||||
10394
pnpm-lock.yaml
generated
Normal file
10394
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
# Project Hints
|
||||||
|
|
||||||
|
## Rust Workspace
|
||||||
|
- Rust code lives in `rust/` with a Cargo workspace
|
||||||
|
- Crates: `mailer-core`, `mailer-smtp`, `mailer-security`, `mailer-napi`, `mailer-bin`
|
||||||
|
- `mailer-bin` is the binary target that `tsrust` builds for cross-compilation
|
||||||
|
- `mailer-napi` is a cdylib for N-API bindings (not built by tsrust, needs separate napi-rs build pipeline)
|
||||||
|
- tsrust only supports binary targets (looks for `src/main.rs` or `[[bin]]` entries)
|
||||||
|
- Cross-compilation targets: `linux_amd64`, `linux_arm64` (configured in `npmextra.json`)
|
||||||
|
- Build output goes to `dist_rust/`
|
||||||
|
|
||||||
|
## Build
|
||||||
|
- `pnpm build` runs `tsbuild tsfolders && tsrust`
|
||||||
|
- Note: `tsbuild tsfolders` requires a `tsconfig.json` at the project root (currently missing - pre-existing issue)
|
||||||
|
- `tsrust` independently works and produces binaries in `dist_rust/`
|
||||||
|
|
||||||
|
## Key Dependencies (Rust)
|
||||||
|
- `tokio` - async runtime
|
||||||
|
- `tokio-rustls` - TLS
|
||||||
|
- `hickory-dns` (hickory-resolver) - DNS resolution
|
||||||
|
- `mail-auth` - DKIM/SPF/DMARC (by Stalwart Labs)
|
||||||
|
- `mailparse` - MIME parsing
|
||||||
|
- `napi` + `napi-derive` - Node.js bindings
|
||||||
|
- `ring` - crypto primitives
|
||||||
|
- `dashmap` - concurrent hash maps
|
||||||
|
|||||||
863
readme.md
863
readme.md
@@ -1,361 +1,636 @@
|
|||||||
# @serve.zone/mailer
|
# @push.rocks/smartmta
|
||||||
|
|
||||||
> Enterprise mail server with SMTP, HTTP API, and DNS management
|
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. 🚀
|
||||||
|
|
||||||
[](license)
|
## Issue Reporting and Security
|
||||||
[](package.json)
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install @push.rocks/smartmta
|
||||||
|
# or
|
||||||
|
npm install @push.rocks/smartmta
|
||||||
|
```
|
||||||
|
|
||||||
|
After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`). The Rust binary is **required** — `smartmta` will not start without it.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`@serve.zone/mailer` is a comprehensive mail server solution built with Deno, featuring:
|
`@push.rocks/smartmta` is a **complete mail server solution** — SMTP server, SMTP client, email security, content scanning, and delivery management — all built with a custom SMTP implementation. The SMTP server itself runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via IPC.
|
||||||
|
|
||||||
- **SMTP Server & Client** - Full-featured SMTP implementation for sending and receiving emails
|
### ⚡ What's Inside
|
||||||
- **HTTP REST API** - Mailgun-compatible API for programmatic email management
|
|
||||||
- **DNS Management** - Automatic DNS setup via Cloudflare API
|
|
||||||
- **DKIM/SPF/DMARC** - Complete email authentication and security
|
|
||||||
- **Daemon Service** - Systemd integration for production deployments
|
|
||||||
- **CLI Interface** - Command-line management of all features
|
|
||||||
|
|
||||||
## Architecture
|
| Module | What It Does |
|
||||||
|
|---|---|
|
||||||
|
| **Rust SMTP Server** | High-performance SMTP engine written in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
|
||||||
|
| **SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation |
|
||||||
|
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
|
||||||
|
| **SPF** | Full SPF record validation via Rust |
|
||||||
|
| **DMARC** | Policy enforcement and verification |
|
||||||
|
| **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions |
|
||||||
|
| **Bounce Manager** | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
|
||||||
|
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
|
||||||
|
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
|
||||||
|
| **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-IP) |
|
||||||
|
| **Delivery Queue** | Persistent queue with exponential backoff retry |
|
||||||
|
| **Template Engine** | Email templates with variable substitution |
|
||||||
|
| **Domain Registry** | Multi-domain management with per-domain configuration |
|
||||||
|
| **DNS Manager** | Automatic DNS record management with Cloudflare API integration |
|
||||||
|
| **Rust Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
|
||||||
|
|
||||||
### Technology Stack
|
### 🏗️ Architecture
|
||||||
|
|
||||||
- **Runtime**: Deno (compiles to standalone binaries)
|
|
||||||
- **Language**: TypeScript
|
|
||||||
- **Distribution**: npm (via binary wrappers)
|
|
||||||
- **Service**: systemd daemon
|
|
||||||
- **DNS**: Cloudflare API integration
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
mailer/
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
├── bin/ # npm binary wrappers
|
│ UnifiedEmailServer │
|
||||||
├── scripts/ # Build scripts
|
│ (orchestrates all components, emits events) │
|
||||||
├── ts/ # TypeScript source
|
├───────────┬───────────┬──────────────┬───────────────────────┤
|
||||||
│ ├── mail/ # Email implementation (ported from dcrouter)
|
│ Email │ Security │ Delivery │ Configuration │
|
||||||
│ │ ├── core/ # Email classes, validation, templates
|
│ Router │ Stack │ System │ │
|
||||||
│ │ ├── delivery/ # SMTP client/server, queues
|
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
|
||||||
│ │ ├── routing/ # Email routing, domain config
|
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
|
||||||
│ │ └── security/ # DKIM, SPF, DMARC
|
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
|
||||||
│ ├── api/ # HTTP REST API (Mailgun-compatible)
|
│ │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator │ │
|
||||||
│ ├── dns/ # DNS management + Cloudflare
|
│ └──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates │ │
|
||||||
│ ├── daemon/ # Systemd service management
|
│ │ │ Scan │ │ └──────────┘ │ └────────────────┘ │
|
||||||
│ ├── config/ # Configuration system
|
│ │ └───────┘ │ │ │
|
||||||
│ └── cli/ # Command-line interface
|
├───────────┴───────────┴──────────────┴───────────────────────┤
|
||||||
├── test/ # Test suite
|
│ Rust Security Bridge (smartrust IPC) │
|
||||||
├── deno.json # Deno configuration
|
├──────────────────────────────────────────────────────────────┤
|
||||||
├── package.json # npm metadata
|
│ Rust Acceleration Layer │
|
||||||
└── mod.ts # Main entry point
|
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
|
||||||
|
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
|
||||||
|
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
|
||||||
|
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation
|
**Data flow for inbound mail:**
|
||||||
|
|
||||||
### Via npm (recommended)
|
1. Rust SMTP server accepts the connection and handles the full SMTP protocol
|
||||||
|
2. On `DATA` completion, Rust runs the security pipeline **in-process** (DKIM/SPF/DMARC verification, content scanning, IP reputation check) — zero IPC round-trips
|
||||||
```bash
|
3. Rust emits an `emailReceived` event via IPC with pre-computed security results attached
|
||||||
npm install -g @serve.zone/mailer
|
4. TypeScript processes the email (routing decisions using the pre-computed results, delivery)
|
||||||
```
|
5. Rust sends the final SMTP response to the client
|
||||||
|
|
||||||
### From source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://code.foss.global/serve.zone/mailer
|
|
||||||
cd mailer
|
|
||||||
deno task compile
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### CLI Commands
|
### 🚀 Setting Up the Email Server
|
||||||
|
|
||||||
#### Service Management
|
The central entry point is `UnifiedEmailServer`, which orchestrates the Rust SMTP server, routing, security, and delivery:
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start the mailer daemon
|
|
||||||
sudo mailer service start
|
|
||||||
|
|
||||||
# Stop the daemon
|
|
||||||
sudo mailer service stop
|
|
||||||
|
|
||||||
# Restart the daemon
|
|
||||||
sudo mailer service restart
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
mailer service status
|
|
||||||
|
|
||||||
# Enable systemd service
|
|
||||||
sudo mailer service enable
|
|
||||||
|
|
||||||
# Disable systemd service
|
|
||||||
sudo mailer service disable
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Domain Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add a domain
|
|
||||||
mailer domain add example.com
|
|
||||||
|
|
||||||
# Remove a domain
|
|
||||||
mailer domain remove example.com
|
|
||||||
|
|
||||||
# List all domains
|
|
||||||
mailer domain list
|
|
||||||
```
|
|
||||||
|
|
||||||
#### DNS Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Auto-configure DNS via Cloudflare
|
|
||||||
mailer dns setup example.com
|
|
||||||
|
|
||||||
# Validate DNS configuration
|
|
||||||
mailer dns validate example.com
|
|
||||||
|
|
||||||
# Show required DNS records
|
|
||||||
mailer dns show example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Sending Email
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Send email via CLI
|
|
||||||
mailer send \\
|
|
||||||
--from sender@example.com \\
|
|
||||||
--to recipient@example.com \\
|
|
||||||
--subject "Hello" \\
|
|
||||||
--text "World"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Show current configuration
|
|
||||||
mailer config show
|
|
||||||
|
|
||||||
# Set configuration value
|
|
||||||
mailer config set smtpPort 25
|
|
||||||
mailer config set apiPort 8080
|
|
||||||
mailer config set hostname mail.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTTP API
|
|
||||||
|
|
||||||
The mailer provides a Mailgun-compatible REST API:
|
|
||||||
|
|
||||||
#### Send Email
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /v1/messages
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"from": "sender@example.com",
|
|
||||||
"to": "recipient@example.com",
|
|
||||||
"subject": "Hello",
|
|
||||||
"text": "World",
|
|
||||||
"html": "<p>World</p>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### List Domains
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GET /v1/domains
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Manage SMTP Credentials
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GET /v1/domains/:domain/credentials
|
|
||||||
POST /v1/domains/:domain/credentials
|
|
||||||
DELETE /v1/domains/:domain/credentials/:id
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Email Events
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GET /v1/events
|
|
||||||
```
|
|
||||||
|
|
||||||
### Programmatic Usage
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Email, SmtpClient } from '@serve.zone/mailer';
|
import { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
// Create an email
|
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||||
const email = new Email({
|
// Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
|
||||||
from: 'sender@example.com',
|
ports: [25, 587, 465],
|
||||||
to: 'recipient@example.com',
|
hostname: 'mail.example.com',
|
||||||
subject: 'Hello from Mailer',
|
|
||||||
text: 'This is a test email',
|
// Multi-domain configuration
|
||||||
html: '<p>This is a test email</p>',
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'example.com',
|
||||||
|
dnsMode: 'external-dns',
|
||||||
|
dkim: {
|
||||||
|
selector: 'default',
|
||||||
|
keySize: 2048,
|
||||||
|
rotateKeys: true,
|
||||||
|
rotationInterval: 90,
|
||||||
|
},
|
||||||
|
rateLimits: {
|
||||||
|
outbound: { messagesPerMinute: 100 },
|
||||||
|
inbound: { messagesPerMinute: 200, connectionsPerIp: 20 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Routing rules (evaluated by priority, highest first)
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'catch-all-forward',
|
||||||
|
priority: 10,
|
||||||
|
match: {
|
||||||
|
recipients: '*@example.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forward: {
|
||||||
|
host: 'internal-mail.example.com',
|
||||||
|
port: 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reject-spam-senders',
|
||||||
|
priority: 100,
|
||||||
|
match: {
|
||||||
|
senders: '*@spamdomain.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'reject',
|
||||||
|
reject: {
|
||||||
|
code: 550,
|
||||||
|
message: 'Sender rejected by policy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Authentication settings for the SMTP server
|
||||||
|
auth: {
|
||||||
|
required: false,
|
||||||
|
methods: ['PLAIN', 'LOGIN'],
|
||||||
|
users: [{ username: 'outbound', password: 'secret' }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// TLS certificates
|
||||||
|
tls: {
|
||||||
|
certPath: '/etc/ssl/mail.crt',
|
||||||
|
keyPath: '/etc/ssl/mail.key',
|
||||||
|
},
|
||||||
|
|
||||||
|
maxMessageSize: 25 * 1024 * 1024, // 25 MB
|
||||||
|
maxClients: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send via SMTP
|
// start() boots the Rust SMTP server, security bridge, DNS records, and delivery queue
|
||||||
const client = new SmtpClient({
|
await emailServer.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
|
||||||
|
|
||||||
|
### 📧 Sending Emails with the SMTP Client
|
||||||
|
|
||||||
|
Create and send emails using the built-in SMTP client with connection pooling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Email, Delivery } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
// Create a client with connection pooling
|
||||||
|
const client = Delivery.smtpClientMod.createSmtpClient({
|
||||||
host: 'smtp.example.com',
|
host: 'smtp.example.com',
|
||||||
port: 587,
|
port: 587,
|
||||||
secure: true,
|
secure: false, // will upgrade via STARTTLS
|
||||||
|
pool: true,
|
||||||
|
maxConnections: 5,
|
||||||
auth: {
|
auth: {
|
||||||
user: 'username',
|
user: 'sender@example.com',
|
||||||
pass: 'password',
|
pass: 'your-password',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.sendMail(email);
|
// Build an email
|
||||||
```
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
## Configuration
|
to: ['recipient@example.com'],
|
||||||
|
cc: ['cc@example.com'],
|
||||||
Configuration is stored in `~/.mailer/config.json`:
|
subject: 'Hello from smartmta!',
|
||||||
|
text: 'Plain text body',
|
||||||
```json
|
html: '<h1>Hello!</h1><p>HTML body with <strong>formatting</strong></p>',
|
||||||
|
priority: 'high',
|
||||||
|
attachments: [
|
||||||
{
|
{
|
||||||
"domains": [
|
filename: 'report.pdf',
|
||||||
{
|
content: pdfBuffer,
|
||||||
"domain": "example.com",
|
contentType: 'application/pdf',
|
||||||
"dnsMode": "external-dns",
|
},
|
||||||
"cloudflare": {
|
|
||||||
"apiToken": "your-cloudflare-token"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"apiKeys": ["api-key-1", "api-key-2"],
|
});
|
||||||
"smtpPort": 25,
|
|
||||||
"apiPort": 8080,
|
// Send it
|
||||||
"hostname": "mail.example.com"
|
const result = await client.sendMail(email);
|
||||||
|
console.log(`Message sent: ${result.messageId}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional client factories are available:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pooled client for high-throughput scenarios
|
||||||
|
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
|
||||||
|
|
||||||
|
// Optimized for bulk sending
|
||||||
|
const bulk = Delivery.smtpClientMod.createBulkSmtpClient({ /* ... */ });
|
||||||
|
|
||||||
|
// Optimized for transactional emails
|
||||||
|
const transactional = Delivery.smtpClientMod.createTransactionalSmtpClient({ /* ... */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔑 DKIM Signing
|
||||||
|
|
||||||
|
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { DKIMCreator } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
const dkimCreator = new DKIMCreator('/path/to/keys');
|
||||||
|
|
||||||
|
// Auto-generate keys if they don't exist
|
||||||
|
await dkimCreator.handleDKIMKeysForDomain('example.com');
|
||||||
|
|
||||||
|
// Get the DNS record you need to publish
|
||||||
|
const dnsRecord = await dkimCreator.getDNSRecordForDomain('example.com');
|
||||||
|
console.log(dnsRecord);
|
||||||
|
// -> { type: 'TXT', name: 'default._domainkey.example.com', value: 'v=DKIM1; k=rsa; p=...' }
|
||||||
|
|
||||||
|
// Check if keys need rotation
|
||||||
|
const needsRotation = await dkimCreator.needsRotation('example.com', 'default', 90);
|
||||||
|
if (needsRotation) {
|
||||||
|
const newSelector = await dkimCreator.rotateDkimKeys('example.com', 'default', 2048);
|
||||||
|
console.log(`Rotated to selector: ${newSelector}`);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## DNS Setup
|
When `UnifiedEmailServer.start()` is called, DKIM signing is applied to all outbound mail automatically using the Rust security bridge's `signDkim()` method for maximum performance.
|
||||||
|
|
||||||
The mailer requires the following DNS records for each domain:
|
### 🛡️ Email Authentication (SPF, DKIM, DMARC)
|
||||||
|
|
||||||
### MX Record
|
Verify incoming emails against all three authentication standards. All verification is powered by the Rust binary:
|
||||||
```
|
|
||||||
Type: MX
|
```typescript
|
||||||
Name: @
|
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
|
||||||
Value: mail.example.com
|
|
||||||
Priority: 10
|
// SPF verification — first arg is an Email object
|
||||||
TTL: 3600
|
const spfVerifier = new SpfVerifier();
|
||||||
|
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
|
||||||
|
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
|
||||||
|
// domain: string, ip: string }
|
||||||
|
|
||||||
|
// DKIM verification — takes raw email content
|
||||||
|
const dkimVerifier = new DKIMVerifier();
|
||||||
|
const dkimResult = await dkimVerifier.verify(rawEmailContent);
|
||||||
|
|
||||||
|
// DMARC verification — first arg is an Email object
|
||||||
|
const dmarcVerifier = new DmarcVerifier();
|
||||||
|
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
|
||||||
|
// -> { action: 'pass' | 'quarantine' | 'reject', hasDmarc: boolean,
|
||||||
|
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
### A Record
|
### 🔀 Email Routing
|
||||||
```
|
|
||||||
Type: A
|
Pattern-based routing engine with priority ordering and flexible match criteria. Routes are evaluated by priority (highest first):
|
||||||
Name: mail
|
|
||||||
Value: <your-server-ip>
|
```typescript
|
||||||
TTL: 3600
|
import { EmailRouter } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
const router = new EmailRouter([
|
||||||
|
{
|
||||||
|
name: 'admin-mail',
|
||||||
|
priority: 100,
|
||||||
|
match: {
|
||||||
|
recipients: 'admin@example.com',
|
||||||
|
authenticated: true,
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'deliver',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'external-forward',
|
||||||
|
priority: 50,
|
||||||
|
match: {
|
||||||
|
recipients: '*@example.com',
|
||||||
|
sizeRange: { max: 10 * 1024 * 1024 }, // under 10MB
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forward: {
|
||||||
|
host: 'backend-mail.internal',
|
||||||
|
port: 25,
|
||||||
|
preserveHeaders: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'process-with-scanning',
|
||||||
|
priority: 10,
|
||||||
|
match: {
|
||||||
|
recipients: '*@*',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
process: {
|
||||||
|
scan: true,
|
||||||
|
dkim: true,
|
||||||
|
queue: 'normal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Evaluate routes against an email context
|
||||||
|
const matchedRoute = await router.evaluateRoutes(emailContext);
|
||||||
```
|
```
|
||||||
|
|
||||||
### SPF Record
|
**Match criteria available:**
|
||||||
```
|
|
||||||
Type: TXT
|
| Criterion | Description |
|
||||||
Name: @
|
|---|---|
|
||||||
Value: v=spf1 mx ip4:<your-server-ip> ~all
|
| `recipients` | Glob patterns for recipient addresses (`*@example.com`) |
|
||||||
TTL: 3600
|
| `senders` | Glob patterns for sender addresses |
|
||||||
|
| `clientIp` | IP addresses or CIDR ranges |
|
||||||
|
| `authenticated` | Require authentication status |
|
||||||
|
| `headers` | Match specific headers (string or RegExp) |
|
||||||
|
| `sizeRange` | Message size constraints (`{ min?, max? }`) |
|
||||||
|
| `subject` | Subject line pattern (string or RegExp) |
|
||||||
|
| `hasAttachments` | Filter by attachment presence |
|
||||||
|
|
||||||
|
### 🔍 Content Scanning
|
||||||
|
|
||||||
|
Built-in content scanner for detecting spam, phishing, malware, and other threats. Text pattern scanning runs in Rust for performance; binary attachment scanning (PE headers, VBA macros) runs in TypeScript:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ContentScanner } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
const scanner = new ContentScanner({
|
||||||
|
scanSubject: true,
|
||||||
|
scanBody: true,
|
||||||
|
scanAttachments: true,
|
||||||
|
blockExecutables: true,
|
||||||
|
blockMacros: true,
|
||||||
|
minThreatScore: 30,
|
||||||
|
highThreatScore: 70,
|
||||||
|
customRules: [
|
||||||
|
{
|
||||||
|
pattern: /bitcoin.*wallet/i,
|
||||||
|
type: 'scam',
|
||||||
|
score: 80,
|
||||||
|
description: 'Cryptocurrency scam pattern',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.scanEmail(email);
|
||||||
|
// -> { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
|
||||||
```
|
```
|
||||||
|
|
||||||
### DKIM Record
|
### 🌐 IP Reputation Checking
|
||||||
```
|
|
||||||
Type: TXT
|
Check sender IP addresses against DNSBL blacklists and classify IP types. DNSBL lookups run in Rust:
|
||||||
Name: default._domainkey
|
|
||||||
Value: <dkim-public-key>
|
```typescript
|
||||||
TTL: 3600
|
import { IPReputationChecker } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
const ipChecker = IPReputationChecker.getInstance({
|
||||||
|
enableDNSBL: true,
|
||||||
|
dnsblServers: ['zen.spamhaus.org', 'bl.spamcop.net'],
|
||||||
|
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
});
|
||||||
|
|
||||||
|
const reputation = await ipChecker.checkReputation('192.168.1.1');
|
||||||
|
// -> { score: 85, isSpam: false, isProxy: false, isTor: false, blacklists: [] }
|
||||||
```
|
```
|
||||||
|
|
||||||
### DMARC Record
|
### ⏱️ Rate Limiting
|
||||||
```
|
|
||||||
Type: TXT
|
Hierarchical rate limiting to protect your server and maintain deliverability:
|
||||||
Name: _dmarc
|
|
||||||
Value: v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com
|
```typescript
|
||||||
TTL: 3600
|
import { Delivery } from '@push.rocks/smartmta';
|
||||||
|
const { UnifiedRateLimiter } = Delivery;
|
||||||
|
|
||||||
|
const rateLimiter = new UnifiedRateLimiter({
|
||||||
|
global: {
|
||||||
|
maxMessagesPerMinute: 1000,
|
||||||
|
maxRecipientsPerMessage: 500,
|
||||||
|
maxConnectionsPerIP: 20,
|
||||||
|
maxErrorsPerIP: 10,
|
||||||
|
maxAuthFailuresPerIP: 5,
|
||||||
|
blockDuration: 600000, // 10 minutes
|
||||||
|
},
|
||||||
|
domains: {
|
||||||
|
'example.com': {
|
||||||
|
maxMessagesPerMinute: 100,
|
||||||
|
maxRecipientsPerMessage: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check before sending
|
||||||
|
const allowed = rateLimiter.checkMessageLimit(
|
||||||
|
'sender@example.com',
|
||||||
|
'192.168.1.1',
|
||||||
|
recipientCount,
|
||||||
|
undefined,
|
||||||
|
'example.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allowed.allowed) {
|
||||||
|
console.log(`Rate limited: ${allowed.reason}`);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `mailer dns setup <domain>` to automatically configure these via Cloudflare.
|
### 📬 Bounce Management
|
||||||
|
|
||||||
## Development
|
Automatic bounce detection (via Rust), classification, and suppression tracking:
|
||||||
|
|
||||||
### Prerequisites
|
```typescript
|
||||||
|
import { Core } from '@push.rocks/smartmta';
|
||||||
|
const { BounceManager } = Core;
|
||||||
|
|
||||||
- Deno 1.40+
|
const bounceManager = new BounceManager();
|
||||||
- Node.js 14+ (for npm distribution)
|
|
||||||
|
|
||||||
### Build
|
// Process an SMTP failure
|
||||||
|
const bounce = await bounceManager.processSmtpFailure(
|
||||||
|
'recipient@example.com',
|
||||||
|
'550 5.1.1 User unknown',
|
||||||
|
{ originalEmailId: 'msg-123' }
|
||||||
|
);
|
||||||
|
// -> { bounceType: 'invalid_recipient', bounceCategory: 'hard', ... }
|
||||||
|
|
||||||
```bash
|
// Check if an address is suppressed due to bounces
|
||||||
# Compile for all platforms
|
const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
|
||||||
deno task compile
|
|
||||||
|
|
||||||
# Run in development mode
|
// Manually manage the suppression list
|
||||||
deno task dev
|
bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
|
||||||
|
bounceManager.removeFromSuppressionList('recovered@example.com');
|
||||||
# Run tests
|
|
||||||
deno task test
|
|
||||||
|
|
||||||
# Format code
|
|
||||||
deno task fmt
|
|
||||||
|
|
||||||
# Lint code
|
|
||||||
deno task lint
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ported Components
|
### 📝 Email Templates
|
||||||
|
|
||||||
The mail implementation is ported from [dcrouter](https://code.foss.global/serve.zone/dcrouter) and adapted for Deno:
|
Template engine with variable substitution for transactional and notification emails:
|
||||||
|
|
||||||
- ✅ Email core (Email, EmailValidator, BounceManager, TemplateManager)
|
```typescript
|
||||||
- ✅ SMTP Server (with TLS support)
|
import { Core } from '@push.rocks/smartmta';
|
||||||
- ✅ SMTP Client (with connection pooling)
|
const { TemplateManager } = Core;
|
||||||
- ✅ Email routing and domain management
|
|
||||||
- ✅ DKIM signing and verification
|
|
||||||
- ✅ SPF and DMARC validation
|
|
||||||
- ✅ Delivery queues and rate limiting
|
|
||||||
|
|
||||||
## Roadmap
|
const templates = new TemplateManager({
|
||||||
|
from: 'noreply@example.com',
|
||||||
|
footerHtml: '<p>© 2026 Example Corp</p>',
|
||||||
|
});
|
||||||
|
|
||||||
### Phase 1 - Core Functionality (Current)
|
// Register a template
|
||||||
- [x] Project structure and build system
|
templates.registerTemplate({
|
||||||
- [x] Port mail implementation from dcrouter
|
id: 'welcome',
|
||||||
- [x] CLI interface
|
name: 'Welcome Email',
|
||||||
- [x] Configuration management
|
description: 'Sent to new users',
|
||||||
- [x] DNS management basics
|
from: 'welcome@example.com',
|
||||||
- [ ] Cloudflare DNS integration
|
subject: 'Welcome, {{name}}!',
|
||||||
- [ ] HTTP REST API implementation
|
bodyHtml: '<h1>Welcome, {{name}}!</h1><p>Your account is ready.</p>',
|
||||||
- [ ] Systemd service integration
|
bodyText: 'Welcome, {{name}}! Your account is ready.',
|
||||||
|
category: 'transactional',
|
||||||
|
});
|
||||||
|
|
||||||
### Phase 2 - Production Ready
|
// Create an Email object from the template
|
||||||
- [ ] Comprehensive testing
|
const email = await templates.createEmail('welcome', {
|
||||||
- [ ] Documentation
|
to: 'newuser@example.com',
|
||||||
- [ ] Performance optimization
|
variables: { name: 'Alice' },
|
||||||
- [ ] Security hardening
|
});
|
||||||
- [ ] Monitoring and logging
|
```
|
||||||
|
|
||||||
### Phase 3 - Advanced Features
|
### 🌍 DNS Management
|
||||||
- [ ] Webhook support
|
|
||||||
- [ ] Email templates
|
|
||||||
- [ ] Analytics and reporting
|
|
||||||
- [ ] Multi-tenancy
|
|
||||||
- [ ] Load balancing
|
|
||||||
|
|
||||||
## License
|
DNS record management for email authentication is handled automatically by `UnifiedEmailServer`. When the server starts, it ensures MX, SPF, DKIM, and DMARC records are in place for all configured domains via the Cloudflare API:
|
||||||
|
|
||||||
MIT © Serve Zone
|
```typescript
|
||||||
|
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'example.com',
|
||||||
|
dnsMode: 'external-dns', // managed via Cloudflare API
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// ... other config
|
||||||
|
});
|
||||||
|
|
||||||
## Contributing
|
// DNS records are set up automatically on start:
|
||||||
|
// - MX records pointing to your mail server
|
||||||
|
// - SPF TXT records authorizing your server IP
|
||||||
|
// - DKIM TXT records with public keys from DKIMCreator
|
||||||
|
// - DMARC TXT records with your policy
|
||||||
|
await emailServer.start();
|
||||||
|
```
|
||||||
|
|
||||||
Contributions are welcome! Please see our [contributing guidelines](https://code.foss.global/serve.zone/mailer/contributing).
|
### 🦀 RustSecurityBridge
|
||||||
|
|
||||||
## Support
|
The `RustSecurityBridge` is the singleton that manages the Rust binary process. It handles security verification, content scanning, bounce detection, and the SMTP server lifecycle — all via `@push.rocks/smartrust` IPC:
|
||||||
|
|
||||||
- Documentation: https://code.foss.global/serve.zone/mailer
|
```typescript
|
||||||
- Issues: https://code.foss.global/serve.zone/mailer/issues
|
import { RustSecurityBridge } from '@push.rocks/smartmta';
|
||||||
- Email: support@serve.zone
|
|
||||||
|
|
||||||
## Acknowledgments
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
await bridge.start();
|
||||||
|
|
||||||
- Mail implementation ported from [dcrouter](https://code.foss.global/serve.zone/dcrouter)
|
// Compound verification: DKIM + SPF + DMARC in a single IPC call
|
||||||
- Inspired by [Mailgun](https://www.mailgun.com/) API design
|
const securityResult = await bridge.verifyEmail({
|
||||||
- Built with [Deno](https://deno.land/)
|
rawMessage: rawEmailString,
|
||||||
|
ip: '203.0.113.10',
|
||||||
|
heloDomain: 'sender.example.com',
|
||||||
|
mailFrom: 'user@example.com',
|
||||||
|
});
|
||||||
|
// -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } }
|
||||||
|
|
||||||
|
// Individual security operations
|
||||||
|
const dkimResults = await bridge.verifyDkim(rawEmailString);
|
||||||
|
const spfResult = await bridge.checkSpf({
|
||||||
|
ip: '203.0.113.10',
|
||||||
|
heloDomain: 'sender.example.com',
|
||||||
|
mailFrom: 'user@example.com',
|
||||||
|
});
|
||||||
|
const reputationResult = await bridge.checkIpReputation('203.0.113.10');
|
||||||
|
|
||||||
|
// DKIM signing
|
||||||
|
const signed = await bridge.signDkim({
|
||||||
|
email: rawEmailString,
|
||||||
|
domain: 'example.com',
|
||||||
|
selector: 'default',
|
||||||
|
privateKeyPem: privateKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Content scanning
|
||||||
|
const scanResult = await bridge.scanContent({
|
||||||
|
subject: 'Win a free iPhone!!!',
|
||||||
|
body: '<a href="http://phishing.example.com">Click here</a>',
|
||||||
|
from: 'scammer@evil.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bounce detection
|
||||||
|
const bounceResult = await bridge.detectBounce({
|
||||||
|
subject: 'Delivery Status Notification (Failure)',
|
||||||
|
body: '550 5.1.1 User unknown',
|
||||||
|
from: 'mailer-daemon@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.stop();
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Important:** The Rust bridge is **mandatory**. There are no TypeScript fallbacks. If the Rust binary is unavailable, `UnifiedEmailServer.start()` will throw an error.
|
||||||
|
|
||||||
|
## 🦀 Rust Acceleration Layer
|
||||||
|
|
||||||
|
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC). The Rust workspace lives at `rust/` with five crates:
|
||||||
|
|
||||||
|
| Crate | Status | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection |
|
||||||
|
| `mailer-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
|
||||||
|
| `mailer-smtp` | ✅ Complete (77 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, in-process security pipeline, rate limiting |
|
||||||
|
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — security, content scanning, SMTP server lifecycle |
|
||||||
|
| `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) |
|
||||||
|
|
||||||
|
### What Runs in Rust
|
||||||
|
|
||||||
|
| Operation | Runs In | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| SMTP server (port listening, protocol, TLS) | Rust | Performance, memory safety, zero-copy parsing |
|
||||||
|
| DKIM signing & verification | Rust | Crypto-heavy, benefits from native speed |
|
||||||
|
| SPF validation | Rust | DNS lookups with async resolver |
|
||||||
|
| DMARC policy checking | Rust | Integrates with SPF/DKIM results |
|
||||||
|
| IP reputation / DNSBL | Rust | Parallel DNS queries |
|
||||||
|
| Content scanning (text patterns) | Rust | Regex engine performance |
|
||||||
|
| Bounce detection (pattern matching) | Rust | Regex engine performance |
|
||||||
|
| Email validation & MIME building | Rust | Parsing performance |
|
||||||
|
| Binary attachment scanning | TypeScript | Buffer data too large for IPC |
|
||||||
|
| Email routing & orchestration | TypeScript | Business logic, flexibility |
|
||||||
|
| Delivery queue & retry | TypeScript | State management, persistence |
|
||||||
|
| Template rendering | TypeScript | String interpolation |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
smartmta/
|
||||||
|
├── ts/ # TypeScript source
|
||||||
|
│ ├── mail/
|
||||||
|
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
|
||||||
|
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
|
||||||
|
│ │ │ └── smtpclient/ # SMTP client with connection pooling
|
||||||
|
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
|
||||||
|
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
|
||||||
|
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
|
||||||
|
├── rust/ # Rust workspace
|
||||||
|
│ └── crates/
|
||||||
|
│ ├── mailer-core/ # Email types, validation, MIME, bounce detection
|
||||||
|
│ ├── mailer-security/ # DKIM, SPF, DMARC, IP reputation, content scanning
|
||||||
|
│ ├── mailer-smtp/ # Full SMTP server (TCP/TLS, state machine, rate limiting)
|
||||||
|
│ ├── mailer-bin/ # CLI + smartrust IPC bridge
|
||||||
|
│ └── mailer-napi/ # N-API addon (planned)
|
||||||
|
├── test/ # Test suite
|
||||||
|
├── dist_ts/ # Compiled TypeScript output
|
||||||
|
└── dist_rust/ # Compiled Rust binaries
|
||||||
|
```
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
|
|||||||
212
readme.plan.md
212
readme.plan.md
@@ -1,198 +1,24 @@
|
|||||||
# Mailer Implementation Plan & Progress
|
# Rust Migration Plan
|
||||||
|
|
||||||
## Project Goals
|
## Completed Phases
|
||||||
|
|
||||||
Build a Deno-based mail server package (`@serve.zone/mailer`) with:
|
### Phase 3: Rust Primary Backend (DKIM/SPF/DMARC/IP Reputation)
|
||||||
1. CLI interface similar to nupst/spark
|
- Rust is the mandatory security backend — no TS fallbacks
|
||||||
2. SMTP server and client (ported from dcrouter)
|
- All DKIM signing/verification, SPF, DMARC, IP reputation through Rust bridge
|
||||||
3. HTTP REST API (Mailgun-compatible)
|
|
||||||
4. Automatic DNS management via Cloudflare
|
|
||||||
5. Systemd daemon service
|
|
||||||
6. Binary distribution via npm
|
|
||||||
|
|
||||||
## Completed Work
|
### Phase 5: BounceManager + ContentScanner
|
||||||
|
- BounceManager bounce detection delegated to Rust `detectBounce` IPC command
|
||||||
|
- ContentScanner pattern matching delegated to new Rust `scanContent` IPC command
|
||||||
|
- New module: `rust/crates/mailer-security/src/content_scanner.rs` (10 Rust tests)
|
||||||
|
- ~215 lines removed from BounceManager, ~350 lines removed from ContentScanner
|
||||||
|
- Binary attachment scanning (PE headers, VBA macros) stays in TS
|
||||||
|
- Custom rules (runtime-configured) stay in TS
|
||||||
|
- Net change: ~-560 TS lines, +265 Rust lines
|
||||||
|
|
||||||
### ✅ Phase 1: Project Structure
|
## Deferred
|
||||||
- [x] Created Deno-based project structure (deno.json, package.json)
|
|
||||||
- [x] Set up bin/ wrappers for npm binary distribution
|
|
||||||
- [x] Created compilation scripts (compile-all.sh)
|
|
||||||
- [x] Set up install scripts (install-binary.js)
|
|
||||||
- [x] Created TypeScript source directory structure
|
|
||||||
|
|
||||||
### ✅ Phase 2: Mail Implementation (Ported from dcrouter)
|
| Component | Rationale |
|
||||||
- [x] Copied and adapted mail/core/ (Email, EmailValidator, BounceManager, TemplateManager)
|
|-----------|-----------|
|
||||||
- [x] Copied and adapted mail/delivery/ (SMTP client, SMTP server, queues, rate limiting)
|
| EmailValidator | Already thin; uses smartmail; minimal gain |
|
||||||
- [x] Copied and adapted mail/routing/ (EmailRouter, DomainRegistry, DnsManager)
|
| DNS record generation | Pure string building; zero benefit from Rust |
|
||||||
- [x] Copied and adapted mail/security/ (DKIM, SPF, DMARC)
|
| MIME building (`toRFC822String`) | Sync in TS, async via IPC; too much blast radius |
|
||||||
- [x] Fixed all imports from .js to .ts extensions
|
|
||||||
- [x] Created stub modules for dcrouter dependencies (storage, security, deliverability, errors)
|
|
||||||
|
|
||||||
### ✅ Phase 3: Supporting Modules
|
|
||||||
- [x] Created logger module (simple console logging)
|
|
||||||
- [x] Created paths module (project paths)
|
|
||||||
- [x] Created plugins.ts (Deno dependencies + Node.js compatibility)
|
|
||||||
- [x] Added required npm dependencies (lru-cache, mailaddress-validator, cloudflare)
|
|
||||||
|
|
||||||
### ✅ Phase 4: DNS Management
|
|
||||||
- [x] Created DnsManager class with DNS record generation
|
|
||||||
- [x] Created CloudflareClient for automatic DNS setup
|
|
||||||
- [x] Added DNS validation functionality
|
|
||||||
|
|
||||||
### ✅ Phase 5: HTTP API
|
|
||||||
- [x] Created ApiServer class with basic routing
|
|
||||||
- [x] Implemented Mailgun-compatible endpoint structure
|
|
||||||
- [x] Added authentication and rate limiting stubs
|
|
||||||
|
|
||||||
### ✅ Phase 6: Configuration Management
|
|
||||||
- [x] Created ConfigManager for JSON-based config storage
|
|
||||||
- [x] Added domain configuration support
|
|
||||||
- [x] Implemented config load/save functionality
|
|
||||||
|
|
||||||
### ✅ Phase 7: Daemon Service
|
|
||||||
- [x] Created DaemonManager to coordinate SMTP server and API server
|
|
||||||
- [x] Added start/stop functionality
|
|
||||||
- [x] Integrated with ConfigManager
|
|
||||||
|
|
||||||
### ✅ Phase 8: CLI Interface
|
|
||||||
- [x] Created MailerCli class with command routing
|
|
||||||
- [x] Implemented service commands (start/stop/restart/status/enable/disable)
|
|
||||||
- [x] Implemented domain commands (add/remove/list)
|
|
||||||
- [x] Implemented DNS commands (setup/validate/show)
|
|
||||||
- [x] Implemented send command
|
|
||||||
- [x] Implemented config commands (show/set)
|
|
||||||
- [x] Added help and version commands
|
|
||||||
|
|
||||||
### ✅ Phase 9: Documentation
|
|
||||||
- [x] Created comprehensive README.md
|
|
||||||
- [x] Documented all CLI commands
|
|
||||||
- [x] Documented HTTP API endpoints
|
|
||||||
- [x] Provided configuration examples
|
|
||||||
- [x] Documented DNS requirements
|
|
||||||
- [x] Created changelog
|
|
||||||
|
|
||||||
## Next Steps (Remaining Work)
|
|
||||||
|
|
||||||
### Testing & Debugging
|
|
||||||
1. Fix remaining import/dependency issues
|
|
||||||
2. Test compilation with `deno compile`
|
|
||||||
3. Test CLI commands end-to-end
|
|
||||||
4. Test SMTP sending/receiving
|
|
||||||
5. Test HTTP API endpoints
|
|
||||||
6. Write unit tests
|
|
||||||
|
|
||||||
### Systemd Integration
|
|
||||||
1. Create systemd service file
|
|
||||||
2. Implement service enable/disable
|
|
||||||
3. Add service status checking
|
|
||||||
4. Test daemon auto-restart
|
|
||||||
|
|
||||||
### Cloudflare Integration
|
|
||||||
1. Test actual Cloudflare API calls
|
|
||||||
2. Handle Cloudflare errors gracefully
|
|
||||||
3. Add zone detection
|
|
||||||
4. Verify DNS record creation
|
|
||||||
|
|
||||||
### Production Readiness
|
|
||||||
1. Add proper error handling throughout
|
|
||||||
2. Implement logging to files
|
|
||||||
3. Add rate limiting implementation
|
|
||||||
4. Implement API key authentication
|
|
||||||
5. Add TLS certificate management
|
|
||||||
6. Implement email queue persistence
|
|
||||||
|
|
||||||
### Advanced Features
|
|
||||||
1. Webhook support for incoming emails
|
|
||||||
2. Email template system
|
|
||||||
3. Analytics and reporting
|
|
||||||
4. SMTP credential management
|
|
||||||
5. Email event tracking
|
|
||||||
6. Bounce handling
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
1. Some npm dependencies may need version adjustments
|
|
||||||
2. Deno crypto APIs may need adaptation for DKIM signing
|
|
||||||
3. Buffer vs Uint8Array conversions may be needed
|
|
||||||
4. Some dcrouter-specific code may need further adaptation
|
|
||||||
|
|
||||||
## File Structure Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
mailer/
|
|
||||||
├── README.md ✅ Complete
|
|
||||||
├── license ✅ Complete
|
|
||||||
├── changelog.md ✅ Complete
|
|
||||||
├── deno.json ✅ Complete
|
|
||||||
├── package.json ✅ Complete
|
|
||||||
├── mod.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── bin/
|
|
||||||
│ └── mailer-wrapper.js ✅ Complete
|
|
||||||
│
|
|
||||||
├── scripts/
|
|
||||||
│ ├── compile-all.sh ✅ Complete
|
|
||||||
│ └── install-binary.js ✅ Complete
|
|
||||||
│
|
|
||||||
└── ts/
|
|
||||||
├── 00_commitinfo_data.ts ✅ Complete
|
|
||||||
├── index.ts ✅ Complete
|
|
||||||
├── cli.ts ✅ Complete
|
|
||||||
├── plugins.ts ✅ Complete
|
|
||||||
├── logger.ts ✅ Complete
|
|
||||||
├── paths.ts ✅ Complete
|
|
||||||
├── classes.mailer.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── cli/
|
|
||||||
│ ├── index.ts ✅ Complete
|
|
||||||
│ └── mailer-cli.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── api/
|
|
||||||
│ ├── index.ts ✅ Complete
|
|
||||||
│ ├── api-server.ts ✅ Complete
|
|
||||||
│ └── routes/ ✅ Structure ready
|
|
||||||
│
|
|
||||||
├── dns/
|
|
||||||
│ ├── index.ts ✅ Complete
|
|
||||||
│ ├── dns-manager.ts ✅ Complete
|
|
||||||
│ └── cloudflare-client.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── daemon/
|
|
||||||
│ ├── index.ts ✅ Complete
|
|
||||||
│ └── daemon-manager.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── config/
|
|
||||||
│ ├── index.ts ✅ Complete
|
|
||||||
│ └── config-manager.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── storage/
|
|
||||||
│ └── index.ts ✅ Stub complete
|
|
||||||
│
|
|
||||||
├── security/
|
|
||||||
│ └── index.ts ✅ Stub complete
|
|
||||||
│
|
|
||||||
├── deliverability/
|
|
||||||
│ └── index.ts ✅ Stub complete
|
|
||||||
│
|
|
||||||
├── errors/
|
|
||||||
│ └── index.ts ✅ Stub complete
|
|
||||||
│
|
|
||||||
└── mail/ ✅ Ported from dcrouter
|
|
||||||
├── core/ ✅ Complete
|
|
||||||
├── delivery/ ✅ Complete
|
|
||||||
├── routing/ ✅ Complete
|
|
||||||
└── security/ ✅ Complete
|
|
||||||
```
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The mailer package structure is **95% complete**. All major components have been implemented:
|
|
||||||
- Project structure and build system ✅
|
|
||||||
- Mail implementation ported from dcrouter ✅
|
|
||||||
- CLI interface ✅
|
|
||||||
- DNS management ✅
|
|
||||||
- HTTP API ✅
|
|
||||||
- Configuration system ✅
|
|
||||||
- Daemon management ✅
|
|
||||||
- Documentation ✅
|
|
||||||
|
|
||||||
**Remaining work**: Testing, debugging dependency issues, systemd integration, and production hardening.
|
|
||||||
|
|||||||
2
rust/.cargo/config.toml
Normal file
2
rust/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[target.aarch64-unknown-linux-gnu]
|
||||||
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
2425
rust/Cargo.lock
generated
Normal file
2425
rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
rust/Cargo.toml
Normal file
37
rust/Cargo.toml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/mailer-core",
|
||||||
|
"crates/mailer-smtp",
|
||||||
|
"crates/mailer-security",
|
||||||
|
"crates/mailer-napi",
|
||||||
|
"crates/mailer-bin",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-rustls = "0.26"
|
||||||
|
hickory-resolver = "0.25"
|
||||||
|
mail-auth = "0.7"
|
||||||
|
mailparse = "0.16"
|
||||||
|
napi = { version = "2", features = ["napi9", "async", "serde-json"] }
|
||||||
|
napi-derive = "2"
|
||||||
|
ring = "0.17"
|
||||||
|
dashmap = "6"
|
||||||
|
thiserror = "2"
|
||||||
|
tracing = "0.1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
bytes = "1"
|
||||||
|
regex = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
ipnet = "2"
|
||||||
|
rustls-pki-types = "1"
|
||||||
|
psl = "2"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
21
rust/crates/mailer-bin/Cargo.toml
Normal file
21
rust/crates/mailer-bin/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "mailer-bin"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "mailer-bin"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mailer-core = { path = "../mailer-core" }
|
||||||
|
mailer-smtp = { path = "../mailer-smtp" }
|
||||||
|
mailer-security = { path = "../mailer-security" }
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
hickory-resolver.workspace = true
|
||||||
|
dashmap.workspace = true
|
||||||
1054
rust/crates/mailer-bin/src/main.rs
Normal file
1054
rust/crates/mailer-bin/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
16
rust/crates/mailer-core/Cargo.toml
Normal file
16
rust/crates/mailer-core/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "mailer-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
mailparse.workspace = true
|
||||||
|
regex.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
485
rust/crates/mailer-core/src/bounce.rs
Normal file
485
rust/crates/mailer-core/src/bounce.rs
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
/// Type of email bounce.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BounceType {
|
||||||
|
// Hard bounces
|
||||||
|
InvalidRecipient,
|
||||||
|
DomainNotFound,
|
||||||
|
MailboxFull,
|
||||||
|
MailboxInactive,
|
||||||
|
Blocked,
|
||||||
|
SpamRelated,
|
||||||
|
PolicyRelated,
|
||||||
|
// Soft bounces
|
||||||
|
ServerUnavailable,
|
||||||
|
TemporaryFailure,
|
||||||
|
QuotaExceeded,
|
||||||
|
NetworkError,
|
||||||
|
Timeout,
|
||||||
|
// Special
|
||||||
|
AutoResponse,
|
||||||
|
ChallengeResponse,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broad category of a bounce.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BounceCategory {
|
||||||
|
Hard,
|
||||||
|
Soft,
|
||||||
|
AutoResponse,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BounceType {
|
||||||
|
/// Get the category for this bounce type.
|
||||||
|
pub fn category(&self) -> BounceCategory {
|
||||||
|
match self {
|
||||||
|
BounceType::InvalidRecipient
|
||||||
|
| BounceType::DomainNotFound
|
||||||
|
| BounceType::MailboxFull
|
||||||
|
| BounceType::MailboxInactive
|
||||||
|
| BounceType::Blocked
|
||||||
|
| BounceType::SpamRelated
|
||||||
|
| BounceType::PolicyRelated => BounceCategory::Hard,
|
||||||
|
|
||||||
|
BounceType::ServerUnavailable
|
||||||
|
| BounceType::TemporaryFailure
|
||||||
|
| BounceType::QuotaExceeded
|
||||||
|
| BounceType::NetworkError
|
||||||
|
| BounceType::Timeout => BounceCategory::Soft,
|
||||||
|
|
||||||
|
BounceType::AutoResponse | BounceType::ChallengeResponse => {
|
||||||
|
BounceCategory::AutoResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
BounceType::Unknown => BounceCategory::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of bounce detection.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BounceDetection {
|
||||||
|
pub bounce_type: BounceType,
|
||||||
|
pub category: BounceCategory,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pattern set for a bounce type: compiled regexes for matching against SMTP responses.
|
||||||
|
struct BouncePatterns {
|
||||||
|
bounce_type: BounceType,
|
||||||
|
patterns: Vec<Regex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All bounce detection patterns, compiled once.
|
||||||
|
static BOUNCE_PATTERNS: LazyLock<Vec<BouncePatterns>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::InvalidRecipient,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)no such user",
|
||||||
|
r"(?i)user unknown",
|
||||||
|
r"(?i)does not exist",
|
||||||
|
r"(?i)invalid recipient",
|
||||||
|
r"(?i)unknown recipient",
|
||||||
|
r"(?i)no mailbox",
|
||||||
|
r"(?i)user not found",
|
||||||
|
r"(?i)recipient address rejected",
|
||||||
|
r"(?i)550 5\.1\.1",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::DomainNotFound,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)domain not found",
|
||||||
|
r"(?i)unknown domain",
|
||||||
|
r"(?i)no such domain",
|
||||||
|
r"(?i)host not found",
|
||||||
|
r"(?i)domain invalid",
|
||||||
|
r"(?i)550 5\.1\.2",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::MailboxFull,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)mailbox full",
|
||||||
|
r"(?i)over quota",
|
||||||
|
r"(?i)quota exceeded",
|
||||||
|
r"(?i)552 5\.2\.2",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::MailboxInactive,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)mailbox disabled",
|
||||||
|
r"(?i)mailbox inactive",
|
||||||
|
r"(?i)account disabled",
|
||||||
|
r"(?i)mailbox not active",
|
||||||
|
r"(?i)account suspended",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::Blocked,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)blocked",
|
||||||
|
r"(?i)rejected",
|
||||||
|
r"(?i)denied",
|
||||||
|
r"(?i)blacklisted",
|
||||||
|
r"(?i)prohibited",
|
||||||
|
r"(?i)refused",
|
||||||
|
r"(?i)550 5\.7\.",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::SpamRelated,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)spam",
|
||||||
|
r"(?i)bulk mail",
|
||||||
|
r"(?i)content rejected",
|
||||||
|
r"(?i)message rejected",
|
||||||
|
r"(?i)550 5\.7\.1",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::ServerUnavailable,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)server unavailable",
|
||||||
|
r"(?i)service unavailable",
|
||||||
|
r"(?i)try again later",
|
||||||
|
r"(?i)try later",
|
||||||
|
r"(?i)451 4\.3\.",
|
||||||
|
r"(?i)421 4\.3\.",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::TemporaryFailure,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)temporary failure",
|
||||||
|
r"(?i)temporary error",
|
||||||
|
r"(?i)temporary problem",
|
||||||
|
r"(?i)try again",
|
||||||
|
r"(?i)451 4\.",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::QuotaExceeded,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)quota temporarily exceeded",
|
||||||
|
r"(?i)mailbox temporarily full",
|
||||||
|
r"(?i)452 4\.2\.2",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::NetworkError,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)network error",
|
||||||
|
r"(?i)connection error",
|
||||||
|
r"(?i)connection timed out",
|
||||||
|
r"(?i)routing error",
|
||||||
|
r"(?i)421 4\.4\.",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::Timeout,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)timed out",
|
||||||
|
r"(?i)timeout",
|
||||||
|
r"(?i)450 4\.4\.2",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::AutoResponse,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)auto[- ]reply",
|
||||||
|
r"(?i)auto[- ]response",
|
||||||
|
r"(?i)vacation",
|
||||||
|
r"(?i)out of office",
|
||||||
|
r"(?i)away from office",
|
||||||
|
r"(?i)on vacation",
|
||||||
|
r"(?i)automatic reply",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
BouncePatterns {
|
||||||
|
bounce_type: BounceType::ChallengeResponse,
|
||||||
|
patterns: compile_patterns(&[
|
||||||
|
r"(?i)challenge[- ]response",
|
||||||
|
r"(?i)verify your email",
|
||||||
|
r"(?i)confirm your email",
|
||||||
|
r"(?i)email verification",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Regex for detecting bounce email subjects.
|
||||||
|
static BOUNCE_SUBJECT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)mail delivery|delivery (?:failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem")
|
||||||
|
.expect("invalid bounce subject regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Regex for extracting recipient from bounce messages.
|
||||||
|
static BOUNCE_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?")
|
||||||
|
.expect("invalid bounce recipient regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Regex for extracting diagnostic code.
|
||||||
|
static DIAGNOSTIC_CODE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)diagnostic(?:-|\s+)code:\s*(.+)")
|
||||||
|
.expect("invalid diagnostic code regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Regex for extracting status code.
|
||||||
|
static STATUS_CODE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)status(?:-|\s+)code:\s*([0-9.]+)")
|
||||||
|
.expect("invalid status code regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Regex for DSN original-recipient.
|
||||||
|
static DSN_ORIGINAL_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)original-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})")
|
||||||
|
.expect("invalid DSN original-recipient regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Regex for DSN final-recipient.
|
||||||
|
static DSN_FINAL_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)final-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})")
|
||||||
|
.expect("invalid DSN final-recipient regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
fn compile_patterns(patterns: &[&str]) -> Vec<Regex> {
|
||||||
|
patterns
|
||||||
|
.iter()
|
||||||
|
.map(|p| Regex::new(p).expect("invalid bounce pattern regex"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect bounce type from an SMTP response, diagnostic code, or status code.
|
||||||
|
pub fn detect_bounce_type(
|
||||||
|
smtp_response: Option<&str>,
|
||||||
|
diagnostic_code: Option<&str>,
|
||||||
|
status_code: Option<&str>,
|
||||||
|
) -> BounceDetection {
|
||||||
|
// Check all text sources against patterns
|
||||||
|
let texts: Vec<&str> = [smtp_response, diagnostic_code, status_code]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for bp in BOUNCE_PATTERNS.iter() {
|
||||||
|
for text in &texts {
|
||||||
|
for pattern in &bp.patterns {
|
||||||
|
if pattern.is_match(text) {
|
||||||
|
return BounceDetection {
|
||||||
|
bounce_type: bp.bounce_type,
|
||||||
|
category: bp.bounce_type.category(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: parse DSN status code (class.subject.detail)
|
||||||
|
if let Some(code) = status_code {
|
||||||
|
if let Some(detection) = parse_dsn_status(code) {
|
||||||
|
return detection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find DSN code in SMTP response
|
||||||
|
if let Some(resp) = smtp_response {
|
||||||
|
if let Some(code) = STATUS_CODE_RE.captures(resp).and_then(|c| c.get(1)) {
|
||||||
|
if let Some(detection) = parse_dsn_status(code.as_str()) {
|
||||||
|
return detection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BounceDetection {
|
||||||
|
bounce_type: BounceType::Unknown,
|
||||||
|
category: BounceCategory::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a DSN enhanced status code like "5.1.1" or "4.2.2".
|
||||||
|
fn parse_dsn_status(code: &str) -> Option<BounceDetection> {
|
||||||
|
let parts: Vec<&str> = code.split('.').collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let class: u8 = parts[0].parse().ok()?;
|
||||||
|
let subject: u8 = parts[1].parse().ok()?;
|
||||||
|
|
||||||
|
let bounce_type = match (class, subject) {
|
||||||
|
(5, 1) => BounceType::InvalidRecipient,
|
||||||
|
(5, 2) => BounceType::MailboxFull,
|
||||||
|
(5, 7) => BounceType::Blocked,
|
||||||
|
(5, _) => BounceType::PolicyRelated,
|
||||||
|
(4, 2) => BounceType::QuotaExceeded,
|
||||||
|
(4, 3) => BounceType::ServerUnavailable,
|
||||||
|
(4, 4) => BounceType::NetworkError,
|
||||||
|
(4, _) => BounceType::TemporaryFailure,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(BounceDetection {
|
||||||
|
category: bounce_type.category(),
|
||||||
|
bounce_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a subject line looks like a bounce notification.
|
||||||
|
pub fn is_bounce_subject(subject: &str) -> bool {
|
||||||
|
BOUNCE_SUBJECT_RE.is_match(subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the bounced recipient email from a bounce message body.
|
||||||
|
pub fn extract_bounce_recipient(body: &str) -> Option<String> {
|
||||||
|
BOUNCE_RECIPIENT_RE
|
||||||
|
.captures(body)
|
||||||
|
.and_then(|c| c.get(1))
|
||||||
|
.map(|m| m.as_str().to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
DSN_FINAL_RECIPIENT_RE
|
||||||
|
.captures(body)
|
||||||
|
.and_then(|c| c.get(1))
|
||||||
|
.map(|m| m.as_str().to_string())
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
DSN_ORIGINAL_RECIPIENT_RE
|
||||||
|
.captures(body)
|
||||||
|
.and_then(|c| c.get(1))
|
||||||
|
.map(|m| m.as_str().to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the diagnostic code from a bounce message body.
|
||||||
|
pub fn extract_diagnostic_code(body: &str) -> Option<String> {
|
||||||
|
DIAGNOSTIC_CODE_RE
|
||||||
|
.captures(body)
|
||||||
|
.and_then(|c| c.get(1))
|
||||||
|
.map(|m| m.as_str().trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the status code from a bounce message body.
|
||||||
|
pub fn extract_status_code(body: &str) -> Option<String> {
|
||||||
|
STATUS_CODE_RE
|
||||||
|
.captures(body)
|
||||||
|
.and_then(|c| c.get(1))
|
||||||
|
.map(|m| m.as_str().trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate retry delay using exponential backoff.
|
||||||
|
///
|
||||||
|
/// * `retry_count` - Number of retries so far (0-based)
|
||||||
|
/// * `initial_delay_ms` - Initial delay in milliseconds (default 15 min = 900_000)
|
||||||
|
/// * `max_delay_ms` - Maximum delay in milliseconds (default 24h = 86_400_000)
|
||||||
|
/// * `backoff_factor` - Multiplier per retry (default 2.0)
|
||||||
|
pub fn retry_delay_ms(
|
||||||
|
retry_count: u32,
|
||||||
|
initial_delay_ms: u64,
|
||||||
|
max_delay_ms: u64,
|
||||||
|
backoff_factor: f64,
|
||||||
|
) -> u64 {
|
||||||
|
let delay = (initial_delay_ms as f64) * backoff_factor.powi(retry_count as i32);
|
||||||
|
(delay as u64).min(max_delay_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default retry delay with standard parameters.
|
||||||
|
pub fn default_retry_delay_ms(retry_count: u32) -> u64 {
|
||||||
|
retry_delay_ms(
|
||||||
|
retry_count,
|
||||||
|
15 * 60 * 1000, // 15 minutes
|
||||||
|
24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
2.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_invalid_recipient() {
|
||||||
|
let result = detect_bounce_type(Some("550 5.1.1 User unknown"), None, None);
|
||||||
|
assert_eq!(result.bounce_type, BounceType::InvalidRecipient);
|
||||||
|
assert_eq!(result.category, BounceCategory::Hard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_mailbox_full() {
|
||||||
|
let result = detect_bounce_type(Some("552 5.2.2 Mailbox full"), None, None);
|
||||||
|
assert_eq!(result.bounce_type, BounceType::MailboxFull);
|
||||||
|
assert_eq!(result.category, BounceCategory::Hard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_temporary_failure() {
|
||||||
|
let result = detect_bounce_type(Some("451 4.3.0 Try again later"), None, None);
|
||||||
|
assert_eq!(result.bounce_type, BounceType::ServerUnavailable);
|
||||||
|
assert_eq!(result.category, BounceCategory::Soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_auto_response() {
|
||||||
|
let result = detect_bounce_type(Some("Auto-reply: Out of office"), None, None);
|
||||||
|
assert_eq!(result.bounce_type, BounceType::AutoResponse);
|
||||||
|
assert_eq!(result.category, BounceCategory::AutoResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_from_dsn_status() {
|
||||||
|
let result = detect_bounce_type(None, None, Some("5.1.1"));
|
||||||
|
assert_eq!(result.bounce_type, BounceType::InvalidRecipient);
|
||||||
|
|
||||||
|
let result = detect_bounce_type(None, None, Some("4.4.1"));
|
||||||
|
assert_eq!(result.bounce_type, BounceType::NetworkError);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_unknown() {
|
||||||
|
let result = detect_bounce_type(Some("Something weird happened"), None, None);
|
||||||
|
assert_eq!(result.bounce_type, BounceType::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_bounce_subject() {
|
||||||
|
assert!(is_bounce_subject("Mail Delivery Failure"));
|
||||||
|
assert!(is_bounce_subject("Delivery Status Notification"));
|
||||||
|
assert!(is_bounce_subject("Returned mail: see transcript for details"));
|
||||||
|
assert!(is_bounce_subject("Undeliverable: Your message"));
|
||||||
|
assert!(!is_bounce_subject("Hello World"));
|
||||||
|
assert!(!is_bounce_subject("Meeting tomorrow"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_bounce_recipient() {
|
||||||
|
let body = "Delivery to the following recipient failed:\n recipient: user@example.com";
|
||||||
|
assert_eq!(
|
||||||
|
extract_bounce_recipient(body),
|
||||||
|
Some("user@example.com".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = "Final-Recipient: rfc822;bounce@test.org";
|
||||||
|
assert_eq!(
|
||||||
|
extract_bounce_recipient(body),
|
||||||
|
Some("bounce@test.org".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retry_delay() {
|
||||||
|
assert_eq!(default_retry_delay_ms(0), 900_000); // 15 min
|
||||||
|
assert_eq!(default_retry_delay_ms(1), 1_800_000); // 30 min
|
||||||
|
assert_eq!(default_retry_delay_ms(2), 3_600_000); // 1 hour
|
||||||
|
|
||||||
|
// Capped at 24h
|
||||||
|
assert_eq!(default_retry_delay_ms(20), 86_400_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
411
rust/crates/mailer-core/src/email.rs
Normal file
411
rust/crates/mailer-core/src/email.rs
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{MailerError, Result};
|
||||||
|
use crate::mime::build_rfc822;
|
||||||
|
use crate::validation::is_valid_email_format;
|
||||||
|
|
||||||
|
/// Email priority level.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Priority {
|
||||||
|
High,
|
||||||
|
Normal,
|
||||||
|
Low,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Priority {
|
||||||
|
fn default() -> Self {
|
||||||
|
Priority::Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Priority {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Priority::High => write!(f, "high"),
|
||||||
|
Priority::Normal => write!(f, "normal"),
|
||||||
|
Priority::Low => write!(f, "low"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed email address with local part and domain.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct EmailAddress {
|
||||||
|
pub local: String,
|
||||||
|
pub domain: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailAddress {
|
||||||
|
/// Parse an email address string like "user@example.com" or "Name <user@example.com>".
|
||||||
|
pub fn parse(input: &str) -> Result<Self> {
|
||||||
|
let addr = extract_email_address(input)
|
||||||
|
.ok_or_else(|| MailerError::InvalidEmail(input.to_string()))?;
|
||||||
|
|
||||||
|
let parts: Vec<&str> = addr.splitn(2, '@').collect();
|
||||||
|
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
||||||
|
return Err(MailerError::InvalidEmail(input.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(EmailAddress {
|
||||||
|
local: parts[0].to_string(),
|
||||||
|
domain: parts[1].to_lowercase(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the full address as "local@domain".
|
||||||
|
pub fn address(&self) -> String {
|
||||||
|
format!("{}@{}", self.local, self.domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for EmailAddress {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}@{}", self.local, self.domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the bare email address from a string that may contain display names or angle brackets.
|
||||||
|
/// Handles formats like:
|
||||||
|
/// - "user@example.com"
|
||||||
|
/// - "<user@example.com>"
|
||||||
|
/// - "John Doe <user@example.com>"
|
||||||
|
pub fn extract_email_address(input: &str) -> Option<String> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
|
||||||
|
// Handle null sender
|
||||||
|
if trimmed == "<>" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from angle brackets
|
||||||
|
if let Some(start) = trimmed.find('<') {
|
||||||
|
if let Some(end) = trimmed.find('>') {
|
||||||
|
if end > start {
|
||||||
|
let addr = trimmed[start + 1..end].trim();
|
||||||
|
if !addr.is_empty() {
|
||||||
|
return Some(addr.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No angle brackets — treat entire string as address if it contains @
|
||||||
|
if trimmed.contains('@') {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An email attachment.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Attachment {
|
||||||
|
pub filename: String,
|
||||||
|
#[serde(with = "serde_bytes_base64")]
|
||||||
|
pub content: Vec<u8>,
|
||||||
|
pub content_type: String,
|
||||||
|
pub content_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serde helper for base64-encoding Vec<u8> in JSON.
|
||||||
|
mod serde_bytes_base64 {
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<S: Serializer>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&STANDARD.encode(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
STANDARD.decode(s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete email message.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Email {
|
||||||
|
pub from: String,
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
pub bcc: Vec<String>,
|
||||||
|
pub subject: String,
|
||||||
|
pub text: String,
|
||||||
|
pub html: Option<String>,
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
|
pub headers: HashMap<String, String>,
|
||||||
|
pub priority: Priority,
|
||||||
|
pub might_be_spam: bool,
|
||||||
|
message_id: Option<String>,
|
||||||
|
envelope_from: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Email {
|
||||||
|
/// Create a new email with the minimum required fields.
|
||||||
|
pub fn new(from: &str, subject: &str, text: &str) -> Self {
|
||||||
|
Email {
|
||||||
|
from: from.to_string(),
|
||||||
|
to: Vec::new(),
|
||||||
|
cc: Vec::new(),
|
||||||
|
bcc: Vec::new(),
|
||||||
|
subject: subject.to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
html: None,
|
||||||
|
attachments: Vec::new(),
|
||||||
|
headers: HashMap::new(),
|
||||||
|
priority: Priority::Normal,
|
||||||
|
might_be_spam: false,
|
||||||
|
message_id: None,
|
||||||
|
envelope_from: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a To recipient.
|
||||||
|
pub fn add_to(&mut self, email: &str) -> &mut Self {
|
||||||
|
self.to.push(email.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a CC recipient.
|
||||||
|
pub fn add_cc(&mut self, email: &str) -> &mut Self {
|
||||||
|
self.cc.push(email.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a BCC recipient.
|
||||||
|
pub fn add_bcc(&mut self, email: &str) -> &mut Self {
|
||||||
|
self.bcc.push(email.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the HTML body.
|
||||||
|
pub fn set_html(&mut self, html: &str) -> &mut Self {
|
||||||
|
self.html = Some(html.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an attachment.
|
||||||
|
pub fn add_attachment(&mut self, attachment: Attachment) -> &mut Self {
|
||||||
|
self.attachments.push(attachment);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a custom header.
|
||||||
|
pub fn add_header(&mut self, name: &str, value: &str) -> &mut Self {
|
||||||
|
self.headers.insert(name.to_string(), value.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set email priority.
|
||||||
|
pub fn set_priority(&mut self, priority: Priority) -> &mut Self {
|
||||||
|
self.priority = priority;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the sender domain.
|
||||||
|
pub fn from_domain(&self) -> Option<String> {
|
||||||
|
EmailAddress::parse(&self.from)
|
||||||
|
.ok()
|
||||||
|
.map(|addr| addr.domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the sender address (bare email, no display name).
|
||||||
|
pub fn from_address(&self) -> Option<String> {
|
||||||
|
extract_email_address(&self.from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all recipients (to + cc + bcc), deduplicated.
|
||||||
|
pub fn all_recipients(&self) -> Vec<String> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for addr in self.to.iter().chain(self.cc.iter()).chain(self.bcc.iter()) {
|
||||||
|
let lower = addr.to_lowercase();
|
||||||
|
if seen.insert(lower) {
|
||||||
|
result.push(addr.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the primary (first To) recipient.
|
||||||
|
pub fn primary_recipient(&self) -> Option<&str> {
|
||||||
|
self.to.first().map(|s| s.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether this email has attachments.
|
||||||
|
pub fn has_attachments(&self) -> bool {
|
||||||
|
!self.attachments.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total attachment size in bytes.
|
||||||
|
pub fn attachments_size(&self) -> usize {
|
||||||
|
self.attachments.iter().map(|a| a.content.len()).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or generate a Message-ID.
|
||||||
|
pub fn message_id(&self) -> String {
|
||||||
|
if let Some(ref id) = self.message_id {
|
||||||
|
return id.clone();
|
||||||
|
}
|
||||||
|
let domain = self.from_domain().unwrap_or_else(|| "localhost".to_string());
|
||||||
|
let unique = uuid::Uuid::new_v4();
|
||||||
|
format!("<{}.{}@{}>", chrono_millis(), unique, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set an explicit Message-ID.
|
||||||
|
pub fn set_message_id(&mut self, id: &str) -> &mut Self {
|
||||||
|
self.message_id = Some(id.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the envelope-from (MAIL FROM), falls back to the From header address.
|
||||||
|
pub fn envelope_from(&self) -> Option<String> {
|
||||||
|
self.envelope_from
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.from_address())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the envelope-from address.
|
||||||
|
pub fn set_envelope_from(&mut self, addr: &str) -> &mut Self {
|
||||||
|
self.envelope_from = Some(addr.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize a string by removing CR/LF (header injection prevention).
|
||||||
|
pub fn sanitize_string(input: &str) -> String {
|
||||||
|
input.replace(['\r', '\n'], " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate all addresses in this email.
|
||||||
|
pub fn validate_addresses(&self) -> Vec<String> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
if !is_valid_email_format(&self.from) {
|
||||||
|
if extract_email_address(&self.from)
|
||||||
|
.map(|a| !is_valid_email_format(&a))
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
errors.push(format!("Invalid from address: {}", self.from));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for addr in &self.to {
|
||||||
|
if !is_valid_email_format(addr) {
|
||||||
|
if extract_email_address(addr)
|
||||||
|
.map(|a| !is_valid_email_format(&a))
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
errors.push(format!("Invalid to address: {}", addr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for addr in &self.cc {
|
||||||
|
if !is_valid_email_format(addr) {
|
||||||
|
if extract_email_address(addr)
|
||||||
|
.map(|a| !is_valid_email_format(&a))
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
errors.push(format!("Invalid cc address: {}", addr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for addr in &self.bcc {
|
||||||
|
if !is_valid_email_format(addr) {
|
||||||
|
if extract_email_address(addr)
|
||||||
|
.map(|a| !is_valid_email_format(&a))
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
errors.push(format!("Invalid bcc address: {}", addr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the email to RFC 5322 format.
|
||||||
|
pub fn to_rfc822(&self) -> Result<String> {
|
||||||
|
build_rfc822(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple epoch millis using std::time (no chrono dependency needed).
|
||||||
|
fn chrono_millis() -> u128 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_email_address_parse() {
|
||||||
|
let addr = EmailAddress::parse("user@example.com").unwrap();
|
||||||
|
assert_eq!(addr.local, "user");
|
||||||
|
assert_eq!(addr.domain, "example.com");
|
||||||
|
|
||||||
|
let addr = EmailAddress::parse("John Doe <john@example.com>").unwrap();
|
||||||
|
assert_eq!(addr.local, "john");
|
||||||
|
assert_eq!(addr.domain, "example.com");
|
||||||
|
|
||||||
|
let addr = EmailAddress::parse("<admin@test.org>").unwrap();
|
||||||
|
assert_eq!(addr.local, "admin");
|
||||||
|
assert_eq!(addr.domain, "test.org");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_email_address() {
|
||||||
|
assert_eq!(
|
||||||
|
extract_email_address("John <john@example.com>"),
|
||||||
|
Some("john@example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_email_address("user@example.com"),
|
||||||
|
Some("user@example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(extract_email_address("<>"), None);
|
||||||
|
assert_eq!(extract_email_address("no-at-sign"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_email_new() {
|
||||||
|
let mut email = Email::new("sender@example.com", "Test", "Hello");
|
||||||
|
email.add_to("recipient@example.com");
|
||||||
|
assert_eq!(email.from_domain(), Some("example.com".to_string()));
|
||||||
|
assert_eq!(email.all_recipients().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_recipients_dedup() {
|
||||||
|
let mut email = Email::new("sender@example.com", "Test", "Hello");
|
||||||
|
email.add_to("a@example.com");
|
||||||
|
email.add_cc("a@example.com"); // duplicate (case-insensitive)
|
||||||
|
email.add_bcc("b@example.com");
|
||||||
|
assert_eq!(email.all_recipients().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_string() {
|
||||||
|
assert_eq!(Email::sanitize_string("hello\r\nworld"), "hello world");
|
||||||
|
assert_eq!(Email::sanitize_string("normal"), "normal");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_id_generation() {
|
||||||
|
let email = Email::new("sender@example.com", "Test", "Hello");
|
||||||
|
let mid = email.message_id();
|
||||||
|
assert!(mid.starts_with('<'));
|
||||||
|
assert!(mid.ends_with('>'));
|
||||||
|
assert!(mid.contains("@example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
rust/crates/mailer-core/src/error.rs
Normal file
31
rust/crates/mailer-core/src/error.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Core error types for the mailer system.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum MailerError {
|
||||||
|
#[error("invalid email address: {0}")]
|
||||||
|
InvalidEmail(String),
|
||||||
|
|
||||||
|
#[error("invalid email format: {0}")]
|
||||||
|
InvalidFormat(String),
|
||||||
|
|
||||||
|
#[error("missing required field: {0}")]
|
||||||
|
MissingField(String),
|
||||||
|
|
||||||
|
#[error("MIME encoding error: {0}")]
|
||||||
|
MimeError(String),
|
||||||
|
|
||||||
|
#[error("validation error: {0}")]
|
||||||
|
ValidationError(String),
|
||||||
|
|
||||||
|
#[error("parse error: {0}")]
|
||||||
|
ParseError(String),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("regex error: {0}")]
|
||||||
|
Regex(#[from] regex::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, MailerError>;
|
||||||
35
rust/crates/mailer-core/src/lib.rs
Normal file
35
rust/crates/mailer-core/src/lib.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//! mailer-core: Email model, validation, and RFC 5322 primitives.
|
||||||
|
|
||||||
|
pub mod bounce;
|
||||||
|
pub mod email;
|
||||||
|
pub mod error;
|
||||||
|
pub mod mime;
|
||||||
|
pub mod validation;
|
||||||
|
|
||||||
|
// Re-exports for convenience
|
||||||
|
pub use bounce::{
|
||||||
|
detect_bounce_type, extract_bounce_recipient, is_bounce_subject, BounceCategory,
|
||||||
|
BounceDetection, BounceType,
|
||||||
|
};
|
||||||
|
pub use email::{extract_email_address, Attachment, Email, EmailAddress, Priority};
|
||||||
|
pub use error::{MailerError, Result};
|
||||||
|
pub use mime::build_rfc822;
|
||||||
|
pub use validation::{is_valid_email_format, validate_email, EmailValidationResult};
|
||||||
|
|
||||||
|
/// Re-export mailparse for MIME parsing.
|
||||||
|
pub use mailparse;
|
||||||
|
|
||||||
|
/// Crate version.
|
||||||
|
pub fn version() -> &'static str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version() {
|
||||||
|
assert!(!version().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
377
rust/crates/mailer-core/src/mime.rs
Normal file
377
rust/crates/mailer-core/src/mime.rs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
use crate::email::Email;
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Generate a MIME boundary string.
|
||||||
|
fn generate_boundary() -> String {
|
||||||
|
let id = uuid::Uuid::new_v4();
|
||||||
|
format!("----=_Part_{}", id.as_simple())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an RFC 5322 compliant email message from an Email struct.
|
||||||
|
pub fn build_rfc822(email: &Email) -> Result<String> {
|
||||||
|
let mut output = String::with_capacity(4096);
|
||||||
|
let message_id = email.message_id();
|
||||||
|
|
||||||
|
// Required headers
|
||||||
|
output.push_str(&format!(
|
||||||
|
"From: {}\r\n",
|
||||||
|
Email::sanitize_string(&email.from)
|
||||||
|
));
|
||||||
|
|
||||||
|
if !email.to.is_empty() {
|
||||||
|
output.push_str(&format!(
|
||||||
|
"To: {}\r\n",
|
||||||
|
email
|
||||||
|
.to
|
||||||
|
.iter()
|
||||||
|
.map(|a| Email::sanitize_string(a))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !email.cc.is_empty() {
|
||||||
|
output.push_str(&format!(
|
||||||
|
"Cc: {}\r\n",
|
||||||
|
email
|
||||||
|
.cc
|
||||||
|
.iter()
|
||||||
|
.map(|a| Email::sanitize_string(a))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str(&format!(
|
||||||
|
"Subject: {}\r\n",
|
||||||
|
Email::sanitize_string(&email.subject)
|
||||||
|
));
|
||||||
|
output.push_str(&format!("Message-ID: {}\r\n", message_id));
|
||||||
|
output.push_str(&format!("Date: {}\r\n", rfc2822_now()));
|
||||||
|
output.push_str("MIME-Version: 1.0\r\n");
|
||||||
|
|
||||||
|
// Priority headers
|
||||||
|
match email.priority {
|
||||||
|
crate::email::Priority::High => {
|
||||||
|
output.push_str("X-Priority: 1\r\n");
|
||||||
|
output.push_str("Importance: high\r\n");
|
||||||
|
}
|
||||||
|
crate::email::Priority::Low => {
|
||||||
|
output.push_str("X-Priority: 5\r\n");
|
||||||
|
output.push_str("Importance: low\r\n");
|
||||||
|
}
|
||||||
|
crate::email::Priority::Normal => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom headers
|
||||||
|
for (name, value) in &email.headers {
|
||||||
|
output.push_str(&format!(
|
||||||
|
"{}: {}\r\n",
|
||||||
|
Email::sanitize_string(name),
|
||||||
|
Email::sanitize_string(value)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_html = email.html.is_some();
|
||||||
|
let has_attachments = !email.attachments.is_empty();
|
||||||
|
|
||||||
|
match (has_html, has_attachments) {
|
||||||
|
(false, false) => {
|
||||||
|
// Plain text only
|
||||||
|
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
|
||||||
|
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||||
|
output.push_str("\r\n");
|
||||||
|
output.push_str("ed_printable_encode(&email.text));
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
// multipart/alternative (text + html)
|
||||||
|
let boundary = generate_boundary();
|
||||||
|
output.push_str(&format!(
|
||||||
|
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n",
|
||||||
|
boundary
|
||||||
|
));
|
||||||
|
output.push_str("\r\n");
|
||||||
|
|
||||||
|
// Text part
|
||||||
|
output.push_str(&format!("--{}\r\n", boundary));
|
||||||
|
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
|
||||||
|
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||||
|
output.push_str("\r\n");
|
||||||
|
output.push_str("ed_printable_encode(&email.text));
|
||||||
|
output.push_str("\r\n");
|
||||||
|
|
||||||
|
// HTML part
|
||||||
|
output.push_str(&format!("--{}\r\n", boundary));
|
||||||
|
output.push_str("Content-Type: text/html; charset=UTF-8\r\n");
|
||||||
|
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||||
|
output.push_str("\r\n");
|
||||||
|
output.push_str("ed_printable_encode(email.html.as_deref().unwrap()));
|
||||||
|
output.push_str("\r\n");
|
||||||
|
|
||||||
|
output.push_str(&format!("--{}--\r\n", boundary));
|
||||||
|
}
|
||||||
|
(_, true) => {
|
||||||
|
// multipart/mixed with optional multipart/alternative inside
|
||||||
|
let mixed_boundary = generate_boundary();
|
||||||
|
output.push_str(&format!(
|
||||||
|
"Content-Type: multipart/mixed; boundary=\"{}\"\r\n",
|
||||||
|
mixed_boundary
|
||||||
|
));
|
||||||
|
output.push_str("\r\n");
|
||||||
|
|
||||||
|
if has_html {
|
||||||
|
// multipart/alternative for text+html
|
||||||
|
let alt_boundary = generate_boundary();
|
||||||
|
output.push_str(&format!("--{}\r\n", mixed_boundary));
|
||||||
|
output.push_str(&format!(
|
||||||
|
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n",
|
||||||
|
alt_boundary
|
||||||
|
));
|
||||||
|
output.push_str("\r\n");
|
||||||
|
|
||||||
|
// Text part
|
||||||
|
output.push_str(&format!("--{}\r\n", alt_boundary));
|
||||||
|
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
|
||||||
|
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||||
|
output.push_str("\r\n");
|
||||||
|
output.push_str("ed_printable_encode(&email.text));
|
||||||
|
output.push_str("\r\n");
|
||||||
|
|
||||||
|
// HTML part
|
||||||
|
output.push_str(&format!("--{}\r\n", alt_boundary));
|
||||||
|
output.push_str("Content-Type: text/html; charset=UTF-8\r\n");
|
||||||
|
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||||
|
output.push_str("\r\n");
|
||||||
|
output.push_str("ed_printable_encode(email.html.as_deref().unwrap()));
|
||||||
|
output.push_str("\r\n");
|
||||||
|
|
||||||
|
output.push_str(&format!("--{}--\r\n", alt_boundary));
|
||||||
|
} else {
|
||||||
|
// Plain text only
|
||||||
|
output.push_str(&format!("--{}\r\n", mixed_boundary));
|
||||||
|
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
|
||||||
|
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||||
|
output.push_str("\r\n");
|
||||||
|
output.push_str("ed_printable_encode(&email.text));
|
||||||
|
output.push_str("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
for attachment in &email.attachments {
|
||||||
|
output.push_str(&format!("--{}\r\n", mixed_boundary));
|
||||||
|
output.push_str(&format!(
|
||||||
|
"Content-Type: {}; name=\"{}\"\r\n",
|
||||||
|
attachment.content_type, attachment.filename
|
||||||
|
));
|
||||||
|
output.push_str("Content-Transfer-Encoding: base64\r\n");
|
||||||
|
|
||||||
|
if let Some(ref cid) = attachment.content_id {
|
||||||
|
output.push_str(&format!("Content-ID: <{}>\r\n", cid));
|
||||||
|
output.push_str("Content-Disposition: inline\r\n");
|
||||||
|
} else {
|
||||||
|
output.push_str(&format!(
|
||||||
|
"Content-Disposition: attachment; filename=\"{}\"\r\n",
|
||||||
|
attachment.filename
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str("\r\n");
|
||||||
|
output.push_str(&base64_encode_wrapped(&attachment.content));
|
||||||
|
output.push_str("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str(&format!("--{}--\r\n", mixed_boundary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a string as quoted-printable (RFC 2045).
|
||||||
|
fn quoted_printable_encode(input: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(input.len() * 2);
|
||||||
|
let mut line_len = 0;
|
||||||
|
|
||||||
|
for byte in input.bytes() {
|
||||||
|
let encoded = match byte {
|
||||||
|
// Printable ASCII that doesn't need encoding (except =)
|
||||||
|
b' '..=b'<' | b'>'..=b'~' => {
|
||||||
|
line_len += 1;
|
||||||
|
(byte as char).to_string()
|
||||||
|
}
|
||||||
|
b'\t' => {
|
||||||
|
line_len += 1;
|
||||||
|
"\t".to_string()
|
||||||
|
}
|
||||||
|
b'\r' => continue, // handled with \n
|
||||||
|
b'\n' => {
|
||||||
|
line_len = 0;
|
||||||
|
"\r\n".to_string()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
line_len += 3;
|
||||||
|
format!("={:02X}", byte)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Soft line break at 76 characters
|
||||||
|
if line_len > 75 && byte != b'\n' {
|
||||||
|
output.push_str("=\r\n");
|
||||||
|
line_len = encoded.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str(&encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base64-encode binary data with 76-character line wrapping.
|
||||||
|
fn base64_encode_wrapped(data: &[u8]) -> String {
|
||||||
|
let encoded = STANDARD.encode(data);
|
||||||
|
let mut output = String::with_capacity(encoded.len() + encoded.len() / 76 * 2);
|
||||||
|
for (i, ch) in encoded.chars().enumerate() {
|
||||||
|
if i > 0 && i % 76 == 0 {
|
||||||
|
output.push_str("\r\n");
|
||||||
|
}
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate current date in RFC 2822 format (e.g., "Tue, 10 Feb 2026 12:00:00 +0000").
|
||||||
|
fn rfc2822_now() -> String {
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
// Simple UTC formatting without chrono dependency
|
||||||
|
let days = now / 86400;
|
||||||
|
let time_of_day = now % 86400;
|
||||||
|
let hours = time_of_day / 3600;
|
||||||
|
let minutes = (time_of_day % 3600) / 60;
|
||||||
|
let seconds = time_of_day % 60;
|
||||||
|
|
||||||
|
// Calculate year/month/day from days since epoch
|
||||||
|
let (year, month, day) = days_to_ymd(days);
|
||||||
|
|
||||||
|
let day_of_week = ((days + 4) % 7) as usize; // Jan 1 1970 = Thursday (4)
|
||||||
|
let dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
let mon = [
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
];
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"{}, {:02} {} {:04} {:02}:{:02}:{:02} +0000",
|
||||||
|
dow[day_of_week],
|
||||||
|
day,
|
||||||
|
mon[(month - 1) as usize],
|
||||||
|
year,
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
seconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert days since Unix epoch to (year, month, day).
|
||||||
|
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
|
||||||
|
// Algorithm from https://howardhinnant.github.io/date_algorithms.html
|
||||||
|
let z = days + 719468;
|
||||||
|
let era = z / 146097;
|
||||||
|
let doe = z - era * 146097;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let y = if m <= 2 { y + 1 } else { y };
|
||||||
|
(y, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::email::Email;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plain_text_email() {
|
||||||
|
let mut email = Email::new("sender@example.com", "Test Subject", "Hello World");
|
||||||
|
email.add_to("recipient@example.com");
|
||||||
|
email.set_message_id("<test@example.com>");
|
||||||
|
|
||||||
|
let rfc822 = build_rfc822(&email).unwrap();
|
||||||
|
assert!(rfc822.contains("From: sender@example.com"));
|
||||||
|
assert!(rfc822.contains("To: recipient@example.com"));
|
||||||
|
assert!(rfc822.contains("Subject: Test Subject"));
|
||||||
|
assert!(rfc822.contains("Content-Type: text/plain; charset=UTF-8"));
|
||||||
|
assert!(rfc822.contains("Hello World"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html_email() {
|
||||||
|
let mut email = Email::new("sender@example.com", "HTML Test", "Plain text");
|
||||||
|
email.add_to("recipient@example.com");
|
||||||
|
email.set_html("<p>HTML content</p>");
|
||||||
|
email.set_message_id("<test@example.com>");
|
||||||
|
|
||||||
|
let rfc822 = build_rfc822(&email).unwrap();
|
||||||
|
assert!(rfc822.contains("multipart/alternative"));
|
||||||
|
assert!(rfc822.contains("text/plain"));
|
||||||
|
assert!(rfc822.contains("text/html"));
|
||||||
|
assert!(rfc822.contains("Plain text"));
|
||||||
|
assert!(rfc822.contains("HTML content"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_email_with_attachment() {
|
||||||
|
let mut email = Email::new("sender@example.com", "Attachment Test", "See attached");
|
||||||
|
email.add_to("recipient@example.com");
|
||||||
|
email.set_message_id("<test@example.com>");
|
||||||
|
email.add_attachment(crate::email::Attachment {
|
||||||
|
filename: "test.txt".to_string(),
|
||||||
|
content: b"Hello attachment".to_vec(),
|
||||||
|
content_type: "text/plain".to_string(),
|
||||||
|
content_id: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let rfc822 = build_rfc822(&email).unwrap();
|
||||||
|
assert!(rfc822.contains("multipart/mixed"));
|
||||||
|
assert!(rfc822.contains("Content-Disposition: attachment"));
|
||||||
|
assert!(rfc822.contains("test.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quoted_printable() {
|
||||||
|
let input = "Hello = World";
|
||||||
|
let encoded = quoted_printable_encode(input);
|
||||||
|
assert!(encoded.contains("=3D")); // = is encoded
|
||||||
|
|
||||||
|
let input = "Plain ASCII text";
|
||||||
|
let encoded = quoted_printable_encode(input);
|
||||||
|
assert_eq!(encoded, "Plain ASCII text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_base64_wrapped() {
|
||||||
|
let data = vec![0u8; 100];
|
||||||
|
let encoded = base64_encode_wrapped(&data);
|
||||||
|
for line in encoded.split("\r\n") {
|
||||||
|
assert!(line.len() <= 76);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rfc2822_date() {
|
||||||
|
let date = rfc2822_now();
|
||||||
|
// Should match pattern like "Tue, 10 Feb 2026 12:00:00 +0000"
|
||||||
|
assert!(date.contains("+0000"));
|
||||||
|
assert!(date.len() > 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
178
rust/crates/mailer-core/src/validation.rs
Normal file
178
rust/crates/mailer-core/src/validation.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
/// Basic email format regex — covers the vast majority of valid email addresses.
|
||||||
|
/// Does NOT attempt to match the full RFC 5321 grammar (which is impractical via regex).
|
||||||
|
static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"(?i)^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$",
|
||||||
|
)
|
||||||
|
.expect("invalid email regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check whether an email address has valid syntax.
|
||||||
|
pub fn is_valid_email_format(email: &str) -> bool {
|
||||||
|
let email = email.trim();
|
||||||
|
if email.is_empty() || email.len() > 254 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let local = parts[1];
|
||||||
|
let domain = parts[0];
|
||||||
|
|
||||||
|
// Local part max 64 chars
|
||||||
|
if local.is_empty() || local.len() > 64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain must have at least one dot (TLD only not valid for email)
|
||||||
|
if !domain.contains('.') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
EMAIL_REGEX.is_match(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Email validation result with scoring.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EmailValidationResult {
|
||||||
|
pub is_valid: bool,
|
||||||
|
pub format_valid: bool,
|
||||||
|
pub score: f64,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an email address (synchronous, format-only).
|
||||||
|
/// DNS-based validation (MX records, disposable domains) would require async and is
|
||||||
|
/// intended for the N-API bridge layer where the TypeScript side already has DNS access.
|
||||||
|
pub fn validate_email(email: &str) -> EmailValidationResult {
|
||||||
|
let format_valid = is_valid_email_format(email);
|
||||||
|
|
||||||
|
if !format_valid {
|
||||||
|
return EmailValidationResult {
|
||||||
|
is_valid: false,
|
||||||
|
format_valid: false,
|
||||||
|
score: 0.0,
|
||||||
|
error_message: Some(format!("Invalid email format: {}", email)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role account detection (weight 0.1 penalty)
|
||||||
|
let local = email.split('@').next().unwrap_or("");
|
||||||
|
let is_role = is_role_account(local);
|
||||||
|
|
||||||
|
// Score: format (0.4) + assumed-mx (0.3) + assumed-not-disposable (0.2) + role (0.1)
|
||||||
|
let mut score = 0.4 + 0.3 + 0.2; // format + mx + not-disposable
|
||||||
|
if !is_role {
|
||||||
|
score += 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmailValidationResult {
|
||||||
|
is_valid: score >= 0.7,
|
||||||
|
format_valid: true,
|
||||||
|
score,
|
||||||
|
error_message: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a local part is a common role account.
|
||||||
|
fn is_role_account(local: &str) -> bool {
|
||||||
|
const ROLE_ACCOUNTS: &[&str] = &[
|
||||||
|
"abuse",
|
||||||
|
"admin",
|
||||||
|
"administrator",
|
||||||
|
"billing",
|
||||||
|
"compliance",
|
||||||
|
"devnull",
|
||||||
|
"dns",
|
||||||
|
"ftp",
|
||||||
|
"hostmaster",
|
||||||
|
"info",
|
||||||
|
"inoc",
|
||||||
|
"ispfeedback",
|
||||||
|
"ispsupport",
|
||||||
|
"list",
|
||||||
|
"list-request",
|
||||||
|
"maildaemon",
|
||||||
|
"mailer-daemon",
|
||||||
|
"mailerdaemon",
|
||||||
|
"marketing",
|
||||||
|
"noc",
|
||||||
|
"no-reply",
|
||||||
|
"noreply",
|
||||||
|
"null",
|
||||||
|
"phish",
|
||||||
|
"phishing",
|
||||||
|
"postmaster",
|
||||||
|
"privacy",
|
||||||
|
"registrar",
|
||||||
|
"root",
|
||||||
|
"sales",
|
||||||
|
"security",
|
||||||
|
"spam",
|
||||||
|
"support",
|
||||||
|
"sysadmin",
|
||||||
|
"tech",
|
||||||
|
"undisclosed-recipients",
|
||||||
|
"unsubscribe",
|
||||||
|
"usenet",
|
||||||
|
"uucp",
|
||||||
|
"webmaster",
|
||||||
|
"www",
|
||||||
|
];
|
||||||
|
let lower = local.to_lowercase();
|
||||||
|
ROLE_ACCOUNTS.contains(&lower.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_emails() {
|
||||||
|
assert!(is_valid_email_format("user@example.com"));
|
||||||
|
assert!(is_valid_email_format("first.last@example.com"));
|
||||||
|
assert!(is_valid_email_format("user+tag@example.com"));
|
||||||
|
assert!(is_valid_email_format("user@sub.domain.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_emails() {
|
||||||
|
assert!(!is_valid_email_format(""));
|
||||||
|
assert!(!is_valid_email_format("@"));
|
||||||
|
assert!(!is_valid_email_format("user@"));
|
||||||
|
assert!(!is_valid_email_format("@domain.com"));
|
||||||
|
assert!(!is_valid_email_format("user@domain")); // no TLD
|
||||||
|
assert!(!is_valid_email_format("user @domain.com")); // space
|
||||||
|
assert!(!is_valid_email_format("user@.com")); // leading dot
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_email_scoring() {
|
||||||
|
let result = validate_email("user@example.com");
|
||||||
|
assert!(result.is_valid);
|
||||||
|
assert!(result.score >= 0.9);
|
||||||
|
|
||||||
|
let result = validate_email("postmaster@example.com");
|
||||||
|
assert!(result.is_valid);
|
||||||
|
assert!(result.score >= 0.7);
|
||||||
|
assert!(result.score < 1.0); // role account penalty
|
||||||
|
|
||||||
|
let result = validate_email("not-an-email");
|
||||||
|
assert!(!result.is_valid);
|
||||||
|
assert_eq!(result.score, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_role_accounts() {
|
||||||
|
assert!(is_role_account("postmaster"));
|
||||||
|
assert!(is_role_account("abuse"));
|
||||||
|
assert!(is_role_account("noreply"));
|
||||||
|
assert!(!is_role_account("john"));
|
||||||
|
assert!(!is_role_account("alice"));
|
||||||
|
}
|
||||||
|
}
|
||||||
21
rust/crates/mailer-napi/Cargo.toml
Normal file
21
rust/crates/mailer-napi/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "mailer-napi"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mailer-core = { path = "../mailer-core" }
|
||||||
|
mailer-smtp = { path = "../mailer-smtp" }
|
||||||
|
mailer-security = { path = "../mailer-security" }
|
||||||
|
napi.workspace = true
|
||||||
|
napi-derive.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
napi-build = "2"
|
||||||
5
rust/crates/mailer-napi/build.rs
Normal file
5
rust/crates/mailer-napi/build.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
extern crate napi_build;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
napi_build::setup();
|
||||||
|
}
|
||||||
15
rust/crates/mailer-napi/src/lib.rs
Normal file
15
rust/crates/mailer-napi/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//! mailer-napi: N-API bindings exposing Rust mailer to Node.js/TypeScript.
|
||||||
|
|
||||||
|
use napi_derive::napi;
|
||||||
|
|
||||||
|
/// Returns the version of the native mailer module.
|
||||||
|
#[napi]
|
||||||
|
pub fn get_version() -> String {
|
||||||
|
format!(
|
||||||
|
"mailer-napi v{} (core: {}, smtp: {}, security: {})",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
mailer_core::version(),
|
||||||
|
mailer_smtp::version(),
|
||||||
|
mailer_security::version(),
|
||||||
|
)
|
||||||
|
}
|
||||||
20
rust/crates/mailer-security/Cargo.toml
Normal file
20
rust/crates/mailer-security/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "mailer-security"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mailer-core = { path = "../mailer-core" }
|
||||||
|
mail-auth.workspace = true
|
||||||
|
ring.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
hickory-resolver.workspace = true
|
||||||
|
ipnet.workspace = true
|
||||||
|
rustls-pki-types.workspace = true
|
||||||
|
psl.workspace = true
|
||||||
|
regex.workspace = true
|
||||||
515
rust/crates/mailer-security/src/content_scanner.rs
Normal file
515
rust/crates/mailer-security/src/content_scanner.rs
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
//! Content scanning for email threat detection.
|
||||||
|
//!
|
||||||
|
//! Provides pattern-based scanning of email subjects, text bodies, HTML bodies,
|
||||||
|
//! and attachment filenames for phishing, spam, malware, suspicious links,
|
||||||
|
//! script injection, and sensitive data patterns.
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Result types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ContentScanResult {
|
||||||
|
pub threat_score: u32,
|
||||||
|
pub threat_type: Option<String>,
|
||||||
|
pub threat_details: Option<String>,
|
||||||
|
pub scanned_elements: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pattern definitions (compiled once via LazyLock)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static PHISHING_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"(?i)(?:verify|confirm|update|login).*(?:account|password|details)").unwrap(),
|
||||||
|
Regex::new(r"(?i)urgent.*(?:action|attention|required)").unwrap(),
|
||||||
|
Regex::new(r"(?i)(?:paypal|apple|microsoft|amazon|google|bank).*(?:verify|confirm|suspend)").unwrap(),
|
||||||
|
Regex::new(r"(?i)your.*(?:account).*(?:suspended|compromised|locked)").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:password reset|security alert|security notice)\b").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
static SPAM_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"(?i)\b(?:viagra|cialis|enlargement|diet pill|lose weight fast|cheap meds)\b").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:million dollars|lottery winner|prize claim|inheritance|rich widow)\b").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:earn from home|make money fast|earn \$\d{3,}/day)\b").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:limited time offer|act now|exclusive deal|only \d+ left)\b").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:forex|stock tip|investment opportunity|cryptocurrency|bitcoin)\b").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
static MALWARE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"(?i)(?:attached file|see attachment).*(?:invoice|receipt|statement|document)").unwrap(),
|
||||||
|
Regex::new(r"(?i)open.*(?:the attached|this attachment)").unwrap(),
|
||||||
|
Regex::new(r"(?i)(?:enable|allow).*(?:macros|content|editing)").unwrap(),
|
||||||
|
Regex::new(r"(?i)download.*(?:attachment|file|document)").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:ransomware protection|virus alert|malware detected)\b").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
static SUSPICIOUS_LINK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"(?i)https?://bit\.ly/").unwrap(),
|
||||||
|
Regex::new(r"(?i)https?://goo\.gl/").unwrap(),
|
||||||
|
Regex::new(r"(?i)https?://t\.co/").unwrap(),
|
||||||
|
Regex::new(r"(?i)https?://tinyurl\.com/").unwrap(),
|
||||||
|
Regex::new(r"(?i)https?://(?:\d{1,3}\.){3}\d{1,3}").unwrap(),
|
||||||
|
Regex::new(r"(?i)https?://.*\.(?:xyz|top|club|gq|cf)/").unwrap(),
|
||||||
|
Regex::new(r"(?i)(?:login|account|signin|auth).*\.(?:xyz|top|club|gq|cf|tk|ml|ga|pw|ws|buzz)\b").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
static SCRIPT_INJECTION_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"(?is)<script.*>.*</script>").unwrap(),
|
||||||
|
Regex::new(r"(?i)javascript:").unwrap(),
|
||||||
|
Regex::new(r#"(?i)on(?:click|load|mouse|error|focus|blur)=".*""#).unwrap(),
|
||||||
|
Regex::new(r"(?i)document\.(?:cookie|write|location)").unwrap(),
|
||||||
|
Regex::new(r"(?i)eval\s*\(").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
static SENSITIVE_DATA_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"\b(?:\d{3}-\d{2}-\d{4}|\d{9})\b").unwrap(),
|
||||||
|
Regex::new(r"\b\d{13,16}\b").unwrap(),
|
||||||
|
Regex::new(r"\b(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})\b").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Link extraction from HTML href attributes.
|
||||||
|
static HREF_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r#"(?i)href=["'](https?://[^"']+)["']"#).unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Executable file extensions that are considered dangerous.
|
||||||
|
static EXECUTABLE_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
".exe", ".dll", ".bat", ".cmd", ".msi", ".vbs", ".ps1",
|
||||||
|
".sh", ".jar", ".py", ".com", ".scr", ".pif", ".hta", ".cpl",
|
||||||
|
".reg", ".vba", ".lnk", ".wsf", ".msp", ".mst",
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Document extensions that may contain macros.
|
||||||
|
static MACRO_DOCUMENT_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
".doc", ".docm", ".xls", ".xlsm", ".ppt", ".pptm",
|
||||||
|
".dotm", ".xlsb", ".ppam", ".potm",
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTML helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Strip HTML tags and decode common entities to produce plain text.
|
||||||
|
fn extract_text_from_html(html: &str) -> String {
|
||||||
|
// Remove style and script blocks first
|
||||||
|
let no_style = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
|
||||||
|
let no_script = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
|
||||||
|
let no_tags = Regex::new(r"<[^>]+>").unwrap();
|
||||||
|
|
||||||
|
let text = no_style.replace_all(html, " ");
|
||||||
|
let text = no_script.replace_all(&text, " ");
|
||||||
|
let text = no_tags.replace_all(&text, " ");
|
||||||
|
|
||||||
|
text.replace(" ", " ")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract all href links from HTML.
|
||||||
|
fn extract_links_from_html(html: &str) -> Vec<String> {
|
||||||
|
HREF_PATTERN
|
||||||
|
.captures_iter(html)
|
||||||
|
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scoring helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn matches_any(text: &str, patterns: &[Regex]) -> bool {
|
||||||
|
patterns.iter().any(|p| p.is_match(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main scan entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Scan email content for threats.
|
||||||
|
///
|
||||||
|
/// This mirrors the TypeScript ContentScanner logic — scanning the subject,
|
||||||
|
/// text body, HTML body, and attachment filenames against predefined patterns.
|
||||||
|
/// Returns an aggregate threat score and the highest-severity threat type.
|
||||||
|
pub fn scan_content(
|
||||||
|
subject: Option<&str>,
|
||||||
|
text_body: Option<&str>,
|
||||||
|
html_body: Option<&str>,
|
||||||
|
attachment_names: &[String],
|
||||||
|
) -> ContentScanResult {
|
||||||
|
let mut score: u32 = 0;
|
||||||
|
let mut threat_type: Option<String> = None;
|
||||||
|
let mut threat_details: Option<String> = None;
|
||||||
|
let mut scanned: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Helper: upgrade threat info only if the new finding is more severe.
|
||||||
|
macro_rules! record {
|
||||||
|
($new_score:expr, $ttype:expr, $details:expr) => {
|
||||||
|
score += $new_score;
|
||||||
|
// Always adopt the threat type from the highest-scoring match.
|
||||||
|
threat_type = Some($ttype.to_string());
|
||||||
|
threat_details = Some($details.to_string());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subject scanning ──────────────────────────────────────────────
|
||||||
|
if let Some(subj) = subject {
|
||||||
|
scanned.push("subject".into());
|
||||||
|
|
||||||
|
if matches_any(subj, &PHISHING_PATTERNS) {
|
||||||
|
record!(25, "phishing", format!("Subject contains potential phishing indicators: {}", subj));
|
||||||
|
} else if matches_any(subj, &SPAM_PATTERNS) {
|
||||||
|
record!(15, "spam", format!("Subject contains potential spam indicators: {}", subj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text body scanning ────────────────────────────────────────────
|
||||||
|
if let Some(text) = text_body {
|
||||||
|
scanned.push("text".into());
|
||||||
|
|
||||||
|
// Check each category and accumulate score (same order as TS)
|
||||||
|
for pat in SUSPICIOUS_LINK_PATTERNS.iter() {
|
||||||
|
if pat.is_match(text) {
|
||||||
|
score += 20;
|
||||||
|
if threat_type.as_deref() != Some("suspicious_link") {
|
||||||
|
threat_type = Some("suspicious_link".into());
|
||||||
|
threat_details = Some("Text contains suspicious links".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pat in PHISHING_PATTERNS.iter() {
|
||||||
|
if pat.is_match(text) {
|
||||||
|
score += 25;
|
||||||
|
threat_type = Some("phishing".into());
|
||||||
|
threat_details = Some("Text contains potential phishing indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pat in SPAM_PATTERNS.iter() {
|
||||||
|
if pat.is_match(text) {
|
||||||
|
score += 15;
|
||||||
|
if threat_type.is_none() {
|
||||||
|
threat_type = Some("spam".into());
|
||||||
|
threat_details = Some("Text contains potential spam indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pat in MALWARE_PATTERNS.iter() {
|
||||||
|
if pat.is_match(text) {
|
||||||
|
score += 30;
|
||||||
|
threat_type = Some("malware".into());
|
||||||
|
threat_details = Some("Text contains potential malware indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pat in SENSITIVE_DATA_PATTERNS.iter() {
|
||||||
|
if pat.is_match(text) {
|
||||||
|
score += 25;
|
||||||
|
if threat_type.is_none() {
|
||||||
|
threat_type = Some("sensitive_data".into());
|
||||||
|
threat_details = Some("Text contains potentially sensitive data patterns".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML body scanning ────────────────────────────────────────────
|
||||||
|
if let Some(html) = html_body {
|
||||||
|
scanned.push("html".into());
|
||||||
|
|
||||||
|
// Script injection check
|
||||||
|
for pat in SCRIPT_INJECTION_PATTERNS.iter() {
|
||||||
|
if pat.is_match(html) {
|
||||||
|
score += 40;
|
||||||
|
if threat_type.as_deref() != Some("xss") {
|
||||||
|
threat_type = Some("xss".into());
|
||||||
|
threat_details = Some("HTML contains potentially malicious script content".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text from HTML and scan (half score to avoid double counting)
|
||||||
|
let text_content = extract_text_from_html(html);
|
||||||
|
if !text_content.is_empty() {
|
||||||
|
let mut html_text_score: u32 = 0;
|
||||||
|
let mut html_text_type: Option<String> = None;
|
||||||
|
let mut html_text_details: Option<String> = None;
|
||||||
|
|
||||||
|
// Re-run text patterns on extracted HTML text
|
||||||
|
for pat in SUSPICIOUS_LINK_PATTERNS.iter() {
|
||||||
|
if pat.is_match(&text_content) {
|
||||||
|
html_text_score += 20;
|
||||||
|
html_text_type = Some("suspicious_link".into());
|
||||||
|
html_text_details = Some("Text contains suspicious links".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pat in PHISHING_PATTERNS.iter() {
|
||||||
|
if pat.is_match(&text_content) {
|
||||||
|
html_text_score += 25;
|
||||||
|
html_text_type = Some("phishing".into());
|
||||||
|
html_text_details = Some("Text contains potential phishing indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pat in SPAM_PATTERNS.iter() {
|
||||||
|
if pat.is_match(&text_content) {
|
||||||
|
html_text_score += 15;
|
||||||
|
if html_text_type.is_none() {
|
||||||
|
html_text_type = Some("spam".into());
|
||||||
|
html_text_details = Some("Text contains potential spam indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pat in MALWARE_PATTERNS.iter() {
|
||||||
|
if pat.is_match(&text_content) {
|
||||||
|
html_text_score += 30;
|
||||||
|
html_text_type = Some("malware".into());
|
||||||
|
html_text_details = Some("Text contains potential malware indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pat in SENSITIVE_DATA_PATTERNS.iter() {
|
||||||
|
if pat.is_match(&text_content) {
|
||||||
|
html_text_score += 25;
|
||||||
|
if html_text_type.is_none() {
|
||||||
|
html_text_type = Some("sensitive_data".into());
|
||||||
|
html_text_details = Some("Text contains potentially sensitive data patterns".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if html_text_score > 0 {
|
||||||
|
// Add half of the text content score to avoid double counting
|
||||||
|
score += html_text_score / 2;
|
||||||
|
if let Some(t) = html_text_type {
|
||||||
|
if threat_type.is_none() || html_text_score > score {
|
||||||
|
threat_type = Some(t);
|
||||||
|
threat_details = html_text_details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and check links from HTML
|
||||||
|
let links = extract_links_from_html(html);
|
||||||
|
if !links.is_empty() {
|
||||||
|
let mut suspicious_count = 0u32;
|
||||||
|
for link in &links {
|
||||||
|
if matches_any(link, &SUSPICIOUS_LINK_PATTERNS) {
|
||||||
|
suspicious_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if suspicious_count > 0 {
|
||||||
|
let pct = (suspicious_count as f64 / links.len() as f64) * 100.0;
|
||||||
|
let additional = std::cmp::min(40, (pct / 2.5) as u32);
|
||||||
|
score += additional;
|
||||||
|
|
||||||
|
if additional > 20 || threat_type.is_none() {
|
||||||
|
threat_type = Some("suspicious_link".into());
|
||||||
|
threat_details = Some(format!(
|
||||||
|
"HTML contains {} suspicious links out of {} total links",
|
||||||
|
suspicious_count,
|
||||||
|
links.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachment filename scanning ──────────────────────────────────
|
||||||
|
for name in attachment_names {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
scanned.push(format!("attachment:{}", lower));
|
||||||
|
|
||||||
|
// Check executable extensions
|
||||||
|
for ext in EXECUTABLE_EXTENSIONS.iter() {
|
||||||
|
if lower.ends_with(ext) {
|
||||||
|
score += 70;
|
||||||
|
threat_type = Some("executable".into());
|
||||||
|
threat_details = Some(format!(
|
||||||
|
"Attachment has a potentially dangerous extension: {}",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check macro document extensions
|
||||||
|
for ext in MACRO_DOCUMENT_EXTENSIONS.iter() {
|
||||||
|
if lower.ends_with(ext) {
|
||||||
|
// Flag macro-capable documents (lower score than executables)
|
||||||
|
score += 20;
|
||||||
|
if threat_type.is_none() {
|
||||||
|
threat_type = Some("malicious_macro".into());
|
||||||
|
threat_details = Some(format!(
|
||||||
|
"Attachment is a macro-capable document: {}",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentScanResult {
|
||||||
|
threat_score: score,
|
||||||
|
threat_type,
|
||||||
|
threat_details,
|
||||||
|
scanned_elements: scanned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clean_content() {
|
||||||
|
let result = scan_content(
|
||||||
|
Some("Project Update"),
|
||||||
|
Some("The project is on track."),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert_eq!(result.threat_score, 0);
|
||||||
|
assert!(result.threat_type.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_phishing_subject() {
|
||||||
|
let result = scan_content(
|
||||||
|
Some("URGENT: Verify your bank account details immediately"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 25);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("phishing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_spam_body() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
Some("Win a million dollars in the lottery winner contest!"),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 15);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("spam"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_suspicious_links() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
Some("Check out https://bit.ly/2x3F5 for more info"),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 20);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("suspicious_link"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_script_injection() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("<p>Hello</p><script>document.cookie='steal';</script>"),
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 40);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("xss"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_executable_attachment() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&["update.exe".into()],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 70);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("executable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_macro_document() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&["report.docm".into()],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 20);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("malicious_macro"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_malware_indicators() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
Some("Please enable macros to view this document properly."),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 30);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("malware"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html_link_extraction() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(r#"<a href="https://bit.ly/abc">click</a> and <a href="https://t.co/xyz">here</a>"#),
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compound_threats() {
|
||||||
|
let result = scan_content(
|
||||||
|
Some("URGENT: Verify your account details immediately"),
|
||||||
|
Some("Your account will be suspended unless you verify at https://bit.ly/2x3F5"),
|
||||||
|
Some(r#"<a href="https://bit.ly/2x3F5">verify</a>"#),
|
||||||
|
&["verification.exe".into()],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score > 70);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
rust/crates/mailer-security/src/dkim.rs
Normal file
152
rust/crates/mailer-security/src/dkim.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
use mail_auth::common::crypto::{RsaKey, Sha256};
|
||||||
|
use mail_auth::common::headers::HeaderWriter;
|
||||||
|
use mail_auth::dkim::{Canonicalization, DkimSigner};
|
||||||
|
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
|
||||||
|
use rustls_pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, pem::PemObject};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{Result, SecurityError};
|
||||||
|
|
||||||
|
/// Result of DKIM verification.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DkimVerificationResult {
|
||||||
|
/// Whether the DKIM signature is valid.
|
||||||
|
pub is_valid: bool,
|
||||||
|
/// The signing domain (d= tag).
|
||||||
|
pub domain: Option<String>,
|
||||||
|
/// The selector (s= tag).
|
||||||
|
pub selector: Option<String>,
|
||||||
|
/// Result status: "pass", "fail", "permerror", "temperror", "none".
|
||||||
|
pub status: String,
|
||||||
|
/// Human-readable details.
|
||||||
|
pub details: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert raw `mail-auth` DKIM outputs to our serializable results.
|
||||||
|
///
|
||||||
|
/// This is used internally by `verify_dkim` and by the compound `verify_email_security`.
|
||||||
|
pub fn dkim_outputs_to_results(dkim_outputs: &[DkimOutput<'_>]) -> Vec<DkimVerificationResult> {
|
||||||
|
if dkim_outputs.is_empty() {
|
||||||
|
return vec![DkimVerificationResult {
|
||||||
|
is_valid: false,
|
||||||
|
domain: None,
|
||||||
|
selector: None,
|
||||||
|
status: "none".to_string(),
|
||||||
|
details: Some("No DKIM signatures found".to_string()),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
dkim_outputs
|
||||||
|
.iter()
|
||||||
|
.map(|output| {
|
||||||
|
let (is_valid, status, details) = match output.result() {
|
||||||
|
DkimResult::Pass => (true, "pass", None),
|
||||||
|
DkimResult::Neutral(err) => (false, "neutral", Some(err.to_string())),
|
||||||
|
DkimResult::Fail(err) => (false, "fail", Some(err.to_string())),
|
||||||
|
DkimResult::PermError(err) => (false, "permerror", Some(err.to_string())),
|
||||||
|
DkimResult::TempError(err) => (false, "temperror", Some(err.to_string())),
|
||||||
|
DkimResult::None => (false, "none", None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (domain, selector) = output
|
||||||
|
.signature()
|
||||||
|
.map(|sig| (Some(sig.d.clone()), Some(sig.s.clone())))
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
|
DkimVerificationResult {
|
||||||
|
is_valid,
|
||||||
|
domain,
|
||||||
|
selector,
|
||||||
|
status: status.to_string(),
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify DKIM signatures on a raw email message.
|
||||||
|
///
|
||||||
|
/// Uses the `mail-auth` crate which performs full RFC 6376 verification
|
||||||
|
/// including DNS lookups for the public key.
|
||||||
|
pub async fn verify_dkim(
|
||||||
|
raw_message: &[u8],
|
||||||
|
authenticator: &MessageAuthenticator,
|
||||||
|
) -> Result<Vec<DkimVerificationResult>> {
|
||||||
|
let message = AuthenticatedMessage::parse(raw_message)
|
||||||
|
.ok_or_else(|| SecurityError::Parse("Failed to parse email for DKIM verification".into()))?;
|
||||||
|
|
||||||
|
let dkim_outputs = authenticator.verify_dkim(&message).await;
|
||||||
|
Ok(dkim_outputs_to_results(&dkim_outputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a raw email message with DKIM (RSA-SHA256).
|
||||||
|
///
|
||||||
|
/// * `raw_message` - The raw RFC 5322 message bytes
|
||||||
|
/// * `domain` - The signing domain (d= tag)
|
||||||
|
/// * `selector` - The DKIM selector (s= tag)
|
||||||
|
/// * `private_key_pem` - RSA private key in PEM format (PKCS#1 or PKCS#8)
|
||||||
|
///
|
||||||
|
/// Returns the DKIM-Signature header string to prepend to the message.
|
||||||
|
pub fn sign_dkim(
|
||||||
|
raw_message: &[u8],
|
||||||
|
domain: &str,
|
||||||
|
selector: &str,
|
||||||
|
private_key_pem: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
// Try PKCS#1 PEM first, then PKCS#8
|
||||||
|
let key_der = PrivatePkcs1KeyDer::from_pem_slice(private_key_pem.as_bytes())
|
||||||
|
.map(PrivateKeyDer::Pkcs1)
|
||||||
|
.or_else(|_| {
|
||||||
|
// Try PKCS#8
|
||||||
|
rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes())
|
||||||
|
.map(PrivateKeyDer::Pkcs8)
|
||||||
|
})
|
||||||
|
.map_err(|e| SecurityError::Key(format!("Failed to parse private key PEM: {}", e)))?;
|
||||||
|
|
||||||
|
let rsa_key = RsaKey::<Sha256>::from_key_der(key_der)
|
||||||
|
.map_err(|e| SecurityError::Key(format!("Failed to load RSA key: {}", e)))?;
|
||||||
|
|
||||||
|
let signature = DkimSigner::from_key(rsa_key)
|
||||||
|
.domain(domain)
|
||||||
|
.selector(selector)
|
||||||
|
.headers(["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type"])
|
||||||
|
.header_canonicalization(Canonicalization::Relaxed)
|
||||||
|
.body_canonicalization(Canonicalization::Relaxed)
|
||||||
|
.sign(raw_message)
|
||||||
|
.map_err(|e| SecurityError::Dkim(format!("DKIM signing failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(signature.to_header())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a DKIM DNS TXT record value for a given public key.
|
||||||
|
///
|
||||||
|
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
|
||||||
|
pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
|
||||||
|
// Extract the base64 content from PEM
|
||||||
|
let key_b64: String = public_key_pem
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.starts_with("-----"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dkim_dns_record_value() {
|
||||||
|
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
|
||||||
|
let record = dkim_dns_record_value(pem);
|
||||||
|
assert!(record.starts_with("v=DKIM1; h=sha256; k=rsa; p="));
|
||||||
|
assert!(record.contains("MIIBIjANBg=="));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_dkim_invalid_key() {
|
||||||
|
let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
127
rust/crates/mailer-security/src/dmarc.rs
Normal file
127
rust/crates/mailer-security/src/dmarc.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
use mail_auth::dmarc::verify::DmarcParameters;
|
||||||
|
use mail_auth::dmarc::Policy;
|
||||||
|
use mail_auth::{
|
||||||
|
AuthenticatedMessage, DkimOutput, DmarcResult as MailAuthDmarcResult, MessageAuthenticator,
|
||||||
|
SpfOutput,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{Result, SecurityError};
|
||||||
|
|
||||||
|
/// DMARC policy.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DmarcPolicy {
|
||||||
|
None,
|
||||||
|
Quarantine,
|
||||||
|
Reject,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Policy> for DmarcPolicy {
|
||||||
|
fn from(p: Policy) -> Self {
|
||||||
|
match p {
|
||||||
|
Policy::None | Policy::Unspecified => DmarcPolicy::None,
|
||||||
|
Policy::Quarantine => DmarcPolicy::Quarantine,
|
||||||
|
Policy::Reject => DmarcPolicy::Reject,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DMARC verification result.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DmarcResult {
|
||||||
|
/// Whether DMARC verification passed overall.
|
||||||
|
pub passed: bool,
|
||||||
|
/// The evaluated policy.
|
||||||
|
pub policy: DmarcPolicy,
|
||||||
|
/// The domain that was checked.
|
||||||
|
pub domain: String,
|
||||||
|
/// DKIM alignment result: "pass", "fail", etc.
|
||||||
|
pub dkim_result: String,
|
||||||
|
/// SPF alignment result: "pass", "fail", etc.
|
||||||
|
pub spf_result: String,
|
||||||
|
/// Recommended action: "pass", "quarantine", "reject".
|
||||||
|
pub action: String,
|
||||||
|
/// Human-readable details.
|
||||||
|
pub details: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check DMARC for an email, given prior DKIM and SPF results.
|
||||||
|
///
|
||||||
|
/// * `raw_message` - The raw RFC 5322 message bytes
|
||||||
|
/// * `dkim_output` - DKIM verification results from `verify_dkim`
|
||||||
|
/// * `spf_output` - SPF verification output from `check_spf`
|
||||||
|
/// * `mail_from_domain` - The MAIL FROM domain (RFC 5321)
|
||||||
|
/// * `authenticator` - The MessageAuthenticator for DNS lookups
|
||||||
|
pub async fn check_dmarc<'x>(
|
||||||
|
raw_message: &'x [u8],
|
||||||
|
dkim_output: &'x [DkimOutput<'x>],
|
||||||
|
spf_output: &'x SpfOutput,
|
||||||
|
mail_from_domain: &'x str,
|
||||||
|
authenticator: &MessageAuthenticator,
|
||||||
|
) -> Result<DmarcResult> {
|
||||||
|
let message = AuthenticatedMessage::parse(raw_message)
|
||||||
|
.ok_or_else(|| SecurityError::Parse("Failed to parse email for DMARC check".into()))?;
|
||||||
|
|
||||||
|
let dmarc_output = authenticator
|
||||||
|
.verify_dmarc(
|
||||||
|
DmarcParameters::new(&message, dkim_output, mail_from_domain, spf_output)
|
||||||
|
.with_domain_suffix_fn(|domain| psl::domain_str(domain).unwrap_or(domain)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let policy = DmarcPolicy::from(dmarc_output.policy());
|
||||||
|
let domain = dmarc_output.domain().to_string();
|
||||||
|
|
||||||
|
let dkim_result_str = dmarc_result_to_string(dmarc_output.dkim_result());
|
||||||
|
let spf_result_str = dmarc_result_to_string(dmarc_output.spf_result());
|
||||||
|
|
||||||
|
let dkim_passed = matches!(dmarc_output.dkim_result(), MailAuthDmarcResult::Pass);
|
||||||
|
let spf_passed = matches!(dmarc_output.spf_result(), MailAuthDmarcResult::Pass);
|
||||||
|
let passed = dkim_passed || spf_passed;
|
||||||
|
|
||||||
|
let action = if passed {
|
||||||
|
"pass".to_string()
|
||||||
|
} else {
|
||||||
|
match policy {
|
||||||
|
DmarcPolicy::None => "pass".to_string(), // p=none means monitor only
|
||||||
|
DmarcPolicy::Quarantine => "quarantine".to_string(),
|
||||||
|
DmarcPolicy::Reject => "reject".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(DmarcResult {
|
||||||
|
passed,
|
||||||
|
policy,
|
||||||
|
domain,
|
||||||
|
dkim_result: dkim_result_str,
|
||||||
|
spf_result: spf_result_str,
|
||||||
|
action,
|
||||||
|
details: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dmarc_result_to_string(result: &MailAuthDmarcResult) -> String {
|
||||||
|
match result {
|
||||||
|
MailAuthDmarcResult::Pass => "pass".to_string(),
|
||||||
|
MailAuthDmarcResult::Fail(err) => format!("fail: {}", err),
|
||||||
|
MailAuthDmarcResult::TempError(err) => format!("temperror: {}", err),
|
||||||
|
MailAuthDmarcResult::PermError(err) => format!("permerror: {}", err),
|
||||||
|
MailAuthDmarcResult::None => "none".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dmarc_policy_from() {
|
||||||
|
assert_eq!(DmarcPolicy::from(Policy::None), DmarcPolicy::None);
|
||||||
|
assert_eq!(
|
||||||
|
DmarcPolicy::from(Policy::Quarantine),
|
||||||
|
DmarcPolicy::Quarantine
|
||||||
|
);
|
||||||
|
assert_eq!(DmarcPolicy::from(Policy::Reject), DmarcPolicy::Reject);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
rust/crates/mailer-security/src/error.rs
Normal file
31
rust/crates/mailer-security/src/error.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Security-related error types.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SecurityError {
|
||||||
|
#[error("DKIM error: {0}")]
|
||||||
|
Dkim(String),
|
||||||
|
|
||||||
|
#[error("SPF error: {0}")]
|
||||||
|
Spf(String),
|
||||||
|
|
||||||
|
#[error("DMARC error: {0}")]
|
||||||
|
Dmarc(String),
|
||||||
|
|
||||||
|
#[error("DNS resolution error: {0}")]
|
||||||
|
Dns(String),
|
||||||
|
|
||||||
|
#[error("key error: {0}")]
|
||||||
|
Key(String),
|
||||||
|
|
||||||
|
#[error("IP reputation error: {0}")]
|
||||||
|
IpReputation(String),
|
||||||
|
|
||||||
|
#[error("parse error: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, SecurityError>;
|
||||||
280
rust/crates/mailer-security/src/ip_reputation.rs
Normal file
280
rust/crates/mailer-security/src/ip_reputation.rs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
use hickory_resolver::TokioResolver;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Default DNSBL servers to check, same as the TypeScript IPReputationChecker.
|
||||||
|
pub const DEFAULT_DNSBL_SERVERS: &[&str] = &[
|
||||||
|
"zen.spamhaus.org",
|
||||||
|
"bl.spamcop.net",
|
||||||
|
"b.barracudacentral.org",
|
||||||
|
"spam.dnsbl.sorbs.net",
|
||||||
|
"dnsbl.sorbs.net",
|
||||||
|
"cbl.abuseat.org",
|
||||||
|
"xbl.spamhaus.org",
|
||||||
|
"pbl.spamhaus.org",
|
||||||
|
"dnsbl-1.uceprotect.net",
|
||||||
|
"psbl.surriel.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Result of a DNSBL check.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsblResult {
|
||||||
|
/// IP address that was checked.
|
||||||
|
pub ip: String,
|
||||||
|
/// Number of DNSBL servers that list this IP.
|
||||||
|
pub listed_count: usize,
|
||||||
|
/// Names of DNSBL servers that list this IP.
|
||||||
|
pub listed_on: Vec<String>,
|
||||||
|
/// Total number of DNSBL servers checked.
|
||||||
|
pub total_checked: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a full IP reputation check.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReputationResult {
|
||||||
|
/// Reputation score: 0 (worst) to 100 (best).
|
||||||
|
pub score: u8,
|
||||||
|
/// Whether the IP is considered spam source.
|
||||||
|
pub is_spam: bool,
|
||||||
|
/// IP address that was checked.
|
||||||
|
pub ip: String,
|
||||||
|
/// DNSBL results.
|
||||||
|
pub dnsbl: DnsblResult,
|
||||||
|
/// Heuristic IP type classification.
|
||||||
|
pub ip_type: IpType,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic IP type classification.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum IpType {
|
||||||
|
Residential,
|
||||||
|
Datacenter,
|
||||||
|
Proxy,
|
||||||
|
Tor,
|
||||||
|
Vpn,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Risk level based on reputation score.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RiskLevel {
|
||||||
|
/// Score < 20
|
||||||
|
High,
|
||||||
|
/// Score 20-49
|
||||||
|
Medium,
|
||||||
|
/// Score 50-79
|
||||||
|
Low,
|
||||||
|
/// Score >= 80
|
||||||
|
Trusted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the risk level for a reputation score.
|
||||||
|
pub fn risk_level(score: u8) -> RiskLevel {
|
||||||
|
match score {
|
||||||
|
0..=19 => RiskLevel::High,
|
||||||
|
20..=49 => RiskLevel::Medium,
|
||||||
|
50..=79 => RiskLevel::Low,
|
||||||
|
_ => RiskLevel::Trusted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check an IP against DNSBL servers.
|
||||||
|
///
|
||||||
|
/// * `ip` - The IP address to check (must be IPv4)
|
||||||
|
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
|
||||||
|
/// * `resolver` - DNS resolver to use
|
||||||
|
pub async fn check_dnsbl(
|
||||||
|
ip: IpAddr,
|
||||||
|
dnsbl_servers: &[&str],
|
||||||
|
resolver: &TokioResolver,
|
||||||
|
) -> Result<DnsblResult> {
|
||||||
|
let ipv4 = match ip {
|
||||||
|
IpAddr::V4(v4) => v4,
|
||||||
|
IpAddr::V6(_) => {
|
||||||
|
// IPv6 DNSBL is less common; return clean result
|
||||||
|
return Ok(DnsblResult {
|
||||||
|
ip: ip.to_string(),
|
||||||
|
listed_count: 0,
|
||||||
|
listed_on: Vec::new(),
|
||||||
|
total_checked: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let reversed = reverse_ipv4(ipv4);
|
||||||
|
let total = dnsbl_servers.len();
|
||||||
|
|
||||||
|
// Query all DNSBL servers in parallel
|
||||||
|
let mut handles = Vec::with_capacity(total);
|
||||||
|
for &server in dnsbl_servers {
|
||||||
|
let query = format!("{}.{}", reversed, server);
|
||||||
|
let resolver = resolver.clone();
|
||||||
|
let server_name = server.to_string();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
match resolver.lookup_ip(&query).await {
|
||||||
|
Ok(_) => Some(server_name), // IP is listed
|
||||||
|
Err(_) => None, // IP is not listed (NXDOMAIN)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut listed_on = Vec::new();
|
||||||
|
for handle in handles {
|
||||||
|
match handle.await {
|
||||||
|
Ok(Some(server)) => listed_on.push(server),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DnsblResult {
|
||||||
|
ip: ip.to_string(),
|
||||||
|
listed_count: listed_on.len(),
|
||||||
|
listed_on,
|
||||||
|
total_checked: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full IP reputation check: DNSBL + heuristic classification + scoring.
|
||||||
|
pub async fn check_reputation(
|
||||||
|
ip: IpAddr,
|
||||||
|
dnsbl_servers: &[&str],
|
||||||
|
resolver: &TokioResolver,
|
||||||
|
) -> Result<ReputationResult> {
|
||||||
|
let dnsbl = check_dnsbl(ip, dnsbl_servers, resolver).await?;
|
||||||
|
let ip_type = classify_ip(ip);
|
||||||
|
|
||||||
|
// Scoring: start at 100
|
||||||
|
let mut score: i16 = 100;
|
||||||
|
|
||||||
|
// Subtract 10 per DNSBL listing
|
||||||
|
score -= (dnsbl.listed_count as i16) * 10;
|
||||||
|
|
||||||
|
// Subtract 30 for suspicious IP types
|
||||||
|
match ip_type {
|
||||||
|
IpType::Proxy | IpType::Tor | IpType::Vpn => {
|
||||||
|
score -= 30;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = score.clamp(0, 100) as u8;
|
||||||
|
let is_spam = score < 50;
|
||||||
|
|
||||||
|
Ok(ReputationResult {
|
||||||
|
score,
|
||||||
|
is_spam,
|
||||||
|
ip: ip.to_string(),
|
||||||
|
dnsbl,
|
||||||
|
ip_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse IPv4 octets for DNSBL queries: "1.2.3.4" -> "4.3.2.1".
|
||||||
|
fn reverse_ipv4(ip: Ipv4Addr) -> String {
|
||||||
|
let octets = ip.octets();
|
||||||
|
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic IP type classification based on well-known prefix ranges.
|
||||||
|
/// Same heuristics as the TypeScript IPReputationChecker.
|
||||||
|
fn classify_ip(ip: IpAddr) -> IpType {
|
||||||
|
let ip_str = ip.to_string();
|
||||||
|
|
||||||
|
// Known Tor exit node prefixes
|
||||||
|
if ip_str.starts_with("171.25.")
|
||||||
|
|| ip_str.starts_with("185.220.")
|
||||||
|
|| ip_str.starts_with("95.216.")
|
||||||
|
{
|
||||||
|
return IpType::Tor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known VPN provider prefixes
|
||||||
|
if ip_str.starts_with("185.156.") || ip_str.starts_with("37.120.") {
|
||||||
|
return IpType::Vpn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known proxy prefixes
|
||||||
|
if ip_str.starts_with("34.92.") || ip_str.starts_with("34.206.") {
|
||||||
|
return IpType::Proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Major cloud provider prefixes (datacenter)
|
||||||
|
if ip_str.starts_with("13.")
|
||||||
|
|| ip_str.starts_with("35.")
|
||||||
|
|| ip_str.starts_with("52.")
|
||||||
|
|| ip_str.starts_with("34.")
|
||||||
|
|| ip_str.starts_with("104.")
|
||||||
|
{
|
||||||
|
return IpType::Datacenter;
|
||||||
|
}
|
||||||
|
|
||||||
|
IpType::Residential
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an IPv4 address string.
|
||||||
|
pub fn is_valid_ipv4(ip: &str) -> bool {
|
||||||
|
ip.parse::<Ipv4Addr>().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reverse_ipv4() {
|
||||||
|
let ip: Ipv4Addr = "1.2.3.4".parse().unwrap();
|
||||||
|
assert_eq!(reverse_ipv4(ip), "4.3.2.1");
|
||||||
|
|
||||||
|
let ip: Ipv4Addr = "192.168.1.100".parse().unwrap();
|
||||||
|
assert_eq!(reverse_ipv4(ip), "100.1.168.192");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_ip() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_ip("171.25.193.20".parse().unwrap()),
|
||||||
|
IpType::Tor
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_ip("185.156.73.1".parse().unwrap()),
|
||||||
|
IpType::Vpn
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_ip("34.92.1.1".parse().unwrap()),
|
||||||
|
IpType::Proxy
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_ip("52.0.0.1".parse().unwrap()),
|
||||||
|
IpType::Datacenter
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_ip("203.0.113.1".parse().unwrap()),
|
||||||
|
IpType::Residential
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_risk_level() {
|
||||||
|
assert_eq!(risk_level(10), RiskLevel::High);
|
||||||
|
assert_eq!(risk_level(30), RiskLevel::Medium);
|
||||||
|
assert_eq!(risk_level(60), RiskLevel::Low);
|
||||||
|
assert_eq!(risk_level(90), RiskLevel::Trusted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_ipv4() {
|
||||||
|
assert!(is_valid_ipv4("1.2.3.4"));
|
||||||
|
assert!(is_valid_ipv4("255.255.255.255"));
|
||||||
|
assert!(!is_valid_ipv4("999.999.999.999"));
|
||||||
|
assert!(!is_valid_ipv4("not-an-ip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_dnsbl_servers() {
|
||||||
|
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
|
||||||
|
assert!(DEFAULT_DNSBL_SERVERS.contains(&"zen.spamhaus.org"));
|
||||||
|
}
|
||||||
|
}
|
||||||
45
rust/crates/mailer-security/src/lib.rs
Normal file
45
rust/crates/mailer-security/src/lib.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
|
||||||
|
|
||||||
|
pub mod content_scanner;
|
||||||
|
pub mod dkim;
|
||||||
|
pub mod dmarc;
|
||||||
|
pub mod error;
|
||||||
|
pub mod ip_reputation;
|
||||||
|
pub mod spf;
|
||||||
|
pub mod verify;
|
||||||
|
|
||||||
|
// Re-exports for convenience
|
||||||
|
pub use dkim::{dkim_dns_record_value, dkim_outputs_to_results, sign_dkim, verify_dkim, DkimVerificationResult};
|
||||||
|
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
|
||||||
|
pub use verify::{verify_email_security, EmailSecurityResult};
|
||||||
|
pub use error::{Result, SecurityError};
|
||||||
|
pub use ip_reputation::{
|
||||||
|
check_dnsbl, check_reputation, risk_level, DnsblResult, IpType, ReputationResult, RiskLevel,
|
||||||
|
DEFAULT_DNSBL_SERVERS,
|
||||||
|
};
|
||||||
|
pub use spf::{check_spf, check_spf_ehlo, received_spf_header, SpfResult};
|
||||||
|
|
||||||
|
// Re-export mail-auth's MessageAuthenticator for callers to construct
|
||||||
|
pub use mail_auth::MessageAuthenticator;
|
||||||
|
|
||||||
|
pub use mailer_core;
|
||||||
|
|
||||||
|
/// Crate version.
|
||||||
|
pub fn version() -> &'static str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a MessageAuthenticator using Cloudflare DNS over TLS.
|
||||||
|
pub fn default_authenticator() -> std::result::Result<MessageAuthenticator, Box<dyn std::error::Error>> {
|
||||||
|
Ok(MessageAuthenticator::new_cloudflare_tls()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version() {
|
||||||
|
assert!(!version().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
167
rust/crates/mailer-security/src/spf.rs
Normal file
167
rust/crates/mailer-security/src/spf.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use mail_auth::spf::verify::SpfParameters;
|
||||||
|
use mail_auth::{MessageAuthenticator, SpfResult as MailAuthSpfResult};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// SPF verification result.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SpfResult {
|
||||||
|
/// The SPF result: "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none".
|
||||||
|
pub result: String,
|
||||||
|
/// The domain that was checked.
|
||||||
|
pub domain: String,
|
||||||
|
/// The IP address that was checked.
|
||||||
|
pub ip: String,
|
||||||
|
/// Optional explanation string from the SPF record.
|
||||||
|
pub explanation: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpfResult {
|
||||||
|
/// Whether the SPF check passed.
|
||||||
|
pub fn passed(&self) -> bool {
|
||||||
|
self.result == "pass"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an `SpfResult` from a raw `mail-auth` `SpfOutput`.
|
||||||
|
///
|
||||||
|
/// Used by the compound `verify_email_security` to avoid re-doing the SPF query.
|
||||||
|
pub fn from_output(output: &mail_auth::SpfOutput, ip: IpAddr) -> Self {
|
||||||
|
let result_str = match output.result() {
|
||||||
|
MailAuthSpfResult::Pass => "pass",
|
||||||
|
MailAuthSpfResult::Fail => "fail",
|
||||||
|
MailAuthSpfResult::SoftFail => "softfail",
|
||||||
|
MailAuthSpfResult::Neutral => "neutral",
|
||||||
|
MailAuthSpfResult::TempError => "temperror",
|
||||||
|
MailAuthSpfResult::PermError => "permerror",
|
||||||
|
MailAuthSpfResult::None => "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
SpfResult {
|
||||||
|
result: result_str.to_string(),
|
||||||
|
domain: output.domain().to_string(),
|
||||||
|
ip: ip.to_string(),
|
||||||
|
explanation: output.explanation().map(|s| s.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check SPF for a given sender IP, HELO domain, and MAIL FROM address.
|
||||||
|
///
|
||||||
|
/// * `ip` - The connecting client's IP address
|
||||||
|
/// * `helo_domain` - The domain from the SMTP EHLO/HELO command
|
||||||
|
/// * `host_domain` - Your receiving server's hostname
|
||||||
|
/// * `mail_from` - The full MAIL FROM address (e.g., "sender@example.com")
|
||||||
|
pub async fn check_spf(
|
||||||
|
ip: IpAddr,
|
||||||
|
helo_domain: &str,
|
||||||
|
host_domain: &str,
|
||||||
|
mail_from: &str,
|
||||||
|
authenticator: &MessageAuthenticator,
|
||||||
|
) -> Result<SpfResult> {
|
||||||
|
let output = authenticator
|
||||||
|
.verify_spf(SpfParameters::verify_mail_from(
|
||||||
|
ip,
|
||||||
|
helo_domain,
|
||||||
|
host_domain,
|
||||||
|
mail_from,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result_str = match output.result() {
|
||||||
|
MailAuthSpfResult::Pass => "pass",
|
||||||
|
MailAuthSpfResult::Fail => "fail",
|
||||||
|
MailAuthSpfResult::SoftFail => "softfail",
|
||||||
|
MailAuthSpfResult::Neutral => "neutral",
|
||||||
|
MailAuthSpfResult::TempError => "temperror",
|
||||||
|
MailAuthSpfResult::PermError => "permerror",
|
||||||
|
MailAuthSpfResult::None => "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SpfResult {
|
||||||
|
result: result_str.to_string(),
|
||||||
|
domain: output.domain().to_string(),
|
||||||
|
ip: ip.to_string(),
|
||||||
|
explanation: output.explanation().map(|s| s.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check SPF for the EHLO identity (before MAIL FROM).
|
||||||
|
pub async fn check_spf_ehlo(
|
||||||
|
ip: IpAddr,
|
||||||
|
helo_domain: &str,
|
||||||
|
host_domain: &str,
|
||||||
|
authenticator: &MessageAuthenticator,
|
||||||
|
) -> Result<SpfResult> {
|
||||||
|
let output = authenticator
|
||||||
|
.verify_spf(SpfParameters::verify_ehlo(ip, helo_domain, host_domain))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result_str = match output.result() {
|
||||||
|
MailAuthSpfResult::Pass => "pass",
|
||||||
|
MailAuthSpfResult::Fail => "fail",
|
||||||
|
MailAuthSpfResult::SoftFail => "softfail",
|
||||||
|
MailAuthSpfResult::Neutral => "neutral",
|
||||||
|
MailAuthSpfResult::TempError => "temperror",
|
||||||
|
MailAuthSpfResult::PermError => "permerror",
|
||||||
|
MailAuthSpfResult::None => "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SpfResult {
|
||||||
|
result: result_str.to_string(),
|
||||||
|
domain: helo_domain.to_string(),
|
||||||
|
ip: ip.to_string(),
|
||||||
|
explanation: output.explanation().map(|s| s.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Received-SPF header value.
|
||||||
|
pub fn received_spf_header(result: &SpfResult) -> String {
|
||||||
|
format!(
|
||||||
|
"{} (domain of {} designates {} as permitted sender) receiver={}; client-ip={};",
|
||||||
|
result.result,
|
||||||
|
result.domain,
|
||||||
|
result.ip,
|
||||||
|
result.domain,
|
||||||
|
result.ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_spf_result_passed() {
|
||||||
|
let result = SpfResult {
|
||||||
|
result: "pass".to_string(),
|
||||||
|
domain: "example.com".to_string(),
|
||||||
|
ip: "1.2.3.4".to_string(),
|
||||||
|
explanation: None,
|
||||||
|
};
|
||||||
|
assert!(result.passed());
|
||||||
|
|
||||||
|
let result = SpfResult {
|
||||||
|
result: "fail".to_string(),
|
||||||
|
domain: "example.com".to_string(),
|
||||||
|
ip: "1.2.3.4".to_string(),
|
||||||
|
explanation: None,
|
||||||
|
};
|
||||||
|
assert!(!result.passed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_received_spf_header() {
|
||||||
|
let result = SpfResult {
|
||||||
|
result: "pass".to_string(),
|
||||||
|
domain: "example.com".to_string(),
|
||||||
|
ip: "1.2.3.4".to_string(),
|
||||||
|
explanation: None,
|
||||||
|
};
|
||||||
|
let header = received_spf_header(&result);
|
||||||
|
assert!(header.contains("pass"));
|
||||||
|
assert!(header.contains("example.com"));
|
||||||
|
assert!(header.contains("1.2.3.4"));
|
||||||
|
}
|
||||||
|
}
|
||||||
115
rust/crates/mailer-security/src/verify.rs
Normal file
115
rust/crates/mailer-security/src/verify.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
//! Compound email security verification.
|
||||||
|
//!
|
||||||
|
//! Runs DKIM, SPF, and DMARC verification in a single call, avoiding multiple
|
||||||
|
//! IPC round-trips and handling the internal `mail-auth` types that DMARC needs.
|
||||||
|
|
||||||
|
use mail_auth::spf::verify::SpfParameters;
|
||||||
|
use mail_auth::{AuthenticatedMessage, MessageAuthenticator};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use crate::dkim::DkimVerificationResult;
|
||||||
|
use crate::dmarc::{check_dmarc, DmarcResult};
|
||||||
|
use crate::error::{Result, SecurityError};
|
||||||
|
use crate::spf::SpfResult;
|
||||||
|
|
||||||
|
/// Combined result of DKIM + SPF + DMARC verification.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EmailSecurityResult {
|
||||||
|
pub dkim: Vec<DkimVerificationResult>,
|
||||||
|
pub spf: Option<SpfResult>,
|
||||||
|
pub dmarc: Option<DmarcResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run all email security checks (DKIM, SPF, DMARC) in one call.
|
||||||
|
///
|
||||||
|
/// This is the preferred entry point for inbound email verification because:
|
||||||
|
/// 1. DMARC requires raw `mail-auth` DKIM/SPF outputs (not our serialized types).
|
||||||
|
/// 2. A single call avoids 3 sequential IPC round-trips.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `raw_message` - The raw RFC 5322 message bytes
|
||||||
|
/// * `ip` - The connecting client's IP address
|
||||||
|
/// * `helo_domain` - The domain from the SMTP EHLO/HELO command
|
||||||
|
/// * `host_domain` - Your receiving server's hostname
|
||||||
|
/// * `mail_from` - The full MAIL FROM address (e.g. "sender@example.com")
|
||||||
|
/// * `authenticator` - The `MessageAuthenticator` for DNS lookups
|
||||||
|
pub async fn verify_email_security(
|
||||||
|
raw_message: &[u8],
|
||||||
|
ip: IpAddr,
|
||||||
|
helo_domain: &str,
|
||||||
|
host_domain: &str,
|
||||||
|
mail_from: &str,
|
||||||
|
authenticator: &MessageAuthenticator,
|
||||||
|
) -> Result<EmailSecurityResult> {
|
||||||
|
// Parse the message once for all checks
|
||||||
|
let message = AuthenticatedMessage::parse(raw_message)
|
||||||
|
.ok_or_else(|| SecurityError::Parse("Failed to parse email message".into()))?;
|
||||||
|
|
||||||
|
// --- DKIM verification ---
|
||||||
|
let dkim_outputs = authenticator.verify_dkim(&message).await;
|
||||||
|
let dkim_results = crate::dkim::dkim_outputs_to_results(&dkim_outputs);
|
||||||
|
|
||||||
|
// --- SPF verification ---
|
||||||
|
let spf_output = authenticator
|
||||||
|
.verify_spf(SpfParameters::verify_mail_from(
|
||||||
|
ip,
|
||||||
|
helo_domain,
|
||||||
|
host_domain,
|
||||||
|
mail_from,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let spf_result = SpfResult::from_output(&spf_output, ip);
|
||||||
|
|
||||||
|
// --- DMARC verification (needs raw dkim_outputs + spf_output) ---
|
||||||
|
let mail_from_domain = mail_from
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, d)| d)
|
||||||
|
.unwrap_or(helo_domain);
|
||||||
|
|
||||||
|
let dmarc_result = check_dmarc(
|
||||||
|
raw_message,
|
||||||
|
&dkim_outputs,
|
||||||
|
&spf_output,
|
||||||
|
mail_from_domain,
|
||||||
|
authenticator,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok(); // DMARC failure is non-fatal; we still return DKIM + SPF results
|
||||||
|
|
||||||
|
Ok(EmailSecurityResult {
|
||||||
|
dkim: dkim_results,
|
||||||
|
spf: Some(spf_result),
|
||||||
|
dmarc: dmarc_result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_email_security_result_serialization() {
|
||||||
|
let result = EmailSecurityResult {
|
||||||
|
dkim: vec![DkimVerificationResult {
|
||||||
|
is_valid: false,
|
||||||
|
domain: None,
|
||||||
|
selector: None,
|
||||||
|
status: "none".to_string(),
|
||||||
|
details: Some("No DKIM signatures".to_string()),
|
||||||
|
}],
|
||||||
|
spf: Some(SpfResult {
|
||||||
|
result: "none".to_string(),
|
||||||
|
domain: "example.com".to_string(),
|
||||||
|
ip: "1.2.3.4".to_string(),
|
||||||
|
explanation: None,
|
||||||
|
}),
|
||||||
|
dmarc: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
|
assert!(json.contains("\"dkim\""));
|
||||||
|
assert!(json.contains("\"spf\""));
|
||||||
|
assert!(json.contains("\"dmarc\":null"));
|
||||||
|
}
|
||||||
|
}
|
||||||
25
rust/crates/mailer-smtp/Cargo.toml
Normal file
25
rust/crates/mailer-smtp/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "mailer-smtp"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mailer-core = { path = "../mailer-core" }
|
||||||
|
mailer-security = { path = "../mailer-security" }
|
||||||
|
tokio.workspace = true
|
||||||
|
tokio-rustls.workspace = true
|
||||||
|
hickory-resolver.workspace = true
|
||||||
|
dashmap.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json = "1"
|
||||||
|
regex = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
base64.workspace = true
|
||||||
|
rustls-pki-types.workspace = true
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
|
rustls-pemfile = "2"
|
||||||
|
mailparse.workspace = true
|
||||||
421
rust/crates/mailer-smtp/src/command.rs
Normal file
421
rust/crates/mailer-smtp/src/command.rs
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
//! SMTP command parser.
|
||||||
|
//!
|
||||||
|
//! Parses raw SMTP command lines into structured `SmtpCommand` variants.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// A parsed SMTP command.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum SmtpCommand {
|
||||||
|
/// EHLO with client hostname/IP
|
||||||
|
Ehlo(String),
|
||||||
|
/// HELO with client hostname/IP
|
||||||
|
Helo(String),
|
||||||
|
/// MAIL FROM with sender address and optional parameters (e.g. SIZE=12345)
|
||||||
|
MailFrom {
|
||||||
|
address: String,
|
||||||
|
params: HashMap<String, Option<String>>,
|
||||||
|
},
|
||||||
|
/// RCPT TO with recipient address and optional parameters
|
||||||
|
RcptTo {
|
||||||
|
address: String,
|
||||||
|
params: HashMap<String, Option<String>>,
|
||||||
|
},
|
||||||
|
/// DATA command — begin message body
|
||||||
|
Data,
|
||||||
|
/// RSET — reset current transaction
|
||||||
|
Rset,
|
||||||
|
/// NOOP — no operation
|
||||||
|
Noop,
|
||||||
|
/// QUIT — close connection
|
||||||
|
Quit,
|
||||||
|
/// STARTTLS — upgrade to TLS
|
||||||
|
StartTls,
|
||||||
|
/// AUTH with mechanism and optional initial response
|
||||||
|
Auth {
|
||||||
|
mechanism: AuthMechanism,
|
||||||
|
initial_response: Option<String>,
|
||||||
|
},
|
||||||
|
/// HELP with optional topic
|
||||||
|
Help(Option<String>),
|
||||||
|
/// VRFY with address or username
|
||||||
|
Vrfy(String),
|
||||||
|
/// EXPN with mailing list name
|
||||||
|
Expn(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported AUTH mechanisms.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum AuthMechanism {
|
||||||
|
Plain,
|
||||||
|
Login,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur during command parsing.
|
||||||
|
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||||
|
pub enum ParseError {
|
||||||
|
#[error("empty command line")]
|
||||||
|
Empty,
|
||||||
|
#[error("unrecognized command: {0}")]
|
||||||
|
UnrecognizedCommand(String),
|
||||||
|
#[error("syntax error in parameters: {0}")]
|
||||||
|
SyntaxError(String),
|
||||||
|
#[error("missing required argument for {0}")]
|
||||||
|
MissingArgument(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a raw SMTP command line (without trailing CRLF) into a `SmtpCommand`.
|
||||||
|
pub fn parse_command(line: &str) -> Result<SmtpCommand, ParseError> {
|
||||||
|
let line = line.trim_end_matches('\r').trim_end_matches('\n');
|
||||||
|
if line.is_empty() {
|
||||||
|
return Err(ParseError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into verb and the rest
|
||||||
|
let (verb, rest) = split_first_word(line);
|
||||||
|
let verb_upper = verb.to_ascii_uppercase();
|
||||||
|
|
||||||
|
match verb_upper.as_str() {
|
||||||
|
"EHLO" => {
|
||||||
|
let hostname = rest.trim();
|
||||||
|
if hostname.is_empty() {
|
||||||
|
return Err(ParseError::MissingArgument("EHLO".into()));
|
||||||
|
}
|
||||||
|
Ok(SmtpCommand::Ehlo(hostname.to_string()))
|
||||||
|
}
|
||||||
|
"HELO" => {
|
||||||
|
let hostname = rest.trim();
|
||||||
|
if hostname.is_empty() {
|
||||||
|
return Err(ParseError::MissingArgument("HELO".into()));
|
||||||
|
}
|
||||||
|
Ok(SmtpCommand::Helo(hostname.to_string()))
|
||||||
|
}
|
||||||
|
"MAIL" => parse_mail_from(rest),
|
||||||
|
"RCPT" => parse_rcpt_to(rest),
|
||||||
|
"DATA" => Ok(SmtpCommand::Data),
|
||||||
|
"RSET" => Ok(SmtpCommand::Rset),
|
||||||
|
"NOOP" => Ok(SmtpCommand::Noop),
|
||||||
|
"QUIT" => Ok(SmtpCommand::Quit),
|
||||||
|
"STARTTLS" => Ok(SmtpCommand::StartTls),
|
||||||
|
"AUTH" => parse_auth(rest),
|
||||||
|
"HELP" => {
|
||||||
|
let topic = rest.trim();
|
||||||
|
if topic.is_empty() {
|
||||||
|
Ok(SmtpCommand::Help(None))
|
||||||
|
} else {
|
||||||
|
Ok(SmtpCommand::Help(Some(topic.to_string())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"VRFY" => {
|
||||||
|
let arg = rest.trim();
|
||||||
|
if arg.is_empty() {
|
||||||
|
return Err(ParseError::MissingArgument("VRFY".into()));
|
||||||
|
}
|
||||||
|
Ok(SmtpCommand::Vrfy(arg.to_string()))
|
||||||
|
}
|
||||||
|
"EXPN" => {
|
||||||
|
let arg = rest.trim();
|
||||||
|
if arg.is_empty() {
|
||||||
|
return Err(ParseError::MissingArgument("EXPN".into()));
|
||||||
|
}
|
||||||
|
Ok(SmtpCommand::Expn(arg.to_string()))
|
||||||
|
}
|
||||||
|
_ => Err(ParseError::UnrecognizedCommand(verb_upper)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `FROM:<addr> [PARAM=VALUE ...]` after "MAIL".
|
||||||
|
fn parse_mail_from(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||||
|
// Expect "FROM:" prefix (case-insensitive, whitespace-flexible)
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
let rest_upper = rest.to_ascii_uppercase();
|
||||||
|
if !rest_upper.starts_with("FROM") {
|
||||||
|
return Err(ParseError::SyntaxError(
|
||||||
|
"expected FROM after MAIL".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rest = &rest[4..]; // skip "FROM"
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
if !rest.starts_with(':') {
|
||||||
|
return Err(ParseError::SyntaxError(
|
||||||
|
"expected colon after MAIL FROM".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rest = &rest[1..]; // skip ':'
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
|
||||||
|
parse_address_and_params(rest, "MAIL FROM").map(|(address, params)| SmtpCommand::MailFrom {
|
||||||
|
address,
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `TO:<addr> [PARAM=VALUE ...]` after "RCPT".
|
||||||
|
fn parse_rcpt_to(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
let rest_upper = rest.to_ascii_uppercase();
|
||||||
|
if !rest_upper.starts_with("TO") {
|
||||||
|
return Err(ParseError::SyntaxError("expected TO after RCPT".into()));
|
||||||
|
}
|
||||||
|
let rest = &rest[2..]; // skip "TO"
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
if !rest.starts_with(':') {
|
||||||
|
return Err(ParseError::SyntaxError(
|
||||||
|
"expected colon after RCPT TO".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rest = &rest[1..]; // skip ':'
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
|
||||||
|
parse_address_and_params(rest, "RCPT TO").map(|(address, params)| SmtpCommand::RcptTo {
|
||||||
|
address,
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `<address> [PARAM=VALUE ...]` from the rest of a MAIL FROM or RCPT TO line.
|
||||||
|
fn parse_address_and_params(
|
||||||
|
input: &str,
|
||||||
|
context: &str,
|
||||||
|
) -> Result<(String, HashMap<String, Option<String>>), ParseError> {
|
||||||
|
if !input.starts_with('<') {
|
||||||
|
return Err(ParseError::SyntaxError(format!(
|
||||||
|
"expected '<' in {context}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let close_bracket = input.find('>').ok_or_else(|| {
|
||||||
|
ParseError::SyntaxError(format!("missing '>' in {context}"))
|
||||||
|
})?;
|
||||||
|
let address = input[1..close_bracket].to_string();
|
||||||
|
let remainder = &input[close_bracket + 1..];
|
||||||
|
let params = parse_params(remainder)?;
|
||||||
|
Ok((address, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse SMTP extension parameters like `SIZE=12345 BODY=8BITMIME`.
|
||||||
|
fn parse_params(input: &str) -> Result<HashMap<String, Option<String>>, ParseError> {
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
for token in input.split_whitespace() {
|
||||||
|
if let Some(eq_pos) = token.find('=') {
|
||||||
|
let key = token[..eq_pos].to_ascii_uppercase();
|
||||||
|
let value = token[eq_pos + 1..].to_string();
|
||||||
|
params.insert(key, Some(value));
|
||||||
|
} else {
|
||||||
|
params.insert(token.to_ascii_uppercase(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse AUTH command: `AUTH <mechanism> [initial-response]`.
|
||||||
|
fn parse_auth(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||||
|
let rest = rest.trim();
|
||||||
|
if rest.is_empty() {
|
||||||
|
return Err(ParseError::MissingArgument("AUTH".into()));
|
||||||
|
}
|
||||||
|
let (mech_str, initial) = split_first_word(rest);
|
||||||
|
let mechanism = match mech_str.to_ascii_uppercase().as_str() {
|
||||||
|
"PLAIN" => AuthMechanism::Plain,
|
||||||
|
"LOGIN" => AuthMechanism::Login,
|
||||||
|
other => {
|
||||||
|
return Err(ParseError::SyntaxError(format!(
|
||||||
|
"unsupported AUTH mechanism: {other}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let initial_response = {
|
||||||
|
let s = initial.trim();
|
||||||
|
if s.is_empty() { None } else { Some(s.to_string()) }
|
||||||
|
};
|
||||||
|
Ok(SmtpCommand::Auth {
|
||||||
|
mechanism,
|
||||||
|
initial_response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a string into the first whitespace-delimited word and the remainder.
|
||||||
|
fn split_first_word(s: &str) -> (&str, &str) {
|
||||||
|
match s.find(char::is_whitespace) {
|
||||||
|
Some(pos) => (&s[..pos], &s[pos + 1..]),
|
||||||
|
None => (s, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo() {
|
||||||
|
let cmd = parse_command("EHLO mail.example.com").unwrap();
|
||||||
|
assert_eq!(cmd, SmtpCommand::Ehlo("mail.example.com".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo_case_insensitive() {
|
||||||
|
let cmd = parse_command("ehlo MAIL.EXAMPLE.COM").unwrap();
|
||||||
|
assert_eq!(cmd, SmtpCommand::Ehlo("MAIL.EXAMPLE.COM".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_helo() {
|
||||||
|
let cmd = parse_command("HELO example.com").unwrap();
|
||||||
|
assert_eq!(cmd, SmtpCommand::Helo("example.com".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo_missing_arg() {
|
||||||
|
let err = parse_command("EHLO").unwrap_err();
|
||||||
|
assert!(matches!(err, ParseError::MissingArgument(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mail_from() {
|
||||||
|
let cmd = parse_command("MAIL FROM:<sender@example.com>").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
SmtpCommand::MailFrom {
|
||||||
|
address: "sender@example.com".into(),
|
||||||
|
params: HashMap::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mail_from_with_params() {
|
||||||
|
let cmd = parse_command("MAIL FROM:<sender@example.com> SIZE=12345 BODY=8BITMIME").unwrap();
|
||||||
|
if let SmtpCommand::MailFrom { address, params } = cmd {
|
||||||
|
assert_eq!(address, "sender@example.com");
|
||||||
|
assert_eq!(params.get("SIZE"), Some(&Some("12345".into())));
|
||||||
|
assert_eq!(params.get("BODY"), Some(&Some("8BITMIME".into())));
|
||||||
|
} else {
|
||||||
|
panic!("expected MailFrom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mail_from_empty_address() {
|
||||||
|
let cmd = parse_command("MAIL FROM:<>").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
SmtpCommand::MailFrom {
|
||||||
|
address: "".into(),
|
||||||
|
params: HashMap::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mail_from_flexible_spacing() {
|
||||||
|
let cmd = parse_command("MAIL FROM: <user@example.com>").unwrap();
|
||||||
|
if let SmtpCommand::MailFrom { address, .. } = cmd {
|
||||||
|
assert_eq!(address, "user@example.com");
|
||||||
|
} else {
|
||||||
|
panic!("expected MailFrom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rcpt_to() {
|
||||||
|
let cmd = parse_command("RCPT TO:<recipient@example.com>").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
SmtpCommand::RcptTo {
|
||||||
|
address: "recipient@example.com".into(),
|
||||||
|
params: HashMap::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_data() {
|
||||||
|
assert_eq!(parse_command("DATA").unwrap(), SmtpCommand::Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rset() {
|
||||||
|
assert_eq!(parse_command("RSET").unwrap(), SmtpCommand::Rset);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_noop() {
|
||||||
|
assert_eq!(parse_command("NOOP").unwrap(), SmtpCommand::Noop);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quit() {
|
||||||
|
assert_eq!(parse_command("QUIT").unwrap(), SmtpCommand::Quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_starttls() {
|
||||||
|
assert_eq!(parse_command("STARTTLS").unwrap(), SmtpCommand::StartTls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_plain() {
|
||||||
|
let cmd = parse_command("AUTH PLAIN dGVzdAB0ZXN0AHBhc3N3b3Jk").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
SmtpCommand::Auth {
|
||||||
|
mechanism: AuthMechanism::Plain,
|
||||||
|
initial_response: Some("dGVzdAB0ZXN0AHBhc3N3b3Jk".into()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_login_no_initial() {
|
||||||
|
let cmd = parse_command("AUTH LOGIN").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
SmtpCommand::Auth {
|
||||||
|
mechanism: AuthMechanism::Login,
|
||||||
|
initial_response: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_help() {
|
||||||
|
assert_eq!(parse_command("HELP").unwrap(), SmtpCommand::Help(None));
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("HELP MAIL").unwrap(),
|
||||||
|
SmtpCommand::Help(Some("MAIL".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vrfy() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("VRFY user@example.com").unwrap(),
|
||||||
|
SmtpCommand::Vrfy("user@example.com".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expn() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("EXPN staff").unwrap(),
|
||||||
|
SmtpCommand::Expn("staff".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty() {
|
||||||
|
assert!(matches!(parse_command(""), Err(ParseError::Empty)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unrecognized() {
|
||||||
|
let err = parse_command("FOOBAR test").unwrap_err();
|
||||||
|
assert!(matches!(err, ParseError::UnrecognizedCommand(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crlf_stripped() {
|
||||||
|
let cmd = parse_command("QUIT\r\n").unwrap();
|
||||||
|
assert_eq!(cmd, SmtpCommand::Quit);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
rust/crates/mailer-smtp/src/config.rs
Normal file
86
rust/crates/mailer-smtp/src/config.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
//! SMTP server configuration.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Configuration for an SMTP server instance.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SmtpServerConfig {
|
||||||
|
/// Server hostname for greeting and EHLO responses.
|
||||||
|
pub hostname: String,
|
||||||
|
/// Ports to listen on (e.g. [25, 587]).
|
||||||
|
pub ports: Vec<u16>,
|
||||||
|
/// Port for implicit TLS (e.g. 465). None = no implicit TLS port.
|
||||||
|
pub secure_port: Option<u16>,
|
||||||
|
/// TLS certificate chain in PEM format.
|
||||||
|
pub tls_cert_pem: Option<String>,
|
||||||
|
/// TLS private key in PEM format.
|
||||||
|
pub tls_key_pem: Option<String>,
|
||||||
|
/// Maximum message size in bytes.
|
||||||
|
pub max_message_size: u64,
|
||||||
|
/// Maximum number of concurrent connections.
|
||||||
|
pub max_connections: u32,
|
||||||
|
/// Maximum recipients per message.
|
||||||
|
pub max_recipients: u32,
|
||||||
|
/// Connection timeout in seconds.
|
||||||
|
pub connection_timeout_secs: u64,
|
||||||
|
/// Data phase timeout in seconds.
|
||||||
|
pub data_timeout_secs: u64,
|
||||||
|
/// Whether authentication is available.
|
||||||
|
pub auth_enabled: bool,
|
||||||
|
/// Maximum authentication failures before disconnect.
|
||||||
|
pub max_auth_failures: u32,
|
||||||
|
/// Socket timeout in seconds (idle timeout for the entire connection).
|
||||||
|
pub socket_timeout_secs: u64,
|
||||||
|
/// Timeout in seconds waiting for TS to respond to email processing.
|
||||||
|
pub processing_timeout_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SmtpServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hostname: "mail.example.com".to_string(),
|
||||||
|
ports: vec![25],
|
||||||
|
secure_port: None,
|
||||||
|
tls_cert_pem: None,
|
||||||
|
tls_key_pem: None,
|
||||||
|
max_message_size: 10 * 1024 * 1024, // 10 MB
|
||||||
|
max_connections: 100,
|
||||||
|
max_recipients: 100,
|
||||||
|
connection_timeout_secs: 30,
|
||||||
|
data_timeout_secs: 60,
|
||||||
|
auth_enabled: false,
|
||||||
|
max_auth_failures: 3,
|
||||||
|
socket_timeout_secs: 300,
|
||||||
|
processing_timeout_secs: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpServerConfig {
|
||||||
|
/// Check if TLS is configured.
|
||||||
|
pub fn has_tls(&self) -> bool {
|
||||||
|
self.tls_cert_pem.is_some() && self.tls_key_pem.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_defaults() {
|
||||||
|
let cfg = SmtpServerConfig::default();
|
||||||
|
assert_eq!(cfg.max_message_size, 10 * 1024 * 1024);
|
||||||
|
assert_eq!(cfg.max_connections, 100);
|
||||||
|
assert!(!cfg.has_tls());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_has_tls() {
|
||||||
|
let mut cfg = SmtpServerConfig::default();
|
||||||
|
cfg.tls_cert_pem = Some("cert".into());
|
||||||
|
assert!(!cfg.has_tls()); // need both
|
||||||
|
cfg.tls_key_pem = Some("key".into());
|
||||||
|
assert!(cfg.has_tls());
|
||||||
|
}
|
||||||
|
}
|
||||||
1308
rust/crates/mailer-smtp/src/connection.rs
Normal file
1308
rust/crates/mailer-smtp/src/connection.rs
Normal file
File diff suppressed because it is too large
Load Diff
289
rust/crates/mailer-smtp/src/data.rs
Normal file
289
rust/crates/mailer-smtp/src/data.rs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
//! Email DATA phase processor.
|
||||||
|
//!
|
||||||
|
//! Handles dot-unstuffing, end-of-data detection, size enforcement,
|
||||||
|
//! and streaming accumulation of email data.
|
||||||
|
|
||||||
|
/// Result of processing a chunk of DATA input.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum DataAction {
|
||||||
|
/// More data needed — continue accumulating.
|
||||||
|
Continue,
|
||||||
|
/// End-of-data detected. The complete message body is ready.
|
||||||
|
Complete,
|
||||||
|
/// Message size limit exceeded.
|
||||||
|
SizeExceeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming email data accumulator.
|
||||||
|
///
|
||||||
|
/// Processes incoming bytes from the DATA phase, handling:
|
||||||
|
/// - CRLF line ending normalization
|
||||||
|
/// - Dot-unstuffing (RFC 5321 §4.5.2)
|
||||||
|
/// - End-of-data marker detection (`<CRLF>.<CRLF>`)
|
||||||
|
/// - Size enforcement
|
||||||
|
pub struct DataAccumulator {
|
||||||
|
/// Accumulated message bytes.
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
/// Maximum allowed size in bytes. 0 = unlimited.
|
||||||
|
max_size: u64,
|
||||||
|
/// Whether we've detected end-of-data.
|
||||||
|
complete: bool,
|
||||||
|
/// Whether the current position is at the start of a line.
|
||||||
|
at_line_start: bool,
|
||||||
|
/// Partial state for cross-chunk boundary handling.
|
||||||
|
partial: PartialState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks partial sequences that span chunk boundaries.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
enum PartialState {
|
||||||
|
/// No partial sequence.
|
||||||
|
None,
|
||||||
|
/// Saw `\r`, waiting for `\n`.
|
||||||
|
Cr,
|
||||||
|
/// At line start, saw `.`, waiting to determine dot-stuffing vs end-of-data.
|
||||||
|
Dot,
|
||||||
|
/// At line start, saw `.\r`, waiting for `\n` (end-of-data) or other.
|
||||||
|
DotCr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataAccumulator {
|
||||||
|
/// Create a new accumulator with the given size limit.
|
||||||
|
pub fn new(max_size: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
buffer: Vec::with_capacity(8192),
|
||||||
|
max_size,
|
||||||
|
complete: false,
|
||||||
|
at_line_start: true, // First byte is at start of first line
|
||||||
|
partial: PartialState::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a chunk of incoming data.
|
||||||
|
///
|
||||||
|
/// Returns the action to take: continue, complete, or size exceeded.
|
||||||
|
pub fn process_chunk(&mut self, chunk: &[u8]) -> DataAction {
|
||||||
|
if self.complete {
|
||||||
|
return DataAction::Complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
for &byte in chunk {
|
||||||
|
match self.partial {
|
||||||
|
PartialState::None => {
|
||||||
|
if self.at_line_start && byte == b'.' {
|
||||||
|
self.partial = PartialState::Dot;
|
||||||
|
} else if byte == b'\r' {
|
||||||
|
self.partial = PartialState::Cr;
|
||||||
|
} else {
|
||||||
|
self.buffer.push(byte);
|
||||||
|
self.at_line_start = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PartialState::Cr => {
|
||||||
|
if byte == b'\n' {
|
||||||
|
self.buffer.extend_from_slice(b"\r\n");
|
||||||
|
self.at_line_start = true;
|
||||||
|
self.partial = PartialState::None;
|
||||||
|
} else {
|
||||||
|
// Bare CR — emit it and process current byte
|
||||||
|
self.buffer.push(b'\r');
|
||||||
|
self.at_line_start = false;
|
||||||
|
self.partial = PartialState::None;
|
||||||
|
// Re-process current byte
|
||||||
|
if byte == b'\r' {
|
||||||
|
self.partial = PartialState::Cr;
|
||||||
|
} else {
|
||||||
|
self.buffer.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PartialState::Dot => {
|
||||||
|
if byte == b'\r' {
|
||||||
|
self.partial = PartialState::DotCr;
|
||||||
|
} else if byte == b'.' {
|
||||||
|
// Dot-unstuffing: \r\n.. → \r\n.
|
||||||
|
// Emit one dot, consume the other
|
||||||
|
self.buffer.push(b'.');
|
||||||
|
self.at_line_start = false;
|
||||||
|
self.partial = PartialState::None;
|
||||||
|
} else {
|
||||||
|
// Dot at line start but not stuffing or end-of-data
|
||||||
|
self.buffer.push(b'.');
|
||||||
|
self.buffer.push(byte);
|
||||||
|
self.at_line_start = false;
|
||||||
|
self.partial = PartialState::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PartialState::DotCr => {
|
||||||
|
if byte == b'\n' {
|
||||||
|
// End-of-data: <CRLF>.<CRLF>
|
||||||
|
// Remove the trailing \r\n from the buffer
|
||||||
|
// (it was part of the terminator, not the message)
|
||||||
|
if self.buffer.ends_with(b"\r\n") {
|
||||||
|
let new_len = self.buffer.len() - 2;
|
||||||
|
self.buffer.truncate(new_len);
|
||||||
|
}
|
||||||
|
self.complete = true;
|
||||||
|
return DataAction::Complete;
|
||||||
|
} else {
|
||||||
|
// Not end-of-data — emit .\r and process current byte
|
||||||
|
self.buffer.push(b'.');
|
||||||
|
self.buffer.push(b'\r');
|
||||||
|
self.at_line_start = false;
|
||||||
|
self.partial = PartialState::None;
|
||||||
|
// Re-process current byte
|
||||||
|
if byte == b'\r' {
|
||||||
|
self.partial = PartialState::Cr;
|
||||||
|
} else {
|
||||||
|
self.buffer.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check size limit
|
||||||
|
if self.max_size > 0 && self.buffer.len() as u64 > self.max_size {
|
||||||
|
return DataAction::SizeExceeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DataAction::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume the accumulator and return the complete message data.
|
||||||
|
///
|
||||||
|
/// Returns `None` if end-of-data has not been detected.
|
||||||
|
pub fn into_message(self) -> Option<Vec<u8>> {
|
||||||
|
if !self.complete {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the accumulated data so far.
|
||||||
|
pub fn data(&self) -> &[u8] {
|
||||||
|
&self.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current accumulated size.
|
||||||
|
pub fn size(&self) -> usize {
|
||||||
|
self.buffer.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether end-of-data has been detected.
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simple_message() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
let data = b"Subject: Test\r\n\r\nHello world\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Subject: Test\r\n\r\nHello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_unstuffing() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
// A line starting with ".." should become "."
|
||||||
|
let data = b"Line 1\r\n..dot-stuffed\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Line 1\r\n.dot-stuffed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_chunks() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
assert_eq!(acc.process_chunk(b"Subject: Test\r\n"), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b"\r\nBody line 1\r\n"), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b"Body line 2\r\n.\r\n"), DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Subject: Test\r\n\r\nBody line 1\r\nBody line 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_of_data_spanning_chunks() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
assert_eq!(acc.process_chunk(b"Body\r\n"), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b".\r"), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b"\n"), DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Body");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_size_limit() {
|
||||||
|
let mut acc = DataAccumulator::new(10);
|
||||||
|
let data = b"This is definitely more than 10 bytes\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::SizeExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_not_complete() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
acc.process_chunk(b"partial data");
|
||||||
|
assert!(!acc.is_complete());
|
||||||
|
assert!(acc.into_message().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_message() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
let action = acc.process_chunk(b".\r\n");
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert!(msg.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_not_at_line_start() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
let data = b"Hello.World\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Hello.World");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_dots_in_line() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
let data = b"...\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
// First dot at line start is dot-unstuffed, leaving ".."
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"..");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crlf_dot_spanning_three_chunks() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
assert_eq!(acc.process_chunk(b"Body\r"), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b"\n."), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b"\r\n"), DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Body");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bare_cr() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
let data = b"Hello\rWorld\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Hello\rWorld");
|
||||||
|
}
|
||||||
|
}
|
||||||
39
rust/crates/mailer-smtp/src/lib.rs
Normal file
39
rust/crates/mailer-smtp/src/lib.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//! mailer-smtp: SMTP protocol engine (server + client).
|
||||||
|
//!
|
||||||
|
//! This crate provides the SMTP protocol implementation including:
|
||||||
|
//! - Command parsing (`command`)
|
||||||
|
//! - State machine (`state`)
|
||||||
|
//! - Response building (`response`)
|
||||||
|
//! - Email data accumulation (`data`)
|
||||||
|
//! - Per-connection session state (`session`)
|
||||||
|
//! - Address/input validation (`validation`)
|
||||||
|
//! - Server configuration (`config`)
|
||||||
|
//! - Rate limiting (`rate_limiter`)
|
||||||
|
//! - TCP/TLS server (`server`)
|
||||||
|
//! - Connection handling (`connection`)
|
||||||
|
|
||||||
|
pub mod command;
|
||||||
|
pub mod config;
|
||||||
|
pub mod connection;
|
||||||
|
pub mod data;
|
||||||
|
pub mod rate_limiter;
|
||||||
|
pub mod response;
|
||||||
|
pub mod server;
|
||||||
|
pub mod session;
|
||||||
|
pub mod state;
|
||||||
|
pub mod validation;
|
||||||
|
|
||||||
|
pub use mailer_core;
|
||||||
|
|
||||||
|
// Re-export key types for convenience.
|
||||||
|
pub use command::{AuthMechanism, SmtpCommand};
|
||||||
|
pub use config::SmtpServerConfig;
|
||||||
|
pub use data::{DataAccumulator, DataAction};
|
||||||
|
pub use response::SmtpResponse;
|
||||||
|
pub use session::SmtpSession;
|
||||||
|
pub use state::SmtpState;
|
||||||
|
|
||||||
|
/// Crate version.
|
||||||
|
pub fn version() -> &'static str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
198
rust/crates/mailer-smtp/src/rate_limiter.rs
Normal file
198
rust/crates/mailer-smtp/src/rate_limiter.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
//! In-process SMTP rate limiter.
|
||||||
|
//!
|
||||||
|
//! Uses DashMap for lock-free concurrent access to rate counters.
|
||||||
|
//! Tracks connections per IP, messages per sender, and auth failures.
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Rate limiter configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RateLimitConfig {
|
||||||
|
/// Maximum connections per IP per window.
|
||||||
|
pub max_connections_per_ip: u32,
|
||||||
|
/// Maximum messages per sender per window.
|
||||||
|
pub max_messages_per_sender: u32,
|
||||||
|
/// Maximum auth failures per IP per window.
|
||||||
|
pub max_auth_failures_per_ip: u32,
|
||||||
|
/// Window duration in seconds.
|
||||||
|
pub window_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimitConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_connections_per_ip: 50,
|
||||||
|
max_messages_per_sender: 100,
|
||||||
|
max_auth_failures_per_ip: 5,
|
||||||
|
window_secs: 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A timestamped counter entry.
|
||||||
|
struct CounterEntry {
|
||||||
|
count: u32,
|
||||||
|
window_start: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-process rate limiter using DashMap.
|
||||||
|
pub struct RateLimiter {
|
||||||
|
config: RateLimitConfig,
|
||||||
|
window: Duration,
|
||||||
|
connections: DashMap<String, CounterEntry>,
|
||||||
|
messages: DashMap<String, CounterEntry>,
|
||||||
|
auth_failures: DashMap<String, CounterEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
/// Create a new rate limiter with the given configuration.
|
||||||
|
pub fn new(config: RateLimitConfig) -> Self {
|
||||||
|
let window = Duration::from_secs(config.window_secs);
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
window,
|
||||||
|
connections: DashMap::new(),
|
||||||
|
messages: DashMap::new(),
|
||||||
|
auth_failures: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the configuration at runtime.
|
||||||
|
pub fn update_config(&mut self, config: RateLimitConfig) {
|
||||||
|
self.window = Duration::from_secs(config.window_secs);
|
||||||
|
self.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check and record a new connection from an IP.
|
||||||
|
/// Returns `true` if the connection should be allowed.
|
||||||
|
pub fn check_connection(&self, ip: &str) -> bool {
|
||||||
|
self.increment_and_check(
|
||||||
|
&self.connections,
|
||||||
|
ip,
|
||||||
|
self.config.max_connections_per_ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check and record a message from a sender.
|
||||||
|
/// Returns `true` if the message should be allowed.
|
||||||
|
pub fn check_message(&self, sender: &str) -> bool {
|
||||||
|
self.increment_and_check(
|
||||||
|
&self.messages,
|
||||||
|
sender,
|
||||||
|
self.config.max_messages_per_sender,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check and record an auth failure from an IP.
|
||||||
|
/// Returns `true` if more attempts should be allowed.
|
||||||
|
pub fn check_auth_failure(&self, ip: &str) -> bool {
|
||||||
|
self.increment_and_check(
|
||||||
|
&self.auth_failures,
|
||||||
|
ip,
|
||||||
|
self.config.max_auth_failures_per_ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment a counter and check against the limit.
|
||||||
|
/// Returns `true` if within limits.
|
||||||
|
fn increment_and_check(
|
||||||
|
&self,
|
||||||
|
map: &DashMap<String, CounterEntry>,
|
||||||
|
key: &str,
|
||||||
|
limit: u32,
|
||||||
|
) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut entry = map
|
||||||
|
.entry(key.to_string())
|
||||||
|
.or_insert_with(|| CounterEntry {
|
||||||
|
count: 0,
|
||||||
|
window_start: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset window if expired
|
||||||
|
if now.duration_since(entry.window_start) > self.window {
|
||||||
|
entry.count = 0;
|
||||||
|
entry.window_start = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count += 1;
|
||||||
|
entry.count <= limit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up expired entries. Call periodically.
|
||||||
|
pub fn cleanup(&self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let window = self.window;
|
||||||
|
self.connections
|
||||||
|
.retain(|_, v| now.duration_since(v.window_start) <= window);
|
||||||
|
self.messages
|
||||||
|
.retain(|_, v| now.duration_since(v.window_start) <= window);
|
||||||
|
self.auth_failures
|
||||||
|
.retain(|_, v| now.duration_since(v.window_start) <= window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connection_limit() {
|
||||||
|
let limiter = RateLimiter::new(RateLimitConfig {
|
||||||
|
max_connections_per_ip: 3,
|
||||||
|
window_secs: 60,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(limiter.check_connection("1.2.3.4"));
|
||||||
|
assert!(limiter.check_connection("1.2.3.4"));
|
||||||
|
assert!(limiter.check_connection("1.2.3.4"));
|
||||||
|
assert!(!limiter.check_connection("1.2.3.4")); // 4th = over limit
|
||||||
|
|
||||||
|
// Different IP is independent
|
||||||
|
assert!(limiter.check_connection("5.6.7.8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_limit() {
|
||||||
|
let limiter = RateLimiter::new(RateLimitConfig {
|
||||||
|
max_messages_per_sender: 2,
|
||||||
|
window_secs: 60,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(limiter.check_message("sender@example.com"));
|
||||||
|
assert!(limiter.check_message("sender@example.com"));
|
||||||
|
assert!(!limiter.check_message("sender@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_failure_limit() {
|
||||||
|
let limiter = RateLimiter::new(RateLimitConfig {
|
||||||
|
max_auth_failures_per_ip: 2,
|
||||||
|
window_secs: 60,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(limiter.check_auth_failure("1.2.3.4"));
|
||||||
|
assert!(limiter.check_auth_failure("1.2.3.4"));
|
||||||
|
assert!(!limiter.check_auth_failure("1.2.3.4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cleanup() {
|
||||||
|
let limiter = RateLimiter::new(RateLimitConfig {
|
||||||
|
max_connections_per_ip: 1,
|
||||||
|
window_secs: 60,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
limiter.check_connection("1.2.3.4");
|
||||||
|
assert_eq!(limiter.connections.len(), 1);
|
||||||
|
|
||||||
|
limiter.cleanup(); // entries not expired
|
||||||
|
assert_eq!(limiter.connections.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
284
rust/crates/mailer-smtp/src/response.rs
Normal file
284
rust/crates/mailer-smtp/src/response.rs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
//! SMTP response builder.
|
||||||
|
//!
|
||||||
|
//! Constructs properly formatted SMTP response lines with status codes,
|
||||||
|
//! multiline support, and EHLO capability advertisement.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// An SMTP response to send to the client.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SmtpResponse {
|
||||||
|
/// 3-digit SMTP status code.
|
||||||
|
pub code: u16,
|
||||||
|
/// Response lines (without the status code prefix).
|
||||||
|
pub lines: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpResponse {
|
||||||
|
/// Create a single-line response.
|
||||||
|
pub fn new(code: u16, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
lines: vec![message.into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a multiline response.
|
||||||
|
pub fn multiline(code: u16, lines: Vec<String>) -> Self {
|
||||||
|
Self { code, lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the response as bytes ready to write to the socket.
|
||||||
|
///
|
||||||
|
/// Multiline responses use `code-text` for intermediate lines
|
||||||
|
/// and `code text` for the final line (RFC 5321 §4.2).
|
||||||
|
pub fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
if self.lines.is_empty() {
|
||||||
|
buf.extend_from_slice(format!("{} \r\n", self.code).as_bytes());
|
||||||
|
} else if self.lines.len() == 1 {
|
||||||
|
buf.extend_from_slice(
|
||||||
|
format!("{} {}\r\n", self.code, self.lines[0]).as_bytes(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for (i, line) in self.lines.iter().enumerate() {
|
||||||
|
if i < self.lines.len() - 1 {
|
||||||
|
buf.extend_from_slice(
|
||||||
|
format!("{}-{}\r\n", self.code, line).as_bytes(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
buf.extend_from_slice(
|
||||||
|
format!("{} {}\r\n", self.code, line).as_bytes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Common response constructors ---
|
||||||
|
|
||||||
|
/// 220 Service ready greeting.
|
||||||
|
pub fn greeting(hostname: &str) -> Self {
|
||||||
|
Self::new(220, format!("{hostname} ESMTP Service Ready"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 221 Service closing.
|
||||||
|
pub fn closing(hostname: &str) -> Self {
|
||||||
|
Self::new(221, format!("{hostname} Service closing transmission channel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 250 OK.
|
||||||
|
pub fn ok(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(250, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EHLO response with capabilities.
|
||||||
|
pub fn ehlo_response(hostname: &str, capabilities: &[String]) -> Self {
|
||||||
|
let mut lines = Vec::with_capacity(capabilities.len() + 1);
|
||||||
|
lines.push(format!("{hostname} greets you"));
|
||||||
|
for cap in capabilities {
|
||||||
|
lines.push(cap.clone());
|
||||||
|
}
|
||||||
|
Self::multiline(250, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 235 Authentication successful.
|
||||||
|
pub fn auth_success() -> Self {
|
||||||
|
Self::new(235, "2.7.0 Authentication successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 334 Auth challenge (base64-encoded prompt).
|
||||||
|
pub fn auth_challenge(prompt: &str) -> Self {
|
||||||
|
Self::new(334, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 354 Start mail input.
|
||||||
|
pub fn start_data() -> Self {
|
||||||
|
Self::new(354, "Start mail input; end with <CRLF>.<CRLF>")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 421 Service not available.
|
||||||
|
pub fn service_unavailable(hostname: &str, reason: &str) -> Self {
|
||||||
|
Self::new(421, format!("{hostname} {reason}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 450 Temporary failure.
|
||||||
|
pub fn temp_failure(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(450, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 451 Local error.
|
||||||
|
pub fn local_error(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(451, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 500 Syntax error.
|
||||||
|
pub fn syntax_error() -> Self {
|
||||||
|
Self::new(500, "Syntax error, command unrecognized")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 501 Syntax error in parameters.
|
||||||
|
pub fn param_error(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(501, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 502 Command not implemented.
|
||||||
|
pub fn not_implemented() -> Self {
|
||||||
|
Self::new(502, "Command not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 503 Bad sequence.
|
||||||
|
pub fn bad_sequence(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(503, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 530 Authentication required.
|
||||||
|
pub fn auth_required() -> Self {
|
||||||
|
Self::new(530, "5.7.0 Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 535 Authentication failed.
|
||||||
|
pub fn auth_failed() -> Self {
|
||||||
|
Self::new(535, "5.7.8 Authentication credentials invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 550 Mailbox unavailable.
|
||||||
|
pub fn mailbox_unavailable(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(550, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 552 Message size exceeded.
|
||||||
|
pub fn size_exceeded(max_size: u64) -> Self {
|
||||||
|
Self::new(
|
||||||
|
552,
|
||||||
|
format!("5.3.4 Message size exceeds maximum of {max_size} bytes"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 554 Transaction failed.
|
||||||
|
pub fn transaction_failed(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(554, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a success response (2xx).
|
||||||
|
pub fn is_success(&self) -> bool {
|
||||||
|
self.code >= 200 && self.code < 300
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a temporary error (4xx).
|
||||||
|
pub fn is_temp_error(&self) -> bool {
|
||||||
|
self.code >= 400 && self.code < 500
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a permanent error (5xx).
|
||||||
|
pub fn is_perm_error(&self) -> bool {
|
||||||
|
self.code >= 500 && self.code < 600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the list of EHLO capabilities for the server.
|
||||||
|
pub fn build_capabilities(
|
||||||
|
max_size: u64,
|
||||||
|
tls_available: bool,
|
||||||
|
already_secure: bool,
|
||||||
|
auth_available: bool,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut caps = vec![
|
||||||
|
format!("SIZE {max_size}"),
|
||||||
|
"8BITMIME".to_string(),
|
||||||
|
"PIPELINING".to_string(),
|
||||||
|
"ENHANCEDSTATUSCODES".to_string(),
|
||||||
|
"HELP".to_string(),
|
||||||
|
];
|
||||||
|
// Only advertise STARTTLS if TLS is available and not already using TLS
|
||||||
|
if tls_available && !already_secure {
|
||||||
|
caps.push("STARTTLS".to_string());
|
||||||
|
}
|
||||||
|
if auth_available {
|
||||||
|
caps.push("AUTH PLAIN LOGIN".to_string());
|
||||||
|
}
|
||||||
|
caps
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_line() {
|
||||||
|
let resp = SmtpResponse::new(250, "OK");
|
||||||
|
assert_eq!(resp.to_bytes(), b"250 OK\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiline() {
|
||||||
|
let resp = SmtpResponse::multiline(
|
||||||
|
250,
|
||||||
|
vec![
|
||||||
|
"mail.example.com greets you".into(),
|
||||||
|
"SIZE 10485760".into(),
|
||||||
|
"STARTTLS".into(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let expected = b"250-mail.example.com greets you\r\n250-SIZE 10485760\r\n250 STARTTLS\r\n";
|
||||||
|
assert_eq!(resp.to_bytes(), expected.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_greeting() {
|
||||||
|
let resp = SmtpResponse::greeting("mail.example.com");
|
||||||
|
assert_eq!(resp.code, 220);
|
||||||
|
assert!(resp.lines[0].contains("mail.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo_response() {
|
||||||
|
let caps = vec!["SIZE 10485760".into(), "STARTTLS".into()];
|
||||||
|
let resp = SmtpResponse::ehlo_response("mail.example.com", &caps);
|
||||||
|
assert_eq!(resp.code, 250);
|
||||||
|
assert_eq!(resp.lines.len(), 3); // hostname + 2 caps
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_status_checks() {
|
||||||
|
assert!(SmtpResponse::new(250, "OK").is_success());
|
||||||
|
assert!(SmtpResponse::new(450, "Try later").is_temp_error());
|
||||||
|
assert!(SmtpResponse::new(550, "No such user").is_perm_error());
|
||||||
|
assert!(!SmtpResponse::new(250, "OK").is_temp_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_capabilities() {
|
||||||
|
let caps = build_capabilities(10485760, true, false, true);
|
||||||
|
assert!(caps.contains(&"SIZE 10485760".to_string()));
|
||||||
|
assert!(caps.contains(&"STARTTLS".to_string()));
|
||||||
|
assert!(caps.contains(&"AUTH PLAIN LOGIN".to_string()));
|
||||||
|
assert!(caps.contains(&"PIPELINING".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_capabilities_secure() {
|
||||||
|
// When already secure, STARTTLS should NOT be advertised
|
||||||
|
let caps = build_capabilities(10485760, true, true, false);
|
||||||
|
assert!(!caps.contains(&"STARTTLS".to_string()));
|
||||||
|
assert!(!caps.contains(&"AUTH PLAIN LOGIN".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_response() {
|
||||||
|
let resp = SmtpResponse::multiline(250, vec![]);
|
||||||
|
assert_eq!(resp.to_bytes(), b"250 \r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_common_responses() {
|
||||||
|
assert_eq!(SmtpResponse::start_data().code, 354);
|
||||||
|
assert_eq!(SmtpResponse::syntax_error().code, 500);
|
||||||
|
assert_eq!(SmtpResponse::not_implemented().code, 502);
|
||||||
|
assert_eq!(SmtpResponse::bad_sequence("test").code, 503);
|
||||||
|
assert_eq!(SmtpResponse::auth_required().code, 530);
|
||||||
|
assert_eq!(SmtpResponse::auth_failed().code, 535);
|
||||||
|
assert_eq!(SmtpResponse::auth_success().code, 235);
|
||||||
|
}
|
||||||
|
}
|
||||||
331
rust/crates/mailer-smtp/src/server.rs
Normal file
331
rust/crates/mailer-smtp/src/server.rs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
//! SMTP TCP/TLS server.
|
||||||
|
//!
|
||||||
|
//! Listens on configured ports, accepts connections, and dispatches
|
||||||
|
//! them to per-connection handlers.
|
||||||
|
|
||||||
|
use crate::config::SmtpServerConfig;
|
||||||
|
use crate::connection::{
|
||||||
|
self, CallbackRegistry, ConnectionEvent, SmtpStream,
|
||||||
|
};
|
||||||
|
use crate::rate_limiter::{RateLimitConfig, RateLimiter};
|
||||||
|
|
||||||
|
use hickory_resolver::TokioResolver;
|
||||||
|
use mailer_security::MessageAuthenticator;
|
||||||
|
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::BufReader as TokioBufReader;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// Handle for a running SMTP server.
|
||||||
|
pub struct SmtpServerHandle {
|
||||||
|
/// Shutdown signal.
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
/// Join handles for the listener tasks.
|
||||||
|
handles: Vec<tokio::task::JoinHandle<()>>,
|
||||||
|
/// Active connection count.
|
||||||
|
pub active_connections: Arc<AtomicU32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpServerHandle {
|
||||||
|
/// Signal shutdown and wait for all listeners to stop.
|
||||||
|
pub async fn shutdown(self) {
|
||||||
|
self.shutdown.store(true, Ordering::SeqCst);
|
||||||
|
for handle in self.handles {
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
|
info!("SMTP server shut down");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the server is running.
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
!self.shutdown.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the SMTP server with the given configuration.
|
||||||
|
///
|
||||||
|
/// Returns a handle that can be used to shut down the server,
|
||||||
|
/// and an event receiver for connection events (emailReceived, authRequest).
|
||||||
|
pub async fn start_server(
|
||||||
|
config: SmtpServerConfig,
|
||||||
|
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
|
||||||
|
rate_limit_config: Option<RateLimitConfig>,
|
||||||
|
) -> Result<(SmtpServerHandle, mpsc::Receiver<ConnectionEvent>), Box<dyn std::error::Error + Send + Sync>>
|
||||||
|
{
|
||||||
|
let config = Arc::new(config);
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let active_connections = Arc::new(AtomicU32::new(0));
|
||||||
|
let rate_limiter = Arc::new(RateLimiter::new(
|
||||||
|
rate_limit_config.unwrap_or_default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let (event_tx, event_rx) = mpsc::channel::<ConnectionEvent>(1024);
|
||||||
|
|
||||||
|
// Create shared security resources for in-process email verification
|
||||||
|
let authenticator: Arc<MessageAuthenticator> = Arc::new(
|
||||||
|
mailer_security::default_authenticator()
|
||||||
|
.map_err(|e| format!("Failed to create MessageAuthenticator: {e}"))?
|
||||||
|
);
|
||||||
|
let resolver: Arc<TokioResolver> = Arc::new(
|
||||||
|
TokioResolver::builder_tokio()
|
||||||
|
.map(|b| b.build())
|
||||||
|
.map_err(|e| format!("Failed to create TokioResolver: {e}"))?
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build TLS acceptor if configured
|
||||||
|
let tls_acceptor = if config.has_tls() {
|
||||||
|
Some(Arc::new(build_tls_acceptor(&config)?))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
|
// Start listeners on each port
|
||||||
|
for &port in &config.ports {
|
||||||
|
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
||||||
|
info!(port = port, "SMTP server listening (STARTTLS)");
|
||||||
|
|
||||||
|
let handle = tokio::spawn(accept_loop(
|
||||||
|
listener,
|
||||||
|
config.clone(),
|
||||||
|
shutdown.clone(),
|
||||||
|
active_connections.clone(),
|
||||||
|
rate_limiter.clone(),
|
||||||
|
event_tx.clone(),
|
||||||
|
callback_registry.clone(),
|
||||||
|
tls_acceptor.clone(),
|
||||||
|
false, // not implicit TLS
|
||||||
|
authenticator.clone(),
|
||||||
|
resolver.clone(),
|
||||||
|
));
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start implicit TLS listener if configured
|
||||||
|
if let Some(secure_port) = config.secure_port {
|
||||||
|
if tls_acceptor.is_some() {
|
||||||
|
let listener =
|
||||||
|
TcpListener::bind(format!("0.0.0.0:{secure_port}")).await?;
|
||||||
|
info!(port = secure_port, "SMTP server listening (implicit TLS)");
|
||||||
|
|
||||||
|
let handle = tokio::spawn(accept_loop(
|
||||||
|
listener,
|
||||||
|
config.clone(),
|
||||||
|
shutdown.clone(),
|
||||||
|
active_connections.clone(),
|
||||||
|
rate_limiter.clone(),
|
||||||
|
event_tx.clone(),
|
||||||
|
callback_registry.clone(),
|
||||||
|
tls_acceptor.clone(),
|
||||||
|
true, // implicit TLS
|
||||||
|
authenticator.clone(),
|
||||||
|
resolver.clone(),
|
||||||
|
));
|
||||||
|
handles.push(handle);
|
||||||
|
} else {
|
||||||
|
warn!("Secure port configured but TLS certificates not provided");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn periodic rate limiter cleanup
|
||||||
|
{
|
||||||
|
let rate_limiter = rate_limiter.clone();
|
||||||
|
let shutdown = shutdown.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval =
|
||||||
|
tokio::time::interval(tokio::time::Duration::from_secs(60));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if shutdown.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
rate_limiter.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
SmtpServerHandle {
|
||||||
|
shutdown,
|
||||||
|
handles,
|
||||||
|
active_connections,
|
||||||
|
},
|
||||||
|
event_rx,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept loop for a single listener.
|
||||||
|
async fn accept_loop(
|
||||||
|
listener: TcpListener,
|
||||||
|
config: Arc<SmtpServerConfig>,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
active_connections: Arc<AtomicU32>,
|
||||||
|
rate_limiter: Arc<RateLimiter>,
|
||||||
|
event_tx: mpsc::Sender<ConnectionEvent>,
|
||||||
|
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
|
||||||
|
tls_acceptor: Option<Arc<tokio_rustls::TlsAcceptor>>,
|
||||||
|
implicit_tls: bool,
|
||||||
|
authenticator: Arc<MessageAuthenticator>,
|
||||||
|
resolver: Arc<TokioResolver>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
if shutdown.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a short timeout to check shutdown periodically
|
||||||
|
let accept_result = tokio::time::timeout(
|
||||||
|
tokio::time::Duration::from_secs(1),
|
||||||
|
listener.accept(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (tcp_stream, peer_addr) = match accept_result {
|
||||||
|
Ok(Ok((stream, addr))) => (stream, addr),
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
error!(error = %e, "Accept error");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(_) => continue, // timeout, check shutdown
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check max connections
|
||||||
|
let current = active_connections.load(Ordering::SeqCst);
|
||||||
|
if current >= config.max_connections {
|
||||||
|
warn!(
|
||||||
|
current = current,
|
||||||
|
max = config.max_connections,
|
||||||
|
"Max connections reached, rejecting"
|
||||||
|
);
|
||||||
|
drop(tcp_stream);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remote_addr = peer_addr.ip().to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
let rate_limiter = rate_limiter.clone();
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
let callback_registry = callback_registry.clone();
|
||||||
|
let tls_acceptor = tls_acceptor.clone();
|
||||||
|
let active_connections = active_connections.clone();
|
||||||
|
let authenticator = authenticator.clone();
|
||||||
|
let resolver = resolver.clone();
|
||||||
|
|
||||||
|
active_connections.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let stream = if implicit_tls {
|
||||||
|
// Implicit TLS: wrap immediately
|
||||||
|
if let Some(acceptor) = &tls_acceptor {
|
||||||
|
match acceptor.accept(tcp_stream).await {
|
||||||
|
Ok(tls_stream) => {
|
||||||
|
SmtpStream::Tls(TokioBufReader::new(tls_stream))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
remote_addr = %remote_addr,
|
||||||
|
error = %e,
|
||||||
|
"Implicit TLS handshake failed"
|
||||||
|
);
|
||||||
|
active_connections.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
active_connections.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SmtpStream::Plain(TokioBufReader::new(tcp_stream))
|
||||||
|
};
|
||||||
|
|
||||||
|
connection::handle_connection(
|
||||||
|
stream,
|
||||||
|
config,
|
||||||
|
rate_limiter,
|
||||||
|
event_tx,
|
||||||
|
callback_registry,
|
||||||
|
tls_acceptor,
|
||||||
|
remote_addr,
|
||||||
|
implicit_tls,
|
||||||
|
authenticator,
|
||||||
|
resolver,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
active_connections.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a TLS acceptor from PEM cert/key strings.
|
||||||
|
fn build_tls_acceptor(
|
||||||
|
config: &SmtpServerConfig,
|
||||||
|
) -> Result<tokio_rustls::TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let cert_pem = config
|
||||||
|
.tls_cert_pem
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("TLS cert not configured")?;
|
||||||
|
let key_pem = config
|
||||||
|
.tls_key_pem
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("TLS key not configured")?;
|
||||||
|
|
||||||
|
// Parse certificates
|
||||||
|
let certs: Vec<CertificateDer<'static>> = {
|
||||||
|
let mut reader = BufReader::new(cert_pem.as_bytes());
|
||||||
|
rustls_pemfile::certs(&mut reader)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
|
||||||
|
if certs.is_empty() {
|
||||||
|
return Err("No certificates found in PEM".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse private key
|
||||||
|
let key: PrivateKeyDer<'static> = {
|
||||||
|
let mut reader = BufReader::new(key_pem.as_bytes());
|
||||||
|
// Try PKCS8 first, then RSA, then EC
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for item in rustls_pemfile::read_all(&mut reader) {
|
||||||
|
match item? {
|
||||||
|
rustls_pemfile::Item::Pkcs8Key(key) => {
|
||||||
|
keys.push(PrivateKeyDer::Pkcs8(key));
|
||||||
|
}
|
||||||
|
rustls_pemfile::Item::Pkcs1Key(key) => {
|
||||||
|
keys.push(PrivateKeyDer::Pkcs1(key));
|
||||||
|
}
|
||||||
|
rustls_pemfile::Item::Sec1Key(key) => {
|
||||||
|
keys.push(PrivateKeyDer::Sec1(key));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or("No private key found in PEM")?
|
||||||
|
};
|
||||||
|
|
||||||
|
let tls_config = rustls::ServerConfig::builder()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(certs, key)?;
|
||||||
|
|
||||||
|
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_server_config_defaults() {
|
||||||
|
let config = SmtpServerConfig::default();
|
||||||
|
assert!(!config.has_tls());
|
||||||
|
assert_eq!(config.ports, vec![25]);
|
||||||
|
}
|
||||||
|
}
|
||||||
206
rust/crates/mailer-smtp/src/session.rs
Normal file
206
rust/crates/mailer-smtp/src/session.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
//! Per-connection SMTP session state.
|
||||||
|
//!
|
||||||
|
//! Tracks the envelope, authentication, TLS status, and counters
|
||||||
|
//! for a single SMTP connection.
|
||||||
|
|
||||||
|
use crate::state::SmtpState;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Envelope accumulator for the current mail transaction.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Envelope {
|
||||||
|
/// Sender address from MAIL FROM.
|
||||||
|
pub mail_from: String,
|
||||||
|
/// Recipient addresses from RCPT TO.
|
||||||
|
pub rcpt_to: Vec<String>,
|
||||||
|
/// Declared message size from MAIL FROM SIZE= param (if any).
|
||||||
|
pub declared_size: Option<u64>,
|
||||||
|
/// BODY parameter (e.g. "8BITMIME").
|
||||||
|
pub body_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication state for the session.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum AuthState {
|
||||||
|
/// Not authenticated and not in progress.
|
||||||
|
None,
|
||||||
|
/// Waiting for AUTH credentials (LOGIN flow step).
|
||||||
|
WaitingForUsername,
|
||||||
|
/// Have username, waiting for password.
|
||||||
|
WaitingForPassword { username: String },
|
||||||
|
/// Successfully authenticated.
|
||||||
|
Authenticated { username: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthState {
|
||||||
|
fn default() -> Self {
|
||||||
|
AuthState::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-connection session state.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SmtpSession {
|
||||||
|
/// Unique session identifier.
|
||||||
|
pub id: String,
|
||||||
|
/// Current protocol state.
|
||||||
|
pub state: SmtpState,
|
||||||
|
/// Client's EHLO/HELO hostname.
|
||||||
|
pub client_hostname: Option<String>,
|
||||||
|
/// Whether the client used EHLO (vs HELO).
|
||||||
|
pub esmtp: bool,
|
||||||
|
/// Whether the connection is using TLS.
|
||||||
|
pub secure: bool,
|
||||||
|
/// Authentication state.
|
||||||
|
pub auth_state: AuthState,
|
||||||
|
/// Current transaction envelope.
|
||||||
|
pub envelope: Envelope,
|
||||||
|
/// Remote IP address.
|
||||||
|
pub remote_addr: String,
|
||||||
|
/// Number of messages sent in this session.
|
||||||
|
pub message_count: u32,
|
||||||
|
/// Number of failed auth attempts.
|
||||||
|
pub auth_failures: u32,
|
||||||
|
/// Number of invalid commands.
|
||||||
|
pub invalid_commands: u32,
|
||||||
|
/// Maximum allowed invalid commands before disconnect.
|
||||||
|
pub max_invalid_commands: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpSession {
|
||||||
|
/// Create a new session for a connection.
|
||||||
|
pub fn new(remote_addr: String, secure: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
state: SmtpState::Connected,
|
||||||
|
client_hostname: None,
|
||||||
|
esmtp: false,
|
||||||
|
secure,
|
||||||
|
auth_state: AuthState::None,
|
||||||
|
envelope: Envelope::default(),
|
||||||
|
remote_addr,
|
||||||
|
message_count: 0,
|
||||||
|
auth_failures: 0,
|
||||||
|
invalid_commands: 0,
|
||||||
|
max_invalid_commands: 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the current transaction (RSET), preserving connection state.
|
||||||
|
pub fn reset_transaction(&mut self) {
|
||||||
|
self.envelope = Envelope::default();
|
||||||
|
if self.state != SmtpState::Connected {
|
||||||
|
self.state = SmtpState::Greeted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset session for a new EHLO (preserves counters and TLS).
|
||||||
|
pub fn reset_for_ehlo(&mut self, hostname: String, esmtp: bool) {
|
||||||
|
self.client_hostname = Some(hostname);
|
||||||
|
self.esmtp = esmtp;
|
||||||
|
self.envelope = Envelope::default();
|
||||||
|
self.state = SmtpState::Greeted;
|
||||||
|
// Auth state is reset on new EHLO per RFC
|
||||||
|
self.auth_state = AuthState::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the client is authenticated.
|
||||||
|
pub fn is_authenticated(&self) -> bool {
|
||||||
|
matches!(self.auth_state, AuthState::Authenticated { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the authenticated username, if any.
|
||||||
|
pub fn authenticated_user(&self) -> Option<&str> {
|
||||||
|
match &self.auth_state {
|
||||||
|
AuthState::Authenticated { username } => Some(username),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a completed message delivery.
|
||||||
|
pub fn record_message(&mut self) {
|
||||||
|
self.message_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a failed auth attempt. Returns true if limit exceeded.
|
||||||
|
pub fn record_auth_failure(&mut self, max_failures: u32) -> bool {
|
||||||
|
self.auth_failures += 1;
|
||||||
|
self.auth_failures >= max_failures
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an invalid command. Returns true if limit exceeded.
|
||||||
|
pub fn record_invalid_command(&mut self) -> bool {
|
||||||
|
self.invalid_commands += 1;
|
||||||
|
self.invalid_commands >= self.max_invalid_commands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_session() {
|
||||||
|
let session = SmtpSession::new("127.0.0.1".into(), false);
|
||||||
|
assert_eq!(session.state, SmtpState::Connected);
|
||||||
|
assert!(!session.secure);
|
||||||
|
assert!(!session.is_authenticated());
|
||||||
|
assert!(session.client_hostname.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset_transaction() {
|
||||||
|
let mut session = SmtpSession::new("127.0.0.1".into(), false);
|
||||||
|
session.state = SmtpState::RcptTo;
|
||||||
|
session.envelope.mail_from = "sender@example.com".into();
|
||||||
|
session.envelope.rcpt_to.push("rcpt@example.com".into());
|
||||||
|
|
||||||
|
session.reset_transaction();
|
||||||
|
|
||||||
|
assert_eq!(session.state, SmtpState::Greeted);
|
||||||
|
assert!(session.envelope.mail_from.is_empty());
|
||||||
|
assert!(session.envelope.rcpt_to.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset_for_ehlo() {
|
||||||
|
let mut session = SmtpSession::new("127.0.0.1".into(), true);
|
||||||
|
session.auth_state = AuthState::Authenticated {
|
||||||
|
username: "user".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
session.reset_for_ehlo("mail.example.com".into(), true);
|
||||||
|
|
||||||
|
assert_eq!(session.state, SmtpState::Greeted);
|
||||||
|
assert_eq!(session.client_hostname.as_deref(), Some("mail.example.com"));
|
||||||
|
assert!(session.esmtp);
|
||||||
|
assert!(!session.is_authenticated()); // Auth reset after EHLO
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_failures() {
|
||||||
|
let mut session = SmtpSession::new("127.0.0.1".into(), false);
|
||||||
|
assert!(!session.record_auth_failure(3));
|
||||||
|
assert!(!session.record_auth_failure(3));
|
||||||
|
assert!(session.record_auth_failure(3)); // 3rd failure -> limit
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_commands() {
|
||||||
|
let mut session = SmtpSession::new("127.0.0.1".into(), false);
|
||||||
|
session.max_invalid_commands = 3;
|
||||||
|
assert!(!session.record_invalid_command());
|
||||||
|
assert!(!session.record_invalid_command());
|
||||||
|
assert!(session.record_invalid_command()); // 3rd -> limit
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_count() {
|
||||||
|
let mut session = SmtpSession::new("127.0.0.1".into(), false);
|
||||||
|
assert_eq!(session.message_count, 0);
|
||||||
|
session.record_message();
|
||||||
|
session.record_message();
|
||||||
|
assert_eq!(session.message_count, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
219
rust/crates/mailer-smtp/src/state.rs
Normal file
219
rust/crates/mailer-smtp/src/state.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
//! SMTP protocol state machine.
|
||||||
|
//!
|
||||||
|
//! Defines valid states and transitions for an SMTP session.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// SMTP session states following RFC 5321.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum SmtpState {
|
||||||
|
/// Initial state — waiting for server greeting.
|
||||||
|
Connected,
|
||||||
|
/// After successful EHLO/HELO.
|
||||||
|
Greeted,
|
||||||
|
/// After MAIL FROM accepted.
|
||||||
|
MailFrom,
|
||||||
|
/// After at least one RCPT TO accepted.
|
||||||
|
RcptTo,
|
||||||
|
/// In DATA mode — accumulating message body.
|
||||||
|
Data,
|
||||||
|
/// Transaction completed — can start a new one or QUIT.
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State transition errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||||
|
pub enum TransitionError {
|
||||||
|
#[error("cannot {action} in state {state:?}")]
|
||||||
|
InvalidTransition {
|
||||||
|
state: SmtpState,
|
||||||
|
action: &'static str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpState {
|
||||||
|
/// Check whether EHLO/HELO is valid in the current state.
|
||||||
|
/// EHLO/HELO can be issued at any time to reset the session.
|
||||||
|
pub fn can_ehlo(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether MAIL FROM is valid in the current state.
|
||||||
|
pub fn can_mail_from(&self) -> bool {
|
||||||
|
matches!(self, SmtpState::Greeted | SmtpState::Finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether RCPT TO is valid in the current state.
|
||||||
|
pub fn can_rcpt_to(&self) -> bool {
|
||||||
|
matches!(self, SmtpState::MailFrom | SmtpState::RcptTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether DATA is valid in the current state.
|
||||||
|
pub fn can_data(&self) -> bool {
|
||||||
|
matches!(self, SmtpState::RcptTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether STARTTLS is valid in the current state.
|
||||||
|
/// Only before a transaction starts.
|
||||||
|
pub fn can_starttls(&self) -> bool {
|
||||||
|
matches!(self, SmtpState::Connected | SmtpState::Greeted | SmtpState::Finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether AUTH is valid in the current state.
|
||||||
|
/// Only after EHLO and before a transaction starts.
|
||||||
|
pub fn can_auth(&self) -> bool {
|
||||||
|
matches!(self, SmtpState::Greeted | SmtpState::Finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to Greeted state (after EHLO/HELO).
|
||||||
|
pub fn transition_ehlo(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
// EHLO is always valid — it resets the session.
|
||||||
|
Ok(SmtpState::Greeted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to MailFrom state (after MAIL FROM accepted).
|
||||||
|
pub fn transition_mail_from(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
if self.can_mail_from() {
|
||||||
|
Ok(SmtpState::MailFrom)
|
||||||
|
} else {
|
||||||
|
Err(TransitionError::InvalidTransition {
|
||||||
|
state: *self,
|
||||||
|
action: "MAIL FROM",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to RcptTo state (after RCPT TO accepted).
|
||||||
|
pub fn transition_rcpt_to(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
if self.can_rcpt_to() {
|
||||||
|
Ok(SmtpState::RcptTo)
|
||||||
|
} else {
|
||||||
|
Err(TransitionError::InvalidTransition {
|
||||||
|
state: *self,
|
||||||
|
action: "RCPT TO",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to Data state (after DATA command accepted).
|
||||||
|
pub fn transition_data(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
if self.can_data() {
|
||||||
|
Ok(SmtpState::Data)
|
||||||
|
} else {
|
||||||
|
Err(TransitionError::InvalidTransition {
|
||||||
|
state: *self,
|
||||||
|
action: "DATA",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to Finished state (after end-of-data).
|
||||||
|
pub fn transition_finished(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
if *self == SmtpState::Data {
|
||||||
|
Ok(SmtpState::Finished)
|
||||||
|
} else {
|
||||||
|
Err(TransitionError::InvalidTransition {
|
||||||
|
state: *self,
|
||||||
|
action: "finish DATA",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset to Greeted state (after RSET command).
|
||||||
|
pub fn transition_rset(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
match self {
|
||||||
|
SmtpState::Connected => Err(TransitionError::InvalidTransition {
|
||||||
|
state: *self,
|
||||||
|
action: "RSET",
|
||||||
|
}),
|
||||||
|
_ => Ok(SmtpState::Greeted),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_state() {
|
||||||
|
let state = SmtpState::Connected;
|
||||||
|
assert!(!state.can_mail_from());
|
||||||
|
assert!(!state.can_rcpt_to());
|
||||||
|
assert!(!state.can_data());
|
||||||
|
assert!(state.can_starttls());
|
||||||
|
assert!(state.can_ehlo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo_always_valid() {
|
||||||
|
for state in [
|
||||||
|
SmtpState::Connected,
|
||||||
|
SmtpState::Greeted,
|
||||||
|
SmtpState::MailFrom,
|
||||||
|
SmtpState::RcptTo,
|
||||||
|
SmtpState::Data,
|
||||||
|
SmtpState::Finished,
|
||||||
|
] {
|
||||||
|
assert!(state.can_ehlo());
|
||||||
|
assert!(state.transition_ehlo().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normal_flow() {
|
||||||
|
let state = SmtpState::Connected;
|
||||||
|
let state = state.transition_ehlo().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::Greeted);
|
||||||
|
|
||||||
|
let state = state.transition_mail_from().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::MailFrom);
|
||||||
|
|
||||||
|
let state = state.transition_rcpt_to().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::RcptTo);
|
||||||
|
|
||||||
|
// Multiple RCPT TO
|
||||||
|
let state = state.transition_rcpt_to().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::RcptTo);
|
||||||
|
|
||||||
|
let state = state.transition_data().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::Data);
|
||||||
|
|
||||||
|
let state = state.transition_finished().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::Finished);
|
||||||
|
|
||||||
|
// New transaction
|
||||||
|
let state = state.transition_mail_from().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::MailFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_transitions() {
|
||||||
|
assert!(SmtpState::Connected.transition_mail_from().is_err());
|
||||||
|
assert!(SmtpState::Connected.transition_rcpt_to().is_err());
|
||||||
|
assert!(SmtpState::Connected.transition_data().is_err());
|
||||||
|
assert!(SmtpState::Greeted.transition_rcpt_to().is_err());
|
||||||
|
assert!(SmtpState::Greeted.transition_data().is_err());
|
||||||
|
assert!(SmtpState::MailFrom.transition_data().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rset() {
|
||||||
|
let state = SmtpState::RcptTo;
|
||||||
|
let state = state.transition_rset().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::Greeted);
|
||||||
|
|
||||||
|
// RSET from Connected is invalid (no EHLO yet)
|
||||||
|
assert!(SmtpState::Connected.transition_rset().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_starttls_validity() {
|
||||||
|
assert!(SmtpState::Connected.can_starttls());
|
||||||
|
assert!(SmtpState::Greeted.can_starttls());
|
||||||
|
assert!(!SmtpState::MailFrom.can_starttls());
|
||||||
|
assert!(!SmtpState::RcptTo.can_starttls());
|
||||||
|
assert!(!SmtpState::Data.can_starttls());
|
||||||
|
assert!(SmtpState::Finished.can_starttls());
|
||||||
|
}
|
||||||
|
}
|
||||||
169
rust/crates/mailer-smtp/src/validation.rs
Normal file
169
rust/crates/mailer-smtp/src/validation.rs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
//! SMTP-level validation utilities.
|
||||||
|
//!
|
||||||
|
//! Address parsing, EHLO hostname validation, and header injection detection.
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
/// Regex for basic email address format validation.
|
||||||
|
static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Regex for valid EHLO hostname (domain name or IPv4/IPv6 literal).
|
||||||
|
/// Currently unused in favor of a more permissive check, but available
|
||||||
|
/// for strict validation if needed.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
static EHLO_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
// Permissive: domain names, IP literals [1.2.3.4], [IPv6:...], or bare words
|
||||||
|
Regex::new(r"^(?:\[(?:IPv6:)?[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9\-\.]*[a-zA-Z0-9])?)$").unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Validate an email address for basic SMTP format.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the address has a valid-looking format.
|
||||||
|
/// Empty addresses (for bounce messages, MAIL FROM:<>) return `true`.
|
||||||
|
pub fn is_valid_smtp_address(address: &str) -> bool {
|
||||||
|
// Empty address is valid for MAIL FROM (bounce)
|
||||||
|
if address.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
EMAIL_RE.is_match(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an EHLO/HELO hostname.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the hostname looks syntactically valid.
|
||||||
|
/// We are permissive because real-world SMTP clients send all kinds of values.
|
||||||
|
pub fn is_valid_ehlo_hostname(hostname: &str) -> bool {
|
||||||
|
if hostname.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Be permissive — most SMTP servers accept anything non-empty.
|
||||||
|
// Only reject obviously malicious patterns.
|
||||||
|
if hostname.len() > 255 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if contains_header_injection(hostname) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Must not contain null bytes
|
||||||
|
if hostname.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for SMTP header injection attempts.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the input contains characters that could be used
|
||||||
|
/// for header injection (bare CR/LF).
|
||||||
|
pub fn contains_header_injection(input: &str) -> bool {
|
||||||
|
input.contains('\r') || input.contains('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the size parameter from MAIL FROM.
|
||||||
|
///
|
||||||
|
/// Returns the parsed size if valid and within the max, or an error message.
|
||||||
|
pub fn validate_size_param(value: &str, max_size: u64) -> Result<u64, String> {
|
||||||
|
let size: u64 = value
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("invalid SIZE value: {value}"))?;
|
||||||
|
if size > max_size {
|
||||||
|
return Err(format!(
|
||||||
|
"message size {size} exceeds maximum {max_size}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the domain part from an email address.
|
||||||
|
pub fn extract_domain(address: &str) -> Option<&str> {
|
||||||
|
if address.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
address.rsplit_once('@').map(|(_, domain)| domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize an email address by lowercasing the domain part.
|
||||||
|
pub fn normalize_address(address: &str) -> String {
|
||||||
|
if address.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
match address.rsplit_once('@') {
|
||||||
|
Some((local, domain)) => format!("{local}@{}", domain.to_ascii_lowercase()),
|
||||||
|
None => address.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_email() {
|
||||||
|
assert!(is_valid_smtp_address("user@example.com"));
|
||||||
|
assert!(is_valid_smtp_address("user+tag@sub.example.com"));
|
||||||
|
assert!(is_valid_smtp_address("a@b.c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_address_valid() {
|
||||||
|
assert!(is_valid_smtp_address(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_email() {
|
||||||
|
assert!(!is_valid_smtp_address("no-at-sign"));
|
||||||
|
assert!(!is_valid_smtp_address("@no-local.com"));
|
||||||
|
assert!(!is_valid_smtp_address("user@"));
|
||||||
|
assert!(!is_valid_smtp_address("user@nodot"));
|
||||||
|
assert!(!is_valid_smtp_address("has space@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_ehlo() {
|
||||||
|
assert!(is_valid_ehlo_hostname("mail.example.com"));
|
||||||
|
assert!(is_valid_ehlo_hostname("localhost"));
|
||||||
|
assert!(is_valid_ehlo_hostname("[127.0.0.1]"));
|
||||||
|
assert!(is_valid_ehlo_hostname("[IPv6:::1]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_ehlo() {
|
||||||
|
assert!(!is_valid_ehlo_hostname(""));
|
||||||
|
assert!(!is_valid_ehlo_hostname("host\r\nname"));
|
||||||
|
assert!(!is_valid_ehlo_hostname(&"a".repeat(256)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_header_injection() {
|
||||||
|
assert!(contains_header_injection("test\r\nBcc: evil@evil.com"));
|
||||||
|
assert!(contains_header_injection("test\ninjection"));
|
||||||
|
assert!(contains_header_injection("test\rinjection"));
|
||||||
|
assert!(!contains_header_injection("normal text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_size_param() {
|
||||||
|
assert_eq!(validate_size_param("12345", 1_000_000), Ok(12345));
|
||||||
|
assert!(validate_size_param("99999999", 1_000).is_err());
|
||||||
|
assert!(validate_size_param("notanumber", 1_000).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_domain() {
|
||||||
|
assert_eq!(extract_domain("user@example.com"), Some("example.com"));
|
||||||
|
assert_eq!(extract_domain(""), None);
|
||||||
|
assert_eq!(extract_domain("nodomain"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_address() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_address("User@EXAMPLE.COM"),
|
||||||
|
"User@example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_address(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
209
test/helpers/smtp.client.ts
Normal file
209
test/helpers/smtp.client.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { smtpClientMod } from '../../ts/mail/delivery/index.js';
|
||||||
|
import type { ISmtpClientOptions, SmtpClient } from '../../ts/mail/delivery/smtpclient/index.js';
|
||||||
|
import { Email } from '../../ts/mail/core/classes.email.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test SMTP client
|
||||||
|
*/
|
||||||
|
export function createTestSmtpClient(options: Partial<ISmtpClientOptions> = {}): SmtpClient {
|
||||||
|
const defaultOptions: ISmtpClientOptions = {
|
||||||
|
host: options.host || 'localhost',
|
||||||
|
port: options.port || 2525,
|
||||||
|
secure: options.secure || false,
|
||||||
|
auth: options.auth,
|
||||||
|
connectionTimeout: options.connectionTimeout || 5000,
|
||||||
|
socketTimeout: options.socketTimeout || 5000,
|
||||||
|
maxConnections: options.maxConnections || 5,
|
||||||
|
maxMessages: options.maxMessages || 100,
|
||||||
|
debug: options.debug || false,
|
||||||
|
tls: options.tls || {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return smtpClientMod.createSmtpClient(defaultOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send test email using SMTP client
|
||||||
|
*/
|
||||||
|
export async function sendTestEmail(
|
||||||
|
client: SmtpClient,
|
||||||
|
options: {
|
||||||
|
from?: string;
|
||||||
|
to?: string | string[];
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<any> {
|
||||||
|
const mailOptions = {
|
||||||
|
from: options.from || 'test@example.com',
|
||||||
|
to: options.to || 'recipient@example.com',
|
||||||
|
subject: options.subject || 'Test Email',
|
||||||
|
text: options.text || 'This is a test email',
|
||||||
|
html: options.html
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: mailOptions.from,
|
||||||
|
to: mailOptions.to,
|
||||||
|
subject: mailOptions.subject,
|
||||||
|
text: mailOptions.text,
|
||||||
|
html: mailOptions.html
|
||||||
|
});
|
||||||
|
return client.sendMail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SMTP client connection
|
||||||
|
*/
|
||||||
|
export async function testClientConnection(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
timeout: number = 5000
|
||||||
|
): Promise<boolean> {
|
||||||
|
const client = createTestSmtpClient({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
connectionTimeout: timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.verify();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (client.close) {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create authenticated SMTP client
|
||||||
|
*/
|
||||||
|
export function createAuthenticatedClient(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
authMethod: 'PLAIN' | 'LOGIN' = 'PLAIN'
|
||||||
|
): SmtpClient {
|
||||||
|
return createTestSmtpClient({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
auth: {
|
||||||
|
user: username,
|
||||||
|
pass: password,
|
||||||
|
method: authMethod
|
||||||
|
},
|
||||||
|
secure: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create TLS-enabled SMTP client
|
||||||
|
*/
|
||||||
|
export function createTlsClient(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
options: {
|
||||||
|
secure?: boolean;
|
||||||
|
rejectUnauthorized?: boolean;
|
||||||
|
} = {}
|
||||||
|
): SmtpClient {
|
||||||
|
return createTestSmtpClient({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: options.secure || false,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: options.rejectUnauthorized || false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test client pool status
|
||||||
|
*/
|
||||||
|
export async function testClientPoolStatus(client: SmtpClient): Promise<any> {
|
||||||
|
if (typeof client.getPoolStatus === 'function') {
|
||||||
|
return client.getPoolStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for clients without pool status
|
||||||
|
return {
|
||||||
|
size: 1,
|
||||||
|
available: 1,
|
||||||
|
pending: 0,
|
||||||
|
connecting: 0,
|
||||||
|
active: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send multiple emails concurrently
|
||||||
|
*/
|
||||||
|
export async function sendConcurrentEmails(
|
||||||
|
client: SmtpClient,
|
||||||
|
count: number,
|
||||||
|
emailOptions: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<any[]> {
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
promises.push(
|
||||||
|
sendTestEmail(client, {
|
||||||
|
...emailOptions,
|
||||||
|
subject: `${emailOptions.subject || 'Test Email'} ${i + 1}`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure client throughput
|
||||||
|
*/
|
||||||
|
export async function measureClientThroughput(
|
||||||
|
client: SmtpClient,
|
||||||
|
duration: number = 10000,
|
||||||
|
emailOptions: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<{ totalSent: number; successCount: number; errorCount: number; throughput: number }> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let totalSent = 0;
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < duration) {
|
||||||
|
try {
|
||||||
|
await sendTestEmail(client, emailOptions);
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
totalSent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualDuration = (Date.now() - startTime) / 1000; // in seconds
|
||||||
|
const throughput = totalSent / actualDuration;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSent,
|
||||||
|
successCount,
|
||||||
|
errorCount,
|
||||||
|
throughput
|
||||||
|
};
|
||||||
|
}
|
||||||
311
test/helpers/utils.ts
Normal file
311
test/helpers/utils.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import * as plugins from '../../ts/plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 and get greeting
|
||||||
|
*/
|
||||||
|
export async function connectToSmtp(host: string, port: number, timeout: number = 5000): Promise<plugins.net.Socket> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = plugins.net.createConnection({ host, port });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(new Error(`Connection timeout after ${timeout}ms`));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
socket.once('connect', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send SMTP command and wait for response
|
||||||
|
*/
|
||||||
|
export async function sendSmtpCommand(
|
||||||
|
socket: plugins.net.Socket,
|
||||||
|
command: string,
|
||||||
|
expectedCode?: string,
|
||||||
|
timeout: number = 5000
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = '';
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
|
||||||
|
// Check if we have a complete response
|
||||||
|
if (buffer.includes('\r\n')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.removeListener('data', onData);
|
||||||
|
|
||||||
|
if (expectedCode && !buffer.startsWith(expectedCode)) {
|
||||||
|
reject(new Error(`Expected ${expectedCode}, got: ${buffer.trim()}`));
|
||||||
|
} else {
|
||||||
|
resolve(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
socket.removeListener('data', onData);
|
||||||
|
reject(new Error(`Command timeout after ${timeout}ms`));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
socket.on('data', onData);
|
||||||
|
socket.write(command + '\r\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for SMTP greeting
|
||||||
|
*/
|
||||||
|
export async function waitForGreeting(socket: plugins.net.Socket, timeout: number = 5000): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = '';
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
|
||||||
|
if (buffer.includes('220')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.removeListener('data', onData);
|
||||||
|
resolve(buffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
socket.removeListener('data', onData);
|
||||||
|
reject(new Error(`Greeting timeout after ${timeout}ms`));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
socket.on('data', onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform SMTP handshake
|
||||||
|
*/
|
||||||
|
export async function performSmtpHandshake(
|
||||||
|
socket: plugins.net.Socket,
|
||||||
|
hostname: string = 'test.example.com'
|
||||||
|
): Promise<string[]> {
|
||||||
|
const capabilities: string[] = [];
|
||||||
|
|
||||||
|
// Wait for greeting
|
||||||
|
await waitForGreeting(socket);
|
||||||
|
|
||||||
|
// Send EHLO
|
||||||
|
const ehloResponse = await sendSmtpCommand(socket, `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<plugins.net.Socket[]> {
|
||||||
|
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(socket: plugins.net.Socket): Promise<void> {
|
||||||
|
try {
|
||||||
|
await sendSmtpCommand(socket, 'QUIT', '221');
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during QUIT
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
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';
|
||||||
|
message += Buffer.from(attachment.content).toString('base64') + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
message += `--${boundary}--\r\n`;
|
||||||
|
} else if (options.html && options.text) {
|
||||||
|
const altBoundary = `----=_Alt_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
||||||
|
message += `Content-Type: multipart/alternative; boundary="${altBoundary}"\r\n`;
|
||||||
|
message += '\r\n';
|
||||||
|
|
||||||
|
// Text part
|
||||||
|
message += `--${altBoundary}\r\n`;
|
||||||
|
message += 'Content-Type: text/plain; charset=utf-8\r\n';
|
||||||
|
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
message += options.text + '\r\n';
|
||||||
|
|
||||||
|
// HTML part
|
||||||
|
message += `--${altBoundary}\r\n`;
|
||||||
|
message += 'Content-Type: text/html; charset=utf-8\r\n';
|
||||||
|
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
message += options.html + '\r\n';
|
||||||
|
|
||||||
|
message += `--${altBoundary}--\r\n`;
|
||||||
|
} else if (options.html) {
|
||||||
|
message += 'Content-Type: text/html; charset=utf-8\r\n';
|
||||||
|
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
message += options.html;
|
||||||
|
} else {
|
||||||
|
message += 'Content-Type: text/plain; charset=utf-8\r\n';
|
||||||
|
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||||
|
message += '\r\n';
|
||||||
|
message += options.text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure operation time
|
||||||
|
*/
|
||||||
|
export async function measureTime<T>(operation: () => Promise<T>): Promise<{ result: T; duration: number }> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const result = await operation();
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return { result, duration };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry operation with exponential backoff
|
||||||
|
*/
|
||||||
|
export async function retryOperation<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
initialDelay: number = 1000
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: Error;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
if (i < maxRetries - 1) {
|
||||||
|
const delay = initialDelay * Math.pow(2, i);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError!;
|
||||||
|
}
|
||||||
443
test/readme.md
Normal file
443
test/readme.md
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
# DCRouter SMTP Test Suite
|
||||||
|
|
||||||
|
```
|
||||||
|
test/
|
||||||
|
├── readme.md # This file
|
||||||
|
├── helpers/
|
||||||
|
│ ├── server.loader.ts # SMTP server lifecycle management
|
||||||
|
│ ├── utils.ts # Common test utilities
|
||||||
|
│ └── smtp.client.ts # Test SMTP client utilities
|
||||||
|
└── suite/
|
||||||
|
├── smtpserver_commands/ # SMTP command tests (CMD)
|
||||||
|
├── smtpserver_connection/ # Connection management tests (CM)
|
||||||
|
├── smtpserver_edge-cases/ # Edge case tests (EDGE)
|
||||||
|
├── smtpserver_email-processing/ # Email processing tests (EP)
|
||||||
|
├── smtpserver_error-handling/ # Error handling tests (ERR)
|
||||||
|
├── smtpserver_performance/ # Performance tests (PERF)
|
||||||
|
├── smtpserver_reliability/ # Reliability tests (REL)
|
||||||
|
├── smtpserver_rfc-compliance/ # RFC compliance tests (RFC)
|
||||||
|
└── smtpserver_security/ # Security tests (SEC)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test ID Convention
|
||||||
|
|
||||||
|
All test files follow a strict naming convention: `test.<category-id>.<description>.ts`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `test.cmd-01.ehlo-command.ts` - EHLO command test
|
||||||
|
- `test.cm-01.tls-connection.ts` - TLS connection test
|
||||||
|
- `test.sec-01.authentication.ts` - Authentication test
|
||||||
|
|
||||||
|
## Test Categories
|
||||||
|
|
||||||
|
### 1. Connection Management (CM)
|
||||||
|
|
||||||
|
Tests for validating SMTP connection handling, TLS support, and connection lifecycle management.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|-------|-------------------------------------------|----------|----------------|
|
||||||
|
| CM-01 | TLS Connection Test | High | `suite/smtpserver_connection/test.cm-01.tls-connection.ts` |
|
||||||
|
| CM-02 | Multiple Simultaneous Connections | High | `suite/smtpserver_connection/test.cm-02.multiple-connections.ts` |
|
||||||
|
| CM-03 | Connection Timeout | High | `suite/smtpserver_connection/test.cm-03.connection-timeout.ts` |
|
||||||
|
| CM-04 | Connection Limits | Medium | `suite/smtpserver_connection/test.cm-04.connection-limits.ts` |
|
||||||
|
| CM-05 | Connection Rejection | Medium | `suite/smtpserver_connection/test.cm-05.connection-rejection.ts` |
|
||||||
|
| CM-06 | STARTTLS Connection Upgrade | High | `suite/smtpserver_connection/test.cm-06.starttls-upgrade.ts` |
|
||||||
|
| CM-07 | Abrupt Client Disconnection | Medium | `suite/smtpserver_connection/test.cm-07.abrupt-disconnection.ts` |
|
||||||
|
| CM-08 | TLS Version Compatibility | Medium | `suite/smtpserver_connection/test.cm-08.tls-versions.ts` |
|
||||||
|
| CM-09 | TLS Cipher Configuration | Medium | `suite/smtpserver_connection/test.cm-09.tls-ciphers.ts` |
|
||||||
|
| CM-10 | Plain Connection Test | Low | `suite/smtpserver_connection/test.cm-10.plain-connection.ts` |
|
||||||
|
| CM-11 | TCP Keep-Alive Test | Low | `suite/smtpserver_connection/test.cm-11.keepalive.ts` |
|
||||||
|
|
||||||
|
### 2. SMTP Commands (CMD)
|
||||||
|
|
||||||
|
Tests for validating proper SMTP protocol command implementation.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| CMD-01 | EHLO Command | High | `suite/smtpserver_commands/test.cmd-01.ehlo-command.ts` |
|
||||||
|
| CMD-02 | MAIL FROM Command | High | `suite/smtpserver_commands/test.cmd-02.mail-from.ts` |
|
||||||
|
| CMD-03 | RCPT TO Command | High | `suite/smtpserver_commands/test.cmd-03.rcpt-to.ts` |
|
||||||
|
| CMD-04 | DATA Command | High | `suite/smtpserver_commands/test.cmd-04.data-command.ts` |
|
||||||
|
| CMD-05 | NOOP Command | Medium | `suite/smtpserver_commands/test.cmd-05.noop-command.ts` |
|
||||||
|
| CMD-06 | RSET Command | Medium | `suite/smtpserver_commands/test.cmd-06.rset-command.ts` |
|
||||||
|
| CMD-07 | VRFY Command | Low | `suite/smtpserver_commands/test.cmd-07.vrfy-command.ts` |
|
||||||
|
| CMD-08 | EXPN Command | Low | `suite/smtpserver_commands/test.cmd-08.expn-command.ts` |
|
||||||
|
| CMD-09 | SIZE Extension | Medium | `suite/smtpserver_commands/test.cmd-09.size-extension.ts` |
|
||||||
|
| CMD-10 | HELP Command | Low | `suite/smtpserver_commands/test.cmd-10.help-command.ts` |
|
||||||
|
| CMD-11 | Command Pipelining | Medium | `suite/smtpserver_commands/test.cmd-11.command-pipelining.ts` |
|
||||||
|
| CMD-12 | HELO Command | Low | `suite/smtpserver_commands/test.cmd-12.helo-command.ts` |
|
||||||
|
| CMD-13 | QUIT Command | High | `suite/smtpserver_commands/test.cmd-13.quit-command.ts` |
|
||||||
|
|
||||||
|
### 3. Email Processing (EP)
|
||||||
|
|
||||||
|
Tests for validating email content handling, parsing, and delivery.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|-------|-------------------------------------------|----------|----------------|
|
||||||
|
| EP-01 | Basic Email Sending | High | `suite/smtpserver_email-processing/test.ep-01.basic-email-sending.ts` |
|
||||||
|
| EP-02 | Invalid Email Address Handling | High | `suite/smtpserver_email-processing/test.ep-02.invalid-email-addresses.ts` |
|
||||||
|
| EP-03 | Multiple Recipients | Medium | `suite/smtpserver_email-processing/test.ep-03.multiple-recipients.ts` |
|
||||||
|
| EP-04 | Large Email Handling | High | `suite/smtpserver_email-processing/test.ep-04.large-email.ts` |
|
||||||
|
| EP-05 | MIME Handling | High | `suite/smtpserver_email-processing/test.ep-05.mime-handling.ts` |
|
||||||
|
| EP-06 | Attachment Handling | Medium | `suite/smtpserver_email-processing/test.ep-06.attachment-handling.ts` |
|
||||||
|
| EP-07 | Special Character Handling | Medium | `suite/smtpserver_email-processing/test.ep-07.special-character-handling.ts` |
|
||||||
|
| EP-08 | Email Routing | High | `suite/smtpserver_email-processing/test.ep-08.email-routing.ts` |
|
||||||
|
| EP-09 | Delivery Status Notifications | Medium | `suite/smtpserver_email-processing/test.ep-09.delivery-status-notifications.ts` |
|
||||||
|
|
||||||
|
### 4. Security (SEC)
|
||||||
|
|
||||||
|
Tests for validating security features and protections.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| SEC-01 | Authentication | High | `suite/smtpserver_security/test.sec-01.authentication.ts` |
|
||||||
|
| SEC-02 | Authorization | High | `suite/smtpserver_security/test.sec-02.authorization.ts` |
|
||||||
|
| SEC-03 | DKIM Processing | High | `suite/smtpserver_security/test.sec-03.dkim-processing.ts` |
|
||||||
|
| SEC-04 | SPF Checking | High | `suite/smtpserver_security/test.sec-04.spf-checking.ts` |
|
||||||
|
| SEC-05 | DMARC Policy Enforcement | Medium | `suite/smtpserver_security/test.sec-05.dmarc-policy.ts` |
|
||||||
|
| SEC-06 | IP Reputation Checking | High | `suite/smtpserver_security/test.sec-06.ip-reputation.ts` |
|
||||||
|
| SEC-07 | Content Scanning | Medium | `suite/smtpserver_security/test.sec-07.content-scanning.ts` |
|
||||||
|
| SEC-08 | Rate Limiting | High | `suite/smtpserver_security/test.sec-08.rate-limiting.ts` |
|
||||||
|
| SEC-09 | TLS Certificate Validation | High | `suite/smtpserver_security/test.sec-09.tls-certificate-validation.ts` |
|
||||||
|
| SEC-10 | Header Injection Prevention | High | `suite/smtpserver_security/test.sec-10.header-injection-prevention.ts` |
|
||||||
|
| SEC-11 | Bounce Management | Medium | `suite/smtpserver_security/test.sec-11.bounce-management.ts` |
|
||||||
|
|
||||||
|
### 5. Error Handling (ERR)
|
||||||
|
|
||||||
|
Tests for validating proper error handling and recovery.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| ERR-01 | Syntax Error Handling | High | `suite/smtpserver_error-handling/test.err-01.syntax-errors.ts` |
|
||||||
|
| ERR-02 | Invalid Sequence Handling | High | `suite/smtpserver_error-handling/test.err-02.invalid-sequence.ts` |
|
||||||
|
| ERR-03 | Temporary Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-03.temporary-failures.ts` |
|
||||||
|
| ERR-04 | Permanent Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-04.permanent-failures.ts` |
|
||||||
|
| ERR-05 | Resource Exhaustion Handling | High | `suite/smtpserver_error-handling/test.err-05.resource-exhaustion.ts` |
|
||||||
|
| ERR-06 | Malformed MIME Handling | Medium | `suite/smtpserver_error-handling/test.err-06.malformed-mime.ts` |
|
||||||
|
| ERR-07 | Exception Handling | High | `suite/smtpserver_error-handling/test.err-07.exception-handling.ts` |
|
||||||
|
| ERR-08 | Error Logging | Medium | `suite/smtpserver_error-handling/test.err-08.error-logging.ts` |
|
||||||
|
|
||||||
|
### 6. Performance (PERF)
|
||||||
|
|
||||||
|
Tests for validating performance characteristics and benchmarks.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|------------------------------------------|----------|----------------|
|
||||||
|
| PERF-01 | Throughput Testing | Medium | `suite/smtpserver_performance/test.perf-01.throughput.ts` |
|
||||||
|
| PERF-02 | Concurrency Testing | High | `suite/smtpserver_performance/test.perf-02.concurrency.ts` |
|
||||||
|
| PERF-03 | CPU Utilization | Medium | `suite/smtpserver_performance/test.perf-03.cpu-utilization.ts` |
|
||||||
|
| PERF-04 | Memory Usage | Medium | `suite/smtpserver_performance/test.perf-04.memory-usage.ts` |
|
||||||
|
| PERF-05 | Connection Processing Time | Medium | `suite/smtpserver_performance/test.perf-05.connection-processing-time.ts` |
|
||||||
|
| PERF-06 | Message Processing Time | Medium | `suite/smtpserver_performance/test.perf-06.message-processing-time.ts` |
|
||||||
|
| PERF-07 | Resource Cleanup | High | `suite/smtpserver_performance/test.perf-07.resource-cleanup.ts` |
|
||||||
|
|
||||||
|
### 7. Reliability (REL)
|
||||||
|
|
||||||
|
Tests for validating system reliability and stability.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| REL-01 | Long-Running Operation | High | `suite/smtpserver_reliability/test.rel-01.long-running-operation.ts` |
|
||||||
|
| REL-02 | Restart Recovery | High | `suite/smtpserver_reliability/test.rel-02.restart-recovery.ts` |
|
||||||
|
| REL-03 | Resource Leak Detection | High | `suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts` |
|
||||||
|
| REL-04 | Error Recovery | High | `suite/smtpserver_reliability/test.rel-04.error-recovery.ts` |
|
||||||
|
| REL-05 | DNS Resolution Failure Handling | Medium | `suite/smtpserver_reliability/test.rel-05.dns-resolution-failure.ts` |
|
||||||
|
| REL-06 | Network Interruption Handling | Medium | `suite/smtpserver_reliability/test.rel-06.network-interruption.ts` |
|
||||||
|
|
||||||
|
### 8. Edge Cases (EDGE)
|
||||||
|
|
||||||
|
Tests for validating handling of unusual or extreme scenarios.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| EDGE-01 | Very Large Email | Low | `suite/smtpserver_edge-cases/test.edge-01.very-large-email.ts` |
|
||||||
|
| EDGE-02 | Very Small Email | Low | `suite/smtpserver_edge-cases/test.edge-02.very-small-email.ts` |
|
||||||
|
| EDGE-03 | Invalid Character Handling | Medium | `suite/smtpserver_edge-cases/test.edge-03.invalid-character-handling.ts` |
|
||||||
|
| EDGE-04 | Empty Commands | Low | `suite/smtpserver_edge-cases/test.edge-04.empty-commands.ts` |
|
||||||
|
| EDGE-05 | Extremely Long Lines | Medium | `suite/smtpserver_edge-cases/test.edge-05.extremely-long-lines.ts` |
|
||||||
|
| EDGE-06 | Extremely Long Headers | Medium | `suite/smtpserver_edge-cases/test.edge-06.extremely-long-headers.ts` |
|
||||||
|
| EDGE-07 | Unusual MIME Types | Low | `suite/smtpserver_edge-cases/test.edge-07.unusual-mime-types.ts` |
|
||||||
|
| EDGE-08 | Nested MIME Structures | Low | `suite/smtpserver_edge-cases/test.edge-08.nested-mime-structures.ts` |
|
||||||
|
|
||||||
|
### 9. RFC Compliance (RFC)
|
||||||
|
|
||||||
|
Tests for validating compliance with SMTP-related RFCs.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| RFC-01 | RFC 5321 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-01.rfc5321-compliance.ts` |
|
||||||
|
| RFC-02 | RFC 5322 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-02.rfc5322-compliance.ts` |
|
||||||
|
| RFC-03 | RFC 7208 SPF Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-03.rfc7208-spf-compliance.ts` |
|
||||||
|
| RFC-04 | RFC 6376 DKIM Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-04.rfc6376-dkim-compliance.ts` |
|
||||||
|
| RFC-05 | RFC 7489 DMARC Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-05.rfc7489-dmarc-compliance.ts` |
|
||||||
|
| RFC-06 | RFC 8314 TLS Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-06.rfc8314-tls-compliance.ts` |
|
||||||
|
| RFC-07 | RFC 3461 DSN Compliance | Low | `suite/smtpserver_rfc-compliance/test.rfc-07.rfc3461-dsn-compliance.ts` |
|
||||||
|
|
||||||
|
## SMTP Client Test Suite
|
||||||
|
|
||||||
|
The following test categories ensure our SMTP client is production-ready, RFC-compliant, and handles all real-world scenarios properly.
|
||||||
|
|
||||||
|
### Client Test Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
test/
|
||||||
|
└── suite/
|
||||||
|
├── smtpclient_connection/ # Client connection management tests (CCM)
|
||||||
|
├── smtpclient_commands/ # Client command execution tests (CCMD)
|
||||||
|
├── smtpclient_email-composition/ # Email composition tests (CEP)
|
||||||
|
├── smtpclient_security/ # Client security tests (CSEC)
|
||||||
|
├── smtpclient_error-handling/ # Client error handling tests (CERR)
|
||||||
|
├── smtpclient_performance/ # Client performance tests (CPERF)
|
||||||
|
├── smtpclient_reliability/ # Client reliability tests (CREL)
|
||||||
|
├── smtpclient_edge-cases/ # Client edge case tests (CEDGE)
|
||||||
|
└── smtpclient_rfc-compliance/ # Client RFC compliance tests (CRFC)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Client Connection Management (CCM)
|
||||||
|
|
||||||
|
Tests for validating how the SMTP client establishes and manages connections to servers.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| CCM-01 | Basic TCP Connection | High | `suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts` |
|
||||||
|
| CCM-02 | TLS Connection Establishment | High | `suite/smtpclient_connection/test.ccm-02.tls-connection.ts` |
|
||||||
|
| CCM-03 | STARTTLS Upgrade | High | `suite/smtpclient_connection/test.ccm-03.starttls-upgrade.ts` |
|
||||||
|
| CCM-04 | Connection Pooling | High | `suite/smtpclient_connection/test.ccm-04.connection-pooling.ts` |
|
||||||
|
| CCM-05 | Connection Reuse | Medium | `suite/smtpclient_connection/test.ccm-05.connection-reuse.ts` |
|
||||||
|
| CCM-06 | Connection Timeout Handling | High | `suite/smtpclient_connection/test.ccm-06.connection-timeout.ts` |
|
||||||
|
| CCM-07 | Automatic Reconnection | High | `suite/smtpclient_connection/test.ccm-07.automatic-reconnection.ts` |
|
||||||
|
| CCM-08 | DNS Resolution & MX Records | High | `suite/smtpclient_connection/test.ccm-08.dns-mx-resolution.ts` |
|
||||||
|
| CCM-09 | IPv4/IPv6 Dual Stack Support | Medium | `suite/smtpclient_connection/test.ccm-09.dual-stack-support.ts` |
|
||||||
|
| CCM-10 | Proxy Support (SOCKS/HTTP) | Low | `suite/smtpclient_connection/test.ccm-10.proxy-support.ts` |
|
||||||
|
| CCM-11 | Keep-Alive Management | Medium | `suite/smtpclient_connection/test.ccm-11.keepalive-management.ts` |
|
||||||
|
|
||||||
|
### 11. Client Command Execution (CCMD)
|
||||||
|
|
||||||
|
Tests for validating how the client sends SMTP commands and processes responses.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| CCMD-01 | EHLO/HELO Command Sending | High | `suite/smtpclient_commands/test.ccmd-01.ehlo-helo-sending.ts` |
|
||||||
|
| CCMD-02 | MAIL FROM Command with Parameters | High | `suite/smtpclient_commands/test.ccmd-02.mail-from-parameters.ts` |
|
||||||
|
| CCMD-03 | RCPT TO Command with Multiple Recipients | High | `suite/smtpclient_commands/test.ccmd-03.rcpt-to-multiple.ts` |
|
||||||
|
| CCMD-04 | DATA Command and Content Transmission | High | `suite/smtpclient_commands/test.ccmd-04.data-transmission.ts` |
|
||||||
|
| CCMD-05 | AUTH Command (LOGIN, PLAIN, CRAM-MD5) | High | `suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts` |
|
||||||
|
| CCMD-06 | Command Pipelining | Medium | `suite/smtpclient_commands/test.ccmd-06.command-pipelining.ts` |
|
||||||
|
| CCMD-07 | Response Code Parsing | High | `suite/smtpclient_commands/test.ccmd-07.response-parsing.ts` |
|
||||||
|
| CCMD-08 | Extended Response Handling | Medium | `suite/smtpclient_commands/test.ccmd-08.extended-responses.ts` |
|
||||||
|
| CCMD-09 | QUIT Command and Graceful Disconnect | High | `suite/smtpclient_commands/test.ccmd-09.quit-disconnect.ts` |
|
||||||
|
| CCMD-10 | RSET Command Usage | Medium | `suite/smtpclient_commands/test.ccmd-10.rset-usage.ts` |
|
||||||
|
| CCMD-11 | NOOP Keep-Alive | Low | `suite/smtpclient_commands/test.ccmd-11.noop-keepalive.ts` |
|
||||||
|
|
||||||
|
### 12. Client Email Composition (CEP)
|
||||||
|
|
||||||
|
Tests for validating email composition, formatting, and encoding.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| CEP-01 | Basic Email Headers | High | `suite/smtpclient_email-composition/test.cep-01.basic-headers.ts` |
|
||||||
|
| CEP-02 | MIME Multipart Messages | High | `suite/smtpclient_email-composition/test.cep-02.mime-multipart.ts` |
|
||||||
|
| CEP-03 | Attachment Encoding | High | `suite/smtpclient_email-composition/test.cep-03.attachment-encoding.ts` |
|
||||||
|
| CEP-04 | UTF-8 and International Characters | High | `suite/smtpclient_email-composition/test.cep-04.utf8-international.ts` |
|
||||||
|
| CEP-05 | Base64 and Quoted-Printable Encoding | Medium | `suite/smtpclient_email-composition/test.cep-05.content-encoding.ts` |
|
||||||
|
| CEP-06 | HTML Email with Inline Images | Medium | `suite/smtpclient_email-composition/test.cep-06.html-inline-images.ts` |
|
||||||
|
| CEP-07 | Custom Headers | Low | `suite/smtpclient_email-composition/test.cep-07.custom-headers.ts` |
|
||||||
|
| CEP-08 | Message-ID Generation | Medium | `suite/smtpclient_email-composition/test.cep-08.message-id.ts` |
|
||||||
|
| CEP-09 | Date Header Formatting | Medium | `suite/smtpclient_email-composition/test.cep-09.date-formatting.ts` |
|
||||||
|
| CEP-10 | Line Length Limits (RFC 5322) | High | `suite/smtpclient_email-composition/test.cep-10.line-length-limits.ts` |
|
||||||
|
|
||||||
|
### 13. Client Security (CSEC)
|
||||||
|
|
||||||
|
Tests for client-side security features and protections.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| CSEC-01 | TLS Certificate Verification | High | `suite/smtpclient_security/test.csec-01.tls-verification.ts` |
|
||||||
|
| CSEC-02 | Authentication Mechanisms | High | `suite/smtpclient_security/test.csec-02.auth-mechanisms.ts` |
|
||||||
|
| CSEC-03 | OAuth2 Support | Medium | `suite/smtpclient_security/test.csec-03.oauth2-support.ts` |
|
||||||
|
| CSEC-04 | Password Security (No Plaintext) | High | `suite/smtpclient_security/test.csec-04.password-security.ts` |
|
||||||
|
| CSEC-05 | DKIM Signing | High | `suite/smtpclient_security/test.csec-05.dkim-signing.ts` |
|
||||||
|
| CSEC-06 | SPF Record Compliance | Medium | `suite/smtpclient_security/test.csec-06.spf-compliance.ts` |
|
||||||
|
| CSEC-07 | Secure Credential Storage | High | `suite/smtpclient_security/test.csec-07.credential-storage.ts` |
|
||||||
|
| CSEC-08 | TLS Version Enforcement | High | `suite/smtpclient_security/test.csec-08.tls-version-enforcement.ts` |
|
||||||
|
| CSEC-09 | Certificate Pinning | Low | `suite/smtpclient_security/test.csec-09.certificate-pinning.ts` |
|
||||||
|
| CSEC-10 | Injection Attack Prevention | High | `suite/smtpclient_security/test.csec-10.injection-prevention.ts` |
|
||||||
|
|
||||||
|
### 14. Client Error Handling (CERR)
|
||||||
|
|
||||||
|
Tests for how the client handles various error conditions.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| CERR-01 | 4xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-01.4xx-errors.ts` |
|
||||||
|
| CERR-02 | 5xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-02.5xx-errors.ts` |
|
||||||
|
| CERR-03 | Network Failure Recovery | High | `suite/smtpclient_error-handling/test.cerr-03.network-failures.ts` |
|
||||||
|
| CERR-04 | Timeout Recovery | High | `suite/smtpclient_error-handling/test.cerr-04.timeout-recovery.ts` |
|
||||||
|
| CERR-05 | Retry Logic with Backoff | High | `suite/smtpclient_error-handling/test.cerr-05.retry-backoff.ts` |
|
||||||
|
| CERR-06 | Greylisting Handling | Medium | `suite/smtpclient_error-handling/test.cerr-06.greylisting.ts` |
|
||||||
|
| CERR-07 | Rate Limit Response Handling | High | `suite/smtpclient_error-handling/test.cerr-07.rate-limits.ts` |
|
||||||
|
| CERR-08 | Malformed Server Response | Medium | `suite/smtpclient_error-handling/test.cerr-08.malformed-responses.ts` |
|
||||||
|
| CERR-09 | Connection Drop During Transfer | High | `suite/smtpclient_error-handling/test.cerr-09.connection-drops.ts` |
|
||||||
|
| CERR-10 | Authentication Failure Handling | High | `suite/smtpclient_error-handling/test.cerr-10.auth-failures.ts` |
|
||||||
|
|
||||||
|
### 15. Client Performance (CPERF)
|
||||||
|
|
||||||
|
Tests for client performance characteristics and optimization.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|----------|-------------------------------------------|----------|----------------|
|
||||||
|
| CPERF-01 | Bulk Email Sending | High | `suite/smtpclient_performance/test.cperf-01.bulk-sending.ts` |
|
||||||
|
| CPERF-02 | Connection Pool Efficiency | High | `suite/smtpclient_performance/test.cperf-02.pool-efficiency.ts` |
|
||||||
|
| CPERF-03 | Memory Usage Under Load | High | `suite/smtpclient_performance/test.cperf-03.memory-usage.ts` |
|
||||||
|
| CPERF-04 | CPU Usage Optimization | Medium | `suite/smtpclient_performance/test.cperf-04.cpu-optimization.ts` |
|
||||||
|
| CPERF-05 | Parallel Sending Performance | High | `suite/smtpclient_performance/test.cperf-05.parallel-sending.ts` |
|
||||||
|
| CPERF-06 | Large Attachment Handling | Medium | `suite/smtpclient_performance/test.cperf-06.large-attachments.ts` |
|
||||||
|
| CPERF-07 | Queue Management | High | `suite/smtpclient_performance/test.cperf-07.queue-management.ts` |
|
||||||
|
| CPERF-08 | DNS Caching Efficiency | Medium | `suite/smtpclient_performance/test.cperf-08.dns-caching.ts` |
|
||||||
|
|
||||||
|
### 16. Client Reliability (CREL)
|
||||||
|
|
||||||
|
Tests for client reliability and resilience.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| CREL-01 | Long Running Stability | High | `suite/smtpclient_reliability/test.crel-01.long-running.ts` |
|
||||||
|
| CREL-02 | Failover to Backup MX | High | `suite/smtpclient_reliability/test.crel-02.mx-failover.ts` |
|
||||||
|
| CREL-03 | Queue Persistence | High | `suite/smtpclient_reliability/test.crel-03.queue-persistence.ts` |
|
||||||
|
| CREL-04 | Crash Recovery | High | `suite/smtpclient_reliability/test.crel-04.crash-recovery.ts` |
|
||||||
|
| CREL-05 | Memory Leak Prevention | High | `suite/smtpclient_reliability/test.crel-05.memory-leaks.ts` |
|
||||||
|
| CREL-06 | Concurrent Operation Safety | High | `suite/smtpclient_reliability/test.crel-06.concurrency-safety.ts` |
|
||||||
|
| CREL-07 | Resource Cleanup | Medium | `suite/smtpclient_reliability/test.crel-07.resource-cleanup.ts` |
|
||||||
|
|
||||||
|
### 17. Client Edge Cases (CEDGE)
|
||||||
|
|
||||||
|
Tests for unusual scenarios and edge cases.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|----------|-------------------------------------------|----------|----------------|
|
||||||
|
| CEDGE-01 | Extremely Slow Server Response | Medium | `suite/smtpclient_edge-cases/test.cedge-01.slow-server.ts` |
|
||||||
|
| CEDGE-02 | Server Sending Invalid UTF-8 | Low | `suite/smtpclient_edge-cases/test.cedge-02.invalid-utf8.ts` |
|
||||||
|
| CEDGE-03 | Extremely Large Recipients List | Medium | `suite/smtpclient_edge-cases/test.cedge-03.large-recipient-list.ts` |
|
||||||
|
| CEDGE-04 | Zero-Byte Attachments | Low | `suite/smtpclient_edge-cases/test.cedge-04.zero-byte-attachments.ts` |
|
||||||
|
| CEDGE-05 | Server Disconnect Mid-Command | High | `suite/smtpclient_edge-cases/test.cedge-05.mid-command-disconnect.ts` |
|
||||||
|
| CEDGE-06 | Unusual Server Banners | Low | `suite/smtpclient_edge-cases/test.cedge-06.unusual-banners.ts` |
|
||||||
|
| CEDGE-07 | Non-Standard Port Connections | Medium | `suite/smtpclient_edge-cases/test.cedge-07.non-standard-ports.ts` |
|
||||||
|
|
||||||
|
### 18. Client RFC Compliance (CRFC)
|
||||||
|
|
||||||
|
Tests for RFC compliance from the client perspective.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| CRFC-01 | RFC 5321 Client Requirements | High | `suite/smtpclient_rfc-compliance/test.crfc-01.rfc5321-client.ts` |
|
||||||
|
| CRFC-02 | RFC 5322 Message Format | High | `suite/smtpclient_rfc-compliance/test.crfc-02.rfc5322-format.ts` |
|
||||||
|
| CRFC-03 | RFC 2045-2049 MIME Compliance | High | `suite/smtpclient_rfc-compliance/test.crfc-03.mime-compliance.ts` |
|
||||||
|
| CRFC-04 | RFC 4954 AUTH Extension | High | `suite/smtpclient_rfc-compliance/test.crfc-04.auth-extension.ts` |
|
||||||
|
| CRFC-05 | RFC 3207 STARTTLS | High | `suite/smtpclient_rfc-compliance/test.crfc-05.starttls.ts` |
|
||||||
|
| CRFC-06 | RFC 1870 SIZE Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-06.size-extension.ts` |
|
||||||
|
| CRFC-07 | RFC 6152 8BITMIME Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-07.8bitmime.ts` |
|
||||||
|
| CRFC-08 | RFC 2920 Command Pipelining | Medium | `suite/smtpclient_rfc-compliance/test.crfc-08.pipelining.ts` |
|
||||||
|
|
||||||
|
## Running SMTP Client Tests
|
||||||
|
|
||||||
|
### Run All Client Tests
|
||||||
|
```bash
|
||||||
|
cd dcrouter
|
||||||
|
pnpm test test/suite/smtpclient_*
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Specific Client Test Category
|
||||||
|
```bash
|
||||||
|
# Run all client connection tests
|
||||||
|
pnpm test test/suite/smtpclient_connection
|
||||||
|
|
||||||
|
# Run all client security tests
|
||||||
|
pnpm test test/suite/smtpclient_security
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Single Client Test File
|
||||||
|
```bash
|
||||||
|
# Run basic TCP connection test
|
||||||
|
tsx test/suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts
|
||||||
|
|
||||||
|
# Run AUTH mechanisms test
|
||||||
|
tsx test/suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Performance Benchmarks
|
||||||
|
|
||||||
|
Expected performance metrics for production-ready SMTP client:
|
||||||
|
- **Sending Rate**: >100 emails per second (with connection pooling)
|
||||||
|
- **Connection Pool Size**: 10-50 concurrent connections efficiently managed
|
||||||
|
- **Memory Usage**: <500MB for 1000 concurrent email operations
|
||||||
|
- **DNS Cache Hit Rate**: >90% for repeated domains
|
||||||
|
- **Retry Success Rate**: >95% for temporary failures
|
||||||
|
- **Large Attachment Support**: Files up to 25MB without performance degradation
|
||||||
|
- **Queue Processing**: >1000 emails/minute with persistent queue
|
||||||
|
|
||||||
|
## Client Security Requirements
|
||||||
|
|
||||||
|
All client security tests must pass for production deployment:
|
||||||
|
- **TLS Support**: TLS 1.2+ required, TLS 1.3 preferred
|
||||||
|
- **Authentication**: Support for LOGIN, PLAIN, CRAM-MD5, OAuth2
|
||||||
|
- **Certificate Validation**: Proper certificate chain validation
|
||||||
|
- **DKIM Signing**: Automatic DKIM signature generation
|
||||||
|
- **Credential Security**: No plaintext password storage
|
||||||
|
- **Injection Prevention**: Protection against header/command injection
|
||||||
|
|
||||||
|
## Client Production Readiness Criteria
|
||||||
|
|
||||||
|
### Production Gate 1: Core Functionality (>95% tests passing)
|
||||||
|
- Basic connection establishment
|
||||||
|
- Command execution and response parsing
|
||||||
|
- Email composition and sending
|
||||||
|
- Error handling and recovery
|
||||||
|
|
||||||
|
### Production Gate 2: Advanced Features (>90% tests passing)
|
||||||
|
- Connection pooling and reuse
|
||||||
|
- Authentication mechanisms
|
||||||
|
- TLS/STARTTLS support
|
||||||
|
- Retry logic and resilience
|
||||||
|
|
||||||
|
### Production Gate 3: Enterprise Ready (>85% tests passing)
|
||||||
|
- High-volume sending capabilities
|
||||||
|
- Advanced security features
|
||||||
|
- Full RFC compliance
|
||||||
|
- Performance under load
|
||||||
|
|
||||||
|
## Key Differences: Server vs Client Tests
|
||||||
|
|
||||||
|
| Aspect | Server Tests | Client Tests |
|
||||||
|
|--------|--------------|--------------|
|
||||||
|
| **Focus** | Accepting connections, processing commands | Making connections, sending commands |
|
||||||
|
| **Security** | Validating incoming data, enforcing policies | Protecting credentials, validating servers |
|
||||||
|
| **Performance** | Handling many clients concurrently | Efficient bulk sending, connection reuse |
|
||||||
|
| **Reliability** | Staying up under attack/load | Retrying failures, handling timeouts |
|
||||||
|
| **RFC Compliance** | Server MUST requirements | Client MUST requirements |
|
||||||
|
|
||||||
|
## Test Implementation Priority
|
||||||
|
|
||||||
|
1. **Critical** (implement first):
|
||||||
|
- Basic connection and command sending
|
||||||
|
- Authentication mechanisms
|
||||||
|
- Error handling and retry logic
|
||||||
|
- TLS/Security features
|
||||||
|
|
||||||
|
2. **High Priority** (implement second):
|
||||||
|
- Connection pooling
|
||||||
|
- Email composition and MIME
|
||||||
|
- Performance optimization
|
||||||
|
- RFC compliance
|
||||||
|
|
||||||
|
3. **Medium Priority** (implement third):
|
||||||
|
- Advanced features (OAuth2, etc.)
|
||||||
|
- Edge case handling
|
||||||
|
- Extended performance tests
|
||||||
|
- Additional RFC extensions
|
||||||
|
|
||||||
|
4. **Low Priority** (implement last):
|
||||||
|
- Proxy support
|
||||||
|
- Certificate pinning
|
||||||
|
- Unusual scenarios
|
||||||
|
- Optional RFC features
|
||||||
|
|
||||||
207
test/test.bouncemanager.ts
Normal file
207
test/test.bouncemanager.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { BounceManager, BounceType, BounceCategory } from '../ts/mail/core/classes.bouncemanager.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
tap.test('setup - start Rust security bridge', async () => {
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
const ok = await bridge.start();
|
||||||
|
expect(ok).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the BounceManager class
|
||||||
|
*/
|
||||||
|
tap.test('BounceManager - should be instantiable', async () => {
|
||||||
|
const bounceManager = new BounceManager();
|
||||||
|
expect(bounceManager).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BounceManager - should process basic bounce categories', async () => {
|
||||||
|
const bounceManager = new BounceManager();
|
||||||
|
|
||||||
|
// Test hard bounce detection
|
||||||
|
const hardBounce = await bounceManager.processBounce({
|
||||||
|
recipient: 'invalid@example.com',
|
||||||
|
sender: 'sender@example.com',
|
||||||
|
smtpResponse: 'user unknown',
|
||||||
|
domain: 'example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hardBounce.bounceCategory).toEqual(BounceCategory.HARD);
|
||||||
|
|
||||||
|
// Test soft bounce detection
|
||||||
|
const softBounce = await bounceManager.processBounce({
|
||||||
|
recipient: 'valid@example.com',
|
||||||
|
sender: 'sender@example.com',
|
||||||
|
smtpResponse: 'server unavailable',
|
||||||
|
domain: 'example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(softBounce.bounceCategory).toEqual(BounceCategory.SOFT);
|
||||||
|
|
||||||
|
// Test auto-response detection
|
||||||
|
const autoResponse = await bounceManager.processBounce({
|
||||||
|
recipient: 'away@example.com',
|
||||||
|
sender: 'sender@example.com',
|
||||||
|
smtpResponse: 'auto-reply: out of office',
|
||||||
|
domain: 'example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(autoResponse.bounceCategory).toEqual(BounceCategory.AUTO_RESPONSE);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BounceManager - should add and check suppression list entries', async () => {
|
||||||
|
const bounceManager = new BounceManager();
|
||||||
|
|
||||||
|
// Add to suppression list permanently
|
||||||
|
bounceManager.addToSuppressionList('permanent@example.com', 'Test hard bounce', undefined);
|
||||||
|
|
||||||
|
// Add to suppression list temporarily (5 seconds)
|
||||||
|
const expireTime = Date.now() + 5000;
|
||||||
|
bounceManager.addToSuppressionList('temporary@example.com', 'Test soft bounce', expireTime);
|
||||||
|
|
||||||
|
// Check suppression status
|
||||||
|
expect(bounceManager.isEmailSuppressed('permanent@example.com')).toEqual(true);
|
||||||
|
expect(bounceManager.isEmailSuppressed('temporary@example.com')).toEqual(true);
|
||||||
|
expect(bounceManager.isEmailSuppressed('notsuppressed@example.com')).toEqual(false);
|
||||||
|
|
||||||
|
// Get suppression info
|
||||||
|
const info = bounceManager.getSuppressionInfo('permanent@example.com');
|
||||||
|
expect(info).toBeTruthy();
|
||||||
|
expect(info.reason).toEqual('Test hard bounce');
|
||||||
|
expect(info.expiresAt).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify temporary suppression info
|
||||||
|
const tempInfo = bounceManager.getSuppressionInfo('temporary@example.com');
|
||||||
|
expect(tempInfo).toBeTruthy();
|
||||||
|
expect(tempInfo.reason).toEqual('Test soft bounce');
|
||||||
|
expect(tempInfo.expiresAt).toEqual(expireTime);
|
||||||
|
|
||||||
|
// Wait for expiration (6 seconds)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 6000));
|
||||||
|
|
||||||
|
// Verify permanent suppression is still active
|
||||||
|
expect(bounceManager.isEmailSuppressed('permanent@example.com')).toEqual(true);
|
||||||
|
|
||||||
|
// Verify temporary suppression has expired
|
||||||
|
expect(bounceManager.isEmailSuppressed('temporary@example.com')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BounceManager - should process SMTP failures correctly', async () => {
|
||||||
|
const bounceManager = new BounceManager();
|
||||||
|
|
||||||
|
const result = await bounceManager.processSmtpFailure(
|
||||||
|
'recipient@example.com',
|
||||||
|
'550 5.1.1 User unknown',
|
||||||
|
{
|
||||||
|
sender: 'sender@example.com',
|
||||||
|
statusCode: '550'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.bounceType).toEqual(BounceType.INVALID_RECIPIENT);
|
||||||
|
expect(result.bounceCategory).toEqual(BounceCategory.HARD);
|
||||||
|
|
||||||
|
// Check that the email was added to the suppression list
|
||||||
|
expect(bounceManager.isEmailSuppressed('recipient@example.com')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BounceManager - should process bounce emails correctly', async () => {
|
||||||
|
const bounceManager = new BounceManager();
|
||||||
|
|
||||||
|
// Create a mock bounce email
|
||||||
|
const bounceEmail = new Email({
|
||||||
|
from: 'mailer-daemon@example.com',
|
||||||
|
subject: 'Mail delivery failed: returning message to sender',
|
||||||
|
text: `
|
||||||
|
This message was created automatically by mail delivery software.
|
||||||
|
|
||||||
|
A message that you sent could not be delivered to one or more of its recipients.
|
||||||
|
The following address(es) failed:
|
||||||
|
|
||||||
|
recipient@example.com
|
||||||
|
mailbox is full
|
||||||
|
|
||||||
|
------ This is a copy of the message, including all the headers. ------
|
||||||
|
|
||||||
|
Original-Recipient: rfc822;recipient@example.com
|
||||||
|
Final-Recipient: rfc822;recipient@example.com
|
||||||
|
Status: 5.2.2
|
||||||
|
diagnostic-code: smtp; 552 5.2.2 Mailbox full
|
||||||
|
`,
|
||||||
|
to: 'sender@example.com' // Bounce emails are typically sent back to the original sender
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await bounceManager.processBounceEmail(bounceEmail);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.bounceType).toEqual(BounceType.MAILBOX_FULL);
|
||||||
|
expect(result.bounceCategory).toEqual(BounceCategory.HARD);
|
||||||
|
expect(result.recipient).toEqual('recipient@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BounceManager - should handle retries for soft bounces', async () => {
|
||||||
|
const bounceManager = new BounceManager({
|
||||||
|
retryStrategy: {
|
||||||
|
maxRetries: 2,
|
||||||
|
initialDelay: 100, // 100ms for test
|
||||||
|
maxDelay: 1000,
|
||||||
|
backoffFactor: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// First attempt
|
||||||
|
const result1 = await bounceManager.processBounce({
|
||||||
|
recipient: 'retry@example.com',
|
||||||
|
sender: 'sender@example.com',
|
||||||
|
bounceType: BounceType.SERVER_UNAVAILABLE,
|
||||||
|
bounceCategory: BounceCategory.SOFT,
|
||||||
|
domain: 'example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email should be suppressed temporarily
|
||||||
|
expect(bounceManager.isEmailSuppressed('retry@example.com')).toEqual(true);
|
||||||
|
expect(result1.retryCount).toEqual(1);
|
||||||
|
expect(result1.nextRetryTime).toBeGreaterThan(Date.now());
|
||||||
|
|
||||||
|
// Second attempt
|
||||||
|
const result2 = await bounceManager.processBounce({
|
||||||
|
recipient: 'retry@example.com',
|
||||||
|
sender: 'sender@example.com',
|
||||||
|
bounceType: BounceType.SERVER_UNAVAILABLE,
|
||||||
|
bounceCategory: BounceCategory.SOFT,
|
||||||
|
domain: 'example.com',
|
||||||
|
retryCount: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result2.retryCount).toEqual(2);
|
||||||
|
|
||||||
|
// Third attempt (should convert to hard bounce)
|
||||||
|
const result3 = await bounceManager.processBounce({
|
||||||
|
recipient: 'retry@example.com',
|
||||||
|
sender: 'sender@example.com',
|
||||||
|
bounceType: BounceType.SERVER_UNAVAILABLE,
|
||||||
|
bounceCategory: BounceCategory.SOFT,
|
||||||
|
domain: 'example.com',
|
||||||
|
retryCount: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should now be a hard bounce after max retries
|
||||||
|
expect(result3.bounceCategory).toEqual(BounceCategory.HARD);
|
||||||
|
|
||||||
|
// Email should be suppressed permanently
|
||||||
|
expect(bounceManager.isEmailSuppressed('retry@example.com')).toEqual(true);
|
||||||
|
const info = bounceManager.getSuppressionInfo('retry@example.com');
|
||||||
|
expect(info.expiresAt).toBeUndefined(); // Permanent
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop Rust security bridge', async () => {
|
||||||
|
await RustSecurityBridge.getInstance().stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
175
test/test.config.md
Normal file
175
test/test.config.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# DCRouter Test Configuration
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Run All Tests
|
||||||
|
```bash
|
||||||
|
cd dcrouter
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Specific Category
|
||||||
|
```bash
|
||||||
|
# Run all connection tests
|
||||||
|
tsx test/run-category.ts connection
|
||||||
|
|
||||||
|
# Run all security tests
|
||||||
|
tsx test/run-category.ts security
|
||||||
|
|
||||||
|
# Run all performance tests
|
||||||
|
tsx test/run-category.ts performance
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Individual Test File
|
||||||
|
```bash
|
||||||
|
# Run TLS connection test
|
||||||
|
tsx test/suite/connection/test.tls-connection.ts
|
||||||
|
|
||||||
|
# Run authentication test
|
||||||
|
tsx test/suite/security/test.authentication.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Tests with Verbose Output
|
||||||
|
```bash
|
||||||
|
# All tests with verbose logging
|
||||||
|
pnpm test -- --verbose
|
||||||
|
|
||||||
|
# Individual test with verbose
|
||||||
|
tsx test/suite/connection/test.tls-connection.ts --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Server Configuration
|
||||||
|
|
||||||
|
Each test file starts its own SMTP server with specific configuration. Common configurations:
|
||||||
|
|
||||||
|
### Basic Server
|
||||||
|
```typescript
|
||||||
|
const testServer = await startTestServer({
|
||||||
|
port: 2525,
|
||||||
|
hostname: 'localhost'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### TLS-Enabled Server
|
||||||
|
```typescript
|
||||||
|
const testServer = await startTestServer({
|
||||||
|
port: 2525,
|
||||||
|
hostname: 'localhost',
|
||||||
|
tlsEnabled: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authenticated Server
|
||||||
|
```typescript
|
||||||
|
const testServer = await startTestServer({
|
||||||
|
port: 2525,
|
||||||
|
hostname: 'localhost',
|
||||||
|
authRequired: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### High-Performance Server
|
||||||
|
```typescript
|
||||||
|
const testServer = await startTestServer({
|
||||||
|
port: 2525,
|
||||||
|
hostname: 'localhost',
|
||||||
|
maxConnections: 1000,
|
||||||
|
size: 50 * 1024 * 1024 // 50MB
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Port Allocation
|
||||||
|
|
||||||
|
Tests use different ports to avoid conflicts:
|
||||||
|
- Connection tests: 2525-2530
|
||||||
|
- Command tests: 2531-2540
|
||||||
|
- Email processing: 2541-2550
|
||||||
|
- Security tests: 2551-2560
|
||||||
|
- Performance tests: 2561-2570
|
||||||
|
- Edge cases: 2571-2580
|
||||||
|
- RFC compliance: 2581-2590
|
||||||
|
|
||||||
|
## Test Utilities
|
||||||
|
|
||||||
|
### Server Lifecycle
|
||||||
|
All tests follow this pattern:
|
||||||
|
```typescript
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||||
|
|
||||||
|
let testServer;
|
||||||
|
|
||||||
|
tap.test('setup', async () => {
|
||||||
|
testServer = await startTestServer({ port: 2525 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Your tests here...
|
||||||
|
|
||||||
|
tap.test('cleanup', async () => {
|
||||||
|
await stopTestServer(testServer);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
### SMTP Client Testing
|
||||||
|
```typescript
|
||||||
|
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||||
|
|
||||||
|
const client = createTestSmtpClient({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 2525
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Low-Level SMTP Testing
|
||||||
|
```typescript
|
||||||
|
import { connectToSmtp, sendSmtpCommand } from '../../helpers/test.utils.js';
|
||||||
|
|
||||||
|
const socket = await connectToSmtp('localhost', 2525);
|
||||||
|
const response = await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Benchmarks
|
||||||
|
|
||||||
|
Expected minimums for production:
|
||||||
|
- Throughput: >10 emails/second
|
||||||
|
- Concurrent connections: >100
|
||||||
|
- Memory increase: <2% under load
|
||||||
|
- Connection time: <5000ms
|
||||||
|
- Error rate: <5%
|
||||||
|
|
||||||
|
## Debugging Failed Tests
|
||||||
|
|
||||||
|
### Enable Verbose Logging
|
||||||
|
```bash
|
||||||
|
DEBUG=* tsx test/suite/connection/test.tls-connection.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Server Logs
|
||||||
|
Tests output server logs to console. Look for:
|
||||||
|
- 🚀 Server start messages
|
||||||
|
- 📧 Email processing logs
|
||||||
|
- ❌ Error messages
|
||||||
|
- ✅ Success confirmations
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Port Already in Use**
|
||||||
|
- Tests use unique ports
|
||||||
|
- Check for orphaned processes: `lsof -i :2525`
|
||||||
|
- Kill process: `kill -9 <PID>`
|
||||||
|
|
||||||
|
2. **TLS Certificate Errors**
|
||||||
|
- Tests use self-signed certificates
|
||||||
|
- Production should use real certificates
|
||||||
|
|
||||||
|
3. **Timeout Errors**
|
||||||
|
- Increase timeout in test configuration
|
||||||
|
- Check network connectivity
|
||||||
|
- Verify server started successfully
|
||||||
|
|
||||||
|
4. **Authentication Failures**
|
||||||
|
- Test servers may not validate credentials
|
||||||
|
- Check authRequired configuration
|
||||||
|
- Verify AUTH mechanisms supported
|
||||||
276
test/test.contentscanner.ts
Normal file
276
test/test.contentscanner.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
tap.test('setup - start Rust security bridge', async () => {
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
const ok = await bridge.start();
|
||||||
|
expect(ok).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test instantiation
|
||||||
|
tap.test('ContentScanner - should be instantiable', async () => {
|
||||||
|
const scanner = ContentScanner.getInstance({
|
||||||
|
scanBody: true,
|
||||||
|
scanSubject: true,
|
||||||
|
scanAttachments: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scanner).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test singleton pattern
|
||||||
|
tap.test('ContentScanner - should use singleton pattern', async () => {
|
||||||
|
const scanner1 = ContentScanner.getInstance();
|
||||||
|
const scanner2 = ContentScanner.getInstance();
|
||||||
|
|
||||||
|
// Both instances should be the same object
|
||||||
|
expect(scanner1 === scanner2).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test clean email can be correctly distinguished from high-risk email
|
||||||
|
tap.test('ContentScanner - should distinguish between clean and suspicious emails', async () => {
|
||||||
|
// Create an instance with a higher minimum threat score
|
||||||
|
const scanner = new ContentScanner({
|
||||||
|
minThreatScore: 50 // Higher threshold to consider clean
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a truly clean email with no potentially sensitive data patterns
|
||||||
|
const cleanEmail = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Project Update',
|
||||||
|
text: 'The project is on track. Let me know if you have questions.',
|
||||||
|
html: '<p>The project is on track. Let me know if you have questions.</p>'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a highly suspicious email
|
||||||
|
const suspiciousEmail = new Email({
|
||||||
|
from: 'admin@bank-fake.com',
|
||||||
|
to: 'victim@example.com',
|
||||||
|
subject: 'URGENT: Your account needs verification now!',
|
||||||
|
text: 'Click here to verify your account or it will be suspended: https://bit.ly/12345',
|
||||||
|
html: '<p>Click here to verify your account or it will be suspended: <a href="https://bit.ly/12345">click here</a></p>'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test both emails
|
||||||
|
const cleanResult = await scanner.scanEmail(cleanEmail);
|
||||||
|
const suspiciousResult = await scanner.scanEmail(suspiciousEmail);
|
||||||
|
|
||||||
|
console.log('Clean vs Suspicious results:', {
|
||||||
|
cleanScore: cleanResult.threatScore,
|
||||||
|
suspiciousScore: suspiciousResult.threatScore
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the scanner can distinguish between them
|
||||||
|
// Suspicious email should have a significantly higher score
|
||||||
|
expect(suspiciousResult.threatScore > cleanResult.threatScore + 40).toEqual(true);
|
||||||
|
|
||||||
|
// Verify clean email scans all expected elements
|
||||||
|
expect(cleanResult.scannedElements.length > 0).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test phishing detection in subject
|
||||||
|
tap.test('ContentScanner - should detect phishing in subject', async () => {
|
||||||
|
// Create a dedicated scanner for this test
|
||||||
|
const scanner = new ContentScanner({
|
||||||
|
scanSubject: true,
|
||||||
|
scanBody: true,
|
||||||
|
scanAttachments: false,
|
||||||
|
customRules: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'security@bank-account-verify.com',
|
||||||
|
to: 'victim@example.com',
|
||||||
|
subject: 'URGENT: Verify your bank account details immediately',
|
||||||
|
text: 'Your account will be suspended. Please verify your details.',
|
||||||
|
html: '<p>Your account will be suspended. Please verify your details.</p>'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.scanEmail(email);
|
||||||
|
|
||||||
|
console.log('Phishing email scan result:', result);
|
||||||
|
|
||||||
|
// We only care that it detected something suspicious
|
||||||
|
expect(result.threatScore >= 20).toEqual(true);
|
||||||
|
|
||||||
|
// Check if any threat was detected (specific type may vary)
|
||||||
|
expect(result.threatType).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test malware indicators in body
|
||||||
|
tap.test('ContentScanner - should detect malware indicators in body', async () => {
|
||||||
|
const scanner = ContentScanner.getInstance();
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'invoice@company.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Your invoice',
|
||||||
|
text: 'Please see the attached invoice. You need to enable macros to view this document properly.',
|
||||||
|
html: '<p>Please see the attached invoice. You need to enable macros to view this document properly.</p>'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.scanEmail(email);
|
||||||
|
|
||||||
|
expect(result.isClean).toEqual(false);
|
||||||
|
expect(result.threatType === ThreatCategory.MALWARE || result.threatType).toBeTruthy();
|
||||||
|
expect(result.threatScore >= 30).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test suspicious link detection
|
||||||
|
tap.test('ContentScanner - should detect suspicious links', async () => {
|
||||||
|
const scanner = ContentScanner.getInstance();
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'newsletter@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Weekly Newsletter',
|
||||||
|
text: 'Check our latest offer at https://bit.ly/2x3F5 and https://t.co/abc123',
|
||||||
|
html: '<p>Check our latest offer at <a href="https://bit.ly/2x3F5">here</a> and <a href="https://t.co/abc123">here</a></p>'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.scanEmail(email);
|
||||||
|
|
||||||
|
expect(result.isClean).toEqual(false);
|
||||||
|
expect(result.threatType).toEqual(ThreatCategory.SUSPICIOUS_LINK);
|
||||||
|
expect(result.threatScore >= 30).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test script injection detection
|
||||||
|
tap.test('ContentScanner - should detect script injection', async () => {
|
||||||
|
const scanner = ContentScanner.getInstance();
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'newsletter@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Newsletter',
|
||||||
|
text: 'Check our website',
|
||||||
|
html: '<p>Check our website</p><script>document.cookie="session="+localStorage.getItem("token");</script>'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.scanEmail(email);
|
||||||
|
|
||||||
|
expect(result.isClean).toEqual(false);
|
||||||
|
expect(result.threatType).toEqual(ThreatCategory.XSS);
|
||||||
|
expect(result.threatScore >= 40).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test executable attachment detection
|
||||||
|
tap.test('ContentScanner - should detect executable attachments', async () => {
|
||||||
|
const scanner = ContentScanner.getInstance();
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Software Update',
|
||||||
|
text: 'Please install the attached software update.',
|
||||||
|
attachments: [{
|
||||||
|
filename: 'update.exe',
|
||||||
|
content: Buffer.from('MZ...fake executable content...'),
|
||||||
|
contentType: 'application/octet-stream'
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.scanEmail(email);
|
||||||
|
|
||||||
|
expect(result.isClean).toEqual(false);
|
||||||
|
expect(result.threatType).toEqual(ThreatCategory.EXECUTABLE);
|
||||||
|
expect(result.threatScore >= 70).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test macro document detection
|
||||||
|
tap.test('ContentScanner - should detect macro documents', async () => {
|
||||||
|
// Create a mock Office document with macro indicators
|
||||||
|
const fakeDocContent = Buffer.from('Document content...vbaProject.bin...Auto_Open...DocumentOpen...Microsoft VBA...');
|
||||||
|
|
||||||
|
const scanner = ContentScanner.getInstance();
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Financial Report',
|
||||||
|
text: 'Please review the attached financial report.',
|
||||||
|
attachments: [{
|
||||||
|
filename: 'report.docm',
|
||||||
|
content: fakeDocContent,
|
||||||
|
contentType: 'application/vnd.ms-word.document.macroEnabled.12'
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.scanEmail(email);
|
||||||
|
|
||||||
|
expect(result.isClean).toEqual(false);
|
||||||
|
expect(result.threatType).toEqual(ThreatCategory.MALICIOUS_MACRO);
|
||||||
|
expect(result.threatScore >= 60).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test compound threat detection (multiple indicators)
|
||||||
|
tap.test('ContentScanner - should detect compound threats', async () => {
|
||||||
|
const scanner = ContentScanner.getInstance();
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'security@bank-verify.com',
|
||||||
|
to: 'victim@example.com',
|
||||||
|
subject: 'URGENT: Verify your account details immediately',
|
||||||
|
text: 'Your account will be suspended unless you verify your details at https://bit.ly/2x3F5',
|
||||||
|
html: '<p>Your account will be suspended unless you verify your details <a href="https://bit.ly/2x3F5">here</a>.</p>',
|
||||||
|
attachments: [{
|
||||||
|
filename: 'verification.exe',
|
||||||
|
content: Buffer.from('MZ...fake executable content...'),
|
||||||
|
contentType: 'application/octet-stream'
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.scanEmail(email);
|
||||||
|
|
||||||
|
expect(result.isClean).toEqual(false);
|
||||||
|
expect(result.threatScore > 70).toEqual(true); // Should have a high score due to multiple threats
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test custom rules
|
||||||
|
tap.test('ContentScanner - should apply custom rules', async () => {
|
||||||
|
// Create a scanner with custom rules
|
||||||
|
const scanner = new ContentScanner({
|
||||||
|
customRules: [
|
||||||
|
{
|
||||||
|
pattern: /CUSTOM_PATTERN_FOR_TESTING/,
|
||||||
|
type: ThreatCategory.CUSTOM_RULE,
|
||||||
|
score: 50,
|
||||||
|
description: 'Custom pattern detected'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Test Custom Rule',
|
||||||
|
text: 'This message contains CUSTOM_PATTERN_FOR_TESTING that should be detected.'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.scanEmail(email);
|
||||||
|
|
||||||
|
expect(result.isClean).toEqual(false);
|
||||||
|
expect(result.threatType).toEqual(ThreatCategory.CUSTOM_RULE);
|
||||||
|
expect(result.threatScore >= 50).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test threat level classification
|
||||||
|
tap.test('ContentScanner - should classify threat levels correctly', async () => {
|
||||||
|
expect(ContentScanner.getThreatLevel(10)).toEqual('none');
|
||||||
|
expect(ContentScanner.getThreatLevel(25)).toEqual('low');
|
||||||
|
expect(ContentScanner.getThreatLevel(50)).toEqual('medium');
|
||||||
|
expect(ContentScanner.getThreatLevel(80)).toEqual('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop Rust security bridge', async () => {
|
||||||
|
await RustSecurityBridge.getInstance().stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
140
test/test.dns-server-config.ts
Normal file
140
test/test.dns-server-config.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test DNS server configuration and record registration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
// Test DNS configuration
|
||||||
|
const testDnsConfig = {
|
||||||
|
udpPort: 5353, // Use non-privileged port for testing
|
||||||
|
httpsPort: 8443,
|
||||||
|
httpsKey: './test/fixtures/test-key.pem',
|
||||||
|
httpsCert: './test/fixtures/test-cert.pem',
|
||||||
|
dnssecZone: 'test.example.com',
|
||||||
|
records: [
|
||||||
|
{ name: 'test.example.com', type: 'A', value: '192.168.1.1' },
|
||||||
|
{ name: 'mail.test.example.com', type: 'A', value: '192.168.1.2' },
|
||||||
|
{ name: 'test.example.com', type: 'MX', value: '10 mail.test.example.com' },
|
||||||
|
{ name: 'test.example.com', type: 'TXT', value: 'v=spf1 a:mail.test.example.com ~all' },
|
||||||
|
{ name: 'test.example.com', type: 'NS', value: 'ns1.test.example.com' },
|
||||||
|
{ name: 'ns1.test.example.com', type: 'A', value: '192.168.1.1' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should extract records correctly', async () => {
|
||||||
|
const { records, ...dnsServerOptions } = testDnsConfig;
|
||||||
|
|
||||||
|
expect(dnsServerOptions.udpPort).toEqual(5353);
|
||||||
|
expect(dnsServerOptions.httpsPort).toEqual(8443);
|
||||||
|
expect(dnsServerOptions.dnssecZone).toEqual('test.example.com');
|
||||||
|
expect(records).toBeArray();
|
||||||
|
expect(records.length).toEqual(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should handle record parsing', async () => {
|
||||||
|
const parseDnsRecordData = (type: string, value: string): any => {
|
||||||
|
switch (type) {
|
||||||
|
case 'A':
|
||||||
|
return value;
|
||||||
|
case 'MX':
|
||||||
|
const [priority, exchange] = value.split(' ');
|
||||||
|
return { priority: parseInt(priority), exchange };
|
||||||
|
case 'TXT':
|
||||||
|
return value;
|
||||||
|
case 'NS':
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test A record parsing
|
||||||
|
const aRecord = parseDnsRecordData('A', '192.168.1.1');
|
||||||
|
expect(aRecord).toEqual('192.168.1.1');
|
||||||
|
|
||||||
|
// Test MX record parsing
|
||||||
|
const mxRecord = parseDnsRecordData('MX', '10 mail.test.example.com');
|
||||||
|
expect(mxRecord).toHaveProperty('priority', 10);
|
||||||
|
expect(mxRecord).toHaveProperty('exchange', 'mail.test.example.com');
|
||||||
|
|
||||||
|
// Test TXT record parsing
|
||||||
|
const txtRecord = parseDnsRecordData('TXT', 'v=spf1 a:mail.test.example.com ~all');
|
||||||
|
expect(txtRecord).toEqual('v=spf1 a:mail.test.example.com ~all');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should group records by domain', async () => {
|
||||||
|
const records = testDnsConfig.records;
|
||||||
|
const recordsByDomain = new Map<string, typeof records>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||||
|
if (!recordsByDomain.has(pattern)) {
|
||||||
|
recordsByDomain.set(pattern, []);
|
||||||
|
}
|
||||||
|
recordsByDomain.get(pattern)!.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check grouping
|
||||||
|
expect(recordsByDomain.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify each group has records
|
||||||
|
for (const [pattern, domainRecords] of recordsByDomain) {
|
||||||
|
expect(domainRecords.length).toBeGreaterThan(0);
|
||||||
|
console.log(`Pattern: ${pattern}, Records: ${domainRecords.length}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should extract unique record types', async () => {
|
||||||
|
const records = testDnsConfig.records;
|
||||||
|
const recordTypes = [...new Set(records.map(r => r.type))];
|
||||||
|
|
||||||
|
expect(recordTypes).toContain('A');
|
||||||
|
expect(recordTypes).toContain('MX');
|
||||||
|
expect(recordTypes).toContain('TXT');
|
||||||
|
expect(recordTypes).toContain('NS');
|
||||||
|
|
||||||
|
console.log('Unique record types:', recordTypes.join(', '));
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server - mock handler registration', async () => {
|
||||||
|
// Mock DNS server for testing
|
||||||
|
const mockDnsServer = {
|
||||||
|
handlers: new Map<string, any>(),
|
||||||
|
registerHandler: function(pattern: string, types: string[], handler: Function) {
|
||||||
|
this.handlers.set(pattern, { types, handler });
|
||||||
|
console.log(`Registered handler for pattern: ${pattern}, types: ${types.join(', ')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate record registration
|
||||||
|
const records = testDnsConfig.records;
|
||||||
|
const recordsByDomain = new Map<string, typeof records>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||||
|
if (!recordsByDomain.has(pattern)) {
|
||||||
|
recordsByDomain.set(pattern, []);
|
||||||
|
}
|
||||||
|
recordsByDomain.get(pattern)!.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register handlers
|
||||||
|
for (const [domainPattern, domainRecords] of recordsByDomain) {
|
||||||
|
const recordTypes = [...new Set(domainRecords.map(r => r.type))];
|
||||||
|
mockDnsServer.registerHandler(domainPattern, recordTypes, (question: any) => {
|
||||||
|
const matchingRecord = domainRecords.find(
|
||||||
|
r => r.name === question.name && r.type === question.type
|
||||||
|
);
|
||||||
|
return matchingRecord || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockDnsServer.handlers.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start({
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
377
test/test.email.integration.ts
Normal file
377
test/test.email.integration.ts
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { type IEmailRoute } from '../ts/mail/routing/interfaces.js';
|
||||||
|
import { EmailRouter } from '../ts/mail/routing/classes.email.router.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
|
||||||
|
tap.test('Email Integration - Route-based forwarding scenario', async () => {
|
||||||
|
// Define routes with match/action pattern
|
||||||
|
const routes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'office-relay',
|
||||||
|
priority: 100,
|
||||||
|
match: {
|
||||||
|
clientIp: '192.168.0.0/16'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forward: {
|
||||||
|
host: 'internal.mail.example.com',
|
||||||
|
port: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'company-mail',
|
||||||
|
priority: 50,
|
||||||
|
match: {
|
||||||
|
recipients: '*@mycompany.com'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
process: {
|
||||||
|
scan: true,
|
||||||
|
dkim: true,
|
||||||
|
queue: 'normal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'admin-priority',
|
||||||
|
priority: 90,
|
||||||
|
match: {
|
||||||
|
recipients: 'admin@mycompany.com'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
process: {
|
||||||
|
scan: true,
|
||||||
|
dkim: true,
|
||||||
|
queue: 'priority'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'spam-reject',
|
||||||
|
priority: 80,
|
||||||
|
match: {
|
||||||
|
senders: '*@spammer.com'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'reject',
|
||||||
|
reject: {
|
||||||
|
code: 550,
|
||||||
|
message: 'Sender blocked'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'default-reject',
|
||||||
|
priority: 1,
|
||||||
|
match: {
|
||||||
|
recipients: '*'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'reject',
|
||||||
|
reject: {
|
||||||
|
code: 550,
|
||||||
|
message: 'Relay denied'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create email router with routes
|
||||||
|
const emailRouter = new EmailRouter(routes);
|
||||||
|
|
||||||
|
// Test route priority sorting
|
||||||
|
const sortedRoutes = emailRouter.getRoutes();
|
||||||
|
expect(sortedRoutes[0].name).toEqual('office-relay'); // Highest priority (100)
|
||||||
|
expect(sortedRoutes[1].name).toEqual('admin-priority'); // Priority 90
|
||||||
|
expect(sortedRoutes[2].name).toEqual('spam-reject'); // Priority 80
|
||||||
|
expect(sortedRoutes[sortedRoutes.length - 1].name).toEqual('default-reject'); // Lowest priority (1)
|
||||||
|
|
||||||
|
// Test route evaluation with different scenarios
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
description: 'Office relay scenario (IP-based)',
|
||||||
|
email: new Email({
|
||||||
|
from: 'user@external.com',
|
||||||
|
to: 'anyone@anywhere.com',
|
||||||
|
subject: 'Test from office',
|
||||||
|
text: 'Test message'
|
||||||
|
}),
|
||||||
|
session: {
|
||||||
|
id: 'test-1',
|
||||||
|
remoteAddress: '192.168.1.100'
|
||||||
|
},
|
||||||
|
expectedRoute: 'office-relay'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Admin priority mail',
|
||||||
|
email: new Email({
|
||||||
|
from: 'user@external.com',
|
||||||
|
to: 'admin@mycompany.com',
|
||||||
|
subject: 'Important admin message',
|
||||||
|
text: 'Admin message content'
|
||||||
|
}),
|
||||||
|
session: {
|
||||||
|
id: 'test-2',
|
||||||
|
remoteAddress: '10.0.0.1'
|
||||||
|
},
|
||||||
|
expectedRoute: 'admin-priority'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Company mail processing',
|
||||||
|
email: new Email({
|
||||||
|
from: 'partner@partner.com',
|
||||||
|
to: 'sales@mycompany.com',
|
||||||
|
subject: 'Business proposal',
|
||||||
|
text: 'Business content'
|
||||||
|
}),
|
||||||
|
session: {
|
||||||
|
id: 'test-3',
|
||||||
|
remoteAddress: '203.0.113.1'
|
||||||
|
},
|
||||||
|
expectedRoute: 'company-mail'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Spam rejection',
|
||||||
|
email: new Email({
|
||||||
|
from: 'bad@spammer.com',
|
||||||
|
to: 'victim@mycompany.com',
|
||||||
|
subject: 'Spam message',
|
||||||
|
text: 'Spam content'
|
||||||
|
}),
|
||||||
|
session: {
|
||||||
|
id: 'test-4',
|
||||||
|
remoteAddress: '203.0.113.2'
|
||||||
|
},
|
||||||
|
expectedRoute: 'spam-reject'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Default rejection',
|
||||||
|
email: new Email({
|
||||||
|
from: 'unknown@unknown.com',
|
||||||
|
to: 'random@random.com',
|
||||||
|
subject: 'Random message',
|
||||||
|
text: 'Random content'
|
||||||
|
}),
|
||||||
|
session: {
|
||||||
|
id: 'test-5',
|
||||||
|
remoteAddress: '203.0.113.3'
|
||||||
|
},
|
||||||
|
expectedRoute: 'default-reject'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
const context = {
|
||||||
|
email: testCase.email,
|
||||||
|
session: testCase.session as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchedRoute = await emailRouter.evaluateRoutes(context);
|
||||||
|
expect(matchedRoute).not.toEqual(null);
|
||||||
|
expect(matchedRoute?.name).toEqual(testCase.expectedRoute);
|
||||||
|
|
||||||
|
console.log(`✓ ${testCase.description}: Matched route '${matchedRoute?.name}'`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Email Integration - CIDR IP matching', async () => {
|
||||||
|
const routes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'internal-network',
|
||||||
|
match: { clientIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] },
|
||||||
|
action: { type: 'deliver' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'specific-subnet',
|
||||||
|
priority: 10,
|
||||||
|
match: { clientIp: '192.168.1.0/24' },
|
||||||
|
action: { type: 'forward', forward: { host: 'subnet-mail.com', port: 25 } }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const emailRouter = new EmailRouter(routes);
|
||||||
|
|
||||||
|
const testIps = [
|
||||||
|
{ ip: '192.168.1.100', expectedRoute: 'specific-subnet' }, // More specific match
|
||||||
|
{ ip: '192.168.2.100', expectedRoute: 'internal-network' }, // General internal
|
||||||
|
{ ip: '10.5.10.20', expectedRoute: 'internal-network' },
|
||||||
|
{ ip: '172.16.5.10', expectedRoute: 'internal-network' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testIps) {
|
||||||
|
const context = {
|
||||||
|
email: new Email({ from: 'test@test.com', to: 'user@test.com', subject: 'Test', text: 'Test' }),
|
||||||
|
session: { id: 'test', remoteAddress: testCase.ip } as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = await emailRouter.evaluateRoutes(context);
|
||||||
|
expect(route?.name).toEqual(testCase.expectedRoute);
|
||||||
|
console.log(`✓ IP ${testCase.ip}: Matched route '${route?.name}'`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Email Integration - Authentication-based routing', async () => {
|
||||||
|
const routes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'authenticated-relay',
|
||||||
|
priority: 100,
|
||||||
|
match: { authenticated: true },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forward: { host: 'relay.example.com', port: 587 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'unauthenticated-local',
|
||||||
|
match: {
|
||||||
|
authenticated: false,
|
||||||
|
recipients: '*@localserver.com'
|
||||||
|
},
|
||||||
|
action: { type: 'deliver' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'unauthenticated-reject',
|
||||||
|
match: { authenticated: false },
|
||||||
|
action: {
|
||||||
|
type: 'reject',
|
||||||
|
reject: { code: 550, message: 'Authentication required' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const emailRouter = new EmailRouter(routes);
|
||||||
|
|
||||||
|
// Test authenticated user
|
||||||
|
const authContext = {
|
||||||
|
email: new Email({ from: 'user@anywhere.com', to: 'dest@anywhere.com', subject: 'Test', text: 'Test' }),
|
||||||
|
session: {
|
||||||
|
id: 'auth-test',
|
||||||
|
remoteAddress: '203.0.113.1',
|
||||||
|
authenticated: true,
|
||||||
|
authenticatedUser: 'user@anywhere.com'
|
||||||
|
} as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const authRoute = await emailRouter.evaluateRoutes(authContext);
|
||||||
|
expect(authRoute?.name).toEqual('authenticated-relay');
|
||||||
|
|
||||||
|
// Test unauthenticated local delivery
|
||||||
|
const localContext = {
|
||||||
|
email: new Email({ from: 'external@external.com', to: 'user@localserver.com', subject: 'Test', text: 'Test' }),
|
||||||
|
session: {
|
||||||
|
id: 'local-test',
|
||||||
|
remoteAddress: '203.0.113.2',
|
||||||
|
authenticated: false
|
||||||
|
} as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const localRoute = await emailRouter.evaluateRoutes(localContext);
|
||||||
|
expect(localRoute?.name).toEqual('unauthenticated-local');
|
||||||
|
|
||||||
|
// Test unauthenticated rejection
|
||||||
|
const rejectContext = {
|
||||||
|
email: new Email({ from: 'external@external.com', to: 'user@external.com', subject: 'Test', text: 'Test' }),
|
||||||
|
session: {
|
||||||
|
id: 'reject-test',
|
||||||
|
remoteAddress: '203.0.113.3',
|
||||||
|
authenticated: false
|
||||||
|
} as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectRoute = await emailRouter.evaluateRoutes(rejectContext);
|
||||||
|
expect(rejectRoute?.name).toEqual('unauthenticated-reject');
|
||||||
|
|
||||||
|
console.log('✓ Authentication-based routing works correctly');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Email Integration - Pattern caching performance', async () => {
|
||||||
|
const routes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'complex-pattern',
|
||||||
|
match: {
|
||||||
|
recipients: ['*@domain1.com', '*@domain2.com', 'admin@*.domain3.com'],
|
||||||
|
senders: 'partner-*@*.partner.net'
|
||||||
|
},
|
||||||
|
action: { type: 'forward', forward: { host: 'partner-relay.com', port: 25 } }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const emailRouter = new EmailRouter(routes);
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'partner-sales@us.partner.net',
|
||||||
|
to: 'admin@sales.domain3.com',
|
||||||
|
subject: 'Test',
|
||||||
|
text: 'Test'
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
email,
|
||||||
|
session: { id: 'perf-test', remoteAddress: '10.0.0.1' } as any
|
||||||
|
};
|
||||||
|
|
||||||
|
// First evaluation - should populate cache
|
||||||
|
const start1 = Date.now();
|
||||||
|
const route1 = await emailRouter.evaluateRoutes(context);
|
||||||
|
const time1 = Date.now() - start1;
|
||||||
|
|
||||||
|
// Second evaluation - should use cache
|
||||||
|
const start2 = Date.now();
|
||||||
|
const route2 = await emailRouter.evaluateRoutes(context);
|
||||||
|
const time2 = Date.now() - start2;
|
||||||
|
|
||||||
|
expect(route1?.name).toEqual('complex-pattern');
|
||||||
|
expect(route2?.name).toEqual('complex-pattern');
|
||||||
|
|
||||||
|
// Cache should make second evaluation faster (though this is timing-dependent)
|
||||||
|
console.log(`✓ Pattern caching: First evaluation: ${time1}ms, Second: ${time2}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Email Integration - Route update functionality', async () => {
|
||||||
|
const initialRoutes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'test-route',
|
||||||
|
match: { recipients: '*@test.com' },
|
||||||
|
action: { type: 'deliver' }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const emailRouter = new EmailRouter(initialRoutes);
|
||||||
|
|
||||||
|
// Test initial configuration
|
||||||
|
expect(emailRouter.getRoutes().length).toEqual(1);
|
||||||
|
expect(emailRouter.getRoutes()[0].name).toEqual('test-route');
|
||||||
|
|
||||||
|
// Update routes
|
||||||
|
const newRoutes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'updated-route',
|
||||||
|
match: { recipients: '*@updated.com' },
|
||||||
|
action: { type: 'forward', forward: { host: 'new-server.com', port: 25 } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'additional-route',
|
||||||
|
match: { recipients: '*@additional.com' },
|
||||||
|
action: { type: 'reject', reject: { code: 550, message: 'Blocked' } }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
emailRouter.updateRoutes(newRoutes);
|
||||||
|
|
||||||
|
// Verify routes were updated
|
||||||
|
expect(emailRouter.getRoutes().length).toEqual(2);
|
||||||
|
expect(emailRouter.getRoutes()[0].name).toEqual('updated-route');
|
||||||
|
expect(emailRouter.getRoutes()[1].name).toEqual('additional-route');
|
||||||
|
|
||||||
|
console.log('✓ Route update functionality works correctly');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
283
test/test.email.router.ts
Normal file
283
test/test.email.router.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { EmailRouter, type IEmailRoute, type IEmailContext } from '../ts/mail/routing/index.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
|
||||||
|
tap.test('EmailRouter - should create and manage routes', async () => {
|
||||||
|
const router = new EmailRouter([]);
|
||||||
|
|
||||||
|
// Test initial state
|
||||||
|
expect(router.getRoutes()).toEqual([]);
|
||||||
|
|
||||||
|
// Add some test routes
|
||||||
|
const routes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'forward-example',
|
||||||
|
priority: 10,
|
||||||
|
match: {
|
||||||
|
recipients: '*@example.com'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forward: {
|
||||||
|
host: 'mail.example.com',
|
||||||
|
port: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reject-spam',
|
||||||
|
priority: 20,
|
||||||
|
match: {
|
||||||
|
senders: '*@spammer.com'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'reject',
|
||||||
|
reject: {
|
||||||
|
code: 550,
|
||||||
|
message: 'Spam not allowed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
router.updateRoutes(routes);
|
||||||
|
expect(router.getRoutes().length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailRouter - should evaluate routes based on priority', async () => {
|
||||||
|
const router = new EmailRouter([]);
|
||||||
|
|
||||||
|
const routes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'low-priority',
|
||||||
|
priority: 5,
|
||||||
|
match: {
|
||||||
|
recipients: '*@test.com'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'deliver'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'high-priority',
|
||||||
|
priority: 10,
|
||||||
|
match: {
|
||||||
|
recipients: 'admin@test.com'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
process: {
|
||||||
|
scan: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
router.updateRoutes(routes);
|
||||||
|
|
||||||
|
// Create test context
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'admin@test.com',
|
||||||
|
subject: 'Test email',
|
||||||
|
text: 'Test email content'
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: IEmailContext = {
|
||||||
|
email,
|
||||||
|
session: {
|
||||||
|
id: 'test-session',
|
||||||
|
remoteAddress: '192.168.1.1',
|
||||||
|
matchedRoute: null
|
||||||
|
} as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = await router.evaluateRoutes(context);
|
||||||
|
expect(route).not.toEqual(null);
|
||||||
|
expect(route?.name).toEqual('high-priority');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailRouter - should match recipient patterns', async () => {
|
||||||
|
const router = new EmailRouter([]);
|
||||||
|
|
||||||
|
const routes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'exact-match',
|
||||||
|
match: {
|
||||||
|
recipients: 'admin@example.com'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forward: {
|
||||||
|
host: 'admin-server.com',
|
||||||
|
port: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'wildcard-match',
|
||||||
|
match: {
|
||||||
|
recipients: '*@example.com'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'deliver'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
router.updateRoutes(routes);
|
||||||
|
|
||||||
|
// Test exact match
|
||||||
|
const email1 = new Email({
|
||||||
|
from: 'sender@test.com',
|
||||||
|
to: 'admin@example.com',
|
||||||
|
subject: 'Admin email',
|
||||||
|
text: 'Admin email content'
|
||||||
|
});
|
||||||
|
|
||||||
|
const context1: IEmailContext = {
|
||||||
|
email: email1,
|
||||||
|
session: { id: 'test1', remoteAddress: '10.0.0.1' } as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const route1 = await router.evaluateRoutes(context1);
|
||||||
|
expect(route1?.name).toEqual('exact-match');
|
||||||
|
|
||||||
|
// Test wildcard match
|
||||||
|
const email2 = new Email({
|
||||||
|
from: 'sender@test.com',
|
||||||
|
to: 'user@example.com',
|
||||||
|
subject: 'User email',
|
||||||
|
text: 'User email content'
|
||||||
|
});
|
||||||
|
|
||||||
|
const context2: IEmailContext = {
|
||||||
|
email: email2,
|
||||||
|
session: { id: 'test2', remoteAddress: '10.0.0.2' } as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const route2 = await router.evaluateRoutes(context2);
|
||||||
|
expect(route2?.name).toEqual('wildcard-match');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailRouter - should match IP ranges with CIDR notation', async () => {
|
||||||
|
const router = new EmailRouter([]);
|
||||||
|
|
||||||
|
const routes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'internal-network',
|
||||||
|
match: {
|
||||||
|
clientIp: '10.0.0.0/24'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'deliver'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'external-network',
|
||||||
|
match: {
|
||||||
|
clientIp: ['192.168.1.0/24', '172.16.0.0/16']
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
process: {
|
||||||
|
scan: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
router.updateRoutes(routes);
|
||||||
|
|
||||||
|
// Test internal network match
|
||||||
|
const email = new Email({
|
||||||
|
from: 'internal@company.com',
|
||||||
|
to: 'user@company.com',
|
||||||
|
subject: 'Internal email',
|
||||||
|
text: 'Internal email content'
|
||||||
|
});
|
||||||
|
|
||||||
|
const context1: IEmailContext = {
|
||||||
|
email,
|
||||||
|
session: { id: 'test1', remoteAddress: '10.0.0.15' } as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const route1 = await router.evaluateRoutes(context1);
|
||||||
|
expect(route1?.name).toEqual('internal-network');
|
||||||
|
|
||||||
|
// Test external network match
|
||||||
|
const context2: IEmailContext = {
|
||||||
|
email,
|
||||||
|
session: { id: 'test2', remoteAddress: '192.168.1.100' } as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const route2 = await router.evaluateRoutes(context2);
|
||||||
|
expect(route2?.name).toEqual('external-network');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailRouter - should handle authentication matching', async () => {
|
||||||
|
const router = new EmailRouter([]);
|
||||||
|
|
||||||
|
const routes: IEmailRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'authenticated-users',
|
||||||
|
match: {
|
||||||
|
authenticated: true
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'deliver'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'unauthenticated-users',
|
||||||
|
match: {
|
||||||
|
authenticated: false
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'reject',
|
||||||
|
reject: {
|
||||||
|
code: 550,
|
||||||
|
message: 'Authentication required'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
router.updateRoutes(routes);
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'user@example.com',
|
||||||
|
to: 'recipient@test.com',
|
||||||
|
subject: 'Test',
|
||||||
|
text: 'Test content'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test authenticated session
|
||||||
|
const context1: IEmailContext = {
|
||||||
|
email,
|
||||||
|
session: {
|
||||||
|
id: 'test1',
|
||||||
|
remoteAddress: '10.0.0.1',
|
||||||
|
authenticated: true,
|
||||||
|
authenticatedUser: 'user@example.com'
|
||||||
|
} as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const route1 = await router.evaluateRoutes(context1);
|
||||||
|
expect(route1?.name).toEqual('authenticated-users');
|
||||||
|
|
||||||
|
// Test unauthenticated session
|
||||||
|
const context2: IEmailContext = {
|
||||||
|
email,
|
||||||
|
session: {
|
||||||
|
id: 'test2',
|
||||||
|
remoteAddress: '10.0.0.2',
|
||||||
|
authenticated: false
|
||||||
|
} as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const route2 = await router.evaluateRoutes(context2);
|
||||||
|
expect(route2?.name).toEqual('unauthenticated-users');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
195
test/test.emailauth.ts
Normal file
195
test/test.emailauth.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js';
|
||||||
|
import { DmarcVerifier, DmarcPolicy, DmarcAlignment } from '../ts/mail/security/classes.dmarcverifier.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test email authentication systems: SPF and DMARC
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SPF Verifier Tests
|
||||||
|
tap.test('SPF Verifier - should parse SPF record', async () => {
|
||||||
|
const spfVerifier = new SpfVerifier();
|
||||||
|
|
||||||
|
// Test valid SPF record parsing
|
||||||
|
const record = 'v=spf1 a mx ip4:192.168.0.1/24 include:example.org ~all';
|
||||||
|
const parsedRecord = spfVerifier.parseSpfRecord(record);
|
||||||
|
|
||||||
|
expect(parsedRecord).toBeTruthy();
|
||||||
|
expect(parsedRecord.version).toEqual('spf1');
|
||||||
|
expect(parsedRecord.mechanisms.length).toEqual(5);
|
||||||
|
|
||||||
|
// Check specific mechanisms
|
||||||
|
expect(parsedRecord.mechanisms[0].type).toEqual(SpfMechanismType.A);
|
||||||
|
expect(parsedRecord.mechanisms[0].qualifier).toEqual(SpfQualifier.PASS);
|
||||||
|
|
||||||
|
expect(parsedRecord.mechanisms[1].type).toEqual(SpfMechanismType.MX);
|
||||||
|
expect(parsedRecord.mechanisms[1].qualifier).toEqual(SpfQualifier.PASS);
|
||||||
|
|
||||||
|
expect(parsedRecord.mechanisms[2].type).toEqual(SpfMechanismType.IP4);
|
||||||
|
expect(parsedRecord.mechanisms[2].value).toEqual('192.168.0.1/24');
|
||||||
|
|
||||||
|
expect(parsedRecord.mechanisms[3].type).toEqual(SpfMechanismType.INCLUDE);
|
||||||
|
expect(parsedRecord.mechanisms[3].value).toEqual('example.org');
|
||||||
|
|
||||||
|
expect(parsedRecord.mechanisms[4].type).toEqual(SpfMechanismType.ALL);
|
||||||
|
expect(parsedRecord.mechanisms[4].qualifier).toEqual(SpfQualifier.SOFTFAIL);
|
||||||
|
|
||||||
|
// Test invalid record
|
||||||
|
const invalidRecord = 'not-a-spf-record';
|
||||||
|
const invalidParsed = spfVerifier.parseSpfRecord(invalidRecord);
|
||||||
|
expect(invalidParsed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// DMARC Verifier Tests
|
||||||
|
tap.test('DMARC Verifier - should parse DMARC record', async () => {
|
||||||
|
const dmarcVerifier = new DmarcVerifier();
|
||||||
|
|
||||||
|
// Test valid DMARC record parsing
|
||||||
|
const record = 'v=DMARC1; p=reject; sp=quarantine; pct=50; adkim=s; aspf=r; rua=mailto:dmarc@example.com';
|
||||||
|
const parsedRecord = dmarcVerifier.parseDmarcRecord(record);
|
||||||
|
|
||||||
|
expect(parsedRecord).toBeTruthy();
|
||||||
|
expect(parsedRecord.version).toEqual('DMARC1');
|
||||||
|
expect(parsedRecord.policy).toEqual(DmarcPolicy.REJECT);
|
||||||
|
expect(parsedRecord.subdomainPolicy).toEqual(DmarcPolicy.QUARANTINE);
|
||||||
|
expect(parsedRecord.pct).toEqual(50);
|
||||||
|
expect(parsedRecord.adkim).toEqual(DmarcAlignment.STRICT);
|
||||||
|
expect(parsedRecord.aspf).toEqual(DmarcAlignment.RELAXED);
|
||||||
|
expect(parsedRecord.reportUriAggregate).toContain('dmarc@example.com');
|
||||||
|
|
||||||
|
// Test invalid record
|
||||||
|
const invalidRecord = 'not-a-dmarc-record';
|
||||||
|
const invalidParsed = dmarcVerifier.parseDmarcRecord(invalidRecord);
|
||||||
|
expect(invalidParsed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DMARC Verifier - should verify DMARC alignment', async () => {
|
||||||
|
const dmarcVerifier = new DmarcVerifier();
|
||||||
|
|
||||||
|
// Test email domains with DMARC alignment
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'recipient@example.net',
|
||||||
|
subject: 'Test DMARC alignment',
|
||||||
|
text: 'This is a test email'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test when both SPF and DKIM pass with alignment
|
||||||
|
const dmarcResult = await dmarcVerifier.verify(
|
||||||
|
email,
|
||||||
|
{ domain: 'example.com', result: true }, // SPF - aligned and passed
|
||||||
|
{ domain: 'example.com', result: true } // DKIM - aligned and passed
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dmarcResult).toBeTruthy();
|
||||||
|
expect(dmarcResult.spfPassed).toEqual(true);
|
||||||
|
expect(dmarcResult.dkimPassed).toEqual(true);
|
||||||
|
expect(dmarcResult.spfDomainAligned).toEqual(true);
|
||||||
|
expect(dmarcResult.dkimDomainAligned).toEqual(true);
|
||||||
|
expect(dmarcResult.action).toEqual('pass');
|
||||||
|
|
||||||
|
// Test when neither SPF nor DKIM is aligned
|
||||||
|
const dmarcResult2 = await dmarcVerifier.verify(
|
||||||
|
email,
|
||||||
|
{ domain: 'differentdomain.com', result: true }, // SPF - passed but not aligned
|
||||||
|
{ domain: 'anotherdomain.com', result: true } // DKIM - passed but not aligned
|
||||||
|
);
|
||||||
|
|
||||||
|
// Without a DNS manager, no DMARC record will be found
|
||||||
|
|
||||||
|
expect(dmarcResult2).toBeTruthy();
|
||||||
|
expect(dmarcResult2.spfPassed).toEqual(true);
|
||||||
|
expect(dmarcResult2.dkimPassed).toEqual(true);
|
||||||
|
expect(dmarcResult2.spfDomainAligned).toEqual(false);
|
||||||
|
expect(dmarcResult2.dkimDomainAligned).toEqual(false);
|
||||||
|
|
||||||
|
// Without a DMARC record, the default action is 'pass'
|
||||||
|
expect(dmarcResult2.hasDmarc).toEqual(false);
|
||||||
|
expect(dmarcResult2.policyEvaluated).toEqual(DmarcPolicy.NONE);
|
||||||
|
expect(dmarcResult2.actualPolicy).toEqual(DmarcPolicy.NONE);
|
||||||
|
expect(dmarcResult2.action).toEqual('pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DMARC Verifier - should apply policy correctly', async () => {
|
||||||
|
const dmarcVerifier = new DmarcVerifier();
|
||||||
|
|
||||||
|
// Create test email
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'recipient@example.net',
|
||||||
|
subject: 'Test DMARC policy application',
|
||||||
|
text: 'This is a test email'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test pass action
|
||||||
|
const passResult: any = {
|
||||||
|
hasDmarc: true,
|
||||||
|
spfDomainAligned: true,
|
||||||
|
dkimDomainAligned: true,
|
||||||
|
spfPassed: true,
|
||||||
|
dkimPassed: true,
|
||||||
|
policyEvaluated: DmarcPolicy.NONE,
|
||||||
|
actualPolicy: DmarcPolicy.NONE,
|
||||||
|
appliedPercentage: 100,
|
||||||
|
action: 'pass',
|
||||||
|
details: 'DMARC passed'
|
||||||
|
};
|
||||||
|
|
||||||
|
const passApplied = dmarcVerifier.applyPolicy(email, passResult);
|
||||||
|
expect(passApplied).toEqual(true);
|
||||||
|
expect(email.mightBeSpam).toEqual(false);
|
||||||
|
expect(email.headers['X-DMARC-Result']).toEqual('DMARC passed');
|
||||||
|
|
||||||
|
// Test quarantine action
|
||||||
|
const quarantineResult: any = {
|
||||||
|
hasDmarc: true,
|
||||||
|
spfDomainAligned: false,
|
||||||
|
dkimDomainAligned: false,
|
||||||
|
spfPassed: false,
|
||||||
|
dkimPassed: false,
|
||||||
|
policyEvaluated: DmarcPolicy.QUARANTINE,
|
||||||
|
actualPolicy: DmarcPolicy.QUARANTINE,
|
||||||
|
appliedPercentage: 100,
|
||||||
|
action: 'quarantine',
|
||||||
|
details: 'DMARC failed, policy=quarantine'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset email spam flag
|
||||||
|
email.mightBeSpam = false;
|
||||||
|
email.headers = {};
|
||||||
|
|
||||||
|
const quarantineApplied = dmarcVerifier.applyPolicy(email, quarantineResult);
|
||||||
|
expect(quarantineApplied).toEqual(true);
|
||||||
|
expect(email.mightBeSpam).toEqual(true);
|
||||||
|
expect(email.headers['X-Spam-Flag']).toEqual('YES');
|
||||||
|
expect(email.headers['X-DMARC-Result']).toEqual('DMARC failed, policy=quarantine');
|
||||||
|
|
||||||
|
// Test reject action
|
||||||
|
const rejectResult: any = {
|
||||||
|
hasDmarc: true,
|
||||||
|
spfDomainAligned: false,
|
||||||
|
dkimDomainAligned: false,
|
||||||
|
spfPassed: false,
|
||||||
|
dkimPassed: false,
|
||||||
|
policyEvaluated: DmarcPolicy.REJECT,
|
||||||
|
actualPolicy: DmarcPolicy.REJECT,
|
||||||
|
appliedPercentage: 100,
|
||||||
|
action: 'reject',
|
||||||
|
details: 'DMARC failed, policy=reject'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset email spam flag
|
||||||
|
email.mightBeSpam = false;
|
||||||
|
email.headers = {};
|
||||||
|
|
||||||
|
const rejectApplied = dmarcVerifier.applyPolicy(email, rejectResult);
|
||||||
|
expect(rejectApplied).toEqual(false);
|
||||||
|
expect(email.mightBeSpam).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
116
test/test.ipreputationchecker.ts
Normal file
116
test/test.ipreputationchecker.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { IPReputationChecker, ReputationThreshold } from '../ts/security/classes.ipreputationchecker.js';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
|
||||||
|
// Start the Rust bridge before tests
|
||||||
|
tap.test('setup - start Rust security bridge', async () => {
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
const ok = await bridge.start();
|
||||||
|
expect(ok).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test instantiation
|
||||||
|
tap.test('IPReputationChecker - should be instantiable', async () => {
|
||||||
|
const checker = IPReputationChecker.getInstance({
|
||||||
|
enableLocalCache: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checker).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test singleton pattern
|
||||||
|
tap.test('IPReputationChecker - should use singleton pattern', async () => {
|
||||||
|
const checker1 = IPReputationChecker.getInstance();
|
||||||
|
const checker2 = IPReputationChecker.getInstance();
|
||||||
|
|
||||||
|
expect(checker1 === checker2).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test IP validation
|
||||||
|
tap.test('IPReputationChecker - should validate IP address format', async () => {
|
||||||
|
const checker = IPReputationChecker.getInstance();
|
||||||
|
|
||||||
|
// Invalid IP should fail with error
|
||||||
|
const invalidResult = await checker.checkReputation('invalid.ip');
|
||||||
|
expect(invalidResult.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test reputation check via Rust bridge
|
||||||
|
tap.test('IPReputationChecker - should check IP reputation via Rust', async () => {
|
||||||
|
const testInstance = new IPReputationChecker({
|
||||||
|
enableLocalCache: false,
|
||||||
|
maxCacheSize: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check a public IP (Google DNS) — should get a result with a score
|
||||||
|
const result = await testInstance.checkReputation('8.8.8.8');
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.score).toBeGreaterThan(0);
|
||||||
|
expect(result.score).toBeLessThanOrEqual(100);
|
||||||
|
expect(typeof result.isSpam).toEqual('boolean');
|
||||||
|
expect(typeof result.isProxy).toEqual('boolean');
|
||||||
|
expect(typeof result.isTor).toEqual('boolean');
|
||||||
|
expect(typeof result.isVPN).toEqual('boolean');
|
||||||
|
expect(result.timestamp).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test caching behavior
|
||||||
|
tap.test('IPReputationChecker - should cache reputation results', async () => {
|
||||||
|
const testInstance = new IPReputationChecker({
|
||||||
|
enableLocalCache: false,
|
||||||
|
maxCacheSize: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = '1.1.1.1';
|
||||||
|
|
||||||
|
// First check should add to cache
|
||||||
|
const result1 = await testInstance.checkReputation(ip);
|
||||||
|
expect(result1).toBeTruthy();
|
||||||
|
|
||||||
|
// Verify it's in cache
|
||||||
|
const hasInCache = (testInstance as any).reputationCache.has(ip);
|
||||||
|
expect(hasInCache).toEqual(true);
|
||||||
|
|
||||||
|
// Call again, should use cache
|
||||||
|
const result2 = await testInstance.checkReputation(ip);
|
||||||
|
expect(result2).toBeTruthy();
|
||||||
|
|
||||||
|
// Results should be identical (from cache)
|
||||||
|
expect(result1.score).toEqual(result2.score);
|
||||||
|
expect(result1.isSpam).toEqual(result2.isSpam);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test risk level classification
|
||||||
|
tap.test('IPReputationChecker - should classify risk levels correctly', async () => {
|
||||||
|
expect(IPReputationChecker.getRiskLevel(10)).toEqual('high');
|
||||||
|
expect(IPReputationChecker.getRiskLevel(30)).toEqual('medium');
|
||||||
|
expect(IPReputationChecker.getRiskLevel(60)).toEqual('low');
|
||||||
|
expect(IPReputationChecker.getRiskLevel(90)).toEqual('trusted');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test error handling for error result
|
||||||
|
tap.test('IPReputationChecker - should handle errors gracefully', async () => {
|
||||||
|
const testInstance = new IPReputationChecker({
|
||||||
|
enableLocalCache: false,
|
||||||
|
maxCacheSize: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalid format should return error result with neutral score
|
||||||
|
const result = await testInstance.checkReputation('not-an-ip');
|
||||||
|
expect(result.score).toEqual(50);
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
expect(result.isSpam).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop bridge
|
||||||
|
tap.test('cleanup - stop Rust security bridge', async () => {
|
||||||
|
await bridge.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
141
test/test.ratelimiter.ts
Normal file
141
test/test.ratelimiter.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { RateLimiter } from '../ts/mail/delivery/classes.ratelimiter.js';
|
||||||
|
|
||||||
|
tap.test('RateLimiter - should be instantiable', async () => {
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
maxPerPeriod: 10,
|
||||||
|
periodMs: 1000,
|
||||||
|
perKey: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(limiter).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RateLimiter - should allow requests within rate limit', async () => {
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
maxPerPeriod: 5,
|
||||||
|
periodMs: 1000,
|
||||||
|
perKey: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should allow 5 requests
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th request should be denied
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RateLimiter - should enforce per-key limits', async () => {
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
maxPerPeriod: 3,
|
||||||
|
periodMs: 1000,
|
||||||
|
perKey: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should allow 3 requests for key1
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
expect(limiter.isAllowed('key1')).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th request for key1 should be denied
|
||||||
|
expect(limiter.isAllowed('key1')).toEqual(false);
|
||||||
|
|
||||||
|
// But key2 should still be allowed
|
||||||
|
expect(limiter.isAllowed('key2')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RateLimiter - should refill tokens over time', async () => {
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
maxPerPeriod: 2,
|
||||||
|
periodMs: 100, // Short period for testing
|
||||||
|
perKey: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use all tokens
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(false);
|
||||||
|
|
||||||
|
// Wait for refill
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// Should have tokens again
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RateLimiter - should support burst allowance', async () => {
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
maxPerPeriod: 2,
|
||||||
|
periodMs: 100,
|
||||||
|
perKey: true,
|
||||||
|
burstTokens: 2, // Allow 2 extra tokens for bursts
|
||||||
|
initialTokens: 4 // Start with max + burst tokens
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should allow 4 requests (2 regular + 2 burst)
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5th request should be denied
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(false);
|
||||||
|
|
||||||
|
// Wait for refill
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// Should have 2 tokens again (rate-limited to normal max, not burst)
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
|
||||||
|
// 3rd request after refill should fail (only normal max is refilled, not burst)
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RateLimiter - should return correct stats', async () => {
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
maxPerPeriod: 10,
|
||||||
|
periodMs: 1000,
|
||||||
|
perKey: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make some requests
|
||||||
|
limiter.isAllowed('test');
|
||||||
|
limiter.isAllowed('test');
|
||||||
|
limiter.isAllowed('test');
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
const stats = limiter.getStats('test');
|
||||||
|
|
||||||
|
expect(stats.remaining).toEqual(7);
|
||||||
|
expect(stats.limit).toEqual(10);
|
||||||
|
expect(stats.allowed).toEqual(3);
|
||||||
|
expect(stats.denied).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RateLimiter - should reset limits', async () => {
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
maxPerPeriod: 3,
|
||||||
|
periodMs: 1000,
|
||||||
|
perKey: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use all tokens
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(false);
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
limiter.reset('test');
|
||||||
|
|
||||||
|
// Should have tokens again
|
||||||
|
expect(limiter.isAllowed('test')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
136
test/test.rustsecuritybridge.node.ts
Normal file
136
test/test.rustsecuritybridge.node.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - should get singleton instance', async () => {
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
expect(bridge).toBeTruthy();
|
||||||
|
expect(bridge).toEqual(RustSecurityBridge.getInstance()); // same instance
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - should start the Rust binary', async () => {
|
||||||
|
const ok = await bridge.start();
|
||||||
|
if (!ok) {
|
||||||
|
console.log('WARNING: Rust binary not available — skipping bridge tests');
|
||||||
|
console.log('Build it with: cd rust && cargo build --release');
|
||||||
|
}
|
||||||
|
// We accept both true and false — the binary may not be built yet
|
||||||
|
expect(typeof ok).toEqual('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - ping should return pong', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pong = await bridge.ping();
|
||||||
|
expect(pong).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - version should return crate versions', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const version = await bridge.getVersion();
|
||||||
|
expect(version.bin).toBeTruthy();
|
||||||
|
expect(version.core).toBeTruthy();
|
||||||
|
expect(version.security).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - validateEmail with valid address', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await bridge.validateEmail('test@example.com');
|
||||||
|
expect(result.valid).toBeTrue();
|
||||||
|
expect(result.formatValid).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - validateEmail with invalid address', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await bridge.validateEmail('not-an-email');
|
||||||
|
expect(result.formatValid).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - detectBounce with known SMTP response', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await bridge.detectBounce({
|
||||||
|
smtpResponse: '550 5.1.1 User unknown',
|
||||||
|
});
|
||||||
|
expect(result.bounce_type).toBeTruthy();
|
||||||
|
expect(result.category).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - checkIpReputation', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Use a well-known IP that should NOT be on blacklists
|
||||||
|
const result = await bridge.checkIpReputation('8.8.8.8');
|
||||||
|
expect(result.ip).toEqual('8.8.8.8');
|
||||||
|
expect(typeof result.score).toEqual('number');
|
||||||
|
expect(result.score).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - verifyDkim with unsigned message', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawMessage = 'From: test@example.com\r\nTo: receiver@example.com\r\nSubject: Test\r\n\r\nHello';
|
||||||
|
const results = await bridge.verifyDkim(rawMessage);
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
expect(results[0].status).toEqual('none'); // no DKIM signature
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - verifyEmail compound call', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawMessage = 'From: test@example.com\r\nTo: receiver@example.com\r\nSubject: Test\r\n\r\nHello';
|
||||||
|
const result = await bridge.verifyEmail({
|
||||||
|
rawMessage,
|
||||||
|
ip: '93.184.216.34', // example.com IP
|
||||||
|
heloDomain: 'example.com',
|
||||||
|
hostname: 'mail.test.local',
|
||||||
|
mailFrom: 'test@example.com',
|
||||||
|
});
|
||||||
|
expect(result.dkim).toBeTruthy();
|
||||||
|
expect(result.dkim.length).toBeGreaterThan(0);
|
||||||
|
expect(result.spf).toBeTruthy();
|
||||||
|
// DMARC may or may not be present depending on DNS
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - should stop gracefully', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bridge.stop();
|
||||||
|
expect(bridge.running).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RustSecurityBridge - commands should fail when bridge is stopped', async () => {
|
||||||
|
// Bridge should not be running now
|
||||||
|
expect(bridge.running).toBeFalse();
|
||||||
|
try {
|
||||||
|
await bridge.ping();
|
||||||
|
// If we get here, the bridge auto-restarted or something unexpected
|
||||||
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeTruthy(); // Expected: bridge not running error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
177
test/test.rustsecuritybridge.resilience.node.ts
Normal file
177
test/test.rustsecuritybridge.resilience.node.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import type { IBridgeResilienceConfig } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
// Use fast backoff settings for testing
|
||||||
|
const TEST_CONFIG: Partial<IBridgeResilienceConfig> = {
|
||||||
|
maxRestartAttempts: 3,
|
||||||
|
healthCheckIntervalMs: 60_000, // long interval so health checks don't interfere
|
||||||
|
restartBackoffBaseMs: 100,
|
||||||
|
restartBackoffMaxMs: 500,
|
||||||
|
healthCheckTimeoutMs: 2_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('Resilience - should start in Idle state', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
RustSecurityBridge.configure(TEST_CONFIG);
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
expect(bridge.state).toEqual(BridgeState.Idle);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Resilience - state transitions: Idle -> Starting -> Running', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
RustSecurityBridge.configure(TEST_CONFIG);
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
|
||||||
|
const transitions: Array<{ oldState: string; newState: string }> = [];
|
||||||
|
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
|
||||||
|
transitions.push(evt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await bridge.start();
|
||||||
|
if (!ok) {
|
||||||
|
console.log('WARNING: Rust binary not available — skipping resilience start tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should have seen Idle -> Starting -> Running
|
||||||
|
expect(transitions.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(transitions[0].oldState).toEqual(BridgeState.Idle);
|
||||||
|
expect(transitions[0].newState).toEqual(BridgeState.Starting);
|
||||||
|
expect(transitions[1].oldState).toEqual(BridgeState.Starting);
|
||||||
|
expect(transitions[1].newState).toEqual(BridgeState.Running);
|
||||||
|
expect(bridge.state).toEqual(BridgeState.Running);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Resilience - deliberate stop transitions to Stopped', async () => {
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitions: Array<{ oldState: string; newState: string }> = [];
|
||||||
|
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
|
||||||
|
transitions.push(evt);
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.stop();
|
||||||
|
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||||
|
|
||||||
|
// Deliberate stop should NOT trigger restart
|
||||||
|
// Wait a bit to ensure no restart happens
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||||
|
|
||||||
|
bridge.removeAllListeners('stateChange');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Resilience - commands throw descriptive errors when not running', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
RustSecurityBridge.configure(TEST_CONFIG);
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
|
||||||
|
// Idle state
|
||||||
|
try {
|
||||||
|
await bridge.ping();
|
||||||
|
expect(true).toBeFalse(); // Should not reach
|
||||||
|
} catch (err) {
|
||||||
|
expect((err as Error).message).toInclude('not been started');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopped state
|
||||||
|
const ok = await bridge.start();
|
||||||
|
if (ok) {
|
||||||
|
await bridge.stop();
|
||||||
|
try {
|
||||||
|
await bridge.ping();
|
||||||
|
expect(true).toBeFalse();
|
||||||
|
} catch (err) {
|
||||||
|
expect((err as Error).message).toInclude('stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Resilience - restart after stop and fresh start', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
RustSecurityBridge.configure(TEST_CONFIG);
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
|
||||||
|
const ok = await bridge.start();
|
||||||
|
if (!ok) {
|
||||||
|
console.log('SKIP: Rust binary not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(bridge.state).toEqual(BridgeState.Running);
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
await bridge.stop();
|
||||||
|
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||||
|
|
||||||
|
// Start again
|
||||||
|
const ok2 = await bridge.start();
|
||||||
|
expect(ok2).toBeTrue();
|
||||||
|
expect(bridge.state).toEqual(BridgeState.Running);
|
||||||
|
|
||||||
|
// Commands should work
|
||||||
|
const pong = await bridge.ping();
|
||||||
|
expect(pong).toBeTrue();
|
||||||
|
|
||||||
|
await bridge.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Resilience - stateChange events emitted correctly', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
RustSecurityBridge.configure(TEST_CONFIG);
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
|
||||||
|
const events: Array<{ oldState: string; newState: string }> = [];
|
||||||
|
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
|
||||||
|
events.push(evt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await bridge.start();
|
||||||
|
if (!ok) {
|
||||||
|
console.log('SKIP: Rust binary not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bridge.stop();
|
||||||
|
|
||||||
|
// Verify the full lifecycle: Idle->Starting->Running->Stopped
|
||||||
|
const stateSequence = events.map(e => e.newState);
|
||||||
|
expect(stateSequence).toContain(BridgeState.Starting);
|
||||||
|
expect(stateSequence).toContain(BridgeState.Running);
|
||||||
|
expect(stateSequence).toContain(BridgeState.Stopped);
|
||||||
|
|
||||||
|
bridge.removeAllListeners('stateChange');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Resilience - configure sets resilience parameters', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
RustSecurityBridge.configure({
|
||||||
|
maxRestartAttempts: 10,
|
||||||
|
healthCheckIntervalMs: 60_000,
|
||||||
|
});
|
||||||
|
// Just verify no errors — config is private, but we can verify
|
||||||
|
// by the behavior in other tests
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
expect(bridge).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Resilience - resetInstance creates fresh singleton', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
const bridge1 = RustSecurityBridge.getInstance();
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
const bridge2 = RustSecurityBridge.getInstance();
|
||||||
|
// They should be different instances (we can't compare directly since
|
||||||
|
// resetInstance nulls the static, and getInstance creates new)
|
||||||
|
expect(bridge2.state).toEqual(BridgeState.Idle);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Resilience - cleanup', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
RustSecurityBridge.configure(TEST_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
248
test/test.smartmail.ts
Normal file
248
test/test.smartmail.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
import * as paths from '../ts/paths.js';
|
||||||
|
|
||||||
|
// Import the components we want to test
|
||||||
|
import { EmailValidator } from '../ts/mail/core/classes.emailvalidator.js';
|
||||||
|
import { TemplateManager } from '../ts/mail/core/classes.templatemanager.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
|
||||||
|
// Ensure test directories exist
|
||||||
|
paths.ensureDirectories();
|
||||||
|
|
||||||
|
tap.test('EmailValidator - should validate email formats correctly', async (tools) => {
|
||||||
|
const validator = new EmailValidator();
|
||||||
|
|
||||||
|
// Test valid email formats
|
||||||
|
expect(validator.isValidFormat('user@example.com')).toEqual(true);
|
||||||
|
expect(validator.isValidFormat('firstname.lastname@example.com')).toEqual(true);
|
||||||
|
expect(validator.isValidFormat('user+tag@example.com')).toEqual(true);
|
||||||
|
|
||||||
|
// Test invalid email formats
|
||||||
|
expect(validator.isValidFormat('user@')).toEqual(false);
|
||||||
|
expect(validator.isValidFormat('@example.com')).toEqual(false);
|
||||||
|
expect(validator.isValidFormat('user@example')).toEqual(false);
|
||||||
|
expect(validator.isValidFormat('user.example.com')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailValidator - should perform comprehensive validation', async (tools) => {
|
||||||
|
const validator = new EmailValidator();
|
||||||
|
|
||||||
|
// Test basic validation (syntax-only)
|
||||||
|
const basicResult = await validator.validate('user@example.com', { checkSyntaxOnly: true });
|
||||||
|
expect(basicResult.isValid).toEqual(true);
|
||||||
|
expect(basicResult.details.formatValid).toEqual(true);
|
||||||
|
|
||||||
|
// We can't reliably test MX validation in all environments, but the function should run
|
||||||
|
const mxResult = await validator.validate('user@example.com', { checkMx: true });
|
||||||
|
expect(typeof mxResult.isValid).toEqual('boolean');
|
||||||
|
expect(typeof mxResult.hasMx).toEqual('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailValidator - should detect invalid emails', async (tools) => {
|
||||||
|
const validator = new EmailValidator();
|
||||||
|
|
||||||
|
const invalidResult = await validator.validate('invalid@@example.com', { checkSyntaxOnly: true });
|
||||||
|
expect(invalidResult.isValid).toEqual(false);
|
||||||
|
expect(invalidResult.details.formatValid).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('TemplateManager - should register and retrieve templates', async (tools) => {
|
||||||
|
const templateManager = new TemplateManager({
|
||||||
|
from: 'test@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register a custom template
|
||||||
|
templateManager.registerTemplate({
|
||||||
|
id: 'test-template',
|
||||||
|
name: 'Test Template',
|
||||||
|
description: 'A test template',
|
||||||
|
from: 'test@example.com',
|
||||||
|
subject: 'Test Subject: {{name}}',
|
||||||
|
bodyHtml: '<p>Hello, {{name}}!</p>',
|
||||||
|
bodyText: 'Hello, {{name}}!',
|
||||||
|
category: 'test'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the template back
|
||||||
|
const template = templateManager.getTemplate('test-template');
|
||||||
|
expect(template).toBeTruthy();
|
||||||
|
expect(template.id).toEqual('test-template');
|
||||||
|
expect(template.subject).toEqual('Test Subject: {{name}}');
|
||||||
|
|
||||||
|
// List templates
|
||||||
|
const templates = templateManager.listTemplates();
|
||||||
|
expect(templates.length > 0).toEqual(true);
|
||||||
|
expect(templates.some(t => t.id === 'test-template')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('TemplateManager - should create email from template', async (tools) => {
|
||||||
|
const templateManager = new TemplateManager({
|
||||||
|
from: 'test@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register a template
|
||||||
|
templateManager.registerTemplate({
|
||||||
|
id: 'welcome-test',
|
||||||
|
name: 'Welcome Test',
|
||||||
|
description: 'A welcome test template',
|
||||||
|
from: 'welcome@example.com',
|
||||||
|
subject: 'Welcome, {{name}}!',
|
||||||
|
bodyHtml: '<p>Hello, {{name}}! Welcome to our service.</p>',
|
||||||
|
bodyText: 'Hello, {{name}}! Welcome to our service.',
|
||||||
|
category: 'test'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create email from template
|
||||||
|
const email = await templateManager.createEmail('welcome-test', {
|
||||||
|
name: 'John Doe'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(email).toBeTruthy();
|
||||||
|
expect(email.from).toEqual('welcome@example.com');
|
||||||
|
expect(email.getSubjectWithVariables()).toEqual('Welcome, John Doe!');
|
||||||
|
expect(email.getHtmlWithVariables()?.indexOf('Hello, John Doe!') > -1).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Email - should handle template variables', async (tools) => {
|
||||||
|
// Create email with variables
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Hello {{name}}!',
|
||||||
|
text: 'Welcome, {{name}}! Your order #{{orderId}} has been processed.',
|
||||||
|
html: '<p>Welcome, <strong>{{name}}</strong>! Your order #{{orderId}} has been processed.</p>',
|
||||||
|
variables: {
|
||||||
|
name: 'John Doe',
|
||||||
|
orderId: '12345'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test variable substitution
|
||||||
|
expect(email.getSubjectWithVariables()).toEqual('Hello John Doe!');
|
||||||
|
expect(email.getTextWithVariables()).toEqual('Welcome, John Doe! Your order #12345 has been processed.');
|
||||||
|
expect(email.getHtmlWithVariables().indexOf('<strong>John Doe</strong>') > -1).toEqual(true);
|
||||||
|
|
||||||
|
// Test with additional variables
|
||||||
|
const additionalVars = {
|
||||||
|
name: 'Jane Smith', // Override existing variable
|
||||||
|
status: 'shipped' // Add new variable
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(email.getSubjectWithVariables(additionalVars)).toEqual('Hello Jane Smith!');
|
||||||
|
|
||||||
|
// Add a new variable
|
||||||
|
email.setVariable('trackingNumber', 'TRK123456');
|
||||||
|
expect(email.getTextWithVariables().indexOf('12345') > -1).toEqual(true);
|
||||||
|
|
||||||
|
// Update multiple variables at once
|
||||||
|
email.setVariables({
|
||||||
|
orderId: '67890',
|
||||||
|
status: 'delivered'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(email.getTextWithVariables().indexOf('67890') > -1).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Email and Smartmail compatibility - should convert between formats', async (tools) => {
|
||||||
|
// Create a Smartmail instance
|
||||||
|
const smartmail = new plugins.smartmail.Smartmail({
|
||||||
|
from: 'smartmail@example.com',
|
||||||
|
subject: 'Test Subject',
|
||||||
|
body: '<p>This is a test email.</p>',
|
||||||
|
creationObjectRef: {
|
||||||
|
orderId: '12345'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add recipient and attachment
|
||||||
|
smartmail.addRecipient('recipient@example.com');
|
||||||
|
|
||||||
|
const attachment = plugins.smartfile.SmartFileFactory.nodeFs().fromString(
|
||||||
|
'test.txt',
|
||||||
|
'This is a test attachment',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
smartmail.addAttachment(attachment);
|
||||||
|
|
||||||
|
// Convert to Email
|
||||||
|
const resolvedSmartmail = await smartmail;
|
||||||
|
const email = Email.fromSmartmail(resolvedSmartmail);
|
||||||
|
|
||||||
|
// Verify first conversion (Smartmail to Email)
|
||||||
|
expect(email.from).toEqual('smartmail@example.com');
|
||||||
|
expect(email.to.indexOf('recipient@example.com') > -1).toEqual(true);
|
||||||
|
expect(email.subject).toEqual('Test Subject');
|
||||||
|
expect(email.html?.indexOf('This is a test email') > -1).toEqual(true);
|
||||||
|
expect(email.attachments.length).toEqual(1);
|
||||||
|
|
||||||
|
// Convert back to Smartmail
|
||||||
|
const convertedSmartmail = await email.toSmartmail();
|
||||||
|
|
||||||
|
// Verify second conversion (Email back to Smartmail) with simplified assertions
|
||||||
|
expect(convertedSmartmail.options.from).toEqual('smartmail@example.com');
|
||||||
|
expect(Array.isArray(convertedSmartmail.options.to)).toEqual(true);
|
||||||
|
expect(convertedSmartmail.options.to.length).toEqual(1);
|
||||||
|
expect(convertedSmartmail.getSubject()).toEqual('Test Subject');
|
||||||
|
expect(convertedSmartmail.getBody(true).indexOf('This is a test email') > -1).toEqual(true);
|
||||||
|
expect(convertedSmartmail.attachments.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Email - should validate email addresses', async (tools) => {
|
||||||
|
// Attempt to create an email with invalid addresses
|
||||||
|
let errorThrown = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = new Email({
|
||||||
|
from: 'invalid-email',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Test',
|
||||||
|
text: 'Test'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message.indexOf('Invalid sender email address') > -1).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
|
||||||
|
// Attempt with invalid recipient
|
||||||
|
errorThrown = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'invalid-recipient',
|
||||||
|
subject: 'Test',
|
||||||
|
text: 'Test'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message.indexOf('Invalid recipient email address') > -1).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
|
||||||
|
// Valid email should not throw
|
||||||
|
let validEmail: Email;
|
||||||
|
try {
|
||||||
|
validEmail = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Test',
|
||||||
|
text: 'Test'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validEmail).toBeTruthy();
|
||||||
|
expect(validEmail.from).toEqual('sender@example.com');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error === undefined).toEqual(true); // This should not happen
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
tap.stopForcefully();
|
||||||
|
})
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
154
test/test.smtp.client.compatibility.ts
Normal file
154
test/test.smtp.client.compatibility.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { smtpClientMod } from '../ts/mail/delivery/index.js';
|
||||||
|
import type { ISmtpClientOptions, SmtpClient } from '../ts/mail/delivery/smtpclient/index.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compatibility tests for the legacy SMTP client facade
|
||||||
|
*/
|
||||||
|
|
||||||
|
tap.test('verify backward compatibility - client creation', async () => {
|
||||||
|
// Create test configuration
|
||||||
|
const options: ISmtpClientOptions = {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
connectionTimeout: 10000,
|
||||||
|
domain: 'test.example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create SMTP client instance using legacy constructor
|
||||||
|
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||||
|
|
||||||
|
// Verify instance was created correctly
|
||||||
|
expect(smtpClient).toBeTruthy();
|
||||||
|
expect(smtpClient.isConnected()).toBeFalsy(); // Should start disconnected
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('verify backward compatibility - methods exist', async () => {
|
||||||
|
const options: ISmtpClientOptions = {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||||
|
|
||||||
|
// Verify all expected methods exist
|
||||||
|
expect(typeof smtpClient.sendMail === 'function').toBeTruthy();
|
||||||
|
expect(typeof smtpClient.verify === 'function').toBeTruthy();
|
||||||
|
expect(typeof smtpClient.isConnected === 'function').toBeTruthy();
|
||||||
|
expect(typeof smtpClient.getPoolStatus === 'function').toBeTruthy();
|
||||||
|
expect(typeof smtpClient.updateOptions === 'function').toBeTruthy();
|
||||||
|
expect(typeof smtpClient.close === 'function').toBeTruthy();
|
||||||
|
expect(typeof smtpClient.on === 'function').toBeTruthy();
|
||||||
|
expect(typeof smtpClient.off === 'function').toBeTruthy();
|
||||||
|
expect(typeof smtpClient.emit === 'function').toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('verify backward compatibility - options update', async () => {
|
||||||
|
const options: ISmtpClientOptions = {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||||
|
|
||||||
|
// Test option updates don't throw
|
||||||
|
expect(() => smtpClient.updateOptions({
|
||||||
|
host: 'new-smtp.example.com',
|
||||||
|
port: 465,
|
||||||
|
secure: true
|
||||||
|
})).not.toThrow();
|
||||||
|
|
||||||
|
expect(() => smtpClient.updateOptions({
|
||||||
|
debug: true,
|
||||||
|
connectionTimeout: 5000
|
||||||
|
})).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('verify backward compatibility - connection failure handling', async () => {
|
||||||
|
const options: ISmtpClientOptions = {
|
||||||
|
host: 'nonexistent.invalid.domain',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
connectionTimeout: 1000 // Short timeout for faster test
|
||||||
|
};
|
||||||
|
|
||||||
|
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||||
|
|
||||||
|
// verify() should return false for invalid hosts
|
||||||
|
const isValid = await smtpClient.verify();
|
||||||
|
expect(isValid).toBeFalsy();
|
||||||
|
|
||||||
|
// sendMail should fail gracefully for invalid hosts
|
||||||
|
const email = new Email({
|
||||||
|
from: 'test@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Test Email',
|
||||||
|
text: 'This is a test email'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await smtpClient.sendMail(email);
|
||||||
|
expect(result.success).toBeFalsy();
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
} catch (error) {
|
||||||
|
// Connection errors are expected for invalid domains
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('verify backward compatibility - pool status', async () => {
|
||||||
|
const options: ISmtpClientOptions = {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
pool: true,
|
||||||
|
maxConnections: 5
|
||||||
|
};
|
||||||
|
|
||||||
|
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||||
|
|
||||||
|
// Get pool status
|
||||||
|
const status = smtpClient.getPoolStatus();
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
expect(typeof status.total === 'number').toBeTruthy();
|
||||||
|
expect(typeof status.active === 'number').toBeTruthy();
|
||||||
|
expect(typeof status.idle === 'number').toBeTruthy();
|
||||||
|
expect(typeof status.pending === 'number').toBeTruthy();
|
||||||
|
|
||||||
|
// Initially should have no connections
|
||||||
|
expect(status.total).toEqual(0);
|
||||||
|
expect(status.active).toEqual(0);
|
||||||
|
expect(status.idle).toEqual(0);
|
||||||
|
expect(status.pending).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('verify backward compatibility - event handling', async () => {
|
||||||
|
const options: ISmtpClientOptions = {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||||
|
|
||||||
|
// Test event listener methods don't throw
|
||||||
|
const testListener = () => {};
|
||||||
|
|
||||||
|
expect(() => smtpClient.on('test', testListener)).not.toThrow();
|
||||||
|
expect(() => smtpClient.off('test', testListener)).not.toThrow();
|
||||||
|
expect(() => smtpClient.emit('test')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('clean up after compatibility tests', async () => {
|
||||||
|
// No-op - just to make sure everything is cleaned up properly
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
191
test/test.smtp.client.ts
Normal file
191
test/test.smtp.client.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
import * as paths from '../ts/paths.js';
|
||||||
|
import { smtpClientMod } from '../ts/mail/delivery/index.js';
|
||||||
|
import type { ISmtpClientOptions, SmtpClient } from '../ts/mail/delivery/smtpclient/index.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the SMTP client class
|
||||||
|
*/
|
||||||
|
tap.test('verify SMTP client initialization', async () => {
|
||||||
|
// Create test configuration
|
||||||
|
const options: ISmtpClientOptions = {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
connectionTimeout: 10000,
|
||||||
|
domain: 'test.example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create SMTP client instance
|
||||||
|
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||||
|
|
||||||
|
// Verify instance was created correctly
|
||||||
|
expect(smtpClient).toBeTruthy();
|
||||||
|
expect(smtpClient.isConnected()).toBeFalsy(); // Should start disconnected
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test SMTP client configuration update', async () => {
|
||||||
|
// Create test configuration
|
||||||
|
const options: ISmtpClientOptions = {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create SMTP client instance
|
||||||
|
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
smtpClient.updateOptions({
|
||||||
|
host: 'new-smtp.example.com',
|
||||||
|
port: 465,
|
||||||
|
secure: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Can't directly test private fields, but we can verify it doesn't throw
|
||||||
|
expect(() => smtpClient.updateOptions({
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}
|
||||||
|
})).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mocked SMTP server for testing
|
||||||
|
class MockSmtpServer {
|
||||||
|
private responses: Map<string, string>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.responses = new Map();
|
||||||
|
|
||||||
|
// Default responses
|
||||||
|
this.responses.set('connect', '220 smtp.example.com ESMTP ready');
|
||||||
|
this.responses.set('EHLO', '250-smtp.example.com\r\n250-PIPELINING\r\n250-SIZE 10240000\r\n250-STARTTLS\r\n250-AUTH PLAIN LOGIN\r\n250 HELP');
|
||||||
|
this.responses.set('MAIL FROM', '250 OK');
|
||||||
|
this.responses.set('RCPT TO', '250 OK');
|
||||||
|
this.responses.set('DATA', '354 Start mail input; end with <CRLF>.<CRLF>');
|
||||||
|
this.responses.set('data content', '250 OK: message accepted');
|
||||||
|
this.responses.set('QUIT', '221 Bye');
|
||||||
|
}
|
||||||
|
|
||||||
|
public setResponse(command: string, response: string): void {
|
||||||
|
this.responses.set(command, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getResponse(command: string): string {
|
||||||
|
if (command.startsWith('MAIL FROM')) {
|
||||||
|
return this.responses.get('MAIL FROM') || '250 OK';
|
||||||
|
} else if (command.startsWith('RCPT TO')) {
|
||||||
|
return this.responses.get('RCPT TO') || '250 OK';
|
||||||
|
} else if (command.startsWith('EHLO') || command.startsWith('HELO')) {
|
||||||
|
return this.responses.get('EHLO') || '250 OK';
|
||||||
|
} else if (command === 'DATA') {
|
||||||
|
return this.responses.get('DATA') || '354 Start mail input; end with <CRLF>.<CRLF>';
|
||||||
|
} else if (command.includes('Content-Type')) {
|
||||||
|
return this.responses.get('data content') || '250 OK: message accepted';
|
||||||
|
} else if (command === 'QUIT') {
|
||||||
|
return this.responses.get('QUIT') || '221 Bye';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.responses.get(command) || '250 OK';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test validates the SMTP client public interface
|
||||||
|
*/
|
||||||
|
tap.test('verify SMTP client email delivery functionality with mock', async () => {
|
||||||
|
// Create a test email
|
||||||
|
const testEmail = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: ['recipient@example.com'],
|
||||||
|
subject: 'Test Email',
|
||||||
|
text: 'This is a test email'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create SMTP client options
|
||||||
|
const options: ISmtpClientOptions = {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
domain: 'test.example.com',
|
||||||
|
auth: {
|
||||||
|
user: 'testuser',
|
||||||
|
pass: 'testpass'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create SMTP client instance
|
||||||
|
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||||
|
|
||||||
|
// Test public methods exist and have correct signatures
|
||||||
|
expect(typeof smtpClient.sendMail).toEqual('function');
|
||||||
|
expect(typeof smtpClient.verify).toEqual('function');
|
||||||
|
expect(typeof smtpClient.isConnected).toEqual('function');
|
||||||
|
expect(typeof smtpClient.getPoolStatus).toEqual('function');
|
||||||
|
expect(typeof smtpClient.updateOptions).toEqual('function');
|
||||||
|
expect(typeof smtpClient.close).toEqual('function');
|
||||||
|
|
||||||
|
// Test connection status before any operation
|
||||||
|
expect(smtpClient.isConnected()).toBeFalsy();
|
||||||
|
|
||||||
|
// Test pool status
|
||||||
|
const poolStatus = smtpClient.getPoolStatus();
|
||||||
|
expect(poolStatus).toBeTruthy();
|
||||||
|
expect(typeof poolStatus.active).toEqual('number');
|
||||||
|
expect(typeof poolStatus.idle).toEqual('number');
|
||||||
|
expect(typeof poolStatus.total).toEqual('number');
|
||||||
|
|
||||||
|
// Since we can't connect to a real server, we'll skip the actual send test
|
||||||
|
// and just verify the client was created correctly
|
||||||
|
expect(smtpClient).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test SMTP client error handling with mock', async () => {
|
||||||
|
// Create SMTP client instance
|
||||||
|
const smtpClient = smtpClientMod.createSmtpClient({
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with valid email (Email class might allow any string)
|
||||||
|
const testEmail = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: ['recipient@example.com'],
|
||||||
|
subject: 'Test Email',
|
||||||
|
text: 'This is a test email'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test event listener methods
|
||||||
|
const mockListener = () => {};
|
||||||
|
smtpClient.on('test-event', mockListener);
|
||||||
|
smtpClient.off('test-event', mockListener);
|
||||||
|
|
||||||
|
// Test update options
|
||||||
|
smtpClient.updateOptions({
|
||||||
|
auth: {
|
||||||
|
user: 'newuser',
|
||||||
|
pass: 'newpass'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify client is still functional
|
||||||
|
expect(smtpClient.isConnected()).toBeFalsy();
|
||||||
|
|
||||||
|
// Test close on a non-connected client
|
||||||
|
await smtpClient.close();
|
||||||
|
expect(smtpClient.isConnected()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final clean-up test
|
||||||
|
tap.test('clean up after tests', async () => {
|
||||||
|
// No-op - just to make sure everything is cleaned up properly
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* autocreated commitinfo by @push.rocks/commitinfo
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/mailer',
|
name: '@push.rocks/smartmta',
|
||||||
version: '1.0.1',
|
version: '3.0.0',
|
||||||
description: 'Enterprise mail server with SMTP, HTTP API, and DNS management - built for serve.zone infrastructure'
|
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* API Server
|
|
||||||
* HTTP REST API compatible with Mailgun
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
|
||||||
|
|
||||||
export interface IApiServerOptions {
|
|
||||||
port: number;
|
|
||||||
apiKeys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiServer {
|
|
||||||
private server: Deno.HttpServer | null = null;
|
|
||||||
|
|
||||||
constructor(private options: IApiServerOptions) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the API server
|
|
||||||
*/
|
|
||||||
async start(): Promise<void> {
|
|
||||||
console.log(`[ApiServer] Starting on port ${this.options.port}...`);
|
|
||||||
|
|
||||||
this.server = Deno.serve({ port: this.options.port }, (req) => {
|
|
||||||
return this.handleRequest(req);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the API server
|
|
||||||
*/
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
console.log('[ApiServer] Stopping...');
|
|
||||||
if (this.server) {
|
|
||||||
await this.server.shutdown();
|
|
||||||
this.server = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming HTTP request
|
|
||||||
*/
|
|
||||||
private async handleRequest(req: Request): Promise<Response> {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
|
|
||||||
// Basic routing
|
|
||||||
if (url.pathname === '/v1/messages' && req.method === 'POST') {
|
|
||||||
return this.handleSendEmail(req);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/v1/domains' && req.method === 'GET') {
|
|
||||||
return this.handleListDomains(req);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response('Not Found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSendEmail(req: Request): Promise<Response> {
|
|
||||||
// TODO: Implement email sending
|
|
||||||
return new Response(JSON.stringify({ message: 'Email queued' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleListDomains(req: Request): Promise<Response> {
|
|
||||||
// TODO: Implement domain listing
|
|
||||||
return new Response(JSON.stringify({ domains: [] }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* HTTP REST API module
|
|
||||||
* Mailgun-compatible API for sending and receiving emails
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './api-server.ts';
|
|
||||||
export * from './routes/index.ts';
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* API Routes
|
|
||||||
* Route handlers for the REST API
|
|
||||||
*/
|
|
||||||
|
|
||||||
// TODO: Implement route handlers
|
|
||||||
// - POST /v1/messages - Send email
|
|
||||||
// - GET/POST/DELETE /v1/domains - Domain management
|
|
||||||
// - GET/POST /v1/domains/:domain/credentials - SMTP credentials
|
|
||||||
// - GET /v1/events - Email events and logs
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mailer class stub
|
|
||||||
* Main mailer application class (replaces DcRouter from dcrouter)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { StorageManager } from './storage/index.ts';
|
|
||||||
import type { IMailerConfig } from './config/config-manager.ts';
|
|
||||||
|
|
||||||
export interface IMailerOptions {
|
|
||||||
config?: IMailerConfig;
|
|
||||||
dnsNsDomains?: string[];
|
|
||||||
dnsScopes?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Mailer {
|
|
||||||
public storageManager: StorageManager;
|
|
||||||
public options?: IMailerOptions;
|
|
||||||
|
|
||||||
constructor(options?: IMailerOptions) {
|
|
||||||
this.options = options;
|
|
||||||
this.storageManager = new StorageManager();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export type alias for compatibility
|
|
||||||
export type DcRouter = Mailer;
|
|
||||||
10
ts/cli.ts
10
ts/cli.ts
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* CLI entry point
|
|
||||||
* Main command-line interface
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MailerCli } from './cli/mailer-cli.ts';
|
|
||||||
|
|
||||||
// Create and run CLI
|
|
||||||
const cli = new MailerCli();
|
|
||||||
await cli.parseAndExecute(Deno.args);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* CLI module
|
|
||||||
* Command-line interface for mailer
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './mailer-cli.ts';
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mailer CLI
|
|
||||||
* Main command-line interface implementation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DaemonManager } from '../daemon/daemon-manager.ts';
|
|
||||||
import { ConfigManager } from '../config/config-manager.ts';
|
|
||||||
import { DnsManager } from '../dns/dns-manager.ts';
|
|
||||||
import { CloudflareClient } from '../dns/cloudflare-client.ts';
|
|
||||||
import { Email } from '../mail/core/index.ts';
|
|
||||||
import { commitinfo } from '../00_commitinfo_data.ts';
|
|
||||||
|
|
||||||
export class MailerCli {
|
|
||||||
private configManager: ConfigManager;
|
|
||||||
private daemonManager: DaemonManager;
|
|
||||||
private dnsManager: DnsManager;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.configManager = new ConfigManager();
|
|
||||||
this.daemonManager = new DaemonManager();
|
|
||||||
this.dnsManager = new DnsManager();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse and execute CLI commands
|
|
||||||
*/
|
|
||||||
async parseAndExecute(args: string[]): Promise<void> {
|
|
||||||
// Get command
|
|
||||||
const command = args[2] || 'help';
|
|
||||||
const subcommand = args[3];
|
|
||||||
const commandArgs = args.slice(4);
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (command) {
|
|
||||||
case 'service':
|
|
||||||
await this.handleServiceCommand(subcommand, commandArgs);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'domain':
|
|
||||||
await this.handleDomainCommand(subcommand, commandArgs);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'dns':
|
|
||||||
await this.handleDnsCommand(subcommand, commandArgs);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'send':
|
|
||||||
await this.handleSendCommand(commandArgs);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'config':
|
|
||||||
await this.handleConfigCommand(subcommand, commandArgs);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'version':
|
|
||||||
case '--version':
|
|
||||||
case '-v':
|
|
||||||
this.showVersion();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'help':
|
|
||||||
case '--help':
|
|
||||||
case '-h':
|
|
||||||
default:
|
|
||||||
this.showHelp();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error: ${error.message}`);
|
|
||||||
Deno.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle service commands (daemon control)
|
|
||||||
*/
|
|
||||||
private async handleServiceCommand(subcommand: string, args: string[]): Promise<void> {
|
|
||||||
switch (subcommand) {
|
|
||||||
case 'start':
|
|
||||||
console.log('Starting mailer daemon...');
|
|
||||||
await this.daemonManager.start();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'stop':
|
|
||||||
console.log('Stopping mailer daemon...');
|
|
||||||
await this.daemonManager.stop();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'restart':
|
|
||||||
console.log('Restarting mailer daemon...');
|
|
||||||
await this.daemonManager.stop();
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
await this.daemonManager.start();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'status':
|
|
||||||
console.log('Checking mailer daemon status...');
|
|
||||||
// TODO: Implement status check
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'enable':
|
|
||||||
console.log('Enabling mailer service (systemd)...');
|
|
||||||
// TODO: Implement systemd enable
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'disable':
|
|
||||||
console.log('Disabling mailer service (systemd)...');
|
|
||||||
// TODO: Implement systemd disable
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.log('Usage: mailer service {start|stop|restart|status|enable|disable}');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle domain management commands
|
|
||||||
*/
|
|
||||||
private async handleDomainCommand(subcommand: string, args: string[]): Promise<void> {
|
|
||||||
const config = await this.configManager.load();
|
|
||||||
|
|
||||||
switch (subcommand) {
|
|
||||||
case 'add': {
|
|
||||||
const domain = args[0];
|
|
||||||
if (!domain) {
|
|
||||||
console.error('Error: Domain name required');
|
|
||||||
console.log('Usage: mailer domain add <domain>');
|
|
||||||
Deno.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.domains.push({
|
|
||||||
domain,
|
|
||||||
dnsMode: 'external-dns',
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.configManager.save(config);
|
|
||||||
console.log(`✓ Domain ${domain} added`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'remove': {
|
|
||||||
const domain = args[0];
|
|
||||||
if (!domain) {
|
|
||||||
console.error('Error: Domain name required');
|
|
||||||
console.log('Usage: mailer domain remove <domain>');
|
|
||||||
Deno.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.domains = config.domains.filter(d => d.domain !== domain);
|
|
||||||
await this.configManager.save(config);
|
|
||||||
console.log(`✓ Domain ${domain} removed`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'list':
|
|
||||||
console.log('Configured domains:');
|
|
||||||
if (config.domains.length === 0) {
|
|
||||||
console.log(' (none)');
|
|
||||||
} else {
|
|
||||||
for (const domain of config.domains) {
|
|
||||||
console.log(` - ${domain.domain} (${domain.dnsMode})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.log('Usage: mailer domain {add|remove|list} [domain]');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle DNS commands
|
|
||||||
*/
|
|
||||||
private async handleDnsCommand(subcommand: string, args: string[]): Promise<void> {
|
|
||||||
const domain = args[0];
|
|
||||||
|
|
||||||
if (!domain && subcommand !== 'help') {
|
|
||||||
console.error('Error: Domain name required');
|
|
||||||
console.log('Usage: mailer dns {setup|validate|show} <domain>');
|
|
||||||
Deno.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (subcommand) {
|
|
||||||
case 'setup': {
|
|
||||||
console.log(`Setting up DNS for ${domain}...`);
|
|
||||||
|
|
||||||
const config = await this.configManager.load();
|
|
||||||
const domainConfig = config.domains.find(d => d.domain === domain);
|
|
||||||
|
|
||||||
if (!domainConfig) {
|
|
||||||
console.error(`Error: Domain ${domain} not configured. Add it first with: mailer domain add ${domain}`);
|
|
||||||
Deno.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!domainConfig.cloudflare?.apiToken) {
|
|
||||||
console.error('Error: Cloudflare API token not configured');
|
|
||||||
console.log('Set it with: mailer config set cloudflare.apiToken <token>');
|
|
||||||
Deno.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloudflare = new CloudflareClient({ apiToken: domainConfig.cloudflare.apiToken });
|
|
||||||
const records = this.dnsManager.getRequiredRecords(domain, config.hostname);
|
|
||||||
await cloudflare.createRecords(domain, records);
|
|
||||||
|
|
||||||
console.log(`✓ DNS records created for ${domain}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'validate': {
|
|
||||||
console.log(`Validating DNS for ${domain}...`);
|
|
||||||
const result = await this.dnsManager.validateDomain(domain);
|
|
||||||
|
|
||||||
if (result.valid) {
|
|
||||||
console.log(`✓ DNS configuration is valid`);
|
|
||||||
} else {
|
|
||||||
console.log(`✗ DNS configuration has errors:`);
|
|
||||||
for (const error of result.errors) {
|
|
||||||
console.log(` - ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.warnings.length > 0) {
|
|
||||||
console.log('Warnings:');
|
|
||||||
for (const warning of result.warnings) {
|
|
||||||
console.log(` - ${warning}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'show': {
|
|
||||||
console.log(`Required DNS records for ${domain}:`);
|
|
||||||
const config = await this.configManager.load();
|
|
||||||
const records = this.dnsManager.getRequiredRecords(domain, config.hostname);
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
console.log(`\n${record.type} Record:`);
|
|
||||||
console.log(` Name: ${record.name}`);
|
|
||||||
console.log(` Value: ${record.value}`);
|
|
||||||
if (record.priority) console.log(` Priority: ${record.priority}`);
|
|
||||||
if (record.ttl) console.log(` TTL: ${record.ttl}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.log('Usage: mailer dns {setup|validate|show} <domain>');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle send command
|
|
||||||
*/
|
|
||||||
private async handleSendCommand(args: string[]): Promise<void> {
|
|
||||||
console.log('Sending email...');
|
|
||||||
|
|
||||||
// Parse basic arguments
|
|
||||||
const from = args[args.indexOf('--from') + 1];
|
|
||||||
const to = args[args.indexOf('--to') + 1];
|
|
||||||
const subject = args[args.indexOf('--subject') + 1];
|
|
||||||
const text = args[args.indexOf('--text') + 1];
|
|
||||||
|
|
||||||
if (!from || !to || !subject || !text) {
|
|
||||||
console.error('Error: Missing required arguments');
|
|
||||||
console.log('Usage: mailer send --from <email> --to <email> --subject <subject> --text <text>');
|
|
||||||
Deno.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = new Email({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
subject,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`✓ Email created: ${email.toString()}`);
|
|
||||||
// TODO: Actually send the email via SMTP client
|
|
||||||
console.log('TODO: Implement actual sending');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle config commands
|
|
||||||
*/
|
|
||||||
private async handleConfigCommand(subcommand: string, args: string[]): Promise<void> {
|
|
||||||
const config = await this.configManager.load();
|
|
||||||
|
|
||||||
switch (subcommand) {
|
|
||||||
case 'show':
|
|
||||||
console.log('Current configuration:');
|
|
||||||
console.log(JSON.stringify(config, null, 2));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'set': {
|
|
||||||
const key = args[0];
|
|
||||||
const value = args[1];
|
|
||||||
|
|
||||||
if (!key || !value) {
|
|
||||||
console.error('Error: Key and value required');
|
|
||||||
console.log('Usage: mailer config set <key> <value>');
|
|
||||||
Deno.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple key-value setting (can be enhanced)
|
|
||||||
if (key === 'smtpPort') config.smtpPort = parseInt(value);
|
|
||||||
else if (key === 'apiPort') config.apiPort = parseInt(value);
|
|
||||||
else if (key === 'hostname') config.hostname = value;
|
|
||||||
else {
|
|
||||||
console.error(`Error: Unknown config key: ${key}`);
|
|
||||||
Deno.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.configManager.save(config);
|
|
||||||
console.log(`✓ Configuration updated: ${key} = ${value}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.log('Usage: mailer config {show|set} [key] [value]');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show version information
|
|
||||||
*/
|
|
||||||
private showVersion(): void {
|
|
||||||
console.log(`${commitinfo.name} v${commitinfo.version}`);
|
|
||||||
console.log(commitinfo.description);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show help information
|
|
||||||
*/
|
|
||||||
private showHelp(): void {
|
|
||||||
console.log(`
|
|
||||||
${commitinfo.name} v${commitinfo.version}
|
|
||||||
${commitinfo.description}
|
|
||||||
|
|
||||||
Usage: mailer <command> [options]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
service <action> Daemon service control
|
|
||||||
start Start the mailer daemon
|
|
||||||
stop Stop the mailer daemon
|
|
||||||
restart Restart the mailer daemon
|
|
||||||
status Show daemon status
|
|
||||||
enable Enable systemd service
|
|
||||||
disable Disable systemd service
|
|
||||||
|
|
||||||
domain <action> [domain] Domain management
|
|
||||||
add <domain> Add a domain
|
|
||||||
remove <domain> Remove a domain
|
|
||||||
list List all domains
|
|
||||||
|
|
||||||
dns <action> <domain> DNS management
|
|
||||||
setup <domain> Auto-configure DNS via Cloudflare
|
|
||||||
validate <domain> Validate DNS configuration
|
|
||||||
show <domain> Show required DNS records
|
|
||||||
|
|
||||||
send [options] Send an email
|
|
||||||
--from <email> Sender email address
|
|
||||||
--to <email> Recipient email address
|
|
||||||
--subject <subject> Email subject
|
|
||||||
--text <text> Email body text
|
|
||||||
|
|
||||||
config <action> Configuration management
|
|
||||||
show Show current configuration
|
|
||||||
set <key> <value> Set configuration value
|
|
||||||
|
|
||||||
version, -v, --version Show version information
|
|
||||||
help, -h, --help Show this help message
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
mailer service start Start the mailer daemon
|
|
||||||
mailer domain add example.com Add example.com domain
|
|
||||||
mailer dns setup example.com Setup DNS for example.com
|
|
||||||
mailer send --from sender@example.com --to recipient@example.com \\
|
|
||||||
--subject "Hello" --text "World"
|
|
||||||
|
|
||||||
For more information, visit:
|
|
||||||
https://code.foss.global/serve.zone/mailer
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* Configuration Manager
|
|
||||||
* Handles configuration storage and retrieval
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
|
||||||
|
|
||||||
export interface IMailerConfig {
|
|
||||||
domains: IDomainConfig[];
|
|
||||||
apiKeys: string[];
|
|
||||||
smtpPort: number;
|
|
||||||
apiPort: number;
|
|
||||||
hostname: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IDomainConfig {
|
|
||||||
domain: string;
|
|
||||||
dnsMode: 'forward' | 'internal-dns' | 'external-dns';
|
|
||||||
cloudflare?: {
|
|
||||||
apiToken: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ConfigManager {
|
|
||||||
private configPath: string;
|
|
||||||
private config: IMailerConfig | null = null;
|
|
||||||
|
|
||||||
constructor(configPath?: string) {
|
|
||||||
this.configPath = configPath || plugins.path.join(Deno.env.get('HOME') || '/root', '.mailer', 'config.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load configuration from disk
|
|
||||||
*/
|
|
||||||
async load(): Promise<IMailerConfig> {
|
|
||||||
try {
|
|
||||||
const data = await Deno.readTextFile(this.configPath);
|
|
||||||
this.config = JSON.parse(data);
|
|
||||||
return this.config!;
|
|
||||||
} catch (error) {
|
|
||||||
// Return default config if file doesn't exist
|
|
||||||
this.config = this.getDefaultConfig();
|
|
||||||
return this.config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save configuration to disk
|
|
||||||
*/
|
|
||||||
async save(config: IMailerConfig): Promise<void> {
|
|
||||||
this.config = config;
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
const dir = plugins.path.dirname(this.configPath);
|
|
||||||
await Deno.mkdir(dir, { recursive: true });
|
|
||||||
|
|
||||||
// Write config
|
|
||||||
await Deno.writeTextFile(this.configPath, JSON.stringify(config, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current configuration
|
|
||||||
*/
|
|
||||||
getConfig(): IMailerConfig {
|
|
||||||
if (!this.config) {
|
|
||||||
throw new Error('Configuration not loaded. Call load() first.');
|
|
||||||
}
|
|
||||||
return this.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default configuration
|
|
||||||
*/
|
|
||||||
private getDefaultConfig(): IMailerConfig {
|
|
||||||
return {
|
|
||||||
domains: [],
|
|
||||||
apiKeys: [],
|
|
||||||
smtpPort: 25,
|
|
||||||
apiPort: 8080,
|
|
||||||
hostname: 'localhost',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* Configuration module
|
|
||||||
* Configuration management and secure storage
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './config-manager.ts';
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
/**
|
|
||||||
* Daemon Manager
|
|
||||||
* Manages the background mailer service
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { SmtpServer } from '../mail/delivery/placeholder.ts';
|
|
||||||
import { ApiServer } from '../api/api-server.ts';
|
|
||||||
import { ConfigManager } from '../config/config-manager.ts';
|
|
||||||
|
|
||||||
export class DaemonManager {
|
|
||||||
private smtpServer: SmtpServer | null = null;
|
|
||||||
private apiServer: ApiServer | null = null;
|
|
||||||
private configManager: ConfigManager;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.configManager = new ConfigManager();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the daemon
|
|
||||||
*/
|
|
||||||
async start(): Promise<void> {
|
|
||||||
console.log('[Daemon] Starting mailer daemon...');
|
|
||||||
|
|
||||||
// Load configuration
|
|
||||||
const config = await this.configManager.load();
|
|
||||||
|
|
||||||
// Start SMTP server
|
|
||||||
this.smtpServer = new SmtpServer({ port: config.smtpPort, hostname: config.hostname });
|
|
||||||
await this.smtpServer.start();
|
|
||||||
|
|
||||||
// Start API server
|
|
||||||
this.apiServer = new ApiServer({ port: config.apiPort, apiKeys: config.apiKeys });
|
|
||||||
await this.apiServer.start();
|
|
||||||
|
|
||||||
console.log('[Daemon] Mailer daemon started successfully');
|
|
||||||
console.log(`[Daemon] SMTP server: ${config.hostname}:${config.smtpPort}`);
|
|
||||||
console.log(`[Daemon] API server: http://${config.hostname}:${config.apiPort}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the daemon
|
|
||||||
*/
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
console.log('[Daemon] Stopping mailer daemon...');
|
|
||||||
|
|
||||||
if (this.smtpServer) {
|
|
||||||
await this.smtpServer.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.apiServer) {
|
|
||||||
await this.apiServer.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Daemon] Mailer daemon stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* Daemon module
|
|
||||||
* Background service for SMTP server and API server
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './daemon-manager.ts';
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Deliverability module stub
|
|
||||||
* IP warmup and sender reputation monitoring
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface IIPWarmupConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
initialLimit: number;
|
|
||||||
maxLimit: number;
|
|
||||||
incrementPerDay: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReputationMonitorConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
checkInterval: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IPWarmupManager {
|
|
||||||
constructor(config: IIPWarmupConfig) {
|
|
||||||
// Stub implementation
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCurrentLimit(ip: string): Promise<number> {
|
|
||||||
return 1000; // Stub: return high limit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SenderReputationMonitor {
|
|
||||||
constructor(config: IReputationMonitorConfig) {
|
|
||||||
// Stub implementation
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkReputation(domain: string): Promise<{ score: number; issues: string[] }> {
|
|
||||||
return { score: 100, issues: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cloudflare DNS Client
|
|
||||||
* Automatic DNS record management via Cloudflare API
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
|
||||||
import type { IDnsRecord } from './dns-manager.ts';
|
|
||||||
|
|
||||||
export interface ICloudflareConfig {
|
|
||||||
apiToken: string;
|
|
||||||
email?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CloudflareClient {
|
|
||||||
constructor(private config: ICloudflareConfig) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create DNS records for a domain
|
|
||||||
*/
|
|
||||||
async createRecords(domain: string, records: IDnsRecord[]): Promise<void> {
|
|
||||||
console.log(`[CloudflareClient] Would create ${records.length} DNS records for ${domain}`);
|
|
||||||
|
|
||||||
// TODO: Implement actual Cloudflare API integration using @apiclient.xyz/cloudflare
|
|
||||||
for (const record of records) {
|
|
||||||
console.log(` - ${record.type} ${record.name} -> ${record.value}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify DNS records exist
|
|
||||||
*/
|
|
||||||
async verifyRecords(domain: string, records: IDnsRecord[]): Promise<boolean> {
|
|
||||||
console.log(`[CloudflareClient] Would verify ${records.length} DNS records for ${domain}`);
|
|
||||||
// TODO: Implement actual verification
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* DNS Manager
|
|
||||||
* Handles DNS record management and validation for email domains
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
|
||||||
|
|
||||||
export interface IDnsRecord {
|
|
||||||
type: 'MX' | 'TXT' | 'A' | 'AAAA';
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
priority?: number;
|
|
||||||
ttl?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IDnsValidationResult {
|
|
||||||
valid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
warnings: string[];
|
|
||||||
requiredRecords: IDnsRecord[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DnsManager {
|
|
||||||
/**
|
|
||||||
* Get required DNS records for a domain
|
|
||||||
*/
|
|
||||||
getRequiredRecords(domain: string, mailServerIp: string): IDnsRecord[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: 'MX',
|
|
||||||
name: domain,
|
|
||||||
value: `mail.${domain}`,
|
|
||||||
priority: 10,
|
|
||||||
ttl: 3600,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'A',
|
|
||||||
name: `mail.${domain}`,
|
|
||||||
value: mailServerIp,
|
|
||||||
ttl: 3600,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'TXT',
|
|
||||||
name: domain,
|
|
||||||
value: `v=spf1 mx ip4:${mailServerIp} ~all`,
|
|
||||||
ttl: 3600,
|
|
||||||
},
|
|
||||||
// TODO: Add DKIM and DMARC records
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate DNS configuration for a domain
|
|
||||||
*/
|
|
||||||
async validateDomain(domain: string): Promise<IDnsValidationResult> {
|
|
||||||
const result: IDnsValidationResult = {
|
|
||||||
valid: true,
|
|
||||||
errors: [],
|
|
||||||
warnings: [],
|
|
||||||
requiredRecords: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: Implement actual DNS validation
|
|
||||||
console.log(`[DnsManager] Would validate DNS for ${domain}`);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* DNS management module
|
|
||||||
* DNS validation and Cloudflare integration for automatic DNS setup
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './dns-manager.ts';
|
|
||||||
export * from './cloudflare-client.ts';
|
|
||||||
@@ -1,24 +1,119 @@
|
|||||||
/**
|
/**
|
||||||
* Error types module stub
|
* MTA error classes for SMTP client operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class SmtpError extends Error {
|
export class MtaConnectionError extends Error {
|
||||||
constructor(message: string, public code?: number) {
|
public code: string;
|
||||||
|
public details?: any;
|
||||||
|
constructor(message: string, detailsOrCode?: any) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'SmtpError';
|
this.name = 'MtaConnectionError';
|
||||||
|
if (typeof detailsOrCode === 'string') {
|
||||||
|
this.code = detailsOrCode;
|
||||||
|
} else {
|
||||||
|
this.code = 'CONNECTION_ERROR';
|
||||||
|
this.details = detailsOrCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static timeout(host: string, port: number, timeoutMs?: number): MtaConnectionError {
|
||||||
|
return new MtaConnectionError(`Connection to ${host}:${port} timed out${timeoutMs ? ` after ${timeoutMs}ms` : ''}`, 'TIMEOUT');
|
||||||
|
}
|
||||||
|
static refused(host: string, port: number): MtaConnectionError {
|
||||||
|
return new MtaConnectionError(`Connection to ${host}:${port} refused`, 'REFUSED');
|
||||||
|
}
|
||||||
|
static dnsError(host: string, err?: any): MtaConnectionError {
|
||||||
|
const errMsg = typeof err === 'string' ? err : err?.message || '';
|
||||||
|
return new MtaConnectionError(`DNS resolution failed for ${host}${errMsg ? `: ${errMsg}` : ''}`, 'DNS_ERROR');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthenticationError extends Error {
|
export class MtaAuthenticationError extends Error {
|
||||||
constructor(message: string) {
|
public code: string;
|
||||||
|
public details?: any;
|
||||||
|
constructor(message: string, detailsOrCode?: any) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'AuthenticationError';
|
this.name = 'MtaAuthenticationError';
|
||||||
|
if (typeof detailsOrCode === 'string') {
|
||||||
|
this.code = detailsOrCode;
|
||||||
|
} else {
|
||||||
|
this.code = 'AUTH_ERROR';
|
||||||
|
this.details = detailsOrCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static invalidCredentials(host?: string, user?: string): MtaAuthenticationError {
|
||||||
|
const detail = host && user ? `${user}@${host}` : host || user || '';
|
||||||
|
return new MtaAuthenticationError(`Authentication failed${detail ? `: ${detail}` : ''}`, 'INVALID_CREDENTIALS');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RateLimitError extends Error {
|
export class MtaDeliveryError extends Error {
|
||||||
constructor(message: string) {
|
public code: string;
|
||||||
|
public responseCode?: number;
|
||||||
|
public details?: any;
|
||||||
|
constructor(message: string, detailsOrCode?: any, responseCode?: number) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'RateLimitError';
|
this.name = 'MtaDeliveryError';
|
||||||
|
if (typeof detailsOrCode === 'string') {
|
||||||
|
this.code = detailsOrCode;
|
||||||
|
this.responseCode = responseCode;
|
||||||
|
} else {
|
||||||
|
this.code = 'DELIVERY_ERROR';
|
||||||
|
this.details = detailsOrCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static temporary(message: string, ...args: any[]): MtaDeliveryError {
|
||||||
|
return new MtaDeliveryError(message, 'TEMPORARY');
|
||||||
|
}
|
||||||
|
static permanent(message: string, ...args: any[]): MtaDeliveryError {
|
||||||
|
return new MtaDeliveryError(message, 'PERMANENT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MtaConfigurationError extends Error {
|
||||||
|
public code: string;
|
||||||
|
public details?: any;
|
||||||
|
constructor(message: string, detailsOrCode?: any) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'MtaConfigurationError';
|
||||||
|
if (typeof detailsOrCode === 'string') {
|
||||||
|
this.code = detailsOrCode;
|
||||||
|
} else {
|
||||||
|
this.code = 'CONFIG_ERROR';
|
||||||
|
this.details = detailsOrCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MtaTimeoutError extends Error {
|
||||||
|
public code: string;
|
||||||
|
public details?: any;
|
||||||
|
constructor(message: string, detailsOrCode?: any) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'MtaTimeoutError';
|
||||||
|
if (typeof detailsOrCode === 'string') {
|
||||||
|
this.code = detailsOrCode;
|
||||||
|
} else {
|
||||||
|
this.code = 'TIMEOUT';
|
||||||
|
this.details = detailsOrCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static commandTimeout(command: string, hostOrTimeout?: any, timeoutMs?: number): MtaTimeoutError {
|
||||||
|
const timeout = typeof hostOrTimeout === 'number' ? hostOrTimeout : timeoutMs;
|
||||||
|
return new MtaTimeoutError(`Command '${command}' timed out${timeout ? ` after ${timeout}ms` : ''}`, 'COMMAND_TIMEOUT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MtaProtocolError extends Error {
|
||||||
|
public code: string;
|
||||||
|
public details?: any;
|
||||||
|
constructor(message: string, detailsOrCode?: any) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'MtaProtocolError';
|
||||||
|
if (typeof detailsOrCode === 'string') {
|
||||||
|
this.code = detailsOrCode;
|
||||||
|
} else {
|
||||||
|
this.code = 'PROTOCOL_ERROR';
|
||||||
|
this.details = detailsOrCode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
ts/index.ts
12
ts/index.ts
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* @serve.zone/mailer
|
|
||||||
* Enterprise mail server with SMTP, HTTP API, and DNS management
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Export public API
|
|
||||||
export * from './mail/core/index.ts';
|
|
||||||
export * from './mail/delivery/index.ts';
|
|
||||||
export * from './mail/routing/index.ts';
|
|
||||||
export * from './api/index.ts';
|
|
||||||
export * from './dns/index.ts';
|
|
||||||
export * from './config/index.ts';
|
|
||||||
98
ts/logger.ts
98
ts/logger.ts
@@ -1,11 +1,91 @@
|
|||||||
/**
|
import * as plugins from './plugins.js';
|
||||||
* Logger module
|
import { randomUUID } from 'node:crypto';
|
||||||
* Simple logging for mailer
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const logger = {
|
// Map NODE_ENV to valid TEnvironment
|
||||||
log: (level: string, message: string, ...args: any[]) => {
|
const nodeEnv = process.env.NODE_ENV || 'production';
|
||||||
const timestamp = new Date().toISOString();
|
const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
|
||||||
console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`, ...args);
|
'development': 'local',
|
||||||
},
|
'test': 'test',
|
||||||
|
'staging': 'staging',
|
||||||
|
'production': 'production'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Default Smartlog instance
|
||||||
|
const baseLogger = new plugins.smartlog.Smartlog({
|
||||||
|
logContext: {
|
||||||
|
environment: envMap[nodeEnv] || 'production',
|
||||||
|
runtime: 'node',
|
||||||
|
zone: 'serve.zone',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extended logger compatible with the original enhanced logger API
|
||||||
|
class StandardLogger {
|
||||||
|
private defaultContext: Record<string, any> = {};
|
||||||
|
private correlationId: string | null = null;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
// Log methods
|
||||||
|
public log(level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context: Record<string, any> = {}) {
|
||||||
|
const combinedContext = {
|
||||||
|
...this.defaultContext,
|
||||||
|
...context
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.correlationId) {
|
||||||
|
combinedContext.correlation_id = this.correlationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseLogger.log(level, message, combinedContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public error(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('error', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public warn(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('warn', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public info(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('info', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public success(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('success', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public debug(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('debug', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context management
|
||||||
|
public setContext(context: Record<string, any>, overwrite: boolean = false) {
|
||||||
|
if (overwrite) {
|
||||||
|
this.defaultContext = context;
|
||||||
|
} else {
|
||||||
|
this.defaultContext = {
|
||||||
|
...this.defaultContext,
|
||||||
|
...context
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correlation ID management
|
||||||
|
public setCorrelationId(id: string | null = null): string {
|
||||||
|
this.correlationId = id || randomUUID();
|
||||||
|
return this.correlationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCorrelationId(): string | null {
|
||||||
|
return this.correlationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearCorrelationId(): void {
|
||||||
|
this.correlationId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton instance
|
||||||
|
export const logger = new StandardLogger();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import * as plugins from '../../plugins.ts';
|
import * as plugins from '../../plugins.js';
|
||||||
import * as paths from '../../paths.ts';
|
import * as paths from '../../paths.js';
|
||||||
import { logger } from '../../logger.ts';
|
import { logger } from '../../logger.js';
|
||||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.ts';
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import type { Email } from './classes.email.ts';
|
import type { Email } from './classes.email.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bounce types for categorizing the reasons for bounces
|
* Bounce types for categorizing the reasons for bounces
|
||||||
@@ -63,112 +64,6 @@ export interface BounceRecord {
|
|||||||
nextRetryTime?: number;
|
nextRetryTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Email bounce patterns to identify bounce types in SMTP responses and bounce messages
|
|
||||||
*/
|
|
||||||
const BOUNCE_PATTERNS = {
|
|
||||||
// Hard bounce patterns
|
|
||||||
[BounceType.INVALID_RECIPIENT]: [
|
|
||||||
/no such user/i,
|
|
||||||
/user unknown/i,
|
|
||||||
/does not exist/i,
|
|
||||||
/invalid recipient/i,
|
|
||||||
/unknown recipient/i,
|
|
||||||
/no mailbox/i,
|
|
||||||
/user not found/i,
|
|
||||||
/recipient address rejected/i,
|
|
||||||
/550 5\.1\.1/i
|
|
||||||
],
|
|
||||||
[BounceType.DOMAIN_NOT_FOUND]: [
|
|
||||||
/domain not found/i,
|
|
||||||
/unknown domain/i,
|
|
||||||
/no such domain/i,
|
|
||||||
/host not found/i,
|
|
||||||
/domain invalid/i,
|
|
||||||
/550 5\.1\.2/i
|
|
||||||
],
|
|
||||||
[BounceType.MAILBOX_FULL]: [
|
|
||||||
/mailbox full/i,
|
|
||||||
/over quota/i,
|
|
||||||
/quota exceeded/i,
|
|
||||||
/552 5\.2\.2/i
|
|
||||||
],
|
|
||||||
[BounceType.MAILBOX_INACTIVE]: [
|
|
||||||
/mailbox disabled/i,
|
|
||||||
/mailbox inactive/i,
|
|
||||||
/account disabled/i,
|
|
||||||
/mailbox not active/i,
|
|
||||||
/account suspended/i
|
|
||||||
],
|
|
||||||
[BounceType.BLOCKED]: [
|
|
||||||
/blocked/i,
|
|
||||||
/rejected/i,
|
|
||||||
/denied/i,
|
|
||||||
/blacklisted/i,
|
|
||||||
/prohibited/i,
|
|
||||||
/refused/i,
|
|
||||||
/550 5\.7\./i
|
|
||||||
],
|
|
||||||
[BounceType.SPAM_RELATED]: [
|
|
||||||
/spam/i,
|
|
||||||
/bulk mail/i,
|
|
||||||
/content rejected/i,
|
|
||||||
/message rejected/i,
|
|
||||||
/550 5\.7\.1/i
|
|
||||||
],
|
|
||||||
|
|
||||||
// Soft bounce patterns
|
|
||||||
[BounceType.SERVER_UNAVAILABLE]: [
|
|
||||||
/server unavailable/i,
|
|
||||||
/service unavailable/i,
|
|
||||||
/try again later/i,
|
|
||||||
/try later/i,
|
|
||||||
/451 4\.3\./i,
|
|
||||||
/421 4\.3\./i
|
|
||||||
],
|
|
||||||
[BounceType.TEMPORARY_FAILURE]: [
|
|
||||||
/temporary failure/i,
|
|
||||||
/temporary error/i,
|
|
||||||
/temporary problem/i,
|
|
||||||
/try again/i,
|
|
||||||
/451 4\./i
|
|
||||||
],
|
|
||||||
[BounceType.QUOTA_EXCEEDED]: [
|
|
||||||
/quota temporarily exceeded/i,
|
|
||||||
/mailbox temporarily full/i,
|
|
||||||
/452 4\.2\.2/i
|
|
||||||
],
|
|
||||||
[BounceType.NETWORK_ERROR]: [
|
|
||||||
/network error/i,
|
|
||||||
/connection error/i,
|
|
||||||
/connection timed out/i,
|
|
||||||
/routing error/i,
|
|
||||||
/421 4\.4\./i
|
|
||||||
],
|
|
||||||
[BounceType.TIMEOUT]: [
|
|
||||||
/timed out/i,
|
|
||||||
/timeout/i,
|
|
||||||
/450 4\.4\.2/i
|
|
||||||
],
|
|
||||||
|
|
||||||
// Auto-responses
|
|
||||||
[BounceType.AUTO_RESPONSE]: [
|
|
||||||
/auto[- ]reply/i,
|
|
||||||
/auto[- ]response/i,
|
|
||||||
/vacation/i,
|
|
||||||
/out of office/i,
|
|
||||||
/away from office/i,
|
|
||||||
/on vacation/i,
|
|
||||||
/automatic reply/i
|
|
||||||
],
|
|
||||||
[BounceType.CHALLENGE_RESPONSE]: [
|
|
||||||
/challenge[- ]response/i,
|
|
||||||
/verify your email/i,
|
|
||||||
/confirm your email/i,
|
|
||||||
/email verification/i
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retry strategy configuration for soft bounces
|
* Retry strategy configuration for soft bounces
|
||||||
*/
|
*/
|
||||||
@@ -269,16 +164,16 @@ export class BounceManager {
|
|||||||
nextRetryTime: bounceData.nextRetryTime
|
nextRetryTime: bounceData.nextRetryTime
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine bounce type and category if not provided
|
// Determine bounce type and category via Rust bridge if not provided
|
||||||
if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) {
|
if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) {
|
||||||
const bounceInfo = this.detectBounceType(
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
bounce.smtpResponse || '',
|
const rustResult = await bridge.detectBounce({
|
||||||
bounce.diagnosticCode || '',
|
smtpResponse: bounce.smtpResponse,
|
||||||
bounce.statusCode || ''
|
diagnosticCode: bounce.diagnosticCode,
|
||||||
);
|
statusCode: bounce.statusCode,
|
||||||
|
});
|
||||||
bounce.bounceType = bounceInfo.type;
|
bounce.bounceType = rustResult.bounce_type as BounceType;
|
||||||
bounce.bounceCategory = bounceInfo.category;
|
bounce.bounceCategory = rustResult.category as BounceCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the bounce based on category
|
// Process the bounce based on category
|
||||||
@@ -647,13 +542,12 @@ export class BounceManager {
|
|||||||
|
|
||||||
if (this.storageManager) {
|
if (this.storageManager) {
|
||||||
// Use storage manager
|
// Use storage manager
|
||||||
await this.storageManager.set('/email/bounces/suppression-list.tson', suppressionData);
|
await this.storageManager.set('/email/bounces/suppression-list.json', suppressionData);
|
||||||
} else {
|
} else {
|
||||||
// Fall back to filesystem
|
// Fall back to filesystem
|
||||||
plugins.smartfile.memory.toFsSync(
|
await plugins.smartfs.file(
|
||||||
suppressionData,
|
plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json')
|
||||||
plugins.path.join(paths.dataDir, 'emails', 'suppression_list.tson')
|
).write(suppressionData);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('error', `Failed to save suppression list: ${error.message}`);
|
logger.log('error', `Failed to save suppression list: ${error.message}`);
|
||||||
@@ -670,13 +564,13 @@ export class BounceManager {
|
|||||||
|
|
||||||
if (this.storageManager) {
|
if (this.storageManager) {
|
||||||
// Try to load from storage manager first
|
// Try to load from storage manager first
|
||||||
const suppressionData = await this.storageManager.get('/email/bounces/suppression-list.tson');
|
const suppressionData = await this.storageManager.get('/email/bounces/suppression-list.json');
|
||||||
|
|
||||||
if (suppressionData) {
|
if (suppressionData) {
|
||||||
entries = JSON.parse(suppressionData);
|
entries = JSON.parse(suppressionData);
|
||||||
} else {
|
} else {
|
||||||
// Check if data exists in filesystem and migrate
|
// Check if data exists in filesystem and migrate
|
||||||
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.tson');
|
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
|
||||||
|
|
||||||
if (plugins.fs.existsSync(suppressionPath)) {
|
if (plugins.fs.existsSync(suppressionPath)) {
|
||||||
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
|
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
|
||||||
@@ -688,7 +582,7 @@ export class BounceManager {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No storage manager, use filesystem directly
|
// No storage manager, use filesystem directly
|
||||||
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.tson');
|
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
|
||||||
|
|
||||||
if (plugins.fs.existsSync(suppressionPath)) {
|
if (plugins.fs.existsSync(suppressionPath)) {
|
||||||
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
|
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
|
||||||
@@ -732,21 +626,21 @@ export class BounceManager {
|
|||||||
|
|
||||||
if (this.storageManager) {
|
if (this.storageManager) {
|
||||||
// Use storage manager
|
// Use storage manager
|
||||||
await this.storageManager.set(`/email/bounces/records/${bounce.id}.tson`, bounceData);
|
await this.storageManager.set(`/email/bounces/records/${bounce.id}.json`, bounceData);
|
||||||
} else {
|
} else {
|
||||||
// Fall back to filesystem
|
// Fall back to filesystem
|
||||||
const bouncePath = plugins.path.join(
|
const bouncePath = plugins.path.join(
|
||||||
paths.dataDir,
|
paths.dataDir,
|
||||||
'emails',
|
'emails',
|
||||||
'bounces',
|
'bounces',
|
||||||
`${bounce.id}.tson`
|
`${bounce.id}.json`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
const bounceDir = plugins.path.join(paths.dataDir, 'emails', 'bounces');
|
const bounceDir = plugins.path.join(paths.dataDir, 'emails', 'bounces');
|
||||||
plugins.smartfile.fs.ensureDirSync(bounceDir);
|
await plugins.smartfs.directory(bounceDir).recursive().create();
|
||||||
|
|
||||||
plugins.smartfile.memory.toFsSync(bounceData, bouncePath);
|
await plugins.smartfs.file(bouncePath).write(bounceData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('error', `Failed to save bounce record: ${error.message}`);
|
logger.log('error', `Failed to save bounce record: ${error.message}`);
|
||||||
@@ -792,134 +686,6 @@ export class BounceManager {
|
|||||||
return this.bounceCache.get(email.toLowerCase()) || null;
|
return this.bounceCache.get(email.toLowerCase()) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze SMTP response and diagnostic codes to determine bounce type
|
|
||||||
* @param smtpResponse SMTP response string
|
|
||||||
* @param diagnosticCode Diagnostic code from bounce
|
|
||||||
* @param statusCode Status code from bounce
|
|
||||||
* @returns Detected bounce type and category
|
|
||||||
*/
|
|
||||||
private detectBounceType(
|
|
||||||
smtpResponse: string,
|
|
||||||
diagnosticCode: string,
|
|
||||||
statusCode: string
|
|
||||||
): {
|
|
||||||
type: BounceType;
|
|
||||||
category: BounceCategory;
|
|
||||||
} {
|
|
||||||
// Combine all text for comprehensive pattern matching
|
|
||||||
const fullText = `${smtpResponse} ${diagnosticCode} ${statusCode}`.toLowerCase();
|
|
||||||
|
|
||||||
// Check for auto-responses first
|
|
||||||
if (this.matchesPattern(fullText, BounceType.AUTO_RESPONSE) ||
|
|
||||||
this.matchesPattern(fullText, BounceType.CHALLENGE_RESPONSE)) {
|
|
||||||
return {
|
|
||||||
type: BounceType.AUTO_RESPONSE,
|
|
||||||
category: BounceCategory.AUTO_RESPONSE
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for hard bounces
|
|
||||||
for (const bounceType of [
|
|
||||||
BounceType.INVALID_RECIPIENT,
|
|
||||||
BounceType.DOMAIN_NOT_FOUND,
|
|
||||||
BounceType.MAILBOX_FULL,
|
|
||||||
BounceType.MAILBOX_INACTIVE,
|
|
||||||
BounceType.BLOCKED,
|
|
||||||
BounceType.SPAM_RELATED,
|
|
||||||
BounceType.POLICY_RELATED
|
|
||||||
]) {
|
|
||||||
if (this.matchesPattern(fullText, bounceType)) {
|
|
||||||
return {
|
|
||||||
type: bounceType,
|
|
||||||
category: BounceCategory.HARD
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for soft bounces
|
|
||||||
for (const bounceType of [
|
|
||||||
BounceType.SERVER_UNAVAILABLE,
|
|
||||||
BounceType.TEMPORARY_FAILURE,
|
|
||||||
BounceType.QUOTA_EXCEEDED,
|
|
||||||
BounceType.NETWORK_ERROR,
|
|
||||||
BounceType.TIMEOUT
|
|
||||||
]) {
|
|
||||||
if (this.matchesPattern(fullText, bounceType)) {
|
|
||||||
return {
|
|
||||||
type: bounceType,
|
|
||||||
category: BounceCategory.SOFT
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle DSN (Delivery Status Notification) status codes
|
|
||||||
if (statusCode) {
|
|
||||||
// Format: class.subject.detail
|
|
||||||
const parts = statusCode.split('.');
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
const statusClass = parts[0];
|
|
||||||
const statusSubject = parts[1];
|
|
||||||
|
|
||||||
// 5.X.X is permanent failure (hard bounce)
|
|
||||||
if (statusClass === '5') {
|
|
||||||
// Try to determine specific type based on subject
|
|
||||||
if (statusSubject === '1') {
|
|
||||||
return { type: BounceType.INVALID_RECIPIENT, category: BounceCategory.HARD };
|
|
||||||
} else if (statusSubject === '2') {
|
|
||||||
return { type: BounceType.MAILBOX_FULL, category: BounceCategory.HARD };
|
|
||||||
} else if (statusSubject === '7') {
|
|
||||||
return { type: BounceType.BLOCKED, category: BounceCategory.HARD };
|
|
||||||
} else {
|
|
||||||
return { type: BounceType.UNKNOWN, category: BounceCategory.HARD };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4.X.X is temporary failure (soft bounce)
|
|
||||||
if (statusClass === '4') {
|
|
||||||
// Try to determine specific type based on subject
|
|
||||||
if (statusSubject === '2') {
|
|
||||||
return { type: BounceType.QUOTA_EXCEEDED, category: BounceCategory.SOFT };
|
|
||||||
} else if (statusSubject === '3') {
|
|
||||||
return { type: BounceType.SERVER_UNAVAILABLE, category: BounceCategory.SOFT };
|
|
||||||
} else if (statusSubject === '4') {
|
|
||||||
return { type: BounceType.NETWORK_ERROR, category: BounceCategory.SOFT };
|
|
||||||
} else {
|
|
||||||
return { type: BounceType.TEMPORARY_FAILURE, category: BounceCategory.SOFT };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to unknown
|
|
||||||
return {
|
|
||||||
type: BounceType.UNKNOWN,
|
|
||||||
category: BounceCategory.UNKNOWN
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if text matches any pattern for a bounce type
|
|
||||||
* @param text Text to check against patterns
|
|
||||||
* @param bounceType Bounce type to get patterns for
|
|
||||||
* @returns Whether the text matches any pattern
|
|
||||||
*/
|
|
||||||
private matchesPattern(text: string, bounceType: BounceType): boolean {
|
|
||||||
const patterns = BOUNCE_PATTERNS[bounceType];
|
|
||||||
|
|
||||||
if (!patterns) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
if (pattern.test(text)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all known hard bounced addresses
|
* Get all known hard bounced addresses
|
||||||
* @returns Array of hard bounced email addresses
|
* @returns Array of hard bounced email addresses
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as plugins from '../../plugins.ts';
|
import * as plugins from '../../plugins.js';
|
||||||
import { EmailValidator } from './classes.emailvalidator.ts';
|
import { EmailValidator } from './classes.emailvalidator.js';
|
||||||
|
|
||||||
export interface IAttachment {
|
export interface IAttachment {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -614,10 +614,11 @@ export class Email {
|
|||||||
|
|
||||||
// Add attachments
|
// Add attachments
|
||||||
for (const attachment of this.attachments) {
|
for (const attachment of this.attachments) {
|
||||||
const smartAttachment = await plugins.smartfile.SmartFile.fromBuffer(
|
const smartAttachment = new plugins.smartfile.SmartFile({
|
||||||
attachment.filename,
|
path: attachment.filename,
|
||||||
attachment.content
|
contentBuffer: attachment.content,
|
||||||
);
|
base: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
// Set content type if available
|
// Set content type if available
|
||||||
if (attachment.contentType) {
|
if (attachment.contentType) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as plugins from '../../plugins.ts';
|
import * as plugins from '../../plugins.js';
|
||||||
import { logger } from '../../logger.ts';
|
import { logger } from '../../logger.js';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
||||||
export interface IEmailValidationResult {
|
export interface IEmailValidationResult {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as plugins from '../../plugins.ts';
|
import * as plugins from '../../plugins.js';
|
||||||
import * as paths from '../../paths.ts';
|
import * as paths from '../../paths.js';
|
||||||
import { logger } from '../../logger.ts';
|
import { logger } from '../../logger.js';
|
||||||
import { Email, type IEmailOptions, type IAttachment } from './classes.email.ts';
|
import { Email, type IEmailOptions, type IAttachment } from './classes.email.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email template type definition
|
* Email template type definition
|
||||||
@@ -291,7 +291,7 @@ export class TemplateManager {
|
|||||||
|
|
||||||
// Get all JSON files
|
// Get all JSON files
|
||||||
const files = plugins.fs.readdirSync(directory)
|
const files = plugins.fs.readdirSync(directory)
|
||||||
.filter(file => file.endsWith('.tson'));
|
.filter(file => file.endsWith('.json'));
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
/**
|
|
||||||
* Mail core module
|
|
||||||
* Email classes, validation, templates, and bounce management
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Core email components
|
// Core email components
|
||||||
export * from './classes.email.ts';
|
export * from './classes.email.js';
|
||||||
export * from './classes.emailvalidator.ts';
|
export * from './classes.emailvalidator.js';
|
||||||
export * from './classes.templatemanager.ts';
|
export * from './classes.templatemanager.js';
|
||||||
export * from './classes.bouncemanager.ts';
|
export * from './classes.bouncemanager.js';
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as plugins from '../../plugins.ts';
|
import * as plugins from '../../plugins.js';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { logger } from '../../logger.ts';
|
import { logger } from '../../logger.js';
|
||||||
import { type EmailProcessingMode } from '../routing/classes.email.config.ts';
|
import { type EmailProcessingMode } from '../routing/classes.email.config.js';
|
||||||
import type { IEmailRoute } from '../routing/interfaces.ts';
|
import type { IEmailRoute } from '../routing/interfaces.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue item status
|
* Queue item status
|
||||||
@@ -426,7 +426,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private async persistItem(item: IQueueItem): Promise<void> {
|
private async persistItem(item: IQueueItem): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(this.options.persistentPath, `${item.id}.tson`);
|
const filePath = path.join(this.options.persistentPath, `${item.id}.json`);
|
||||||
await fs.promises.writeFile(filePath, JSON.stringify(item, null, 2), 'utf8');
|
await fs.promises.writeFile(filePath, JSON.stringify(item, null, 2), 'utf8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('error', `Failed to persist item ${item.id}: ${error.message}`);
|
logger.log('error', `Failed to persist item ${item.id}: ${error.message}`);
|
||||||
@@ -440,7 +440,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private async removeItemFromDisk(id: string): Promise<void> {
|
private async removeItemFromDisk(id: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(this.options.persistentPath, `${id}.tson`);
|
const filePath = path.join(this.options.persistentPath, `${id}.json`);
|
||||||
|
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
@@ -462,7 +462,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all JSON files
|
// Get all JSON files
|
||||||
const files = fs.readdirSync(this.options.persistentPath).filter(file => file.endsWith('.tson'));
|
const files = fs.readdirSync(this.options.persistentPath).filter(file => file.endsWith('.json'));
|
||||||
|
|
||||||
// Load each file
|
// Load each file
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import * as plugins from '../../plugins.ts';
|
import * as plugins from '../../plugins.js';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import * as net from 'node:net';
|
import * as net from 'node:net';
|
||||||
import * as tls from 'node:tls';
|
import * as tls from 'node:tls';
|
||||||
import { logger } from '../../logger.ts';
|
import { logger } from '../../logger.js';
|
||||||
import {
|
import {
|
||||||
SecurityLogger,
|
SecurityLogger,
|
||||||
SecurityLogLevel,
|
SecurityLogLevel,
|
||||||
SecurityEventType
|
SecurityEventType
|
||||||
} from '../../security/index.ts';
|
} from '../../security/index.js';
|
||||||
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.ts';
|
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
|
||||||
import type { Email } from '../core/classes.email.ts';
|
import type { Email } from '../core/classes.email.js';
|
||||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.ts';
|
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||||
import type { SmtpClient } from './smtpclient/smtp-client.ts';
|
import type { SmtpClient } from './smtpclient/smtp-client.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delivery status enumeration
|
* Delivery status enumeration
|
||||||
@@ -764,28 +765,23 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|||||||
// Ensure DKIM keys exist for the domain
|
// Ensure DKIM keys exist for the domain
|
||||||
await this.emailServer.dkimCreator.handleDKIMKeysForDomain(domainName);
|
await this.emailServer.dkimCreator.handleDKIMKeysForDomain(domainName);
|
||||||
|
|
||||||
|
// Get the private key
|
||||||
|
const dkimPrivateKey = (await this.emailServer.dkimCreator.readDKIMKeys(domainName)).privateKey;
|
||||||
|
|
||||||
// Convert Email to raw format for signing
|
// Convert Email to raw format for signing
|
||||||
const rawEmail = email.toRFC822String();
|
const rawEmail = email.toRFC822String();
|
||||||
|
|
||||||
// Sign the email
|
// Sign via Rust bridge
|
||||||
const signResult = await plugins.dkimSign(rawEmail, {
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
canonicalization: 'relaxed/relaxed',
|
const signResult = await bridge.signDkim({
|
||||||
algorithm: 'rsa-sha256',
|
rawMessage: rawEmail,
|
||||||
signTime: new Date(),
|
domain: domainName,
|
||||||
signatureData: [
|
|
||||||
{
|
|
||||||
signingDomain: domainName,
|
|
||||||
selector: keySelector,
|
selector: keySelector,
|
||||||
privateKey: (await this.emailServer.dkimCreator.readDKIMKeys(domainName)).privateKey,
|
privateKey: dkimPrivateKey,
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
canonicalization: 'relaxed/relaxed'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add the DKIM-Signature header to the email
|
if (signResult.header) {
|
||||||
if (signResult.signatures) {
|
email.addHeader('DKIM-Signature', signResult.header);
|
||||||
email.addHeader('DKIM-Signature', signResult.signatures);
|
|
||||||
logger.log('info', `Successfully added DKIM signature for ${domainName}`);
|
logger.log('info', `Successfully added DKIM signature for ${domainName}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as plugins from '../../plugins.ts';
|
import * as plugins from '../../plugins.js';
|
||||||
import * as paths from '../../paths.ts';
|
import * as paths from '../../paths.js';
|
||||||
import { Email } from '../core/classes.email.ts';
|
import { Email } from '../core/classes.email.js';
|
||||||
import { EmailSignJob } from './classes.emailsignjob.ts';
|
import { EmailSignJob } from './classes.emailsignjob.js';
|
||||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.ts';
|
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||||
import type { SmtpClient } from './smtpclient/smtp-client.ts';
|
import type { SmtpClient } from './smtpclient/smtp-client.js';
|
||||||
import type { ISmtpSendResult } from './smtpclient/interfaces.ts';
|
import type { ISmtpSendResult } from './smtpclient/interfaces.js';
|
||||||
|
|
||||||
// Configuration options for email sending
|
// Configuration options for email sending
|
||||||
export interface IEmailSendOptions {
|
export interface IEmailSendOptions {
|
||||||
@@ -225,28 +225,6 @@ export class EmailSendJob {
|
|||||||
this.log(`Connecting to ${mxServer}:25`);
|
this.log(`Connecting to ${mxServer}:25`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if IP warmup is enabled and get an IP to use
|
|
||||||
let localAddress: string | undefined = undefined;
|
|
||||||
try {
|
|
||||||
const fromDomain = this.email.getFromDomain();
|
|
||||||
const bestIP = this.emailServerRef.getBestIPForSending({
|
|
||||||
from: this.email.from,
|
|
||||||
to: this.email.getAllRecipients(),
|
|
||||||
domain: fromDomain,
|
|
||||||
isTransactional: this.email.priority === 'high'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (bestIP) {
|
|
||||||
this.log(`Using warmed-up IP ${bestIP} for sending`);
|
|
||||||
localAddress = bestIP;
|
|
||||||
|
|
||||||
// Record the send for warm-up tracking
|
|
||||||
this.emailServerRef.recordIPSend(bestIP);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Error selecting IP address: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get SMTP client from UnifiedEmailServer
|
// Get SMTP client from UnifiedEmailServer
|
||||||
const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25);
|
const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25);
|
||||||
|
|
||||||
@@ -400,13 +378,13 @@ export class EmailSendJob {
|
|||||||
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_success.eml`;
|
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_success.eml`;
|
||||||
const filePath = plugins.path.join(paths.sentEmailsDir, fileName);
|
const filePath = plugins.path.join(paths.sentEmailsDir, fileName);
|
||||||
|
|
||||||
await plugins.smartfile.fs.ensureDir(paths.sentEmailsDir);
|
await plugins.smartfs.directory(paths.sentEmailsDir).recursive().create();
|
||||||
await plugins.smartfile.memory.toFs(emailContent, filePath);
|
await plugins.smartfs.file(filePath).write(emailContent);
|
||||||
|
|
||||||
// Also save delivery info
|
// Also save delivery info
|
||||||
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.tson`;
|
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.json`;
|
||||||
const infoPath = plugins.path.join(paths.sentEmailsDir, infoFileName);
|
const infoPath = plugins.path.join(paths.sentEmailsDir, infoFileName);
|
||||||
await plugins.smartfile.memory.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath);
|
await plugins.smartfs.file(infoPath).write(JSON.stringify(this.deliveryInfo, null, 2));
|
||||||
|
|
||||||
this.log(`Email saved to ${fileName}`);
|
this.log(`Email saved to ${fileName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -424,13 +402,13 @@ export class EmailSendJob {
|
|||||||
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_failed.eml`;
|
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_failed.eml`;
|
||||||
const filePath = plugins.path.join(paths.failedEmailsDir, fileName);
|
const filePath = plugins.path.join(paths.failedEmailsDir, fileName);
|
||||||
|
|
||||||
await plugins.smartfile.fs.ensureDir(paths.failedEmailsDir);
|
await plugins.smartfs.directory(paths.failedEmailsDir).recursive().create();
|
||||||
await plugins.smartfile.memory.toFs(emailContent, filePath);
|
await plugins.smartfs.file(filePath).write(emailContent);
|
||||||
|
|
||||||
// Also save delivery info with error details
|
// Also save delivery info with error details
|
||||||
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.tson`;
|
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.json`;
|
||||||
const infoPath = plugins.path.join(paths.failedEmailsDir, infoFileName);
|
const infoPath = plugins.path.join(paths.failedEmailsDir, infoFileName);
|
||||||
await plugins.smartfile.memory.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath);
|
await plugins.smartfs.file(infoPath).write(JSON.stringify(this.deliveryInfo, null, 2));
|
||||||
|
|
||||||
this.log(`Failed email saved to ${fileName}`);
|
this.log(`Failed email saved to ${fileName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.ts';
|
import * as plugins from '../../plugins.js';
|
||||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.ts';
|
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
interface Headers {
|
interface Headers {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
@@ -27,41 +28,14 @@ export class EmailSignJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
||||||
const signResult = await plugins.dkimSign(emailMessage, {
|
const privateKey = await this.loadPrivateKey();
|
||||||
// Optional, default canonicalization, default is "relaxed/relaxed"
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
canonicalization: 'relaxed/relaxed', // c=
|
const signResult = await bridge.signDkim({
|
||||||
|
rawMessage: emailMessage,
|
||||||
// Optional, default signing and hashing algorithm
|
domain: this.jobOptions.domain,
|
||||||
// Mostly useful when you want to use rsa-sha1, otherwise no need to set
|
selector: this.jobOptions.selector,
|
||||||
algorithm: 'rsa-sha256',
|
privateKey,
|
||||||
|
|
||||||
// Optional, default is current time
|
|
||||||
signTime: new Date(), // t=
|
|
||||||
|
|
||||||
// Keys for one or more signatures
|
|
||||||
// Different signatures can use different algorithms (mostly useful when
|
|
||||||
// you want to sign a message both with RSA and Ed25519)
|
|
||||||
signatureData: [
|
|
||||||
{
|
|
||||||
signingDomain: this.jobOptions.domain, // d=
|
|
||||||
selector: this.jobOptions.selector, // s=
|
|
||||||
// supported key types: RSA, Ed25519
|
|
||||||
privateKey: await this.loadPrivateKey(), // k=
|
|
||||||
|
|
||||||
// Optional algorithm, default is derived from the key.
|
|
||||||
// Overrides whatever was set in parent object
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
|
|
||||||
// Optional signature specifc canonicalization, overrides whatever was set in parent object
|
|
||||||
canonicalization: 'relaxed/relaxed', // c=
|
|
||||||
|
|
||||||
// Maximum number of canonicalized body bytes to sign (eg. the "l=" tag).
|
|
||||||
// Do not use though. This is available only for compatibility testing.
|
|
||||||
// maxBodyLength: 12345
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const signature = signResult.signatures;
|
return signResult.header;
|
||||||
return signature;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import * as plugins from '../../plugins.ts';
|
import * as plugins from '../../plugins.js';
|
||||||
import * as paths from '../../paths.ts';
|
import * as paths from '../../paths.js';
|
||||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.ts';
|
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configures email server storage settings
|
* Configures email server storage settings
|
||||||
* @param emailServer Reference to the unified email server
|
* @param emailServer Reference to the unified email server
|
||||||
* @param options Configuration options containing storage paths
|
* @param options Configuration options containing storage paths
|
||||||
*/
|
*/
|
||||||
export function configureEmailStorage(emailServer: UnifiedEmailServer, options: any): void {
|
export async function configureEmailStorage(emailServer: UnifiedEmailServer, options: any): Promise<void> {
|
||||||
// Extract the receivedEmailsPath if available
|
// Extract the receivedEmailsPath if available
|
||||||
if (options?.emailPortConfig?.receivedEmailsPath) {
|
if (options?.emailPortConfig?.receivedEmailsPath) {
|
||||||
const receivedEmailsPath = options.emailPortConfig.receivedEmailsPath;
|
const receivedEmailsPath = options.emailPortConfig.receivedEmailsPath;
|
||||||
|
|
||||||
// Ensure the directory exists
|
// Ensure the directory exists
|
||||||
plugins.smartfile.fs.ensureDirSync(receivedEmailsPath);
|
await plugins.smartfs.directory(receivedEmailsPath).recursive().create();
|
||||||
|
|
||||||
// Set path for received emails
|
// Set path for received emails
|
||||||
if (emailServer) {
|
if (emailServer) {
|
||||||
// Storage paths are now handled by the unified email server system
|
// Storage paths are now handled by the unified email server system
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
|
await plugins.smartfs.directory(paths.receivedEmailsDir).recursive().create();
|
||||||
|
|
||||||
console.log(`Configured email server to store received emails to: ${receivedEmailsPath}`);
|
console.log(`Configured email server to store received emails to: ${receivedEmailsPath}`);
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ export function configureEmailStorage(emailServer: UnifiedEmailServer, options:
|
|||||||
* @param emailServer Reference to the unified email server
|
* @param emailServer Reference to the unified email server
|
||||||
* @param config Configuration settings for email server
|
* @param config Configuration settings for email server
|
||||||
*/
|
*/
|
||||||
export function configureEmailServer(
|
export async function configureEmailServer(
|
||||||
emailServer: UnifiedEmailServer,
|
emailServer: UnifiedEmailServer,
|
||||||
config: {
|
config: {
|
||||||
ports?: number[];
|
ports?: number[];
|
||||||
@@ -42,7 +42,7 @@ export function configureEmailServer(
|
|||||||
};
|
};
|
||||||
storagePath?: string;
|
storagePath?: string;
|
||||||
}
|
}
|
||||||
): boolean {
|
): Promise<boolean> {
|
||||||
if (!emailServer) {
|
if (!emailServer) {
|
||||||
console.error('Email server not available');
|
console.error('Email server not available');
|
||||||
return false;
|
return false;
|
||||||
@@ -62,7 +62,7 @@ export function configureEmailServer(
|
|||||||
|
|
||||||
// Set up storage path if provided
|
// Set up storage path if provided
|
||||||
if (config.storagePath) {
|
if (config.storagePath) {
|
||||||
configureEmailStorage(emailServer, {
|
await configureEmailStorage(emailServer, {
|
||||||
emailPortConfig: {
|
emailPortConfig: {
|
||||||
receivedEmailsPath: config.storagePath
|
receivedEmailsPath: config.storagePath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { logger } from '../../logger.ts';
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for rate limiter
|
* Configuration options for rate limiter
|
||||||
@@ -38,6 +38,12 @@ interface TokenBucket {
|
|||||||
|
|
||||||
/** Total denied requests */
|
/** Total denied requests */
|
||||||
denied: number;
|
denied: number;
|
||||||
|
|
||||||
|
/** Error count for blocking decisions */
|
||||||
|
errors: number;
|
||||||
|
|
||||||
|
/** Timestamp of first error in current window */
|
||||||
|
firstErrorTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,7 +80,9 @@ export class RateLimiter {
|
|||||||
tokens: this.config.initialTokens,
|
tokens: this.config.initialTokens,
|
||||||
lastRefill: Date.now(),
|
lastRefill: Date.now(),
|
||||||
allowed: 0,
|
allowed: 0,
|
||||||
denied: 0
|
denied: 0,
|
||||||
|
errors: 0,
|
||||||
|
firstErrorTime: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log initialization
|
// Log initialization
|
||||||
@@ -196,7 +204,9 @@ export class RateLimiter {
|
|||||||
tokens: this.config.initialTokens,
|
tokens: this.config.initialTokens,
|
||||||
lastRefill: Date.now(),
|
lastRefill: Date.now(),
|
||||||
allowed: 0,
|
allowed: 0,
|
||||||
denied: 0
|
denied: 0,
|
||||||
|
errors: 0,
|
||||||
|
firstErrorTime: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,4 +288,38 @@ export class RateLimiter {
|
|||||||
logger.log('debug', `Cleaned up ${removed} stale rate limit buckets`);
|
logger.log('debug', `Cleaned up ${removed} stale rate limit buckets`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an error for a key (e.g., IP address) and determine if blocking is needed
|
||||||
|
* RFC 5321 Section 4.5.4.1 suggests limiting errors to prevent abuse
|
||||||
|
*
|
||||||
|
* @param key Key to record error for (typically an IP address)
|
||||||
|
* @param errorWindow Time window for error tracking in ms (default: 5 minutes)
|
||||||
|
* @param errorThreshold Maximum errors before blocking (default: 10)
|
||||||
|
* @returns true if the key should be blocked due to excessive errors
|
||||||
|
*/
|
||||||
|
public recordError(key: string, errorWindow: number = 5 * 60 * 1000, errorThreshold: number = 10): boolean {
|
||||||
|
const bucket = this.getBucket(key);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Reset error count if the time window has expired
|
||||||
|
if (bucket.firstErrorTime === 0 || now - bucket.firstErrorTime > errorWindow) {
|
||||||
|
bucket.errors = 0;
|
||||||
|
bucket.firstErrorTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment error count
|
||||||
|
bucket.errors++;
|
||||||
|
|
||||||
|
// Log error tracking
|
||||||
|
logger.log('debug', `Error recorded for ${key}: ${bucket.errors}/${errorThreshold} in window`);
|
||||||
|
|
||||||
|
// Check if threshold exceeded
|
||||||
|
if (bucket.errors >= errorThreshold) {
|
||||||
|
logger.log('warn', `Error threshold exceeded for ${key}: ${bucket.errors} errors`);
|
||||||
|
return true; // Should block
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // Continue allowing
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user