Initial commit on Forgejo
Fresh repository history for elektrine/elektrine-haraka hosted at https://git.elektrine.com/elektrine/elektrine-haraka.
This commit is contained in:
commit
67c1d41a0c
82 changed files with 8713 additions and 0 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
node_modules
|
||||
npm-debug.log*
|
||||
.git
|
||||
.gitignore
|
||||
README
|
||||
docs/
|
||||
logs/
|
||||
ansible/
|
||||
nginx/
|
||||
*.md
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
148
.forgejo/workflows/ci-deploy.yml
Normal file
148
.forgejo/workflows/ci-deploy.yml
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# Forgejo Actions: test, build/push image, deploy to production (same host as Elektrine).
|
||||
# Secrets: DEPLOY_SSH_KEY, DEPLOY_SSH_HOST_KEY, REGISTRY_USER, REGISTRY_TOKEN
|
||||
name: CI and deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: haraka-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: git.elektrine.com
|
||||
IMAGE_NAME: elektrine/elektrine-haraka
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$GITHUB_WORKSPACE" in
|
||||
/data/*) HOST_WORKSPACE="/opt/forgejo-runner/data${GITHUB_WORKSPACE#/data}" ;;
|
||||
*) HOST_WORKSPACE="$GITHUB_WORKSPACE" ;;
|
||||
esac
|
||||
test -f "$GITHUB_WORKSPACE/package.json"
|
||||
docker run --rm --network host \
|
||||
--volume "$HOST_WORKSPACE:/app" \
|
||||
--workdir /app \
|
||||
node:20-bookworm \
|
||||
bash -lc 'npm ci && npm test'
|
||||
|
||||
build_and_deploy:
|
||||
name: Build, push, and deploy
|
||||
needs: test
|
||||
if: >-
|
||||
github.ref == 'refs/heads/main' &&
|
||||
github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST || 'elektrine.com' }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER || 'elektrine-deploy' }}
|
||||
DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '22' }}
|
||||
DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/elektrine-haraka' }}
|
||||
IMAGE: git.elektrine.com/elektrine/elektrine-haraka:${{ github.sha }}
|
||||
IMAGE_LATEST: git.elektrine.com/elektrine/elektrine-haraka:latest
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Log in to Forgejo registry
|
||||
env:
|
||||
REG_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REG_PASS: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin
|
||||
|
||||
- name: Build and push image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -t "$IMAGE" -t "$IMAGE_LATEST" .
|
||||
docker push "$IMAGE"
|
||||
docker push "$IMAGE_LATEST"
|
||||
echo "Pushed $IMAGE"
|
||||
|
||||
- name: Configure SSH
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_SSH_HOST_KEY: ${{ secrets.DEPLOY_SSH_HOST_KEY || '66.42.127.87 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM5Vx8UDcqvgmBwDs5SlCHd8/oGDoQytGHhbbp4LNK4o' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 0700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" | sed 's/\r$//' | sed 's/\\n/\n/g' > ~/.ssh/id_ed25519
|
||||
chmod 0600 ~/.ssh/id_ed25519
|
||||
: > ~/.ssh/known_hosts
|
||||
printf '%s\n' "$DEPLOY_SSH_HOST_KEY" | sed 's/\r$//' | sed 's/\\n/\n/g' >> ~/.ssh/known_hosts
|
||||
ssh-keyscan -p "$DEPLOY_PORT" -T 5 -t ed25519,rsa "$DEPLOY_HOST" 2>/dev/null >> ~/.ssh/known_hosts || true
|
||||
ip=$(getent ahostsv4 "$DEPLOY_HOST" 2>/dev/null | awk '{print $1; exit}' || true)
|
||||
if [ -n "${ip:-}" ]; then
|
||||
ssh-keyscan -p "$DEPLOY_PORT" -T 5 -t ed25519,rsa "$ip" 2>/dev/null >> ~/.ssh/known_hosts || true
|
||||
fi
|
||||
chmod 0600 ~/.ssh/known_hosts
|
||||
ssh -p "$DEPLOY_PORT" \
|
||||
-i "$HOME/.ssh/id_ed25519" \
|
||||
-o BatchMode=yes -o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" 'echo ssh_ok; hostname'
|
||||
|
||||
- name: Deploy over SSH
|
||||
env:
|
||||
REG_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REG_PASS: ${{ secrets.REGISTRY_TOKEN }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -p "$DEPLOY_PORT" \
|
||||
-i "$HOME/.ssh/id_ed25519" \
|
||||
-o BatchMode=yes -o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" bash -s <<EOF
|
||||
set -euo pipefail
|
||||
DEPLOY_PATH="$DEPLOY_PATH"
|
||||
IMAGE="$IMAGE"
|
||||
IMAGE_LATEST="$IMAGE_LATEST"
|
||||
REG_USER="$REG_USER"
|
||||
REG_PASS="$REG_PASS"
|
||||
SHA="$GITHUB_SHA"
|
||||
|
||||
echo "\$REG_PASS" | docker login git.elektrine.com -u "\$REG_USER" --password-stdin
|
||||
docker pull "\$IMAGE"
|
||||
docker tag "\$IMAGE" "\$IMAGE_LATEST"
|
||||
|
||||
# Persist image preference when writable; always override for this roll-out.
|
||||
ENV_FILE="\$DEPLOY_PATH/deployment/.env"
|
||||
if [ -w "\$ENV_FILE" ]; then
|
||||
if grep -q '^HARAKA_IMAGE=' "\$ENV_FILE"; then
|
||||
sed -i 's|^HARAKA_IMAGE=.*|HARAKA_IMAGE=git.elektrine.com/elektrine/elektrine-haraka|' "\$ENV_FILE"
|
||||
else
|
||||
echo 'HARAKA_IMAGE=git.elektrine.com/elektrine/elektrine-haraka' >> "\$ENV_FILE"
|
||||
fi
|
||||
if grep -q '^HARAKA_IMAGE_TAG=' "\$ENV_FILE"; then
|
||||
sed -i "s|^HARAKA_IMAGE_TAG=.*|HARAKA_IMAGE_TAG=\$SHA|" "\$ENV_FILE"
|
||||
else
|
||||
echo "HARAKA_IMAGE_TAG=\$SHA" >> "\$ENV_FILE"
|
||||
fi
|
||||
else
|
||||
echo "Warn: \$ENV_FILE not writable; using env overrides for this deploy"
|
||||
fi
|
||||
|
||||
cd "\$DEPLOY_PATH/deployment"
|
||||
export HARAKA_IMAGE=git.elektrine.com/elektrine/elektrine-haraka
|
||||
export HARAKA_IMAGE_TAG="\$SHA"
|
||||
COMPOSE="docker compose --env-file .env -f docker-compose.same-server.yml"
|
||||
\$COMPOSE pull haraka-inbound haraka-submission haraka-outbound haraka-worker || true
|
||||
\$COMPOSE up -d haraka-inbound haraka-submission haraka-outbound haraka-worker
|
||||
\$COMPOSE ps
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep -i haraka || true
|
||||
echo DEPLOY_OK
|
||||
EOF
|
||||
118
.gitignore
vendored
Normal file
118
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Queue files (emails being processed)
|
||||
queue/
|
||||
spool/
|
||||
mail_spool/
|
||||
|
||||
# Log files
|
||||
log/
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Runtime/temporary files
|
||||
*.pid
|
||||
*.tmp
|
||||
run/
|
||||
tmp/
|
||||
|
||||
# Node.js dependencies (if any)
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# SSL/TLS certificates and keys
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
config/tls_cert.pem
|
||||
config/tls_key.pem
|
||||
certs/
|
||||
config/etc/
|
||||
|
||||
# Authentication files (contain passwords)
|
||||
config/smtp_users
|
||||
config/auth_flat_file_users
|
||||
config/auth_vpopmaild_users
|
||||
|
||||
# API keys and secrets
|
||||
config/*_api.ini
|
||||
config/webhook.ini
|
||||
config/elektrine_http_api.ini
|
||||
.env
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*~
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# DKIM keys (private keys should not be in git)
|
||||
dkim/*.private
|
||||
config/dkim/*.private
|
||||
|
||||
# Database files (if using any)
|
||||
*.db
|
||||
*.sqlite*
|
||||
|
||||
# Testing artifacts
|
||||
test_results/
|
||||
coverage/
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS generated files
|
||||
.directory
|
||||
.fseventsd
|
||||
.DocumentRevisions-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
|
||||
# Haraka specific
|
||||
haraka.pid
|
||||
|
||||
Or for minimal version:
|
||||
|
||||
# Essential Haraka .gitignore
|
||||
|
||||
# Never commit these
|
||||
queue/
|
||||
log/
|
||||
*.log
|
||||
*.pid
|
||||
|
||||
# Security sensitive
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
config/smtp_users
|
||||
config/*_api.ini
|
||||
config/etc/
|
||||
.env
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# Backups
|
||||
*.bak
|
||||
*~
|
||||
# Local environment files
|
||||
.env.*
|
||||
deployment/.env
|
||||
deployment/.env.*
|
||||
!deployment/.env.example
|
||||
!deployment/.env.*.example
|
||||
|
||||
# Local DKIM material
|
||||
config/dkim.backup*/
|
||||
config/dkim/
|
||||
/config/dkim/
|
||||
/config/dkim.backup*/
|
||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
FROM node:18-alpine
|
||||
|
||||
# Install build dependencies for native modules
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
ENV HARAKA_DKIM_DIR=/data/haraka/dkim
|
||||
|
||||
# Install system dependencies and Haraka globally.
|
||||
# iconv must be global because Haraka is installed/executed globally.
|
||||
RUN apk add --no-cache libarchive-tools openssl caddy redis && npm install -g Haraka toobusy-js iconv
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy configuration and plugins
|
||||
COPY config/ ./config/
|
||||
COPY docker/ ./docker/
|
||||
COPY plugins/ ./plugins/
|
||||
COPY lib/ ./lib/
|
||||
COPY scripts/ ./scripts/
|
||||
|
||||
# Create runtime directories. DKIM keys are normalized into per-domain directories at container start.
|
||||
RUN mkdir -p ./config/dkim ./logs "$HARAKA_DKIM_DIR" && \
|
||||
chmod 755 ./config ./plugins ./lib ./scripts ./logs ./config/dkim "$HARAKA_DKIM_DIR" && \
|
||||
chmod +x ./scripts/*.sh ./scripts/*.js 2>/dev/null || true && \
|
||||
find ./config/dkim -mindepth 1 -maxdepth 1 -type d -exec chmod 755 {} + 2>/dev/null || true && \
|
||||
for f in ./config/dkim/*/private; do [ -f "$f" ] && chmod 644 "$f"; done; \
|
||||
for f in ./config/dkim/*/selector; do [ -f "$f" ] && chmod 644 "$f"; done; \
|
||||
true
|
||||
|
||||
# Expose SMTP ports
|
||||
EXPOSE 25 587 8080
|
||||
|
||||
# Start Haraka role selected by HARAKA_ROLE
|
||||
CMD ["/app/scripts/start-haraka.sh"]
|
||||
228
README.md
Normal file
228
README.md
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
# elektrine-haraka
|
||||
|
||||
Haraka mail relay for Elektrine.
|
||||
|
||||
Elektrine owns mailbox storage, webmail, IMAP/POP3/JMAP, user authentication,
|
||||
and the Phoenix API endpoints. This repo owns the Haraka side: public MX intake,
|
||||
inbound queueing, outbound internet delivery, DKIM signing, and the internal HTTP
|
||||
send API Elektrine calls for external mail.
|
||||
|
||||
Docker Compose is the supported deployment path.
|
||||
|
||||
## Architecture
|
||||
|
||||
The stack is split into small roles:
|
||||
|
||||
- `haraka-inbound`: public MX listener on port `25`.
|
||||
- `haraka-outbound`: internal HTTP send and ops API on port `8080`.
|
||||
- `haraka-worker`: Redis consumer that parses inbound mail and posts it to Phoenix.
|
||||
- `haraka-submission`: optional Haraka-managed SMTP submission role, not published by default.
|
||||
- `redis`, `clamav`, `spamassassin`: supporting services for queueing and scanning.
|
||||
|
||||
Normal inbound path:
|
||||
|
||||
1. Remote mail arrives at `haraka-inbound` on port `25`.
|
||||
2. Haraka verifies recipients against Phoenix.
|
||||
3. Haraka queues the raw RFC 5322 message in Redis.
|
||||
4. `haraka-worker` parses the queued message and posts it to Phoenix.
|
||||
5. Elektrine stores and displays the message.
|
||||
|
||||
Normal outbound path from Elektrine:
|
||||
|
||||
1. Elektrine accepts mail from webmail, SMTP submission, or another Elektrine mail interface.
|
||||
2. Elektrine calls `haraka-outbound` at `POST /api/v1/send`.
|
||||
3. Haraka builds or accepts the RFC 5322 message.
|
||||
4. Haraka signs with DKIM and queues outbound SMTP delivery.
|
||||
|
||||
Client SMTP submission usually lives in Elektrine. Only publish
|
||||
`haraka-submission` if you explicitly want Haraka to handle authenticated client
|
||||
submission.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp deployment/.env.example deployment/.env
|
||||
# edit deployment/.env
|
||||
cd deployment
|
||||
./start.sh
|
||||
```
|
||||
|
||||
For manual Compose control:
|
||||
|
||||
```bash
|
||||
cd deployment
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
For local image builds, change the Haraka services in
|
||||
`deployment/docker-compose.yml` from `image:` to `build: ../`, then run:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Same-Host Deploy
|
||||
|
||||
When Elektrine and Haraka run on the same host, use the same-server deployment:
|
||||
|
||||
```bash
|
||||
cp deployment/.env.same-server.example deployment/.env
|
||||
# edit deployment/.env
|
||||
scripts/deploy/docker_deploy.sh
|
||||
```
|
||||
|
||||
This mode:
|
||||
|
||||
- publishes public MX SMTP on port `25`.
|
||||
- binds Haraka's HTTP API to `127.0.0.1:18080`.
|
||||
- joins the main Elektrine Docker network so Haraka can call Phoenix directly.
|
||||
- copies TLS certificates from the main Elektrine Caddy volume.
|
||||
- avoids running a second public `80/443` reverse proxy.
|
||||
|
||||
Configure Elektrine with a matching Haraka URL, commonly:
|
||||
|
||||
```dotenv
|
||||
HARAKA_BASE_URL=http://127.0.0.1:18080
|
||||
```
|
||||
|
||||
If both containers share a Docker network, use the service URL instead:
|
||||
|
||||
```dotenv
|
||||
HARAKA_BASE_URL=http://haraka-outbound:8080
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Required environment values:
|
||||
|
||||
- `HARAKA_DOMAIN`: mail host name, for example `mail.example.com`.
|
||||
- `PHOENIX_WEBHOOK_URL`: inbound webhook endpoint.
|
||||
- `PHOENIX_VERIFY_URL`: recipient verification endpoint.
|
||||
- `PHOENIX_DOMAINS_URL`: local-domain cache endpoint.
|
||||
- `PHOENIX_API_KEY`: key Haraka uses when calling Phoenix.
|
||||
- `HARAKA_HTTP_API_KEY`: key Elektrine uses when calling Haraka.
|
||||
|
||||
Legacy fallback:
|
||||
|
||||
- `HARAKA_API_KEY`: accepted as a shared fallback when directional keys are not set.
|
||||
|
||||
Common optional values:
|
||||
|
||||
- `REDIS_URL`, default `redis://redis:6379`.
|
||||
- `ELEKTRINE_QUEUE_NAME`, default `elektrine:inbound`.
|
||||
- `ELEKTRINE_DLQ_NAME`, default `elektrine:inbound:dlq`.
|
||||
- `WEBHOOK_MAX_RETRIES`.
|
||||
- `WEBHOOK_RETRY_BASE_MS`.
|
||||
- `HARAKA_IMAGE`.
|
||||
- `HARAKA_IMAGE_TAG`.
|
||||
- `OPS_ALLOWED_CIDRS`.
|
||||
- `METRICS_ALLOWED_CIDRS`.
|
||||
- `HARAKA_TRUSTED_PROXY_CIDRS`.
|
||||
|
||||
See `deployment/.env.example` and `deployment/.env.same-server.example` for
|
||||
the deployment templates.
|
||||
|
||||
## HTTP API
|
||||
|
||||
The HTTP API is served by `haraka-outbound` on port `8080`.
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `POST /api/v1/send`
|
||||
- `GET /status`
|
||||
- `GET /healthz`
|
||||
- `GET /metrics`
|
||||
|
||||
`POST /api/v1/send` requires `X-API-Key: <HARAKA_HTTP_API_KEY>`.
|
||||
|
||||
Structured send payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"from": "sender@example.com",
|
||||
"to": "recipient@example.net",
|
||||
"subject": "Hello",
|
||||
"text_body": "Plain text body",
|
||||
"html_body": "<p>HTML body</p>"
|
||||
}
|
||||
```
|
||||
|
||||
Required fields for structured mail:
|
||||
|
||||
- `from`
|
||||
- `to`
|
||||
- `subject`
|
||||
|
||||
Accepted plain-text body fields:
|
||||
|
||||
- `text_body`
|
||||
- `text`
|
||||
- `body`
|
||||
|
||||
Accepted HTML body fields:
|
||||
|
||||
- `html_body`
|
||||
- `html`
|
||||
|
||||
When text and HTML are both present, Haraka builds a `multipart/alternative`
|
||||
message. Attachments use the `attachments` array with base64 data.
|
||||
|
||||
For prebuilt RFC 5322 messages, send `raw_base64` plus `from` and `to`. Prefer
|
||||
structured fields unless you intentionally need to preserve a supplied MIME tree.
|
||||
|
||||
Ops endpoints accept `X-API-Key` by default. `OPS_ALLOWED_CIDRS` and
|
||||
`METRICS_ALLOWED_CIDRS` can allow keyless access from trusted networks.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# from deployment/
|
||||
docker compose ps
|
||||
|
||||
docker compose logs -f haraka-inbound
|
||||
docker compose logs -f haraka-outbound
|
||||
docker compose logs -f haraka-worker
|
||||
docker compose logs -f haraka-submission
|
||||
|
||||
# queue depth
|
||||
docker compose exec redis redis-cli LLEN elektrine:inbound
|
||||
|
||||
# dead-letter queue depth
|
||||
docker compose exec redis redis-cli LLEN elektrine:inbound:dlq
|
||||
|
||||
# queue alarm helper
|
||||
../scripts/check-queues.sh
|
||||
|
||||
# local API checks
|
||||
curl -s -H 'X-API-Key: <key>' http://127.0.0.1:18080/status
|
||||
curl -s -H 'X-API-Key: <key>' http://127.0.0.1:18080/metrics
|
||||
```
|
||||
|
||||
## DKIM
|
||||
|
||||
DKIM keys live under `config/dkim/<domain>/` and are normalized at container
|
||||
start. Use the helper when provisioning a local key:
|
||||
|
||||
```bash
|
||||
scripts/generate-dkim-keys.sh example.com
|
||||
```
|
||||
|
||||
Publish the generated DNS record before relying on outbound delivery for that
|
||||
domain.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `421` or other temporary inbound failures: check Redis, `haraka-inbound`, and `haraka-worker` logs.
|
||||
- Mail is accepted but never reaches Elektrine: check worker logs and `elektrine:inbound:dlq` depth.
|
||||
- Outbound mail has an empty body: check the `POST /api/v1/send` payload contains `text_body`, `text`, `body`, `html_body`, or `html`; then check `haraka-outbound` logs.
|
||||
- `ENETUNREACH ... :25` for IPv6 MX targets: this stack defaults outbound delivery to IPv4 first with `inet_prefer=v4` because many Docker hosts do not have working outbound IPv6.
|
||||
- `ERR_TLS_CERT_ALTNAME_INVALID` on the internal `haraka-inbound` hop: local-domain routing disables STARTTLS for that container hop because the Docker hostname does not match the public certificate.
|
||||
- Outbound delivery is rejected for authentication: verify `HARAKA_HTTP_API_KEY` matches Elektrine's Haraka API key.
|
||||
- Recipient verification fails: verify `PHOENIX_VERIFY_URL`, `PHOENIX_DOMAINS_URL`, and `PHOENIX_API_KEY`.
|
||||
- TLS issues: check the `ssl-certs` volume and same-server certificate copier logs.
|
||||
|
||||
## Notes
|
||||
|
||||
- Redeploy all Haraka roles and the worker together after plugin or shared library changes.
|
||||
- Use immutable `HARAKA_IMAGE_TAG` values for reproducible deploys.
|
||||
- Plugin role profiles are documented in `docs/Plugins.md`.
|
||||
12
config/access.ini
Normal file
12
config/access.ini
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Access control configuration
|
||||
[main]
|
||||
# Allow or deny based on patterns
|
||||
|
||||
# Default action when no rules match
|
||||
default_action=accept
|
||||
|
||||
# Enable access control
|
||||
enabled=true
|
||||
|
||||
# Log access decisions
|
||||
log_decisions=true
|
||||
18
config/auth_proxy.ini
Normal file
18
config/auth_proxy.ini
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[core]
|
||||
# Authentication proxy configuration
|
||||
# Forwards auth requests to Phoenix app for validation
|
||||
|
||||
# Only require AUTH on port 587 (submission), not port 25 (for receiving inbound mail)
|
||||
[port:587]
|
||||
host=app.example.com
|
||||
port=443
|
||||
path=/api/haraka/auth
|
||||
timeout=5000
|
||||
protocol=https
|
||||
|
||||
[domains]
|
||||
elektrine.com=elektrine_mail:2587
|
||||
elektrine.net=elektrine_mail:2587
|
||||
elektrine.org=elektrine_mail:2587
|
||||
z.org=elektrine_mail:2587
|
||||
mail.elektrine.com=elektrine_mail:2587
|
||||
22
config/clamd.ini
Normal file
22
config/clamd.ini
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[main]
|
||||
; ClamAV daemon configuration
|
||||
clamd_socket = clamav:3310
|
||||
|
||||
; How long to wait for ClamAV response (seconds)
|
||||
timeout = 30
|
||||
|
||||
; What to do when virus is detected
|
||||
action = reject
|
||||
|
||||
; Whether to enable quarantine of infected messages
|
||||
quarantine = true
|
||||
|
||||
; Log level (LOGDEBUG, LOGINFO, LOGNOTICE, LOGWARN, LOGERROR, LOGCRIT)
|
||||
loglevel = LOGINFO
|
||||
|
||||
[reject]
|
||||
; Enable rejection for virus detection
|
||||
virus = true
|
||||
|
||||
; Reject message text
|
||||
virus_msg = Message contains a virus and has been rejected
|
||||
3
config/connect.access.blacklist
Normal file
3
config/connect.access.blacklist
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Blacklist malicious IPs/networks (one per line)
|
||||
# Example: 192.168.1.100
|
||||
# Example: 10.0.0.0/8
|
||||
3
config/connect.access.whitelist
Normal file
3
config/connect.access.whitelist
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Whitelist trusted IPs/networks (one per line)
|
||||
# Example: 192.168.1.0/24
|
||||
# Example: 10.0.0.5
|
||||
70
config/connection.ini
Normal file
70
config/connection.ini
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
;
|
||||
[main]
|
||||
|
||||
; Require senders to conform to RFC 1869 and RFC 821 when sending the MAIL FROM and RCPT TO commands. In particular, the inclusion of spurious spaces or missing angle brackets will be rejected.
|
||||
; Disabled to allow compatibility with real-world mail servers (e.g., QQ Mail) that add extra spaces
|
||||
strict_rfc1869 = false
|
||||
|
||||
; Advertise support for SMTPUTF8 (RFC-6531)
|
||||
; smtputf8=true
|
||||
|
||||
; Limit connections per remote host/IP
|
||||
max_connections_per_ip = 10
|
||||
|
||||
; Reject if too many connections from same IP
|
||||
max_rate_per_ip_per_minute = 30
|
||||
|
||||
|
||||
[haproxy]
|
||||
; Array: hosts or CIDRs that Haraka should enable the PROXY protocol from. See docs/HAProxy for format
|
||||
hosts[] =
|
||||
; hosts[] = 192.0.2.4
|
||||
; hosts[] = 192.0.2.5
|
||||
; hosts[] = [2001:db8::1]
|
||||
; hosts[] = [2001:db8::2]
|
||||
|
||||
|
||||
[headers]
|
||||
; add_received=true
|
||||
; clean_auth_results=true
|
||||
|
||||
; show_version=true
|
||||
|
||||
max_lines=1000
|
||||
|
||||
max_received=100
|
||||
|
||||
|
||||
[max]
|
||||
; Integer. The maximum SIZE of an email
|
||||
bytes=26214400
|
||||
|
||||
; Integer. Limit a potential denial of service in potentially hostile emails.
|
||||
mime_parts=1000
|
||||
|
||||
; Integer. The maximum length of lines in SMTP session commands (e.g. RCPT, HELO etc). Defaults to 512 (bytes) as mandated by RFC 5321 §4.5.3.1.4. Clients exceeding this limit will be immediately disconnected with a "521 Command line too long" error.
|
||||
line_length=512
|
||||
|
||||
; Integer. The maximum length of lines in the DATA section of emails. Defaults to 992 (bytes), the limit set by Sendmail. When this limit is exceeded the three bytes "\r\n " (0x0d 0x0a 0x20) are inserted into the stream to "fix" it. This has the potential to "break" some email, but makes it more likely to be accepted by upstream/downstream services, and is the same behaviour as Sendmail. Also when the data line length limit is exceeded `transaction.notes.data_line_length_exceeded` is set to `true`.
|
||||
data_line_length=992
|
||||
|
||||
|
||||
[message]
|
||||
; Array. The greeting used when a client connects.
|
||||
; greeting[]=My Custom
|
||||
; greeting[]=Greeting Message
|
||||
|
||||
helo=Haraka is at your service.
|
||||
|
||||
; String. Override the default connection close message.
|
||||
close=closing connection. Have a jolly good day.
|
||||
|
||||
|
||||
[uuid]
|
||||
; integer, how many UUID chars to show.
|
||||
; 0 = none, 6 is enough to be unique per day, 40 will include the
|
||||
; full connection and transaction UUID
|
||||
banner_chars=6
|
||||
|
||||
; include N characters of the uuid (in brackets) at the start of each line of the deny message
|
||||
deny_chars=0
|
||||
13
config/dkim.ini
Normal file
13
config/dkim.ini
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[main]
|
||||
|
||||
[verify]
|
||||
enabled = true
|
||||
|
||||
[sign]
|
||||
enabled = true
|
||||
selector = default
|
||||
domain = example.com
|
||||
headers = From, Sender, Reply-To, Subject, Date, Message-ID, To, Cc, MIME-Version
|
||||
|
||||
[example.com]
|
||||
selector = default
|
||||
30
config/dns-list.ini
Normal file
30
config/dns-list.ini
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# DNS list plugin configuration (haraka-plugin-dns-list)
|
||||
# IMPORTANT:
|
||||
# - `dns-list` is currently commented out in inbound plugin profiles for incident mitigation.
|
||||
# - If re-enabled, this file keeps DNSBL checks informational only (no SMTP hard reject).
|
||||
|
||||
[main]
|
||||
# Explicit zone list to avoid plugin defaults that can include noisy lists.
|
||||
zones=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org
|
||||
|
||||
# Query all configured zones and log overlap for analysis.
|
||||
search=all
|
||||
periodic_checks=30
|
||||
|
||||
[stats]
|
||||
enable=true
|
||||
|
||||
[zen.spamhaus.org]
|
||||
type=block
|
||||
reject=false
|
||||
ipv6=false
|
||||
|
||||
[bl.spamcop.net]
|
||||
type=block
|
||||
reject=false
|
||||
ipv6=false
|
||||
|
||||
[b.barracudacentral.org]
|
||||
type=block
|
||||
reject=false
|
||||
ipv6=false
|
||||
114
config/elektrine.ini
Normal file
114
config/elektrine.ini
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
; Haraka plugin configuration
|
||||
;
|
||||
; This file provides default configuration for all Elektrine plugins.
|
||||
; Environment variables take precedence over these settings.
|
||||
;
|
||||
; Environment Variables:
|
||||
; HARAKA_ROLE - Runtime role (inbound-mx/submission/outbound-relay)
|
||||
; PHOENIX_WEBHOOK_URL - Webhook URL for inbound emails
|
||||
; PHOENIX_VERIFY_URL - Recipient verification URL
|
||||
; PHOENIX_DOMAINS_URL - Domains endpoint URL
|
||||
; PHOENIX_API_KEY - API key for Haraka -> Phoenix calls
|
||||
; HARAKA_HTTP_API_KEY - API key required by Haraka /api/v1/send
|
||||
; HARAKA_API_KEY - Optional shared key
|
||||
; HARAKA_HTTP_PORT - HTTP API port (default: 8080)
|
||||
; HARAKA_HTTP_HOST - HTTP API host (default: 0.0.0.0)
|
||||
; HARAKA_CORS_ORIGIN - Allowed CORS origin for browser calls
|
||||
; HARAKA_DKIM_DIR - Persistent directory for DKIM key storage
|
||||
; LOCAL_DOMAINS - Comma-separated list of local domains
|
||||
; OPS_ALLOWED_CIDRS - CIDRs allowed to call /status and /healthz
|
||||
; METRICS_ALLOWED_CIDRS - CIDRs allowed to call /metrics
|
||||
; HARAKA_TRUSTED_PROXY_CIDRS - CIDRs trusted for forwarded client IP headers
|
||||
; REDIS_URL - Redis URL for async queue
|
||||
; ELEKTRINE_QUEUE_NAME - Queue key name
|
||||
; ELEKTRINE_DLQ_NAME - Dead letter queue key name
|
||||
|
||||
[main]
|
||||
; Runtime role used by startup profile selection
|
||||
role = inbound-mx
|
||||
|
||||
; Phoenix app webhook URL for inbound emails
|
||||
url = https://app.example.com/api/haraka/inbound
|
||||
|
||||
; Phoenix app recipient verification URL
|
||||
verify_url = https://app.example.com/api/haraka/verify-recipient
|
||||
|
||||
; Phoenix app domains URL
|
||||
domains_url = https://app.example.com/api/haraka/domains
|
||||
|
||||
; Haraka -> Phoenix key
|
||||
; phoenix_api_key =
|
||||
|
||||
; Haraka /api/v1/send key
|
||||
; http_api_key =
|
||||
|
||||
; Optional shared key
|
||||
; api_key =
|
||||
|
||||
; Webhook timeout in milliseconds
|
||||
timeout = 30000
|
||||
|
||||
; Recipient verification timeout in milliseconds
|
||||
verify_timeout = 5000
|
||||
|
||||
; Enable/disable webhook processing
|
||||
enabled = true
|
||||
|
||||
; Include email headers in webhook payload
|
||||
include_headers = true
|
||||
|
||||
; Include email body in webhook payload
|
||||
include_body = true
|
||||
|
||||
; Include attachment content in webhook payload
|
||||
include_attachments = true
|
||||
|
||||
[http_api]
|
||||
; HTTP API server port
|
||||
port = 8080
|
||||
|
||||
; HTTP API server host
|
||||
host = 0.0.0.0
|
||||
|
||||
; Persistent DKIM key storage directory used by /api/v1/dkim/domains/*
|
||||
; dkim_storage_dir = /data/haraka/dkim
|
||||
|
||||
; Browser API callers origin allowlist (optional)
|
||||
; cors_origin =
|
||||
|
||||
; Trusted reverse proxy CIDRs used when resolving client IP
|
||||
trusted_proxy_cidrs = 127.0.0.1/32, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
|
||||
|
||||
[rate_limit]
|
||||
; Rate limit window in milliseconds (default: 60000 = 1 minute)
|
||||
window_ms = 60000
|
||||
|
||||
; Maximum requests per window per IP
|
||||
max_requests = 50
|
||||
|
||||
[ops]
|
||||
; Optional CIDRs allowed to access /status and /healthz without an API key
|
||||
allowed_cidrs =
|
||||
|
||||
[metrics]
|
||||
; Optional CIDRs allowed to access /metrics without an API key
|
||||
allowed_cidrs =
|
||||
|
||||
[domains]
|
||||
; Local domains (space or comma separated)
|
||||
; These domains receive inbound mail and are protected from spoofing
|
||||
local = example.com
|
||||
|
||||
; Domain cache refresh interval in ms
|
||||
cache_ttl_ms = 300000
|
||||
|
||||
[queue]
|
||||
redis_url = redis://redis:6379
|
||||
name = elektrine:inbound
|
||||
dlq_name = elektrine:inbound:dlq
|
||||
pop_timeout_sec = 5
|
||||
max_raw_bytes = 26214400
|
||||
|
||||
[worker]
|
||||
webhook_max_retries = 5
|
||||
webhook_retry_base_delay_ms = 1000
|
||||
11
config/elektrine_local_mx.ini
Normal file
11
config/elektrine_local_mx.ini
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
; Elektrine local-domain MX override
|
||||
; Ensures local/protected domains are delivered to internal inbound Haraka.
|
||||
;
|
||||
; Environment overrides:
|
||||
; ELEKTRINE_LOCAL_MX_HOST
|
||||
; ELEKTRINE_LOCAL_MX_PORT
|
||||
|
||||
[main]
|
||||
enabled=true
|
||||
host=haraka-inbound
|
||||
port=25
|
||||
34
config/elektrine_queue.ini
Normal file
34
config/elektrine_queue.ini
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
; Elektrine async queue + worker configuration
|
||||
;
|
||||
; Environment variables override these values:
|
||||
; REDIS_URL
|
||||
; ELEKTRINE_QUEUE_NAME
|
||||
; ELEKTRINE_DLQ_NAME
|
||||
; ELEKTRINE_QUEUE_POP_TIMEOUT
|
||||
; ELEKTRINE_QUEUE_MAX_RAW_BYTES
|
||||
; WEBHOOK_MAX_RETRIES
|
||||
; WEBHOOK_RETRY_BASE_MS
|
||||
|
||||
[main]
|
||||
; Keep payload fields aligned with the webhook plugin behavior
|
||||
enabled = true
|
||||
include_headers = true
|
||||
include_body = true
|
||||
include_attachments = true
|
||||
|
||||
; Phoenix webhook URL and auth can also be set here,
|
||||
; but production should use env vars.
|
||||
; url = https://app.example.com/api/haraka/inbound
|
||||
; phoenix_api_key =
|
||||
; timeout = 30000
|
||||
|
||||
[queue]
|
||||
redis_url = redis://redis:6379
|
||||
name = elektrine:inbound
|
||||
dlq_name = elektrine:inbound:dlq
|
||||
pop_timeout_sec = 5
|
||||
max_raw_bytes = 26214400
|
||||
|
||||
[worker]
|
||||
webhook_max_retries = 5
|
||||
webhook_retry_base_delay_ms = 1000
|
||||
18
config/elektrine_webhook.ini
Normal file
18
config/elektrine_webhook.ini
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[main]
|
||||
# Enable the webhook
|
||||
enabled=true
|
||||
|
||||
# Elektrine webhook URL - can be overridden by PHOENIX_WEBHOOK_URL env var
|
||||
# url=https://your-phoenix-app.com/api/haraka/inbound
|
||||
|
||||
# Timeout for webhook requests (milliseconds)
|
||||
timeout=30000
|
||||
|
||||
# What data to include
|
||||
include_headers=true
|
||||
include_body=true
|
||||
include_attachments=true
|
||||
|
||||
# Authentication - API key for webhook
|
||||
# Set PHOENIX_API_KEY (or HARAKA_API_KEY)
|
||||
# api_key=
|
||||
16
config/helo.checks.ini
Normal file
16
config/helo.checks.ini
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# HELO/EHLO checking configuration
|
||||
[main]
|
||||
# Check if HELO hostname is valid
|
||||
check_valid_hostname=true
|
||||
|
||||
# Check if HELO matches rDNS
|
||||
check_rdns_match=false
|
||||
|
||||
# Reject invalid HELO
|
||||
reject_invalid_helo=false
|
||||
|
||||
# Reject non-FQDN HELO
|
||||
reject_non_fqdn_helo=false
|
||||
|
||||
# Log HELO check results
|
||||
log_results=true
|
||||
4
config/host_list
Normal file
4
config/host_list
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
elektrine.com
|
||||
elektrine.net
|
||||
elektrine.org
|
||||
z.org
|
||||
9
config/http.ini
Normal file
9
config/http.ini
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[main]
|
||||
; HTTP server disabled - watch plugin not in use
|
||||
; listen=[::0]:8081
|
||||
|
||||
; Directory for static files (used by watch plugin)
|
||||
; htdocs=./plugins/watch/html
|
||||
|
||||
; Websockets disabled
|
||||
websockets=false
|
||||
1
config/internalcmd_key
Normal file
1
config/internalcmd_key
Normal file
|
|
@ -0,0 +1 @@
|
|||
f1c4e6ff4c90a71d4e26eddb5a38fcf7f0b176946e18197914421018fd2a91e2
|
||||
36
config/karma.ini
Normal file
36
config/karma.ini
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
[redis]
|
||||
host=redis
|
||||
port=6379
|
||||
|
||||
[awards]
|
||||
# Award points for passing checks
|
||||
spf_pass=1
|
||||
dkim_pass=2
|
||||
fcrdns_pass=1
|
||||
asn_all_good=1
|
||||
|
||||
[penalties]
|
||||
# Penalize bad behavior
|
||||
spf_fail=-5
|
||||
dkim_fail=-3
|
||||
fcrdns_fail=-1
|
||||
# Heavy penalty for rapid connections
|
||||
tarpit=-1
|
||||
relaying=-10
|
||||
|
||||
[concurrency]
|
||||
# Limit concurrent connections per IP
|
||||
max=5
|
||||
|
||||
[disconnect]
|
||||
# Disconnect when karma score drops below threshold
|
||||
karma=-8
|
||||
|
||||
[limits]
|
||||
# Limit messages per connection
|
||||
message_max=10
|
||||
|
||||
[tarpit]
|
||||
# Delay in seconds for IPs with negative karma
|
||||
delay=5
|
||||
max=15
|
||||
7
config/log.inbound-mx.ini
Normal file
7
config/log.inbound-mx.ini
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[main]
|
||||
level=info
|
||||
timestamps=true
|
||||
format=default
|
||||
|
||||
[file]
|
||||
filename=/app/logs/haraka-inbound.log
|
||||
15
config/log.ini
Normal file
15
config/log.ini
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[main]
|
||||
|
||||
; level=data, protocol, debug, info, notice, warn, error, crit, alert, emerg
|
||||
level=info
|
||||
|
||||
; prepend timestamps to log entries? This setting does NOT affect logs emitted
|
||||
; by logging plugins (like syslog).
|
||||
timestamps=true
|
||||
|
||||
; format=default, logfmt, json
|
||||
format=default
|
||||
|
||||
; Log to file
|
||||
[file]
|
||||
filename=/app/logs/haraka.log
|
||||
7
config/log.outbound-relay.ini
Normal file
7
config/log.outbound-relay.ini
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[main]
|
||||
level=info
|
||||
timestamps=true
|
||||
format=default
|
||||
|
||||
[file]
|
||||
filename=/app/logs/haraka-outbound.log
|
||||
7
config/log.submission.ini
Normal file
7
config/log.submission.ini
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[main]
|
||||
level=info
|
||||
timestamps=true
|
||||
format=default
|
||||
|
||||
[file]
|
||||
filename=/app/logs/haraka-submission.log
|
||||
1
config/me
Normal file
1
config/me
Normal file
|
|
@ -0,0 +1 @@
|
|||
mail.elektrine.com
|
||||
10
config/outbound.ini
Normal file
10
config/outbound.ini
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[main]
|
||||
disabled=false
|
||||
concurrency_max=100
|
||||
enable_tls=true
|
||||
local_mx_ok=true
|
||||
|
||||
; Prefer IPv4 for outbound delivery. This host does not have working outbound
|
||||
; IPv6 from Docker, so trying AAAA targets first causes noisy ENETUNREACH logs
|
||||
; and delayed delivery. Haraka 3 uses inet_prefer; ipv6_enabled is obsolete.
|
||||
inet_prefer=v4
|
||||
72
config/plugins
Normal file
72
config/plugins
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Default profile for local runs (inbound-mx).
|
||||
# In container deployments, this file is overwritten at startup by
|
||||
# scripts/start-haraka.sh using config/plugins.<HARAKA_ROLE>.
|
||||
|
||||
# status
|
||||
# process_title
|
||||
# syslog
|
||||
# watch
|
||||
|
||||
# CONNECT
|
||||
# ----------
|
||||
toobusy
|
||||
redis
|
||||
# relay
|
||||
access
|
||||
# p0f
|
||||
# geoip
|
||||
# asn
|
||||
fcrdns
|
||||
# Incident mitigation: DNSBL lookups can block legitimate inbound delivery.
|
||||
# Keep disabled until logs confirm blacklist false positives are resolved.
|
||||
# dns-list
|
||||
|
||||
# HELO
|
||||
# ----------
|
||||
early_talker
|
||||
helo.checks
|
||||
# Enable TLS for secure connections
|
||||
tls
|
||||
#
|
||||
# AUTH plugins require TLS before AUTH is advertised, see
|
||||
# https://github.com/haraka/Haraka/wiki/Require-SSL-TLS
|
||||
# ----------
|
||||
# Use auth_proxy to authenticate against Phoenix app
|
||||
auth/auth_proxy
|
||||
|
||||
# MAIL FROM
|
||||
# ----------
|
||||
mail_from.is_resolvable
|
||||
spf
|
||||
elektrine_spf_enforcer
|
||||
|
||||
# RCPT TO
|
||||
# ----------
|
||||
# At least one rcpt_to plugin is REQUIRED for inbound email.
|
||||
# elektrine_rcpt_verify handles both local domain validation AND recipient verification
|
||||
# by querying Phoenix API for the list of valid domains (including custom domains)
|
||||
# and verifying recipients exist.
|
||||
# rcpt_to.in_host_list is no longer needed since elektrine_rcpt_verify handles all cases.
|
||||
elektrine_rcpt_verify
|
||||
# qmail-deliverable
|
||||
# rcpt_to.routes
|
||||
|
||||
# DATA
|
||||
# ----------
|
||||
attachment
|
||||
# bounce
|
||||
# Quick checks first (reject fast)
|
||||
uribl
|
||||
# Resource-intensive checks last
|
||||
clamd
|
||||
spamassassin
|
||||
dkim
|
||||
# headers
|
||||
# limit
|
||||
# rspamd
|
||||
|
||||
# QUEUE
|
||||
# ----------
|
||||
# queues: discard qmail-queue quarantine smtp_forward smtp_proxy
|
||||
# Inbound path: enqueue for async worker/webhook processing
|
||||
elektrine_async_queue
|
||||
29
config/plugins.inbound-mx
Normal file
29
config/plugins.inbound-mx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Haraka role profile: inbound-mx
|
||||
# Accept internet mail for local domains, run anti-abuse checks,
|
||||
# then enqueue for async webhook processing.
|
||||
|
||||
toobusy
|
||||
redis
|
||||
access
|
||||
fcrdns
|
||||
# Incident mitigation: DNSBL lookups can block legitimate inbound delivery.
|
||||
# Keep disabled until logs confirm blacklist false positives are resolved.
|
||||
# dns-list
|
||||
|
||||
early_talker
|
||||
helo.checks
|
||||
tls
|
||||
|
||||
mail_from.is_resolvable
|
||||
spf
|
||||
elektrine_spf_enforcer
|
||||
|
||||
elektrine_rcpt_verify
|
||||
|
||||
attachment
|
||||
uribl
|
||||
clamd
|
||||
spamassassin
|
||||
dkim
|
||||
|
||||
elektrine_async_queue
|
||||
10
config/plugins.outbound-relay
Normal file
10
config/plugins.outbound-relay
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Haraka role profile: outbound-relay
|
||||
# Expose HTTP send API and perform outbound handoff/signing.
|
||||
|
||||
toobusy
|
||||
redis
|
||||
elektrine_local_mx
|
||||
tls
|
||||
dkim
|
||||
|
||||
elektrine_http_api
|
||||
15
config/plugins.submission
Normal file
15
config/plugins.submission
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Haraka role profile: submission
|
||||
# Accept authenticated client mail on 587 and relay via native Haraka outbound.
|
||||
|
||||
toobusy
|
||||
redis
|
||||
access
|
||||
elektrine_local_mx
|
||||
tls
|
||||
auth/auth_proxy
|
||||
|
||||
mail_from.is_resolvable
|
||||
spf
|
||||
dkim
|
||||
|
||||
elektrine_rcpt_verify
|
||||
10
config/smtp.inbound-mx.ini
Normal file
10
config/smtp.inbound-mx.ini
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[main]
|
||||
# Public MX listener
|
||||
listen=[::0]:25
|
||||
|
||||
max_connections=2000
|
||||
max_unrecognized_commands=10
|
||||
max_errors=10
|
||||
connect_timeout=60
|
||||
data_timeout=600
|
||||
nodes=0
|
||||
14
config/smtp.ini
Normal file
14
config/smtp.ini
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[main]
|
||||
# Listen on port 25 (SMTP) and 587 (submission)
|
||||
listen=[::0]:25,[::0]:587
|
||||
|
||||
# Connection limits
|
||||
max_connections=1000
|
||||
max_unrecognized_commands=10
|
||||
max_errors=10
|
||||
|
||||
# Timeouts (in seconds)
|
||||
connect_timeout=60
|
||||
data_timeout=600
|
||||
|
||||
nodes=0
|
||||
10
config/smtp.outbound-relay.ini
Normal file
10
config/smtp.outbound-relay.ini
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[main]
|
||||
# Keep SMTP listener internal for this role.
|
||||
listen=127.0.0.1:2525
|
||||
|
||||
max_connections=200
|
||||
max_unrecognized_commands=10
|
||||
max_errors=10
|
||||
connect_timeout=60
|
||||
data_timeout=600
|
||||
nodes=0
|
||||
10
config/smtp.submission.ini
Normal file
10
config/smtp.submission.ini
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[main]
|
||||
# Authenticated submission listener
|
||||
listen=[::0]:587,[::0]:465
|
||||
|
||||
max_connections=1000
|
||||
max_unrecognized_commands=10
|
||||
max_errors=10
|
||||
connect_timeout=60
|
||||
data_timeout=600
|
||||
nodes=0
|
||||
38
config/spamassassin.ini
Normal file
38
config/spamassassin.ini
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
[main]
|
||||
; SpamAssassin daemon configuration
|
||||
spamd_socket = spamassassin:783
|
||||
|
||||
; Maximum message size to scan (bytes) - 256KB default
|
||||
max_size = 262144
|
||||
|
||||
; Timeout for spamd connection (seconds)
|
||||
timeout = 30
|
||||
|
||||
; Should we add spam headers to all emails?
|
||||
add_headers = true
|
||||
|
||||
; Should we reject emails over the spam threshold?
|
||||
reject_spam = false
|
||||
|
||||
; Spam threshold for rejection (only if reject_spam = true)
|
||||
reject_threshold = 15.0
|
||||
|
||||
; Should we quarantine spam messages?
|
||||
quarantine_spam = false
|
||||
|
||||
[headers]
|
||||
; Add X-Spam-Status header
|
||||
status = true
|
||||
|
||||
; Add X-Spam-Score header
|
||||
score = true
|
||||
|
||||
; Add X-Spam-Report header (detailed report)
|
||||
report = true
|
||||
|
||||
[thresholds]
|
||||
; Score at which to add spam headers
|
||||
add_header = 5.0
|
||||
|
||||
; Score at which to reject (if reject_spam = true)
|
||||
reject = 15.0
|
||||
22
config/spf.ini
Normal file
22
config/spf.ini
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[main]
|
||||
# SPF checking timeout (seconds)
|
||||
timeout=30
|
||||
|
||||
# Enable SPF checking
|
||||
enabled=true
|
||||
|
||||
# Log SPF results
|
||||
log_result=true
|
||||
|
||||
# For general emails, be lenient - elektrine_spf_enforcer plugin handles our domains
|
||||
[fail]
|
||||
action=accept
|
||||
message=SPF validation failed
|
||||
|
||||
# Soft fail is treated leniently
|
||||
[softfail]
|
||||
action=accept
|
||||
|
||||
# Allow relay for outbound emails
|
||||
[relay]
|
||||
action=accept
|
||||
30
config/tls.ini
Normal file
30
config/tls.ini
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
[main]
|
||||
key=/app/ssl/cert.key
|
||||
cert=/app/ssl/cert.crt
|
||||
|
||||
# Strong ciphers only - ECDHE preferred, AES-GCM and ChaCha20
|
||||
ciphers=ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP
|
||||
|
||||
# TLS 1.2 minimum - TLS 1.0 and 1.1 are deprecated (RFC 8996)
|
||||
minVersion=TLSv1.2
|
||||
maxVersion=TLSv1.3
|
||||
|
||||
# No client certificate requirements
|
||||
requestCert=false
|
||||
rejectUnauthorized=false
|
||||
|
||||
# Enable renegotiation protection (CVE-2009-3555)
|
||||
secureRenegotiation=true
|
||||
|
||||
# Server chooses cipher order (prefer strongest available)
|
||||
honorCipherOrder=true
|
||||
no_starttls_ports[]=465
|
||||
dhparam=/app/ssl/dhparam.pem
|
||||
|
||||
[outbound]
|
||||
rejectUnauthorized=false
|
||||
|
||||
; Local-domain mail is routed to the internal inbound container. That hop does
|
||||
; not need STARTTLS, and the container hostname does not match the public
|
||||
; *.elektrine.com certificate, which otherwise logs ERR_TLS_CERT_ALTNAME_INVALID.
|
||||
no_tls_hosts[]=haraka-inbound
|
||||
14
config/toobusy.ini
Normal file
14
config/toobusy.ini
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Rate limiting configuration
|
||||
[main]
|
||||
# Enable rate limiting
|
||||
enabled=true
|
||||
|
||||
# Maximum load average before rejecting connections
|
||||
max_load=2.0
|
||||
|
||||
# Check interval (milliseconds)
|
||||
check_interval=1000
|
||||
|
||||
# Action when too busy
|
||||
action=defer
|
||||
message=Server too busy, try again later
|
||||
72
config/uribl.ini
Normal file
72
config/uribl.ini
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
[main]
|
||||
; Enable URIBL checking
|
||||
enabled = true
|
||||
|
||||
; Timeout for DNS lookups (seconds)
|
||||
timeout = 10
|
||||
|
||||
; Maximum number of hosts to check per message
|
||||
max_uris_per_list = 20
|
||||
|
||||
; Should we check redirected URLs? (performance impact)
|
||||
check_redirects = false
|
||||
|
||||
; Disable IPv6 lookups (often faster)
|
||||
disable_ipv6 = true
|
||||
|
||||
[lists]
|
||||
; SURBL Multi - Commercial spam URLs
|
||||
; Score: 5 points for spam URLs
|
||||
surbl.org = 5
|
||||
|
||||
; SpamHaus SBL-CSS - Known spam URLs
|
||||
; Score: 8 points (high confidence)
|
||||
sbl.spamhaus.org = 8
|
||||
|
||||
; URIBL Black - Spam and malicious URLs
|
||||
; Score: 6 points
|
||||
black.uribl.com = 6
|
||||
|
||||
; URIBL Grey - Suspicious URLs (lower score)
|
||||
; Score: 3 points
|
||||
grey.uribl.com = 3
|
||||
|
||||
; DBL (Domain Block List) - SpamHaus domain reputation
|
||||
; Score: 7 points
|
||||
dbl.spamhaus.org = 7
|
||||
|
||||
; Multi.URIBL.COM - Multiple reputation feeds
|
||||
; Score: 4 points
|
||||
multi.uribl.com = 4
|
||||
|
||||
[whitelist]
|
||||
; Popular legitimate domains that might get false positives
|
||||
; These domains will never be blocked
|
||||
whitelist = google.com,microsoft.com,amazon.com,facebook.com,twitter.com,linkedin.com,github.com,stackoverflow.com,wikipedia.org,apple.com
|
||||
|
||||
[thresholds]
|
||||
; Score at which to add headers but allow message
|
||||
add_header = 3
|
||||
|
||||
; Score at which to soft reject (450 - try again later)
|
||||
soft_reject = 8
|
||||
|
||||
; Score at which to hard reject (550 - permanent failure)
|
||||
hard_reject = 15
|
||||
|
||||
[headers]
|
||||
; Add X-URIBL-Score header to show scoring
|
||||
add_score_header = true
|
||||
|
||||
; Add X-URIBL-Lists header to show which lists matched
|
||||
add_lists_header = true
|
||||
|
||||
; Add detailed X-URIBL-Report header
|
||||
add_report_header = false
|
||||
|
||||
[reject]
|
||||
; Enable rejection based on URIBL scores
|
||||
enable_reject = true
|
||||
|
||||
; Custom rejection message
|
||||
reject_msg = Message contains URLs listed in spam databases
|
||||
10
config/watch.ini
Normal file
10
config/watch.ini
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[main]
|
||||
; Enable sampling to limit display connections to one per second
|
||||
sampling=true
|
||||
|
||||
; WebSocket URL configuration - will use same scheme, host, port as http by default
|
||||
; wss.url=wss://your-domain.com:8080
|
||||
|
||||
; Alternative document root for watch HTML files
|
||||
; Using default haraka-plugin-watch location
|
||||
; wss.htdocs=./html
|
||||
53
deployment/.env.example
Normal file
53
deployment/.env.example
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Haraka environment variables
|
||||
# Copy to .env and set your values
|
||||
|
||||
# Domain Configuration
|
||||
HARAKA_DOMAIN=mail.example.com
|
||||
|
||||
# SSL Configuration
|
||||
SSL_EMAIL=admin@example.com
|
||||
STAGING=0
|
||||
|
||||
# Shared key (optional)
|
||||
HARAKA_API_KEY=your-secure-api-key-here
|
||||
|
||||
# Directional keys
|
||||
# Haraka -> Phoenix
|
||||
PHOENIX_API_KEY=replace-with-haraka-to-phoenix-key
|
||||
# Phoenix -> Haraka
|
||||
HARAKA_HTTP_API_KEY=replace-with-phoenix-to-haraka-key
|
||||
|
||||
# Phoenix App Integration
|
||||
PHOENIX_WEBHOOK_URL=https://your-phoenix-app.com/api/haraka/inbound
|
||||
PHOENIX_VERIFY_URL=https://your-phoenix-app.com/api/haraka/verify-recipient
|
||||
PHOENIX_DOMAINS_URL=https://your-phoenix-app.com/api/haraka/domains
|
||||
|
||||
# Image tag
|
||||
HARAKA_IMAGE=git.elektrine.com/elektrine/elektrine-haraka
|
||||
HARAKA_IMAGE_TAG=latest
|
||||
|
||||
# Optional service images
|
||||
CADDY_IMAGE=caddy:2-alpine
|
||||
REDIS_IMAGE=redis:7-alpine
|
||||
CLAMAV_IMAGE=clamav/clamav:stable
|
||||
SPAMASSASSIN_IMAGE=dinkel/spamassassin:latest
|
||||
FAIL2BAN_IMAGE=crazymax/fail2ban:latest
|
||||
CERT_COPIER_IMAGE=alpine:3.20
|
||||
|
||||
# Redis + async queue
|
||||
REDIS_URL=redis://redis:6379
|
||||
ELEKTRINE_QUEUE_NAME=elektrine:inbound
|
||||
ELEKTRINE_DLQ_NAME=elektrine:inbound:dlq
|
||||
|
||||
# Worker retry policy
|
||||
WEBHOOK_MAX_RETRIES=5
|
||||
WEBHOOK_RETRY_BASE_MS=1000
|
||||
|
||||
# Optional ops CIDRs for keyless access to /status, /healthz, and /metrics.
|
||||
# Leave blank to require X-API-Key by default.
|
||||
METRICS_ALLOWED_CIDRS=
|
||||
OPS_ALLOWED_CIDRS=
|
||||
|
||||
# Queue thresholds
|
||||
QUEUE_WARN_THRESHOLD=1000
|
||||
DLQ_CRIT_THRESHOLD=10
|
||||
38
deployment/.env.same-server.example
Normal file
38
deployment/.env.same-server.example
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Same-server bare-metal deployment
|
||||
# Run this beside the main elektrine Docker stack.
|
||||
|
||||
HARAKA_DOMAIN=mail.example.com
|
||||
LOCAL_DOMAINS=example.com,mail.example.com
|
||||
|
||||
# Shared from the main Elektrine deployment
|
||||
PHOENIX_WEBHOOK_URL=http://elektrine_app:8080/api/haraka/inbound
|
||||
PHOENIX_VERIFY_URL=http://elektrine_app:8080/api/haraka/verify-recipient
|
||||
PHOENIX_DOMAINS_URL=http://elektrine_app:8080/api/haraka/domains
|
||||
|
||||
# Haraka -> Phoenix
|
||||
PHOENIX_API_KEY=replace-with-haraka-to-phoenix-key
|
||||
|
||||
# Phoenix -> Haraka
|
||||
HARAKA_HTTP_API_KEY=replace-with-phoenix-to-haraka-key
|
||||
|
||||
# Optional legacy shared key
|
||||
# HARAKA_API_KEY=
|
||||
|
||||
# Match the main Elektrine Caddy volume name.
|
||||
MAIN_CADDY_VOLUME=elektrine_caddy_data
|
||||
MAIN_APP_NETWORK=docker_elektrine_network
|
||||
|
||||
# Haraka outbound API stays loopback-only on the same host.
|
||||
HARAKA_HTTP_BIND_PORT=18080
|
||||
|
||||
# Optional trusted networks that may read /status, /healthz, and /metrics without X-API-Key.
|
||||
OPS_ALLOWED_CIDRS=
|
||||
METRICS_ALLOWED_CIDRS=
|
||||
|
||||
HARAKA_IMAGE=git.elektrine.com/elektrine/elektrine-haraka
|
||||
HARAKA_IMAGE_TAG=latest
|
||||
REDIS_URL=redis://redis:6379
|
||||
ELEKTRINE_QUEUE_NAME=elektrine:inbound
|
||||
ELEKTRINE_DLQ_NAME=elektrine:inbound:dlq
|
||||
WEBHOOK_MAX_RETRIES=5
|
||||
WEBHOOK_RETRY_BASE_MS=1000
|
||||
71
deployment/README.md
Normal file
71
deployment/README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Deployment
|
||||
|
||||
Compose-based production deployment for the multi-role Haraka topology. This is
|
||||
the supported deployment path for this repository.
|
||||
|
||||
## Services
|
||||
|
||||
- `haraka-inbound`: public MX listener on `:25`
|
||||
- `haraka-outbound`: internal HTTP send/ops API on `:8080`
|
||||
- `haraka-worker`: Redis consumer for inbound delivery to Phoenix
|
||||
- `haraka-submission`: optional authenticated submission role, not published by default
|
||||
- `redis`, `clamav`, `spamassassin`
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd deployment
|
||||
cp .env.example .env
|
||||
# edit .env
|
||||
./start.sh
|
||||
```
|
||||
|
||||
## Same-server setup
|
||||
|
||||
For a single-host deployment beside the main Elektrine stack:
|
||||
|
||||
```bash
|
||||
cp .env.same-server.example .env
|
||||
# edit .env
|
||||
../scripts/deploy/docker_deploy.sh
|
||||
```
|
||||
|
||||
This uses `docker-compose.same-server.yml`, publishes MX on `25`, binds the
|
||||
Haraka HTTP API to `127.0.0.1:18080`, and reads certificates from the main
|
||||
Elektrine Caddy volume. It also joins the main Elektrine Docker network so
|
||||
Haraka can call `http://elektrine_app:8080` directly.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# inspect all services
|
||||
docker compose ps
|
||||
|
||||
# view role logs
|
||||
docker compose logs -f haraka-inbound
|
||||
docker compose logs -f haraka-submission
|
||||
docker compose logs -f haraka-outbound
|
||||
docker compose logs -f haraka-worker
|
||||
|
||||
# queue state
|
||||
docker compose exec redis redis-cli LLEN elektrine:inbound
|
||||
docker compose exec redis redis-cli LLEN elektrine:inbound:dlq
|
||||
|
||||
# queue alert check (non-zero exit when thresholds exceeded)
|
||||
../scripts/check-queues.sh
|
||||
|
||||
# health checks from the host running Haraka
|
||||
curl -s -H 'X-API-Key: <key>' http://127.0.0.1:18080/status
|
||||
curl -s -H 'X-API-Key: <key>' http://127.0.0.1:18080/metrics
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Elektrine should call the HTTP API through `HARAKA_BASE_URL`, commonly `http://127.0.0.1:18080` on same-host installs or `http://haraka-outbound:8080` on a shared Docker network.
|
||||
- SMTP HTTP API traffic is served directly by `haraka-outbound:8080` inside Compose.
|
||||
- Inbound SMTP processing is async: accept fast on `haraka-inbound`, parse/deliver from `haraka-worker`.
|
||||
- Client SMTP submission normally lives in Elektrine. Publish `haraka-submission` only if you intentionally want Haraka-managed submission.
|
||||
- `/status`, `/healthz`, and `/metrics` accept `X-API-Key` by default.
|
||||
- Set `OPS_ALLOWED_CIDRS` and `METRICS_ALLOWED_CIDRS` in `.env` only if you also want keyless access from trusted networks.
|
||||
- Use immutable `HARAKA_IMAGE_TAG` values for reproducible rollouts.
|
||||
- Override `HARAKA_IMAGE` in `.env` if you need to pull from a different registry/repo.
|
||||
223
deployment/docker-compose.same-server.yml
Normal file
223
deployment/docker-compose.same-server.yml
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
services:
|
||||
haraka-inbound:
|
||||
image: ${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}:${HARAKA_IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- "25:25"
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
- ../config:/app/config
|
||||
- ../config/dkim:/data/haraka/dkim
|
||||
- ../plugins:/app/plugins:ro
|
||||
- ../lib:/app/lib:ro
|
||||
- ../scripts:/app/scripts:ro
|
||||
- ssl-certs:/app/ssl
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HARAKA_ROLE=inbound-mx
|
||||
- HARAKA_API_KEY=${HARAKA_API_KEY:-}
|
||||
- PHOENIX_API_KEY=${PHOENIX_API_KEY:-}
|
||||
- PHOENIX_WEBHOOK_URL=${PHOENIX_WEBHOOK_URL:-}
|
||||
- PHOENIX_VERIFY_URL=${PHOENIX_VERIFY_URL:-}
|
||||
- PHOENIX_DOMAINS_URL=${PHOENIX_DOMAINS_URL:-}
|
||||
- LOCAL_DOMAINS=${LOCAL_DOMAINS:-}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- ELEKTRINE_QUEUE_NAME=${ELEKTRINE_QUEUE_NAME:-elektrine:inbound}
|
||||
- ELEKTRINE_DLQ_NAME=${ELEKTRINE_DLQ_NAME:-elektrine:inbound:dlq}
|
||||
- HARAKA_DOMAIN=${HARAKA_DOMAIN:-mail.example.com}
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- haraka-network
|
||||
- main-app-network
|
||||
depends_on:
|
||||
- redis
|
||||
- clamav
|
||||
- spamassassin
|
||||
|
||||
haraka-submission:
|
||||
image: ${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}:${HARAKA_IMAGE_TAG:-latest}
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
- ../config:/app/config
|
||||
- ../config/dkim:/data/haraka/dkim
|
||||
- ../plugins:/app/plugins:ro
|
||||
- ../lib:/app/lib:ro
|
||||
- ../scripts:/app/scripts:ro
|
||||
- ssl-certs:/app/ssl
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HARAKA_ROLE=submission
|
||||
- HARAKA_API_KEY=${HARAKA_API_KEY:-}
|
||||
- PHOENIX_API_KEY=${PHOENIX_API_KEY:-}
|
||||
- PHOENIX_VERIFY_URL=${PHOENIX_VERIFY_URL:-}
|
||||
- PHOENIX_DOMAINS_URL=${PHOENIX_DOMAINS_URL:-}
|
||||
- LOCAL_DOMAINS=${LOCAL_DOMAINS:-}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- HARAKA_DOMAIN=${HARAKA_DOMAIN:-mail.example.com}
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- haraka-network
|
||||
- main-app-network
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
haraka-outbound:
|
||||
image: ${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}:${HARAKA_IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- "127.0.0.1:${HARAKA_HTTP_BIND_PORT:-18080}:8080"
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
- ../config:/app/config
|
||||
- ../config/dkim:/data/haraka/dkim
|
||||
- ../plugins:/app/plugins:ro
|
||||
- ../lib:/app/lib:ro
|
||||
- ../scripts:/app/scripts:ro
|
||||
- ssl-certs:/app/ssl
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HARAKA_ROLE=outbound-relay
|
||||
- HARAKA_API_KEY=${HARAKA_API_KEY:-}
|
||||
- PHOENIX_API_KEY=${PHOENIX_API_KEY:-}
|
||||
- HARAKA_HTTP_API_KEY=${HARAKA_HTTP_API_KEY:-}
|
||||
- PHOENIX_WEBHOOK_URL=${PHOENIX_WEBHOOK_URL:-}
|
||||
- PHOENIX_VERIFY_URL=${PHOENIX_VERIFY_URL:-}
|
||||
- PHOENIX_DOMAINS_URL=${PHOENIX_DOMAINS_URL:-}
|
||||
- LOCAL_DOMAINS=${LOCAL_DOMAINS:-}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- HARAKA_HTTP_PORT=8080
|
||||
- HARAKA_HTTP_HOST=0.0.0.0
|
||||
- HARAKA_DOMAIN=${HARAKA_DOMAIN:-mail.example.com}
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- haraka-network
|
||||
- main-app-network
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
haraka-worker:
|
||||
image: ${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}:${HARAKA_IMAGE_TAG:-latest}
|
||||
command: ["node", "/app/scripts/elektrine-worker.js"]
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
- ../config:/app/config
|
||||
- ../config/dkim:/data/haraka/dkim
|
||||
- ../plugins:/app/plugins:ro
|
||||
- ../lib:/app/lib:ro
|
||||
- ../scripts:/app/scripts:ro
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HARAKA_ROLE=worker
|
||||
- HARAKA_API_KEY=${HARAKA_API_KEY:-}
|
||||
- PHOENIX_API_KEY=${PHOENIX_API_KEY:-}
|
||||
- PHOENIX_WEBHOOK_URL=${PHOENIX_WEBHOOK_URL:-}
|
||||
- PHOENIX_VERIFY_URL=${PHOENIX_VERIFY_URL:-}
|
||||
- PHOENIX_DOMAINS_URL=${PHOENIX_DOMAINS_URL:-}
|
||||
- LOCAL_DOMAINS=${LOCAL_DOMAINS:-}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- ELEKTRINE_QUEUE_NAME=${ELEKTRINE_QUEUE_NAME:-elektrine:inbound}
|
||||
- ELEKTRINE_DLQ_NAME=${ELEKTRINE_DLQ_NAME:-elektrine:inbound:dlq}
|
||||
- WEBHOOK_MAX_RETRIES=${WEBHOOK_MAX_RETRIES:-5}
|
||||
- WEBHOOK_RETRY_BASE_MS=${WEBHOOK_RETRY_BASE_MS:-1000}
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- haraka-network
|
||||
- main-app-network
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
cert-copier:
|
||||
image: ${CERT_COPIER_IMAGE:-alpine:3.20}
|
||||
volumes:
|
||||
- main-caddy-data:/caddy-data:ro
|
||||
- ssl-certs:/ssl-certs
|
||||
environment:
|
||||
- HARAKA_DOMAIN=${HARAKA_DOMAIN:-mail.example.com}
|
||||
command: >
|
||||
sh -c "
|
||||
while true; do
|
||||
if [ -f /caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.elektrine.com/wildcard_.elektrine.com.crt ]; then
|
||||
cp /caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.elektrine.com/wildcard_.elektrine.com.crt /ssl-certs/cert.crt
|
||||
cp /caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.elektrine.com/wildcard_.elektrine.com.key /ssl-certs/cert.key
|
||||
chmod 644 /ssl-certs/cert.crt
|
||||
chmod 600 /ssl-certs/cert.key
|
||||
elif [ -f /caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/$$HARAKA_DOMAIN/$$HARAKA_DOMAIN.crt ]; then
|
||||
cp /caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/$$HARAKA_DOMAIN/$$HARAKA_DOMAIN.crt /ssl-certs/cert.crt
|
||||
cp /caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/$$HARAKA_DOMAIN/$$HARAKA_DOMAIN.key /ssl-certs/cert.key
|
||||
chmod 644 /ssl-certs/cert.crt
|
||||
chmod 600 /ssl-certs/cert.key
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
"
|
||||
networks:
|
||||
- haraka-network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: ${REDIS_IMAGE:-redis:7-alpine}
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- yes
|
||||
- --appendfsync
|
||||
- everysec
|
||||
- --save
|
||||
- "900 1"
|
||||
- --save
|
||||
- "300 10"
|
||||
- --save
|
||||
- "60 10000"
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- haraka-network
|
||||
|
||||
clamav:
|
||||
image: ${CLAMAV_IMAGE:-clamav/clamav:stable}
|
||||
ports:
|
||||
- "127.0.0.1:3310:3310"
|
||||
volumes:
|
||||
- clamav-data:/var/lib/clamav
|
||||
environment:
|
||||
- CLAMAV_NO_FRESHCLAM=false
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- haraka-network
|
||||
|
||||
spamassassin:
|
||||
image: ${SPAMASSASSIN_IMAGE:-dinkel/spamassassin:latest}
|
||||
ports:
|
||||
- "127.0.0.1:783:783"
|
||||
volumes:
|
||||
- spamassassin-data:/var/lib/spamassassin
|
||||
environment:
|
||||
- SA_UPDATE=1
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- haraka-network
|
||||
|
||||
networks:
|
||||
haraka-network:
|
||||
driver: bridge
|
||||
main-app-network:
|
||||
external: true
|
||||
name: ${MAIN_APP_NETWORK:-docker_elektrine_network}
|
||||
|
||||
volumes:
|
||||
main-caddy-data:
|
||||
external: true
|
||||
name: ${MAIN_CADDY_VOLUME:-elektrine_caddy_data}
|
||||
redis-data:
|
||||
ssl-certs:
|
||||
clamav-data:
|
||||
spamassassin-data:
|
||||
240
deployment/docker-compose.yml
Normal file
240
deployment/docker-compose.yml
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
services:
|
||||
haraka-inbound:
|
||||
image: ${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}:${HARAKA_IMAGE_TAG:-latest}
|
||||
# For local development, comment out image and uncomment build:
|
||||
# build: ../
|
||||
ports:
|
||||
- "25:25"
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
- ../config:/app/config
|
||||
- ../config/dkim:/data/haraka/dkim
|
||||
- ../plugins:/app/plugins:ro
|
||||
- ../lib:/app/lib:ro
|
||||
- ../scripts:/app/scripts:ro
|
||||
- ssl-certs:/app/ssl
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HARAKA_ROLE=inbound-mx
|
||||
- HARAKA_API_KEY=${HARAKA_API_KEY:-}
|
||||
- PHOENIX_API_KEY=${PHOENIX_API_KEY:-}
|
||||
- PHOENIX_WEBHOOK_URL=${PHOENIX_WEBHOOK_URL:-}
|
||||
- PHOENIX_VERIFY_URL=${PHOENIX_VERIFY_URL:-}
|
||||
- PHOENIX_DOMAINS_URL=${PHOENIX_DOMAINS_URL:-}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- ELEKTRINE_QUEUE_NAME=${ELEKTRINE_QUEUE_NAME:-elektrine:inbound}
|
||||
- ELEKTRINE_DLQ_NAME=${ELEKTRINE_DLQ_NAME:-elektrine:inbound:dlq}
|
||||
- HARAKA_DOMAIN=${HARAKA_DOMAIN:-mail.example.com}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- haraka-network
|
||||
- main-app-network
|
||||
depends_on:
|
||||
- redis
|
||||
- clamav
|
||||
- spamassassin
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
cpus: '2.0'
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "25"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
haraka-submission:
|
||||
image: ${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}:${HARAKA_IMAGE_TAG:-latest}
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
- ../config:/app/config
|
||||
- ../config/dkim:/data/haraka/dkim
|
||||
- ../plugins:/app/plugins:ro
|
||||
- ../lib:/app/lib:ro
|
||||
- ../scripts:/app/scripts:ro
|
||||
- ssl-certs:/app/ssl
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HARAKA_ROLE=submission
|
||||
- HARAKA_API_KEY=${HARAKA_API_KEY:-}
|
||||
- PHOENIX_API_KEY=${PHOENIX_API_KEY:-}
|
||||
- PHOENIX_VERIFY_URL=${PHOENIX_VERIFY_URL:-}
|
||||
- PHOENIX_DOMAINS_URL=${PHOENIX_DOMAINS_URL:-}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- HARAKA_DOMAIN=${HARAKA_DOMAIN:-mail.example.com}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- haraka-network
|
||||
- main-app-network
|
||||
depends_on:
|
||||
- redis
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
cpus: '1.0'
|
||||
|
||||
haraka-outbound:
|
||||
image: ${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}:${HARAKA_IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- "127.0.0.1:${HARAKA_HTTP_BIND_PORT:-18080}:8080"
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
- ../config:/app/config
|
||||
- ../config/dkim:/data/haraka/dkim
|
||||
- ../plugins:/app/plugins:ro
|
||||
- ../lib:/app/lib:ro
|
||||
- ../scripts:/app/scripts:ro
|
||||
- ssl-certs:/app/ssl
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HARAKA_ROLE=outbound-relay
|
||||
- HARAKA_API_KEY=${HARAKA_API_KEY:-}
|
||||
- PHOENIX_API_KEY=${PHOENIX_API_KEY:-}
|
||||
- HARAKA_HTTP_API_KEY=${HARAKA_HTTP_API_KEY:-}
|
||||
- PHOENIX_WEBHOOK_URL=${PHOENIX_WEBHOOK_URL:-}
|
||||
- PHOENIX_VERIFY_URL=${PHOENIX_VERIFY_URL:-}
|
||||
- PHOENIX_DOMAINS_URL=${PHOENIX_DOMAINS_URL:-}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- HARAKA_HTTP_PORT=8080
|
||||
- HARAKA_HTTP_HOST=0.0.0.0
|
||||
- HARAKA_DOMAIN=${HARAKA_DOMAIN:-mail.example.com}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- haraka-network
|
||||
- main-app-network
|
||||
depends_on:
|
||||
- redis
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
cpus: '1.0'
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"const key = process.env.HARAKA_HTTP_API_KEY || process.env.HARAKA_API_KEY || ''; const req = require('http').request('http://127.0.0.1:8080/status', { headers: key ? { 'x-api-key': key } : {} }, (res) => process.exit(res.statusCode === 200 ? 0 : 1)); req.on('error', () => process.exit(1)); req.end();"
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
haraka-worker:
|
||||
image: ${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}:${HARAKA_IMAGE_TAG:-latest}
|
||||
command: ["node", "/app/scripts/elektrine-worker.js"]
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
- ../config:/app/config
|
||||
- ../config/dkim:/data/haraka/dkim
|
||||
- ../plugins:/app/plugins:ro
|
||||
- ../lib:/app/lib:ro
|
||||
- ../scripts:/app/scripts:ro
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HARAKA_ROLE=worker
|
||||
- HARAKA_API_KEY=${HARAKA_API_KEY:-}
|
||||
- PHOENIX_API_KEY=${PHOENIX_API_KEY:-}
|
||||
- PHOENIX_WEBHOOK_URL=${PHOENIX_WEBHOOK_URL:-}
|
||||
- PHOENIX_VERIFY_URL=${PHOENIX_VERIFY_URL:-}
|
||||
- PHOENIX_DOMAINS_URL=${PHOENIX_DOMAINS_URL:-}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- ELEKTRINE_QUEUE_NAME=${ELEKTRINE_QUEUE_NAME:-elektrine:inbound}
|
||||
- ELEKTRINE_DLQ_NAME=${ELEKTRINE_DLQ_NAME:-elektrine:inbound:dlq}
|
||||
- WEBHOOK_MAX_RETRIES=${WEBHOOK_MAX_RETRIES:-5}
|
||||
- WEBHOOK_RETRY_BASE_MS=${WEBHOOK_RETRY_BASE_MS:-1000}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- haraka-network
|
||||
- main-app-network
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: ${REDIS_IMAGE:-redis:7-alpine}
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- yes
|
||||
- --appendfsync
|
||||
- everysec
|
||||
- --save
|
||||
- "900 1"
|
||||
- --save
|
||||
- "300 10"
|
||||
- --save
|
||||
- "60 10000"
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- haraka-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "PING"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
clamav:
|
||||
image: ${CLAMAV_IMAGE:-clamav/clamav:stable}
|
||||
ports:
|
||||
- "127.0.0.1:3310:3310"
|
||||
volumes:
|
||||
- clamav-data:/var/lib/clamav
|
||||
environment:
|
||||
- CLAMAV_NO_FRESHCLAM=false
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- haraka-network
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/local/bin/clamdcheck.sh"]
|
||||
interval: 60s
|
||||
timeout: 120s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: '1.0'
|
||||
|
||||
spamassassin:
|
||||
image: ${SPAMASSASSIN_IMAGE:-dinkel/spamassassin:latest}
|
||||
ports:
|
||||
- "127.0.0.1:783:783"
|
||||
volumes:
|
||||
- spamassassin-data:/var/lib/spamassassin
|
||||
environment:
|
||||
- SA_UPDATE=1
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- haraka-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
|
||||
networks:
|
||||
haraka-network:
|
||||
driver: bridge
|
||||
main-app-network:
|
||||
external: true
|
||||
name: ${MAIN_APP_NETWORK:-docker_elektrine_network}
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
ssl-certs:
|
||||
clamav-data:
|
||||
spamassassin-data:
|
||||
96
deployment/start.sh
Executable file
96
deployment/start.sh
Executable file
|
|
@ -0,0 +1,96 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Start elektrine-haraka step by step
|
||||
set -euo pipefail
|
||||
|
||||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
compose() { docker compose "$@"; }
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
compose() { docker-compose "$@"; }
|
||||
else
|
||||
echo "ERROR: docker compose or docker-compose is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting elektrine-haraka deployment..."
|
||||
|
||||
# Validate required environment variables from shell and/or deployment/.env
|
||||
required_vars=(
|
||||
"HARAKA_DOMAIN"
|
||||
"PHOENIX_WEBHOOK_URL"
|
||||
"PHOENIX_VERIFY_URL"
|
||||
"PHOENIX_DOMAINS_URL"
|
||||
)
|
||||
|
||||
get_var() {
|
||||
local var_name="$1"
|
||||
local value="${!var_name:-}"
|
||||
|
||||
if [ -n "$value" ]; then
|
||||
printf "%s" "$value"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f .env ]; then
|
||||
value=$(grep -E "^${var_name}=" .env | tail -n 1 | cut -d '=' -f 2-)
|
||||
printf "%s" "$value"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf ""
|
||||
}
|
||||
|
||||
for var_name in "${required_vars[@]}"; do
|
||||
if [ -z "$(get_var "$var_name")" ]; then
|
||||
echo "ERROR: ${var_name} is required (set in shell or deployment/.env)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$(get_var "PHOENIX_API_KEY")" ] && [ -z "$(get_var "HARAKA_API_KEY")" ]; then
|
||||
echo "ERROR: set PHOENIX_API_KEY (recommended) or HARAKA_API_KEY (legacy fallback)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$(get_var "HARAKA_HTTP_API_KEY")" ] && [ -z "$(get_var "HARAKA_API_KEY")" ]; then
|
||||
echo "ERROR: set HARAKA_HTTP_API_KEY (recommended) or HARAKA_API_KEY (legacy fallback)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate compose interpolation early
|
||||
compose config >/dev/null
|
||||
|
||||
# 1. Start supporting services first
|
||||
echo "Starting Redis, ClamAV, and SpamAssassin..."
|
||||
compose up -d redis clamav spamassassin
|
||||
|
||||
# 2. Wait for services to be ready
|
||||
echo "Waiting for services to initialize..."
|
||||
sleep 10
|
||||
|
||||
# 3. Start Haraka roles and worker
|
||||
echo "Starting Haraka inbound/submission/outbound roles and worker..."
|
||||
compose up -d haraka-inbound haraka-submission haraka-outbound haraka-worker
|
||||
|
||||
# 4. Wait for Haraka roles to be ready
|
||||
sleep 5
|
||||
|
||||
# 5. Start Caddy (handles HTTPS/TLS automatically)
|
||||
echo "Starting Caddy reverse proxy..."
|
||||
compose up -d caddy
|
||||
|
||||
# 6. Start cert-copier (copies Caddy certs to Haraka for STARTTLS)
|
||||
echo "Starting certificate copier..."
|
||||
compose up -d cert-copier
|
||||
|
||||
echo ""
|
||||
echo "Deployment complete!"
|
||||
echo ""
|
||||
echo "Services running:"
|
||||
compose ps
|
||||
|
||||
echo ""
|
||||
echo "Test your setup:"
|
||||
echo " SMTP: telnet $(hostname) 25"
|
||||
echo " Submission: telnet $(hostname) 587"
|
||||
echo " API: curl -H 'X-API-Key: <key>' https://$(hostname)/api/v1/send"
|
||||
38
docker/caddy/Caddyfile
Normal file
38
docker/caddy/Caddyfile
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{$HARAKA_DOMAIN:mail.example.com} {
|
||||
# Automatic HTTPS with Let's Encrypt
|
||||
# Caddy handles certificate generation and renewal automatically
|
||||
|
||||
# Access logs
|
||||
log {
|
||||
output file /data/access.log
|
||||
}
|
||||
|
||||
# Internal API endpoints are not exposed through the public Haraka Caddy edge.
|
||||
@api path /api/*
|
||||
handle @api {
|
||||
respond "Forbidden" 403
|
||||
}
|
||||
|
||||
# Status, health, and metrics auth is enforced by Haraka itself.
|
||||
@ops path /status /healthz
|
||||
|
||||
handle @ops {
|
||||
reverse_proxy {$HARAKA_HTTP_UPSTREAM:haraka-outbound:8080} {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
handle /metrics {
|
||||
reverse_proxy {$HARAKA_HTTP_UPSTREAM:haraka-outbound:8080} {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
# Default response for other paths
|
||||
respond "elektrine-haraka Email Server" 404
|
||||
}
|
||||
|
||||
# HTTP redirect (automatic)
|
||||
http://{$HARAKA_DOMAIN:mail.example.com} {
|
||||
redir https://{host}{uri} permanent
|
||||
}
|
||||
420
docs/Plugins.md
Normal file
420
docs/Plugins.md
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
# Plugins
|
||||
|
||||
## Elektrine Role Profiles
|
||||
|
||||
This project uses role-specific plugin chains:
|
||||
|
||||
- `config/plugins.inbound-mx`: public MX receive path with `elektrine_async_queue`.
|
||||
- `config/plugins.submission`: authenticated submission path using native Haraka outbound.
|
||||
- `config/plugins.outbound-relay`: HTTP API send path with `elektrine_http_api`.
|
||||
|
||||
At runtime, `scripts/start-haraka.sh` copies `config/plugins.<HARAKA_ROLE>` to
|
||||
`config/plugins`.
|
||||
|
||||
## Custom Plugins
|
||||
|
||||
- `elektrine_async_queue`: queue hook plugin that stores full RFC822 messages in Redis.
|
||||
- `elektrine_http_api`: outbound send API (`/api/v1/send`) + status/metrics endpoints.
|
||||
- `elektrine_local_mx`: overrides MX resolution for local domains to internal inbound Haraka.
|
||||
- `elektrine_rcpt_verify`: recipient + relay enforcement backed by Phoenix.
|
||||
- `elektrine_spf_enforcer`: strict SPF policy for protected local domains.
|
||||
|
||||
Inbound webhook delivery is now performed by `scripts/elektrine-worker.js` (async worker),
|
||||
not during SMTP queue hook execution.
|
||||
|
||||
Most aspects of receiving an email in Haraka are controlled by plugins. Mail cannot even be received unless at least a 'rcpt' and 'queue' plugin are
|
||||
enabled.
|
||||
|
||||
Recipient (*rcpt*) plugins determine if a particular recipient is allowed to be relayed or received for. A *queue* plugin queues the message somewhere - normally to disk or to an another SMTP server.
|
||||
|
||||
## Plugin Lists
|
||||
|
||||
Get a list of installed plugins by running `haraka -l`. To include locally installed plugins, add the `-c /path/to/config` option.
|
||||
|
||||
We also have a [registry of known plugins](https://github.com/haraka/Haraka/blob/master/Plugins.md).
|
||||
|
||||
Display the help text for a plugin by running:
|
||||
|
||||
`haraka -h <name> -c /path/to/config`
|
||||
|
||||
# Writing Haraka Plugins
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
## Anatomy of a Plugin
|
||||
|
||||
Plugins in Haraka are JS files in the `plugins` directory (legacy) and npm
|
||||
modules in the node\_modules directory. See "Plugins as Modules" below.
|
||||
|
||||
Plugins can be installed in the Haraka global directory (default:
|
||||
/$os/$specific/lib/node\_modules/Haraka) or in the Haraka install directory
|
||||
(whatever you chose when you typed `haraka -i`. Example: `haraka -i /etc/haraka`
|
||||
|
||||
To enable a plugin, add its name to `config/plugins`. For npm packaged plugins, the name does not include the `haraka-plugin` prefix.
|
||||
|
||||
### Register
|
||||
|
||||
Register is the only plugin function that is syncronous and receives no arguments. Its primary purpose is enabling your plugin to register SMTP hooks. It is also used for syncronous initialization tasks such as [loading a config file](https://github.com/haraka/haraka-config). For heavier initialization tasks such as establishing database connections, look to `init_master` and `init_child` instead.
|
||||
|
||||
### Register a Hook
|
||||
|
||||
There are two ways for plugins to register hooks. Both examples register a function on the *rcpt* hook:
|
||||
|
||||
1. The `register_hook` function in register():
|
||||
|
||||
```js
|
||||
exports.register = function () {
|
||||
this.register_hook('rcpt', 'my_rcpt_validate')
|
||||
};
|
||||
|
||||
exports.my_rcpt_validate = function (next, connection, params) {
|
||||
// do processing
|
||||
next()
|
||||
};
|
||||
```
|
||||
|
||||
2. The hook_[$name] syntax:
|
||||
|
||||
```js
|
||||
exports.hook_rcpt = function (next, connection, params) {
|
||||
// do processing
|
||||
next()
|
||||
}
|
||||
```
|
||||
|
||||
The register_hook function within `register()` offers a few advantages:
|
||||
|
||||
1. register a hook multiple times (see below)
|
||||
2. a unique function name in stack traces
|
||||
3. [a better function name](https://google.com/search?q=programming%20good%20function%20names)
|
||||
4. hooks can be registered conditionally (ie, based on a config setting)
|
||||
|
||||
### Register a Hook Multiple Times
|
||||
|
||||
To register the same hook more than once, call `register_hook()` multiple times with the same hook name:
|
||||
|
||||
```js
|
||||
exports.register = function () {
|
||||
this.register_hook('queue', 'try_queue_my_way')
|
||||
this.register_hook('queue', 'try_queue_highway')
|
||||
};
|
||||
```
|
||||
|
||||
When `try_queue_my_way()` calls `next()`, the next function registered on hook *queue* will be called, in this case, `try_queue_highway()`.
|
||||
|
||||
#### Determine hook name
|
||||
|
||||
When a single function runs on multiple hooks, the function can check the
|
||||
*hook* property of the *connection* or *hmail* argument to determine which hook it is running on:
|
||||
|
||||
```js
|
||||
exports.register = function () {
|
||||
this.register_hook('rcpt', 'my_rcpt')
|
||||
this.register_hook('rcpt_ok', 'my_rcpt')
|
||||
};
|
||||
|
||||
exports.my_rcpt = function (next, connection, params) {
|
||||
const hook_name = connection.hook; // rcpt or rcpt_ok
|
||||
// email address is in params[0]
|
||||
// do processing
|
||||
}
|
||||
```
|
||||
|
||||
### Next()
|
||||
|
||||
After registering a hook, functions are called with that hooks arguments (see **Available Hooks** below. The first argument is a callback function, conventionally named `next`. When the function is completed, it calls `next()` and the connection continues. Failing to call `next()` will result in the connection hanging until that plugin's timer expires.
|
||||
|
||||
`next([code, msg])` accepts two optional parameters:
|
||||
|
||||
1. `code` is one of the listed return codes.
|
||||
2. `msg` is a string to send to the client in case of a failure. Use an array to send a multi-line message. `msg` should NOT contain the code number - that is handled by Haraka.
|
||||
|
||||
#### Next() Return Codes
|
||||
|
||||
These constants are in your plugin when it is loaded, you do not
|
||||
need to define them:
|
||||
|
||||
* CONT
|
||||
|
||||
Continue and let other plugins handle this particular hook. This is the
|
||||
default. These are identical: `next()` and `next(CONT)`;
|
||||
|
||||
* DENY - Reject with a 5xx error.
|
||||
|
||||
* DENYSOFT - Reject with a 4xx error.
|
||||
|
||||
* DENYDISCONNECT - Reject with a 5xx error and immediately disconnect.
|
||||
|
||||
* DISCONNECT - Immediately disconnect
|
||||
|
||||
* OK
|
||||
|
||||
Required by `rcpt` plugins to accept a recipient and `queue` plugins when the queue was successful.
|
||||
|
||||
After a plugin calls `next(OK)`, no further plugins on that hook will run.
|
||||
|
||||
Exceptions to next(OK):
|
||||
|
||||
* connect_init and disconnect hooks are **always called**.
|
||||
|
||||
* On the deny hook, `next(OK)` overrides the default CONT.
|
||||
|
||||
* HOOK\_NEXT
|
||||
|
||||
HOOK_NEXT is only available on the `unrecognized_command` hook. It instructs Haraka to run a different plugin hook. The `msg` argument must be set to the name of the hook to be run. Ex: `next(HOOK_NEXT, 'rcpt_ok');`
|
||||
|
||||
## Available Hooks
|
||||
|
||||
These are the hook and their parameters (next excluded):
|
||||
|
||||
* init\_master - called when the main (master) process is started
|
||||
* init\_child - in cluster, called when a child process is started
|
||||
* init\_http - called when Haraka is started.
|
||||
* init_wss - called after init_http
|
||||
* connect\_init - used to init data structures, called for *every* connection
|
||||
* lookup\_rdns - called to look up the rDNS - return the rDNS via `next(OK, rdns)`
|
||||
* connect - called after we got rDNS
|
||||
* capabilities - called to get the ESMTP capabilities (such as STARTTLS)
|
||||
* unrecognized\_command - called when the remote end sends a command we don't recognise
|
||||
* disconnect - called upon disconnect
|
||||
* helo (hostname)
|
||||
* ehlo (hostname)
|
||||
* quit
|
||||
* vrfy
|
||||
* noop
|
||||
* rset
|
||||
* mail ([from, esmtp\_params])
|
||||
* rcpt ([to, esmtp\_params])
|
||||
* rcpt\_ok (to)
|
||||
* data - called at the DATA command
|
||||
* data\_post - called at the end-of-data marker
|
||||
* max\_data\_exceeded - called when the message exceeds connection.max\_bytes
|
||||
* queue - called to queue the mail
|
||||
* queue\_outbound - called to queue the mail when connection.relaying is set
|
||||
* queue\_ok - called when a mail has been queued successfully
|
||||
* reset\_transaction - called before the transaction is reset (via RSET, or MAIL)
|
||||
* deny - called when a plugin returns DENY, DENYSOFT or DENYDISCONNECT
|
||||
* get\_mx (hmail, domain) - called by outbound to resolve the MX record
|
||||
* deferred (hmail, params) - called when an outbound message is deferred
|
||||
* bounce (hmail, err) - called when an outbound message bounces
|
||||
* delivered (hmail, [host, ip, response, delay, port, mode, ok_recips, secured, authenticated]) - called when outbound mail is delivered
|
||||
* send\_email (hmail) - called when outbound is about to be sent
|
||||
* pre\_send\_trans\_email (fake_connection) - called just before an email is queued to disk with a faked connection object
|
||||
|
||||
### rcpt
|
||||
|
||||
The *rcpt* hook is slightly special.
|
||||
|
||||
When **connection.relaying == false** (the default, to avoid being an open relay), a rcpt plugin MUST return `next(OK)` or the sender will receive the error message "I cannot deliver for that user". The default *rcpt* plugin is **rcpt_to.in_host_list**, which lists the domains for which to accept email.
|
||||
|
||||
After a *rcpt* plugin calls `next(OK)`, the *rcpt_ok* hook is run.
|
||||
|
||||
If a plugin prior to the *rcpt* hook sets **connection.relaying = true**, then it is not necessary for a rcpt plugin to call `next(OK)`.
|
||||
|
||||
### connect_init
|
||||
|
||||
The `connect_init` hook is unique in that all return codes are ignored. This is so that plugins that need to do initialization for every connection can be assured they will run. Return values are ignored.
|
||||
|
||||
### hook_init_http (next, Server)
|
||||
|
||||
If http listeners are are enabled in http.ini and the express module loaded, the express library will be located at Server.http.express. More importantly, the express [app / instance](http://expressjs.com/4x/api.html#app) will be located at Server.http.app. Plugins can register routes on the app just as they would with any [Express.js](http://expressjs.com/) app.
|
||||
|
||||
### hook_init_wss (next, Server)
|
||||
|
||||
If express loaded, an attempt is made to load [ws](https://www.npmjs.com/package/ws), the websocket server. If it succeeds, the wss server will be located at Server.http.wss. Because of how websockets work, only one websocket plugin will work at a time. One plugin using wss is [watch](https://github.com/haraka/Haraka/tree/master/plugins/watch).
|
||||
|
||||
### pre\_send\_trans\_email (next, fake_connection)
|
||||
|
||||
The `fake` connection here is a holder for a new transaction object. It only has the log methods and a `transaction` property
|
||||
so don't expect it to behave like a a real connection object. This hook is designed so you can add headers and modify mails
|
||||
sent via `outbound.send_email()`, see the dkim_sign plugin for an example.
|
||||
|
||||
## Hook Order
|
||||
|
||||
The ordering of hooks is determined by the SMTP protocol. Knowledge of [RFC 5321](http://tools.ietf.org/html/rfc5321) is beneficial.
|
||||
|
||||
##### Typical Inbound Connection
|
||||
|
||||
- hook_connect_init
|
||||
- hook_lookup_rdns
|
||||
- hook_connect
|
||||
- hook_helo **OR** hook_ehlo (EHLO is sent when ESMTP is desired which allows extensions
|
||||
such as STARTTLS, AUTH, SIZE etc.)
|
||||
- hook_helo
|
||||
- hook_ehlo
|
||||
- hook_capabilities
|
||||
- *hook_unrecognized_command* is run for each ESMTP extension the client requests
|
||||
e.g. STARTTLS, AUTH etc.)
|
||||
- hook_mail
|
||||
- hook_rcpt (once per-recipient)
|
||||
- hook_rcpt_ok (for every recipient that hook_rcpt returned `next(OK)` for)
|
||||
- hook_data
|
||||
- *[attachment hooks]*
|
||||
- hook_data_post
|
||||
- hook_queue **OR** hook_queue_outbound
|
||||
- hook_queue_ok (called if hook_queue or hook_queue_outbound returns `next(OK)`)
|
||||
- hook_quit **OR** hook_rset **OR** hook_helo **OR** hook_ehlo (after a message has been sent or rejected, the client can disconnect or start a new transaction with RSET, EHLO or HELO)
|
||||
- hook_reset_transaction
|
||||
- hook_disconnect
|
||||
|
||||
##### Typical Outbound mail
|
||||
|
||||
By 'outbound' we mean messages using Haraka's built-in queue and delivery
|
||||
mechanism. The Outbound queue is used when `connection.relaying = true` is set during the transaction and `hook_queue_outbound` is called to queue the message.
|
||||
|
||||
The Outbound hook ordering mirrors the Inbound hook order above until after `hook_queue_outbound`, which is followed by:
|
||||
|
||||
- hook_send_email
|
||||
- hook_get_mx
|
||||
- at least one of:
|
||||
- hook_delivered (once per delivery domain with at least one successful recipient)
|
||||
- hook_deferred (once per delivery domain where at least one recipient or connection was deferred)
|
||||
- hook_bounce (once per delivery domain where the recipient(s) or message was rejected by the destination)
|
||||
|
||||
## Plugin Run Order
|
||||
|
||||
Plugins are run on each hook in the order that they are specified in `config/plugins`. When a plugin returns anything other than `next()` on a hook, all subsequent plugins due to run on that hook are skipped (exceptions: connect_init, disconnect).
|
||||
|
||||
This is important as some plugins might rely on `results` or `notes` that have been set by plugins that need to run before them. This should be noted in the plugins documentation. Make sure to read it.
|
||||
|
||||
If you are writing a complex plugin, you may have to split it into multiple plugins to run in a specific order e.g. you want hook_deny to run last after all other plugins and hook_lookup_rdns to run first, then you can explicitly register your hooks and provide a `priority` value which is an integer between -100 (highest priority) to 100 (lowest priority) which defaults to 0 (zero) if not supplied. You can apply a priority to your hook in the following way:
|
||||
|
||||
```js
|
||||
exports.register = function () {
|
||||
this.register_hook('connect', 'do_connect_stuff', -100);
|
||||
}
|
||||
```
|
||||
|
||||
This would ensure that your `do_connect_stuff` function will run before any other
|
||||
plugins registered on the `connect` hook, regardless of the order it was
|
||||
specified in `config/plugins`.
|
||||
|
||||
Check the order that the plugins will run on each hook by running:
|
||||
|
||||
`haraka -o -c /path/to/config`
|
||||
|
||||
## Skipping Plugins
|
||||
|
||||
Plugins can be skipped at runtime by pushing the name of the plugin into the `skip_plugins` array in `transaction.notes`. This array is reset for every transaction and once a plugin is added to the list, it will not run any hooks in that plugin for the remainder of the transaction. For example, one could create a whitelist plugin that skipped `spamassassin` if the sender was in a whitelist.
|
||||
|
||||
## Logging
|
||||
|
||||
Plugins inherit all the logging methods of `logger.js`, which are:
|
||||
|
||||
* logprotocol
|
||||
* logdebug
|
||||
* loginfo
|
||||
* lognotice
|
||||
* logwarn
|
||||
* logerror
|
||||
* logcrit
|
||||
* logalert
|
||||
* logemerg
|
||||
|
||||
If plugins throw an exception when in a hook, the exception will be caught
|
||||
and generate a logcrit level error. However, exceptions will not be caught
|
||||
as gracefully when plugins are running async code. Use error codes for that,
|
||||
log the error, and run your next() function appropriately.
|
||||
|
||||
## Sharing State
|
||||
|
||||
There are several cases where you might need to share information between
|
||||
plugins. This is done using `notes` - there are three types available:
|
||||
|
||||
* server.notes
|
||||
|
||||
Available in all plugins. This is created at PID start-up and is shared
|
||||
amongst all plugins on the same PID and listener.
|
||||
Typical uses for notes at this level would be to share database
|
||||
connections between multiple plugins or connection pools etc.
|
||||
|
||||
* connection.notes
|
||||
|
||||
Available on any hook that passes 'connection' as a function parameter.
|
||||
This is shared amongst all plugins for a single connection and is
|
||||
destroyed after the client disconnects.
|
||||
Typical uses for notes at this level would be to store information
|
||||
about the connected client e.g. rDNS names, HELO/EHLO, white/black
|
||||
list status etc.
|
||||
|
||||
* connection.transaction.notes
|
||||
|
||||
Available on any hook that passes 'connection' as a function parameter
|
||||
between hook\_mail and hook\_data\_post.
|
||||
This is shared amongst all plugins for this transaction (e.g. MAIL FROM
|
||||
through until a message is received or the connection is reset).
|
||||
Typical uses for notes at this level would be to store information
|
||||
on things like greylisting which uses client, sender and recipient
|
||||
information etc.
|
||||
|
||||
* hmail.todo.notes
|
||||
|
||||
Available on any outbound hook that passes `hmail` as a function parameter.
|
||||
This is the same object as 'connection.transaction.notes', so anything
|
||||
you store in the transaction notes is automatically available in the
|
||||
outbound functions here.
|
||||
|
||||
All of these notes are JS objects - use them as simple key/value store e.g.
|
||||
|
||||
connection.transaction.notes.test = 'testing';
|
||||
|
||||
## Plugins as Modules
|
||||
|
||||
Plugins as NPM modules are named with the `haraka-plugin` prefix. Therefore, a
|
||||
plugin that frobnobricates might be called `haraka-plugin-frobnobricate` and
|
||||
published to NPM with that name. The prefix is not required in the
|
||||
`config/plugins` file.
|
||||
|
||||
Plugins loaded as NPM modules behave slightly different than plugins loaded
|
||||
as plain JS files.
|
||||
|
||||
Plain JS plugins have a custom `require()` which allows loading core Haraka
|
||||
modules via specifying `require('./name')` (note the `./` prefix). Although
|
||||
the core modules aren't in the same folder, the custom `require` intercepts
|
||||
this and look for core modules. Note that if there is a module in your plugins
|
||||
folder of the same name that will not take preference, so avoid using names
|
||||
similar to core modules.
|
||||
|
||||
Plugins loaded as modules do not have the special `require()`. To load
|
||||
a core Haraka module you must use `this.haraka_require('name')`.
|
||||
This should also be preferred for plain JS plugins, as the
|
||||
`./` hack is likely to be removed in the future.
|
||||
|
||||
Plugins loaded as modules are not compiled in the Haraka plugin sandbox,
|
||||
which blocks access to certain globals and provides a global `server` object.
|
||||
To access the `server` object, use `connection.server` instead.
|
||||
|
||||
Module plugins support default config in their local `config` directory. See the
|
||||
"Default Config and Overrides" section in [Config](Config.md).
|
||||
|
||||
## Shutdown
|
||||
|
||||
On graceful reload, Haraka will call a plugin's `shutdown` method.
|
||||
|
||||
This is so you can clear any timers or intervals, or shut down any connections
|
||||
to remote servers. See [Issue 2024](https://github.com/haraka/Haraka/issues/2024).
|
||||
|
||||
e.g.
|
||||
|
||||
```js
|
||||
exports.shutdown = function () {
|
||||
clearInterval(this._interval);
|
||||
}
|
||||
```
|
||||
|
||||
If you don't implement this in your plugin and have a connection open or a
|
||||
timer running then Haraka will take 30 seconds to shut down and have to
|
||||
forcibly kill your process.
|
||||
|
||||
Note: This only applies when running with a `nodes=...` value in smtp.ini.
|
||||
|
||||
## See also, [Results](Results.md)
|
||||
|
||||
|
||||
Further Reading
|
||||
--------------
|
||||
|
||||
Read about the [Connection](Connection.md) object.
|
||||
|
||||
Outbound hooks are [also documented](Outbound.md).
|
||||
181
lib/attachment-handler.js
Normal file
181
lib/attachment-handler.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Attachment Handler Module
|
||||
*
|
||||
* Extracts and processes email attachments from parsed emails.
|
||||
* Converts attachment content to base64 for API transmission.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Extract attachment information from a parsed email
|
||||
*
|
||||
* @param {Object} transaction - Haraka transaction object
|
||||
* @param {Object} parsed - Parsed email from mailparser
|
||||
* @param {Object} [options] - Options
|
||||
* @param {boolean} [options.include_content=true] - Include base64 content
|
||||
* @param {boolean} [options.debug=false] - Enable debug logging
|
||||
* @param {Function} [options.logger] - Logger function
|
||||
* @returns {Object} Object with attachments array and count
|
||||
*/
|
||||
function extract(transaction, parsed, options = {}) {
|
||||
const {
|
||||
include_content = true,
|
||||
debug = false,
|
||||
logger = console.log
|
||||
} = options;
|
||||
|
||||
if (debug) logger('Extracting attachment information');
|
||||
|
||||
let attachments = [];
|
||||
let attachment_count = 0;
|
||||
|
||||
// Primary: Use mailparser attachments (best source for content)
|
||||
if (parsed && parsed.attachments && parsed.attachments.length > 0) {
|
||||
if (debug) logger(`Found ${parsed.attachments.length} parsed attachments`);
|
||||
|
||||
attachment_count = parsed.attachments.length;
|
||||
attachments = parsed.attachments.map((att, index) => {
|
||||
return format_attachment(att, index, include_content, debug, logger);
|
||||
});
|
||||
}
|
||||
// Fallback: Check transaction notes for attachment plugin results
|
||||
else if (transaction && transaction.notes && transaction.notes.attachment) {
|
||||
if (debug) logger('Using attachment notes fallback');
|
||||
|
||||
const att_notes = transaction.notes.attachment;
|
||||
|
||||
if (att_notes.files && Array.isArray(att_notes.files)) {
|
||||
attachment_count = att_notes.files.length;
|
||||
attachments = att_notes.files.map((file, index) => ({
|
||||
filename: file.filename || file.name || `attachment_${index}`,
|
||||
content_type: file.ctype || file.content_type || 'application/octet-stream',
|
||||
size: file.bytes || file.size || 0,
|
||||
md5: file.md5,
|
||||
content: null, // No content available from plugin notes
|
||||
encoding: 'base64',
|
||||
index: index
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
const with_content = attachments.filter(a => a.content).length;
|
||||
logger(`Final attachment count: ${attachment_count}, with content: ${with_content}`);
|
||||
}
|
||||
|
||||
return {
|
||||
attachments,
|
||||
count: attachment_count,
|
||||
has_attachments: attachment_count > 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single attachment for API transmission
|
||||
*
|
||||
* @param {Object} att - Attachment object from mailparser
|
||||
* @param {number} index - Attachment index
|
||||
* @param {boolean} include_content - Include base64 content
|
||||
* @param {boolean} debug - Debug mode
|
||||
* @param {Function} logger - Logger function
|
||||
* @returns {Object} Formatted attachment object
|
||||
*/
|
||||
function format_attachment(att, index, include_content, debug, logger) {
|
||||
let content_base64 = null;
|
||||
|
||||
if (include_content && att.content) {
|
||||
content_base64 = to_base64(att.content);
|
||||
}
|
||||
|
||||
const attachment_info = {
|
||||
filename: att.filename || att.name || `attachment_${index}`,
|
||||
content_type: att.contentType || att.content_type || 'application/octet-stream',
|
||||
size: att.size || (att.content ? att.content.length : 0),
|
||||
content_id: att.cid || null,
|
||||
content: content_base64,
|
||||
encoding: 'base64',
|
||||
index: index
|
||||
};
|
||||
|
||||
if (debug) {
|
||||
logger(`Attachment ${index}: ${attachment_info.filename}, ` +
|
||||
`${attachment_info.size} bytes, type: ${attachment_info.content_type}`);
|
||||
}
|
||||
|
||||
return attachment_info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert content to base64 string
|
||||
*
|
||||
* @param {Buffer|string} content - Content to convert
|
||||
* @returns {string|null} Base64 encoded string or null
|
||||
*/
|
||||
function to_base64(content) {
|
||||
if (!content) return null;
|
||||
|
||||
if (Buffer.isBuffer(content)) {
|
||||
return content.toString('base64');
|
||||
}
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return Buffer.from(content).toString('base64');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total attachment size
|
||||
*
|
||||
* @param {Array} attachments - Array of attachment objects
|
||||
* @returns {number} Total size in bytes
|
||||
*/
|
||||
function get_total_size(attachments) {
|
||||
if (!attachments || !Array.isArray(attachments)) return 0;
|
||||
|
||||
return attachments.reduce((total, att) => {
|
||||
return total + (att.size || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any attachment exceeds size limit
|
||||
*
|
||||
* @param {Array} attachments - Array of attachment objects
|
||||
* @param {number} max_size - Maximum size in bytes
|
||||
* @returns {boolean} True if any attachment exceeds limit
|
||||
*/
|
||||
function has_oversized(attachments, max_size) {
|
||||
if (!attachments || !Array.isArray(attachments)) return false;
|
||||
|
||||
return attachments.some(att => (att.size || 0) > max_size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter attachments by content type
|
||||
*
|
||||
* @param {Array} attachments - Array of attachment objects
|
||||
* @param {string|string[]} content_types - Content type(s) to match
|
||||
* @returns {Array} Filtered attachments
|
||||
*/
|
||||
function filter_by_type(attachments, content_types) {
|
||||
if (!attachments || !Array.isArray(attachments)) return [];
|
||||
|
||||
const types = Array.isArray(content_types) ? content_types : [content_types];
|
||||
|
||||
return attachments.filter(att => {
|
||||
const att_type = (att.content_type || '').toLowerCase();
|
||||
return types.some(type => att_type.includes(type.toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extract,
|
||||
format_attachment,
|
||||
to_base64,
|
||||
get_total_size,
|
||||
has_oversized,
|
||||
filter_by_type
|
||||
};
|
||||
267
lib/bounce-detector.js
Normal file
267
lib/bounce-detector.js
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
/**
|
||||
* Bounce Detector Module
|
||||
*
|
||||
* Detects bounce/DSN (Delivery Status Notification) messages
|
||||
* to prevent forwarding them to webhooks and avoid bounce loops.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Indicators that suggest an email is a bounce message
|
||||
*/
|
||||
const BOUNCE_INDICATORS = {
|
||||
// Common bounce sender addresses
|
||||
sender_patterns: [
|
||||
'mailer-daemon',
|
||||
'postmaster',
|
||||
'mail-daemon',
|
||||
'mailerdaemon'
|
||||
],
|
||||
|
||||
// Common bounce subject keywords
|
||||
subject_patterns: [
|
||||
'delivery',
|
||||
'bounce',
|
||||
'failure',
|
||||
'undelivered',
|
||||
'returned',
|
||||
'undeliverable',
|
||||
'delivery status',
|
||||
'delivery failed',
|
||||
'mail delivery',
|
||||
'delivery notification',
|
||||
'could not be delivered',
|
||||
'not delivered'
|
||||
],
|
||||
|
||||
// DSN (Delivery Status Notification) body indicators
|
||||
body_patterns: [
|
||||
'Original-Envelope-Id:',
|
||||
'Reporting-MTA:',
|
||||
'Final-Recipient:',
|
||||
'Action: failed',
|
||||
'Action: delayed',
|
||||
'Diagnostic-Code:',
|
||||
'Remote-MTA:',
|
||||
'X-Postfix-Queue-ID:',
|
||||
'This is the mail system at host',
|
||||
'This message was created automatically',
|
||||
'Delivery to the following recipient'
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an email is a bounce message
|
||||
*
|
||||
* @param {string} from_email - Sender email address
|
||||
* @param {string} subject - Email subject
|
||||
* @param {string} text_body - Plain text body
|
||||
* @param {Object} [options] - Options
|
||||
* @param {boolean} [options.strict=false] - Require multiple indicators
|
||||
* @param {boolean} [options.debug=false] - Enable debug logging
|
||||
* @param {Function} [options.logger] - Logger function
|
||||
* @returns {boolean} True if message appears to be a bounce
|
||||
*/
|
||||
function is_bounce(from_email, subject, text_body, options = {}) {
|
||||
const { strict = false, debug = false, logger = console.log } = options;
|
||||
const envelope_from = options.envelope_from;
|
||||
const indicators_found = [];
|
||||
let sender_indicator = false;
|
||||
let subject_indicator = false;
|
||||
let body_indicator_count = 0;
|
||||
let null_sender = false;
|
||||
|
||||
// Prefer envelope sender when available. True bounces normally use "<>".
|
||||
if (typeof envelope_from === 'string') {
|
||||
const trimmed = envelope_from.trim();
|
||||
if (!trimmed || trimmed === '<>') {
|
||||
null_sender = true;
|
||||
indicators_found.push('envelope:null_sender');
|
||||
}
|
||||
} else if (!from_email || from_email.trim() === '') {
|
||||
null_sender = true;
|
||||
indicators_found.push('header:empty_sender');
|
||||
}
|
||||
|
||||
// Check sender patterns
|
||||
if (from_email) {
|
||||
const from_lower = from_email.toLowerCase();
|
||||
for (const pattern of BOUNCE_INDICATORS.sender_patterns) {
|
||||
if (from_lower.includes(pattern)) {
|
||||
sender_indicator = true;
|
||||
indicators_found.push(`sender:${pattern}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check subject patterns
|
||||
if (subject) {
|
||||
const subject_lower = subject.toLowerCase();
|
||||
for (const pattern of BOUNCE_INDICATORS.subject_patterns) {
|
||||
if (subject_lower.includes(pattern)) {
|
||||
subject_indicator = true;
|
||||
indicators_found.push(`subject:${pattern}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check body patterns (DSN indicators)
|
||||
if (text_body) {
|
||||
for (const pattern of BOUNCE_INDICATORS.body_patterns) {
|
||||
if (text_body.includes(pattern)) {
|
||||
body_indicator_count += 1;
|
||||
indicators_found.push(`body:${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (debug && indicators_found.length > 0) {
|
||||
logger(`Bounce indicators found: ${indicators_found.join(', ')}`);
|
||||
}
|
||||
|
||||
// In strict mode, require stronger DSN evidence.
|
||||
if (strict) {
|
||||
if (null_sender && (subject_indicator || body_indicator_count >= 1 || sender_indicator)) return true;
|
||||
if (body_indicator_count >= 2) return true;
|
||||
if (sender_indicator && (subject_indicator || body_indicator_count >= 1)) return true;
|
||||
if (subject_indicator && body_indicator_count >= 1) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default mode: avoid false positives (e.g. noreply notifications) by
|
||||
// requiring correlated bounce/DSN signals instead of a single keyword.
|
||||
if (null_sender && (subject_indicator || body_indicator_count >= 1 || sender_indicator)) return true;
|
||||
if (body_indicator_count >= 2) return true;
|
||||
if (sender_indicator && (subject_indicator || body_indicator_count >= 1)) return true;
|
||||
if (subject_indicator && body_indicator_count >= 1) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed bounce analysis
|
||||
*
|
||||
* @param {string} from_email - Sender email address
|
||||
* @param {string} subject - Email subject
|
||||
* @param {string} text_body - Plain text body
|
||||
* @returns {Object} Detailed bounce analysis
|
||||
*/
|
||||
function analyze(from_email, subject, text_body) {
|
||||
const result = {
|
||||
is_bounce: false,
|
||||
confidence: 'none',
|
||||
indicators: [],
|
||||
dsn_detected: false,
|
||||
bounce_type: null
|
||||
};
|
||||
|
||||
// Check empty sender header
|
||||
if (!from_email || from_email.trim() === '') {
|
||||
result.indicators.push({ type: 'sender', reason: 'empty_sender_header' });
|
||||
}
|
||||
|
||||
// Check sender patterns
|
||||
if (from_email) {
|
||||
const from_lower = from_email.toLowerCase();
|
||||
for (const pattern of BOUNCE_INDICATORS.sender_patterns) {
|
||||
if (from_lower.includes(pattern)) {
|
||||
result.indicators.push({ type: 'sender', reason: pattern });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check subject patterns
|
||||
if (subject) {
|
||||
const subject_lower = subject.toLowerCase();
|
||||
for (const pattern of BOUNCE_INDICATORS.subject_patterns) {
|
||||
if (subject_lower.includes(pattern)) {
|
||||
result.indicators.push({ type: 'subject', reason: pattern });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check body patterns
|
||||
if (text_body) {
|
||||
let dsn_indicators = 0;
|
||||
for (const pattern of BOUNCE_INDICATORS.body_patterns) {
|
||||
if (text_body.includes(pattern)) {
|
||||
result.indicators.push({ type: 'body', reason: pattern });
|
||||
dsn_indicators++;
|
||||
}
|
||||
}
|
||||
// If multiple DSN indicators, it's definitely a DSN
|
||||
if (dsn_indicators >= 2) {
|
||||
result.dsn_detected = true;
|
||||
}
|
||||
}
|
||||
|
||||
result.is_bounce = is_bounce(from_email, subject, text_body);
|
||||
|
||||
// Determine confidence (conservative: single weak indicator is low confidence
|
||||
// but not necessarily classified as a bounce).
|
||||
const count = result.indicators.length;
|
||||
if (!result.is_bounce && count === 0) {
|
||||
result.confidence = 'none';
|
||||
} else if (count <= 1) {
|
||||
result.confidence = 'low';
|
||||
} else if (count === 2) {
|
||||
result.confidence = 'medium';
|
||||
} else {
|
||||
result.confidence = 'high';
|
||||
}
|
||||
|
||||
// Determine bounce type
|
||||
if (result.is_bounce) {
|
||||
if (result.dsn_detected) {
|
||||
result.bounce_type = 'dsn';
|
||||
} else if (result.indicators.some(i => i.reason === 'empty_sender' || i.reason === 'empty_sender_header')) {
|
||||
result.bounce_type = 'null_sender';
|
||||
} else {
|
||||
result.bounce_type = 'automated';
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message is an auto-reply (out of office, etc.)
|
||||
*
|
||||
* @param {Object} headers - Email headers
|
||||
* @returns {boolean} True if auto-reply
|
||||
*/
|
||||
function is_auto_reply(headers) {
|
||||
if (!headers) return false;
|
||||
|
||||
// Check Auto-Submitted header (RFC 3834)
|
||||
const auto_submitted = headers['auto-submitted'] || headers['Auto-Submitted'];
|
||||
if (auto_submitted && auto_submitted !== 'no') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check X-Auto-Response-Suppress header
|
||||
const auto_suppress = headers['x-auto-response-suppress'] || headers['X-Auto-Response-Suppress'];
|
||||
if (auto_suppress) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check Precedence header
|
||||
const precedence = headers['precedence'] || headers['Precedence'];
|
||||
if (precedence) {
|
||||
const prec_lower = precedence.toLowerCase();
|
||||
if (prec_lower === 'bulk' || prec_lower === 'junk' || prec_lower === 'auto_reply') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
is_bounce,
|
||||
analyze,
|
||||
is_auto_reply,
|
||||
BOUNCE_INDICATORS
|
||||
};
|
||||
328
lib/config.js
Normal file
328
lib/config.js
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
/**
|
||||
* Centralized Configuration Module
|
||||
*
|
||||
* Single source of truth for Haraka plugin configuration.
|
||||
* Prioritizes environment variables over .ini file settings.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// Default configuration values
|
||||
const DEFAULTS = {
|
||||
// Runtime role
|
||||
role: 'inbound-mx',
|
||||
|
||||
// Phoenix API endpoints
|
||||
webhook_url: 'https://app.example.com/api/haraka/inbound',
|
||||
verify_url: 'https://app.example.com/api/haraka/verify-recipient',
|
||||
domains_url: 'https://app.example.com/api/haraka/domains',
|
||||
|
||||
// Directional API authentication
|
||||
// phoenix_api_key: used when Haraka calls Phoenix endpoints
|
||||
// http_api_key: used to authenticate callers to Haraka /api/v1/send
|
||||
phoenix_api_key: '',
|
||||
http_api_key: '',
|
||||
|
||||
// HTTP server settings
|
||||
http_port: 8080,
|
||||
http_host: '0.0.0.0',
|
||||
cors_origin: '',
|
||||
dkim_storage_dir: '',
|
||||
|
||||
// HTTP endpoint access controls
|
||||
ops_allowed_cidrs: [],
|
||||
metrics_allowed_cidrs: [],
|
||||
trusted_proxy_cidrs: [
|
||||
'127.0.0.1/32',
|
||||
'::1/128',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'fc00::/7'
|
||||
],
|
||||
|
||||
// Timeouts (milliseconds)
|
||||
webhook_timeout: 30000,
|
||||
verify_timeout: 5000,
|
||||
|
||||
// Webhook retry controls
|
||||
webhook_max_retries: 5,
|
||||
webhook_retry_base_delay_ms: 1000,
|
||||
|
||||
// Rate limiting
|
||||
rate_limit_window_ms: 60000, // 1 minute
|
||||
rate_limit_max_requests: 50, // 50 requests per window
|
||||
|
||||
// Local domains (protected from spoofing, receive inbound mail)
|
||||
local_domains: ['example.com'],
|
||||
|
||||
// Domain cache behavior
|
||||
domain_cache_ttl_ms: 5 * 60 * 1000,
|
||||
|
||||
// Async queue settings
|
||||
redis_url: 'redis://redis:6379',
|
||||
queue_name: 'elektrine:inbound',
|
||||
queue_dlq_name: 'elektrine:inbound:dlq',
|
||||
queue_pop_timeout_sec: 5,
|
||||
queue_max_raw_bytes: 25 * 1024 * 1024,
|
||||
|
||||
// Feature flags
|
||||
webhook_enabled: true,
|
||||
include_headers: true,
|
||||
include_body: true,
|
||||
include_attachments: true
|
||||
};
|
||||
|
||||
function to_int(value, fallback) {
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function to_bool(value, fallback) {
|
||||
if (value === undefined || value === null) return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parse_string_list(value, { lowercase = false } = {}) {
|
||||
if (!value) return null;
|
||||
|
||||
const normalized = Array.isArray(value)
|
||||
? value.map((entry) => String(entry).trim()).filter(Boolean)
|
||||
: String(value).split(/[\s,]+/).map((entry) => entry.trim()).filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) return null;
|
||||
if (!lowercase) return normalized;
|
||||
return normalized.map((entry) => entry.toLowerCase());
|
||||
}
|
||||
|
||||
function parse_domain_list(value) {
|
||||
return parse_string_list(value, { lowercase: true });
|
||||
}
|
||||
|
||||
function parse_cidr_list(value) {
|
||||
return parse_string_list(value, { lowercase: true });
|
||||
}
|
||||
|
||||
function apply_env_overrides(config) {
|
||||
if (process.env.HARAKA_ROLE) config.role = process.env.HARAKA_ROLE;
|
||||
if (process.env.PHOENIX_WEBHOOK_URL) config.webhook_url = process.env.PHOENIX_WEBHOOK_URL;
|
||||
if (process.env.PHOENIX_VERIFY_URL) config.verify_url = process.env.PHOENIX_VERIFY_URL;
|
||||
if (process.env.PHOENIX_DOMAINS_URL) config.domains_url = process.env.PHOENIX_DOMAINS_URL;
|
||||
|
||||
if (process.env.PHOENIX_API_KEY) config.phoenix_api_key = process.env.PHOENIX_API_KEY;
|
||||
if (process.env.HARAKA_HTTP_API_KEY) config.http_api_key = process.env.HARAKA_HTTP_API_KEY;
|
||||
if (process.env.HARAKA_API_KEY) {
|
||||
if (!process.env.PHOENIX_API_KEY) {
|
||||
config.phoenix_api_key = process.env.HARAKA_API_KEY;
|
||||
}
|
||||
if (!process.env.HARAKA_HTTP_API_KEY) {
|
||||
config.http_api_key = process.env.HARAKA_API_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.HARAKA_HTTP_PORT) config.http_port = to_int(process.env.HARAKA_HTTP_PORT, DEFAULTS.http_port);
|
||||
if (process.env.HARAKA_HTTP_HOST) config.http_host = process.env.HARAKA_HTTP_HOST;
|
||||
if (process.env.HARAKA_CORS_ORIGIN !== undefined) config.cors_origin = process.env.HARAKA_CORS_ORIGIN;
|
||||
if (process.env.HARAKA_DKIM_DIR !== undefined) config.dkim_storage_dir = process.env.HARAKA_DKIM_DIR;
|
||||
if (process.env.LOCAL_DOMAINS) config.local_domains = parse_domain_list(process.env.LOCAL_DOMAINS) || DEFAULTS.local_domains;
|
||||
if (process.env.OPS_ALLOWED_CIDRS) {
|
||||
config.ops_allowed_cidrs = parse_cidr_list(process.env.OPS_ALLOWED_CIDRS) || DEFAULTS.ops_allowed_cidrs;
|
||||
}
|
||||
if (process.env.METRICS_ALLOWED_CIDRS) {
|
||||
config.metrics_allowed_cidrs = parse_cidr_list(process.env.METRICS_ALLOWED_CIDRS) || DEFAULTS.metrics_allowed_cidrs;
|
||||
}
|
||||
if (process.env.HARAKA_TRUSTED_PROXY_CIDRS) {
|
||||
config.trusted_proxy_cidrs = parse_cidr_list(process.env.HARAKA_TRUSTED_PROXY_CIDRS) || DEFAULTS.trusted_proxy_cidrs;
|
||||
}
|
||||
|
||||
if (process.env.REDIS_URL) config.redis_url = process.env.REDIS_URL;
|
||||
if (process.env.ELEKTRINE_QUEUE_NAME) config.queue_name = process.env.ELEKTRINE_QUEUE_NAME;
|
||||
if (process.env.ELEKTRINE_DLQ_NAME) config.queue_dlq_name = process.env.ELEKTRINE_DLQ_NAME;
|
||||
if (process.env.ELEKTRINE_QUEUE_POP_TIMEOUT) {
|
||||
config.queue_pop_timeout_sec = to_int(process.env.ELEKTRINE_QUEUE_POP_TIMEOUT, DEFAULTS.queue_pop_timeout_sec);
|
||||
}
|
||||
if (process.env.ELEKTRINE_QUEUE_MAX_RAW_BYTES) {
|
||||
config.queue_max_raw_bytes = to_int(process.env.ELEKTRINE_QUEUE_MAX_RAW_BYTES, DEFAULTS.queue_max_raw_bytes);
|
||||
}
|
||||
|
||||
if (process.env.WEBHOOK_MAX_RETRIES) {
|
||||
config.webhook_max_retries = to_int(process.env.WEBHOOK_MAX_RETRIES, DEFAULTS.webhook_max_retries);
|
||||
}
|
||||
if (process.env.WEBHOOK_RETRY_BASE_MS) {
|
||||
config.webhook_retry_base_delay_ms = to_int(process.env.WEBHOOK_RETRY_BASE_MS, DEFAULTS.webhook_retry_base_delay_ms);
|
||||
}
|
||||
if (process.env.DOMAIN_CACHE_TTL_MS) {
|
||||
config.domain_cache_ttl_ms = to_int(process.env.DOMAIN_CACHE_TTL_MS, DEFAULTS.domain_cache_ttl_ms);
|
||||
}
|
||||
if (process.env.HARAKA_INCLUDE_HEADERS !== undefined) {
|
||||
config.include_headers = to_bool(process.env.HARAKA_INCLUDE_HEADERS, config.include_headers);
|
||||
}
|
||||
if (process.env.HARAKA_INCLUDE_BODY !== undefined) {
|
||||
config.include_body = to_bool(process.env.HARAKA_INCLUDE_BODY, config.include_body);
|
||||
}
|
||||
if (process.env.HARAKA_INCLUDE_ATTACHMENTS !== undefined) {
|
||||
config.include_attachments = to_bool(process.env.HARAKA_INCLUDE_ATTACHMENTS, config.include_attachments);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from environment variables and optional Haraka config
|
||||
* @param {Object} haraka_config - Optional Haraka plugin config object
|
||||
* @returns {Object} Merged configuration object
|
||||
*/
|
||||
function load(haraka_config = null) {
|
||||
const config = { ...DEFAULTS };
|
||||
|
||||
// Environment variables (highest priority)
|
||||
apply_env_overrides(config);
|
||||
|
||||
// Haraka .ini config overrides (if provided)
|
||||
if (haraka_config && haraka_config.main) {
|
||||
const main = haraka_config.main;
|
||||
|
||||
if (main.role) config.role = main.role;
|
||||
if (main.url) config.webhook_url = main.url;
|
||||
if (main.verify_url) config.verify_url = main.verify_url;
|
||||
if (main.domains_url) config.domains_url = main.domains_url;
|
||||
|
||||
if (main.phoenix_api_key) config.phoenix_api_key = main.phoenix_api_key;
|
||||
if (main.http_api_key) config.http_api_key = main.http_api_key;
|
||||
if (main.api_key) {
|
||||
if (!main.phoenix_api_key) config.phoenix_api_key = main.api_key;
|
||||
if (!main.http_api_key) config.http_api_key = main.api_key;
|
||||
}
|
||||
|
||||
if (main.port) config.http_port = to_int(main.port, config.http_port);
|
||||
if (main.host) config.http_host = main.host;
|
||||
if (main.cors_origin !== undefined) config.cors_origin = main.cors_origin;
|
||||
if (main.dkim_storage_dir !== undefined) config.dkim_storage_dir = main.dkim_storage_dir;
|
||||
if (main.timeout) config.webhook_timeout = to_int(main.timeout, config.webhook_timeout);
|
||||
if (main.verify_timeout) config.verify_timeout = to_int(main.verify_timeout, config.verify_timeout);
|
||||
|
||||
if (main.domain_cache_ttl_ms) {
|
||||
config.domain_cache_ttl_ms = to_int(main.domain_cache_ttl_ms, config.domain_cache_ttl_ms);
|
||||
}
|
||||
|
||||
// Boolean flags
|
||||
if (main.enabled !== undefined) config.webhook_enabled = main.enabled;
|
||||
if (main.include_headers !== undefined) config.include_headers = main.include_headers;
|
||||
if (main.include_body !== undefined) config.include_body = main.include_body;
|
||||
if (main.include_attachments !== undefined) config.include_attachments = main.include_attachments;
|
||||
}
|
||||
|
||||
if (haraka_config && haraka_config.http_api) {
|
||||
const http_api = haraka_config.http_api;
|
||||
if (http_api.port) config.http_port = to_int(http_api.port, config.http_port);
|
||||
if (http_api.host) config.http_host = http_api.host;
|
||||
if (http_api.cors_origin !== undefined) config.cors_origin = http_api.cors_origin;
|
||||
if (http_api.dkim_storage_dir !== undefined) config.dkim_storage_dir = http_api.dkim_storage_dir;
|
||||
if (http_api.ops_allowed_cidrs) {
|
||||
const parsed_ops_cidrs = parse_cidr_list(http_api.ops_allowed_cidrs);
|
||||
if (parsed_ops_cidrs && parsed_ops_cidrs.length > 0) {
|
||||
config.ops_allowed_cidrs = parsed_ops_cidrs;
|
||||
}
|
||||
}
|
||||
if (http_api.metrics_allowed_cidrs) {
|
||||
const parsed_metrics_cidrs = parse_cidr_list(http_api.metrics_allowed_cidrs);
|
||||
if (parsed_metrics_cidrs && parsed_metrics_cidrs.length > 0) {
|
||||
config.metrics_allowed_cidrs = parsed_metrics_cidrs;
|
||||
}
|
||||
}
|
||||
if (http_api.trusted_proxy_cidrs) {
|
||||
const parsed_trusted_proxy_cidrs = parse_cidr_list(http_api.trusted_proxy_cidrs);
|
||||
if (parsed_trusted_proxy_cidrs && parsed_trusted_proxy_cidrs.length > 0) {
|
||||
config.trusted_proxy_cidrs = parsed_trusted_proxy_cidrs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (haraka_config && haraka_config.ops && haraka_config.ops.allowed_cidrs) {
|
||||
const parsed_ops_cidrs = parse_cidr_list(haraka_config.ops.allowed_cidrs);
|
||||
if (parsed_ops_cidrs && parsed_ops_cidrs.length > 0) {
|
||||
config.ops_allowed_cidrs = parsed_ops_cidrs;
|
||||
}
|
||||
}
|
||||
|
||||
if (haraka_config && haraka_config.metrics && haraka_config.metrics.allowed_cidrs) {
|
||||
const parsed_metrics_cidrs = parse_cidr_list(haraka_config.metrics.allowed_cidrs);
|
||||
if (parsed_metrics_cidrs && parsed_metrics_cidrs.length > 0) {
|
||||
config.metrics_allowed_cidrs = parsed_metrics_cidrs;
|
||||
}
|
||||
}
|
||||
|
||||
if (haraka_config && haraka_config.rate_limit) {
|
||||
const rate_limit = haraka_config.rate_limit;
|
||||
if (rate_limit.window_ms) {
|
||||
config.rate_limit_window_ms = to_int(rate_limit.window_ms, config.rate_limit_window_ms);
|
||||
}
|
||||
if (rate_limit.max_requests) {
|
||||
config.rate_limit_max_requests = to_int(rate_limit.max_requests, config.rate_limit_max_requests);
|
||||
}
|
||||
}
|
||||
|
||||
if (haraka_config && haraka_config.domains) {
|
||||
const domains = haraka_config.domains;
|
||||
if (domains.local) {
|
||||
const parsed = parse_domain_list(domains.local);
|
||||
if (parsed && parsed.length > 0) {
|
||||
config.local_domains = parsed;
|
||||
}
|
||||
}
|
||||
if (domains.cache_ttl_ms) {
|
||||
config.domain_cache_ttl_ms = to_int(domains.cache_ttl_ms, config.domain_cache_ttl_ms);
|
||||
}
|
||||
}
|
||||
|
||||
if (haraka_config && haraka_config.queue) {
|
||||
const queue = haraka_config.queue;
|
||||
if (queue.redis_url) config.redis_url = queue.redis_url;
|
||||
if (queue.name) config.queue_name = queue.name;
|
||||
if (queue.dlq_name) config.queue_dlq_name = queue.dlq_name;
|
||||
if (queue.pop_timeout_sec) {
|
||||
config.queue_pop_timeout_sec = to_int(queue.pop_timeout_sec, config.queue_pop_timeout_sec);
|
||||
}
|
||||
if (queue.max_raw_bytes) {
|
||||
config.queue_max_raw_bytes = to_int(queue.max_raw_bytes, config.queue_max_raw_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
if (haraka_config && haraka_config.dkim && haraka_config.dkim.storage_dir !== undefined) {
|
||||
config.dkim_storage_dir = haraka_config.dkim.storage_dir;
|
||||
}
|
||||
|
||||
if (haraka_config && haraka_config.worker) {
|
||||
const worker = haraka_config.worker;
|
||||
if (worker.webhook_max_retries) {
|
||||
config.webhook_max_retries = to_int(worker.webhook_max_retries, config.webhook_max_retries);
|
||||
}
|
||||
if (worker.webhook_retry_base_delay_ms) {
|
||||
config.webhook_retry_base_delay_ms = to_int(worker.webhook_retry_base_delay_ms, config.webhook_retry_base_delay_ms);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-apply env values last so they always win over any .ini value.
|
||||
apply_env_overrides(config);
|
||||
config.api_key = config.phoenix_api_key;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration value with fallback
|
||||
* @param {string} key - Configuration key
|
||||
* @param {*} fallback - Fallback value if key not found
|
||||
* @returns {*} Configuration value
|
||||
*/
|
||||
function get(key, fallback = null) {
|
||||
const loaded = load();
|
||||
return Object.prototype.hasOwnProperty.call(loaded, key) ? loaded[key] : fallback;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
load,
|
||||
get,
|
||||
DEFAULTS
|
||||
};
|
||||
246
lib/domains.js
Normal file
246
lib/domains.js
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* Domain Utilities Module
|
||||
*
|
||||
* Single source of truth for local/protected domain management.
|
||||
* Provides helper functions for domain-related operations.
|
||||
*
|
||||
* Supports dynamic domain loading from Phoenix API for custom domains.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const config = require('./config');
|
||||
const http_client = require('./http-client');
|
||||
|
||||
// Cache for domains fetched from Phoenix
|
||||
let cached_domains = null;
|
||||
let cache_timestamp = 0;
|
||||
let refresh_interval = null;
|
||||
|
||||
// Flag to track if we're currently refreshing
|
||||
let refresh_in_progress = false;
|
||||
|
||||
/**
|
||||
* Get the list of local domains from configuration
|
||||
* These are domains that receive inbound mail and are protected from spoofing.
|
||||
* @returns {string[]} Array of local domain names
|
||||
*/
|
||||
function get_local_domains() {
|
||||
const cfg = config.load();
|
||||
const cache_ttl_ms = cfg.domain_cache_ttl_ms || config.DEFAULTS.domain_cache_ttl_ms;
|
||||
|
||||
// If we have cached domains from Phoenix, use them
|
||||
if (cached_domains && (Date.now() - cache_timestamp < cache_ttl_ms)) {
|
||||
return cached_domains;
|
||||
}
|
||||
|
||||
// Return config domains while we refresh in background
|
||||
return cfg.local_domains || ['example.com'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the domain cache from Phoenix API
|
||||
* This is called periodically and on startup
|
||||
* @param {Function} [logger] - Optional logger function
|
||||
* @returns {Promise<string[]>} Array of domain names
|
||||
*/
|
||||
async function refresh_domains(logger = null) {
|
||||
if (refresh_in_progress) {
|
||||
if (logger) logger('Domain refresh already in progress, skipping');
|
||||
return cached_domains || get_local_domains();
|
||||
}
|
||||
|
||||
refresh_in_progress = true;
|
||||
const cfg = config.load();
|
||||
|
||||
try {
|
||||
if (logger) logger(`Fetching domains from ${cfg.domains_url}`);
|
||||
|
||||
const domains = await http_client.fetch_domains(cfg.domains_url, {
|
||||
api_key: cfg.phoenix_api_key,
|
||||
timeout: 10000,
|
||||
logger: logger
|
||||
});
|
||||
|
||||
if (domains && domains.length > 0) {
|
||||
cached_domains = domains.map(d => d.toLowerCase());
|
||||
cache_timestamp = Date.now();
|
||||
|
||||
if (logger) {
|
||||
logger(`Domain cache updated with ${domains.length} domains: ${domains.join(', ')}`);
|
||||
}
|
||||
|
||||
return cached_domains;
|
||||
} else {
|
||||
if (logger) logger('No domains returned from Phoenix, keeping existing cache');
|
||||
return cached_domains || cfg.local_domains || ['example.com'];
|
||||
}
|
||||
} catch (err) {
|
||||
if (logger) {
|
||||
logger(`Failed to refresh domains from Phoenix: ${err.message}`);
|
||||
}
|
||||
// On error, keep using cached domains or fall back to config
|
||||
return cached_domains || cfg.local_domains || ['example.com'];
|
||||
} finally {
|
||||
refresh_in_progress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the domain cache
|
||||
* Call this on Haraka startup to pre-populate the cache
|
||||
* @param {Function} [logger] - Optional logger function
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function init(logger = null) {
|
||||
if (logger) logger('Initializing domain cache from Phoenix');
|
||||
const cfg = config.load();
|
||||
const cache_ttl_ms = cfg.domain_cache_ttl_ms || config.DEFAULTS.domain_cache_ttl_ms;
|
||||
|
||||
// Initial fetch
|
||||
await refresh_domains(logger);
|
||||
|
||||
// Set up periodic refresh once per process
|
||||
if (!refresh_interval) {
|
||||
refresh_interval = setInterval(() => {
|
||||
refresh_domains(logger).catch(err => {
|
||||
if (logger) logger(`Periodic domain refresh failed: ${err.message}`);
|
||||
});
|
||||
}, cache_ttl_ms);
|
||||
}
|
||||
|
||||
if (logger) logger('Domain cache initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is a local (protected) domain
|
||||
* @param {string} domain - Domain name to check
|
||||
* @returns {boolean} True if domain is local
|
||||
*/
|
||||
function is_local_domain(domain) {
|
||||
if (!domain) return false;
|
||||
const local_domains = get_local_domains();
|
||||
return local_domains.includes(domain.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all recipients are on local domains
|
||||
* @param {string[]} recipients - Array of email addresses
|
||||
* @returns {boolean} True if all recipients are on local domains
|
||||
*/
|
||||
function all_recipients_local(recipients) {
|
||||
if (!recipients || recipients.length === 0) return false;
|
||||
|
||||
const local_domains = get_local_domains();
|
||||
|
||||
return recipients.every(recipient => {
|
||||
const domain = extract_domain(recipient);
|
||||
return domain && local_domains.includes(domain);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any recipient is on a local domain
|
||||
* @param {string[]} recipients - Array of email addresses
|
||||
* @returns {boolean} True if any recipient is on a local domain
|
||||
*/
|
||||
function any_recipient_local(recipients) {
|
||||
if (!recipients || recipients.length === 0) return false;
|
||||
|
||||
const local_domains = get_local_domains();
|
||||
|
||||
return recipients.some(recipient => {
|
||||
const domain = extract_domain(recipient);
|
||||
return domain && local_domains.includes(domain);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from an email address
|
||||
* Handles formats like "email@domain.com" and "Display Name <email@domain.com>"
|
||||
* @param {string} email - Email address (possibly with display name)
|
||||
* @returns {string|null} Domain name or null if invalid
|
||||
*/
|
||||
function extract_domain(email) {
|
||||
if (!email) return null;
|
||||
|
||||
// Handle "Display Name <email@domain.com>" format
|
||||
const match = email.match(/<([^>]+)>/);
|
||||
const clean_email = match ? match[1] : email;
|
||||
|
||||
// Extract domain part
|
||||
const parts = clean_email.split('@');
|
||||
if (parts.length !== 2) return null;
|
||||
|
||||
return parts[1].toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the clean email address from potentially formatted input
|
||||
* Handles formats like "email@domain.com" and "Display Name <email@domain.com>"
|
||||
* @param {string} email - Email address (possibly with display name)
|
||||
* @returns {string} Clean email address
|
||||
*/
|
||||
function extract_email(email) {
|
||||
if (!email) return '';
|
||||
|
||||
// Handle "Display Name <email@domain.com>" format
|
||||
const match = email.match(/<([^>]+)>/);
|
||||
return match ? match[1] : email.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter recipients by local/external domain
|
||||
* @param {string[]} recipients - Array of email addresses
|
||||
* @returns {Object} Object with 'local' and 'external' arrays
|
||||
*/
|
||||
function partition_recipients(recipients) {
|
||||
const result = {
|
||||
local: [],
|
||||
external: []
|
||||
};
|
||||
|
||||
if (!recipients || recipients.length === 0) return result;
|
||||
|
||||
const local_domains = get_local_domains();
|
||||
|
||||
recipients.forEach(recipient => {
|
||||
const domain = extract_domain(recipient);
|
||||
if (domain && local_domains.includes(domain)) {
|
||||
result.local.push(recipient);
|
||||
} else {
|
||||
result.external.push(recipient);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cached domains (for debugging/monitoring)
|
||||
* @returns {Object} Cache status and domains
|
||||
*/
|
||||
function get_cache_status() {
|
||||
const cfg = config.load();
|
||||
const cache_ttl_ms = cfg.domain_cache_ttl_ms || config.DEFAULTS.domain_cache_ttl_ms;
|
||||
|
||||
return {
|
||||
cached: cached_domains !== null,
|
||||
domains: cached_domains || [],
|
||||
age_ms: cached_domains ? Date.now() - cache_timestamp : null,
|
||||
ttl_ms: cache_ttl_ms
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
refresh_domains,
|
||||
get_local_domains,
|
||||
is_local_domain,
|
||||
all_recipients_local,
|
||||
any_recipient_local,
|
||||
extract_domain,
|
||||
extract_email,
|
||||
partition_recipients,
|
||||
get_cache_status
|
||||
};
|
||||
522
lib/email-builder.js
Normal file
522
lib/email-builder.js
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
/**
|
||||
* Email Builder Module
|
||||
*
|
||||
* Constructs RFC 5322 compliant email messages with proper MIME structure.
|
||||
* Supports plain text, HTML, multipart/alternative, and attachments.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const domains = require('./domains');
|
||||
|
||||
const HEADER_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
|
||||
// Headers a client may not set via email_data.headers: identity/routing
|
||||
// fields the builder owns, and MIME structure fields whose first occurrence
|
||||
// would shadow the real ones emitted with the body.
|
||||
const FORBIDDEN_CUSTOM_HEADERS = new Set([
|
||||
'from', 'to', 'cc', 'bcc', 'subject', 'date', 'message-id', 'mime-version',
|
||||
'reply-to', 'sender', 'return-path', 'received',
|
||||
'content-type', 'content-transfer-encoding'
|
||||
]);
|
||||
|
||||
function sanitize_header_value(value) {
|
||||
if (value === undefined || value === null) return '';
|
||||
return String(value).replace(/[\r\n]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function sanitize_header_name(name) {
|
||||
const normalized = sanitize_header_value(name);
|
||||
if (!normalized || !HEADER_TOKEN_RE.test(normalized)) return '';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitize_message_id(value) {
|
||||
const normalized = sanitize_header_value(value).replace(/[^A-Za-z0-9._-]/g, '');
|
||||
return normalized || crypto.randomUUID();
|
||||
}
|
||||
|
||||
function normalize_address(value) {
|
||||
const extracted = sanitize_header_value(domains.extract_email(sanitize_header_value(value)));
|
||||
if (!extracted) return '';
|
||||
if (!/^[^\s@<>]+@[A-Za-z0-9.-]+$/.test(extracted)) return '';
|
||||
return extracted;
|
||||
}
|
||||
|
||||
function normalize_address_list(value) {
|
||||
if (value === undefined || value === null) return [];
|
||||
const list = Array.isArray(value) ? value : [value];
|
||||
return list.map((entry) => normalize_address(entry)).filter(Boolean);
|
||||
}
|
||||
|
||||
// RFC 2047 "B" encoded-word for header text containing non-ASCII characters.
|
||||
// Chunks on code-point boundaries so a multi-byte character is never split
|
||||
// across two encoded-words (each word's base64 payload stays well under 75).
|
||||
function encode_mime_word(value) {
|
||||
const str = sanitize_header_value(value);
|
||||
if (str === '') return '';
|
||||
// Pure ASCII (printable) needs no encoding.
|
||||
if (/^[\x20-\x7E]*$/.test(str)) return str;
|
||||
// Already a single MIME encoded-word: leave it alone (don't double-encode).
|
||||
if (/^=\?[^?\s]+\?[BbQq]\?[^?\s]*\?=$/.test(str)) return str;
|
||||
|
||||
const words = [];
|
||||
let chunk = [];
|
||||
let chunk_bytes = 0;
|
||||
const flush = () => {
|
||||
if (chunk.length === 0) return;
|
||||
const encoded = Buffer.from(chunk.join(''), 'utf8').toString('base64');
|
||||
words.push(`=?UTF-8?B?${encoded}?=`);
|
||||
chunk = [];
|
||||
chunk_bytes = 0;
|
||||
};
|
||||
for (const ch of str) {
|
||||
const ch_bytes = Buffer.byteLength(ch, 'utf8');
|
||||
if (chunk_bytes + ch_bytes > 45) flush();
|
||||
chunk.push(ch);
|
||||
chunk_bytes += ch_bytes;
|
||||
}
|
||||
flush();
|
||||
// Fold multiple encoded-words with CRLF + space per RFC 2047/5322.
|
||||
return words.join('\r\n ');
|
||||
}
|
||||
|
||||
// Render an address header value preserving a sanitized display name.
|
||||
// normalize_address() strips the name down to the bare address, which is right
|
||||
// for envelope/routing but wrong for the From/Reply-To/To/Cc headers shown to
|
||||
// the recipient. This keeps "Display Name <email>" while validating the
|
||||
// address, MIME-encoding non-ASCII names, and guarding against header injection.
|
||||
function format_address_header(value) {
|
||||
const email = normalize_address(value);
|
||||
if (!email) return '';
|
||||
|
||||
const raw = sanitize_header_value(value);
|
||||
const match = raw.match(/^(.*?)\s*<[^<>]*>\s*$/);
|
||||
let name = match ? match[1].trim() : '';
|
||||
if (!name) return email;
|
||||
|
||||
// A pre-formed MIME encoded-word must not be quoted or re-encoded.
|
||||
if (/^=\?[^?\s]+\?[BbQq]\?[^?\s]*\?=$/.test(name)) {
|
||||
return `${name} <${email}>`;
|
||||
}
|
||||
|
||||
// Unwrap an existing quoted-string to recover the raw display name.
|
||||
let raw_name = name;
|
||||
if (name.length >= 2 && name.startsWith('"') && name.endsWith('"')) {
|
||||
raw_name = name.slice(1, -1).replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
if (/[^\x20-\x7E]/.test(raw_name)) {
|
||||
// Non-ASCII display name: RFC 2047 encode (encoded-words are not quoted).
|
||||
name = encode_mime_word(raw_name);
|
||||
} else if (/[(),:;<>@[\]".\\]/.test(raw_name)) {
|
||||
// Quote names containing RFC 5322 special characters.
|
||||
name = '"' + raw_name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||||
} else {
|
||||
name = raw_name;
|
||||
}
|
||||
|
||||
return `${name} <${email}>`;
|
||||
}
|
||||
|
||||
function format_address_header_list(value) {
|
||||
if (value === undefined || value === null) return [];
|
||||
const list = Array.isArray(value) ? value : [value];
|
||||
return list.map((entry) => format_address_header(entry)).filter(Boolean);
|
||||
}
|
||||
|
||||
// RFC 5322 date, e.g. "Fri, 27 Jun 2026 09:59:05 +0000".
|
||||
// Date.toUTCString() emits the obsolete "GMT" zone; this uses the numeric zone.
|
||||
function rfc5322_date(date) {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${days[date.getUTCDay()]}, ${pad(date.getUTCDate())} ` +
|
||||
`${months[date.getUTCMonth()]} ${date.getUTCFullYear()} ` +
|
||||
`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} +0000`;
|
||||
}
|
||||
|
||||
function first_string(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function get_text_body(email_data) {
|
||||
return first_string(email_data.text_body, email_data.text, email_data.body);
|
||||
}
|
||||
|
||||
function get_html_body(email_data) {
|
||||
return first_string(email_data.html_body, email_data.html);
|
||||
}
|
||||
|
||||
function encode_quoted_printable(value) {
|
||||
const lines = String(value || '').replace(/\r\n|\r|\n/g, '\n').split('\n');
|
||||
|
||||
return lines.map((line) => {
|
||||
const tokens = [];
|
||||
|
||||
for (const byte of Buffer.from(line, 'utf8')) {
|
||||
if ((byte >= 33 && byte <= 60) || (byte >= 62 && byte <= 126)) {
|
||||
tokens.push(String.fromCharCode(byte));
|
||||
} else {
|
||||
tokens.push(`=${byte.toString(16).toUpperCase().padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const wrapped = [];
|
||||
let current = '';
|
||||
|
||||
for (const token of tokens) {
|
||||
if (current && current.length + token.length > 75) {
|
||||
wrapped.push(`${current}=`);
|
||||
current = '';
|
||||
}
|
||||
|
||||
current += token;
|
||||
}
|
||||
|
||||
wrapped.push(current);
|
||||
return wrapped.join('\r\n');
|
||||
}).join('\r\n');
|
||||
}
|
||||
|
||||
// Attachments arrive as base64 strings (see README). Validate the payload and
|
||||
// re-wrap it at 76 characters so a large blob never exceeds the SMTP
|
||||
// 998-octet line limit. Returns null for missing or non-base64 data.
|
||||
function encode_attachment_data(data) {
|
||||
if (typeof data !== 'string') return null;
|
||||
const compact = data.replace(/\s+/g, '');
|
||||
if (compact === '' || compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) {
|
||||
return null;
|
||||
}
|
||||
// Re-encode to normalize non-canonical trailing bits.
|
||||
const normalized = Buffer.from(compact, 'base64').toString('base64');
|
||||
return normalized.match(/.{1,76}/g).join('\r\n');
|
||||
}
|
||||
|
||||
// Content-Disposition filename parameter(s). ASCII names use the plain quoted
|
||||
// form; non-ASCII names get an RFC 2231 filename* value with an ASCII
|
||||
// fallback filename for parsers that don't support the extended syntax.
|
||||
function format_attachment_filename(filename) {
|
||||
const raw = sanitize_header_value(filename) || 'attachment';
|
||||
const safe = raw.replace(/["\\/]/g, '_');
|
||||
if (/^[\x20-\x7E]*$/.test(safe)) {
|
||||
return `filename="${safe}"`;
|
||||
}
|
||||
const fallback = safe.replace(/[^\x20-\x7E]/g, '_');
|
||||
const encoded = Array.from(Buffer.from(safe, 'utf8'))
|
||||
.map((byte) => {
|
||||
const ch = String.fromCharCode(byte);
|
||||
return /[A-Za-z0-9._-]/.test(ch)
|
||||
? ch
|
||||
: `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
||||
})
|
||||
.join('');
|
||||
return `filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an RFC 5322 compliant email message
|
||||
* @param {Object} email_data - Email data object
|
||||
* @param {string} email_data.from - Sender email address
|
||||
* @param {string|string[]} email_data.to - Recipient(s)
|
||||
* @param {string} [email_data.cc] - CC recipients
|
||||
* @param {string} [email_data.subject] - Email subject
|
||||
* @param {string} [email_data.text_body] - Plain text body
|
||||
* @param {string} [email_data.html_body] - HTML body
|
||||
* @param {string} [email_data.reply_to] - Reply-To address
|
||||
* @param {Object} [email_data.headers] - Custom headers
|
||||
* @param {Array} [email_data.attachments] - Attachments array
|
||||
* @param {string} [message_id] - Optional message ID (generated if not provided)
|
||||
* @returns {Object} Object with email_content and message_id
|
||||
*/
|
||||
function build(email_data, message_id = null) {
|
||||
message_id = sanitize_message_id(message_id || crypto.randomUUID());
|
||||
|
||||
const safe_from = normalize_address(email_data.from);
|
||||
const from_header = format_address_header(email_data.from);
|
||||
const reply_to_header = format_address_header(email_data.reply_to || email_data.from) || from_header;
|
||||
const safe_to_recipients = normalize_address_list(email_data.to);
|
||||
const safe_subject = sanitize_header_value(email_data.subject || '');
|
||||
const text_body = get_text_body(email_data);
|
||||
const html_body = get_html_body(email_data);
|
||||
|
||||
if (!safe_from) {
|
||||
throw new Error('Invalid from address');
|
||||
}
|
||||
if (safe_to_recipients.length === 0) {
|
||||
throw new Error('Invalid to recipient list');
|
||||
}
|
||||
|
||||
// Extract sender domain for Message-ID
|
||||
const sender_email = domains.extract_email(safe_from);
|
||||
const sender_domain = domains.extract_domain(safe_from) || 'haraka.local';
|
||||
|
||||
// Header rendering keeps display names; envelope/routing uses the bare
|
||||
// lists above (and collect_recipients()), so this is header-only.
|
||||
const to_header_recipients = format_address_header_list(email_data.to);
|
||||
const cc_header_recipients = format_address_header_list(email_data.cc);
|
||||
|
||||
// Start building headers
|
||||
const headers = [
|
||||
`Message-ID: <${message_id}@${sender_domain}>`,
|
||||
`Date: ${rfc5322_date(new Date())}`,
|
||||
`From: ${from_header}`,
|
||||
`Reply-To: ${reply_to_header}`,
|
||||
// Fold after each address so long recipient lists never exceed the
|
||||
// RFC 5322 998-octet line limit.
|
||||
`To: ${to_header_recipients.join(',\r\n ')}`
|
||||
];
|
||||
|
||||
// Add CC if present
|
||||
if (cc_header_recipients.length > 0) {
|
||||
headers.push(`Cc: ${cc_header_recipients.join(',\r\n ')}`);
|
||||
}
|
||||
|
||||
// Add subject
|
||||
headers.push(`Subject: ${encode_mime_word(safe_subject)}`);
|
||||
headers.push('MIME-Version: 1.0');
|
||||
|
||||
// Add custom headers (with injection prevention)
|
||||
if (email_data.headers) {
|
||||
for (const [key, value] of Object.entries(email_data.headers)) {
|
||||
const safe_key = sanitize_header_name(key);
|
||||
if (!safe_key) continue;
|
||||
|
||||
const safe_value = sanitize_header_value(value);
|
||||
// Skip dangerous headers that could override critical fields.
|
||||
// Content-Type/Content-Transfer-Encoding matter because custom
|
||||
// headers are emitted before the body's real Content-Type, and
|
||||
// many parsers honor the first occurrence.
|
||||
const lower_key = safe_key.toLowerCase();
|
||||
if (FORBIDDEN_CUSTOM_HEADERS.has(lower_key)) {
|
||||
continue;
|
||||
}
|
||||
headers.push(`${safe_key}: ${encode_mime_word(safe_value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine email structure and build body
|
||||
const has_attachments = email_data.attachments &&
|
||||
Array.isArray(email_data.attachments) &&
|
||||
email_data.attachments.length > 0;
|
||||
|
||||
let body_parts;
|
||||
|
||||
if (has_attachments) {
|
||||
body_parts = build_multipart_mixed(email_data);
|
||||
} else if (text_body && html_body) {
|
||||
body_parts = build_multipart_alternative(text_body, html_body);
|
||||
} else if (html_body) {
|
||||
body_parts = build_html_body(html_body);
|
||||
} else {
|
||||
body_parts = build_text_body(text_body || '');
|
||||
}
|
||||
|
||||
// Combine headers and body
|
||||
const email_content = [...headers, ...body_parts].join('\r\n');
|
||||
|
||||
return {
|
||||
email_content,
|
||||
message_id,
|
||||
sender_email
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a plain text body
|
||||
* @param {string} text - Plain text content
|
||||
* @returns {string[]} Body lines
|
||||
*/
|
||||
function build_text_body(text) {
|
||||
return [
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
encode_quoted_printable(text)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an HTML body
|
||||
* @param {string} html - HTML content
|
||||
* @returns {string[]} Body lines
|
||||
*/
|
||||
function build_html_body(html) {
|
||||
return [
|
||||
'Content-Type: text/html; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
encode_quoted_printable(html)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multipart/alternative body (text + HTML)
|
||||
* @param {string} text - Plain text content
|
||||
* @param {string} html - HTML content
|
||||
* @returns {string[]} Body lines
|
||||
*/
|
||||
function build_multipart_alternative(text, html) {
|
||||
const boundary = `boundary-${crypto.randomUUID()}`;
|
||||
|
||||
return [
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
encode_quoted_printable(text),
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
encode_quoted_printable(html),
|
||||
'',
|
||||
`--${boundary}--`
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multipart/mixed body (content + attachments)
|
||||
* @param {Object} email_data - Email data with attachments
|
||||
* @returns {string[]} Body lines
|
||||
*/
|
||||
function build_multipart_mixed(email_data) {
|
||||
const mixed_boundary = `boundary-mixed-${crypto.randomUUID()}`;
|
||||
const text_body = get_text_body(email_data);
|
||||
const html_body = get_html_body(email_data);
|
||||
const parts = [
|
||||
`Content-Type: multipart/mixed; boundary="${mixed_boundary}"`,
|
||||
'',
|
||||
`--${mixed_boundary}`
|
||||
];
|
||||
|
||||
// Add message body as first part
|
||||
if (text_body && html_body) {
|
||||
// Nested multipart/alternative for text + HTML
|
||||
const alt_boundary = `boundary-alt-${crypto.randomUUID()}`;
|
||||
parts.push(
|
||||
`Content-Type: multipart/alternative; boundary="${alt_boundary}"`,
|
||||
'',
|
||||
`--${alt_boundary}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
encode_quoted_printable(text_body),
|
||||
'',
|
||||
`--${alt_boundary}`,
|
||||
'Content-Type: text/html; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
encode_quoted_printable(html_body),
|
||||
'',
|
||||
`--${alt_boundary}--`
|
||||
);
|
||||
} else if (html_body) {
|
||||
parts.push(
|
||||
'Content-Type: text/html; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
encode_quoted_printable(html_body)
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
encode_quoted_printable(text_body || '')
|
||||
);
|
||||
}
|
||||
|
||||
// Add attachment parts
|
||||
for (const attachment of email_data.attachments) {
|
||||
const safe_content_type = sanitize_header_value(attachment.content_type || 'application/octet-stream') || 'application/octet-stream';
|
||||
const encoded_data = encode_attachment_data(attachment.data);
|
||||
if (encoded_data === null) {
|
||||
throw new Error(`Invalid attachment data for "${sanitize_header_value(attachment.filename) || 'attachment'}": expected a base64 string`);
|
||||
}
|
||||
parts.push(
|
||||
'',
|
||||
`--${mixed_boundary}`,
|
||||
`Content-Type: ${safe_content_type}`,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
`Content-Disposition: attachment; ${format_attachment_filename(attachment.filename)}`,
|
||||
'',
|
||||
encoded_data
|
||||
);
|
||||
}
|
||||
|
||||
// Close boundary
|
||||
parts.push('', `--${mixed_boundary}--`);
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert structured email data to webhook format for local delivery
|
||||
* @param {Object} email_data - Structured email data
|
||||
* @param {string} message_id - Message ID
|
||||
* @returns {Object} Webhook-formatted data
|
||||
*/
|
||||
function to_webhook_format(email_data, message_id) {
|
||||
return {
|
||||
message_id: message_id,
|
||||
from: email_data.from,
|
||||
to: Array.isArray(email_data.to) ? email_data.to[0] : email_data.to,
|
||||
cc: email_data.cc,
|
||||
bcc: email_data.bcc,
|
||||
subject: email_data.subject,
|
||||
text_body: get_text_body(email_data),
|
||||
html_body: get_html_body(email_data),
|
||||
attachments: email_data.attachments || [],
|
||||
timestamp: new Date().toISOString(),
|
||||
id: message_id
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all recipients from email data (to, cc, bcc)
|
||||
* @param {Object} email_data - Email data
|
||||
* @returns {string[]} Array of all recipient email addresses
|
||||
*/
|
||||
function collect_recipients(email_data) {
|
||||
const recipients = [];
|
||||
|
||||
// Add TO recipients
|
||||
if (email_data.to) {
|
||||
const to_list = normalize_address_list(email_data.to);
|
||||
recipients.push(...to_list);
|
||||
}
|
||||
|
||||
// Add CC recipients
|
||||
if (email_data.cc) {
|
||||
const cc_list = normalize_address_list(email_data.cc);
|
||||
recipients.push(...cc_list);
|
||||
}
|
||||
|
||||
// Add BCC recipients
|
||||
if (email_data.bcc) {
|
||||
const bcc_list = normalize_address_list(email_data.bcc);
|
||||
recipients.push(...bcc_list);
|
||||
}
|
||||
|
||||
return recipients;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
build,
|
||||
build_text_body,
|
||||
build_html_body,
|
||||
build_multipart_alternative,
|
||||
build_multipart_mixed,
|
||||
encode_quoted_printable,
|
||||
to_webhook_format,
|
||||
collect_recipients
|
||||
};
|
||||
240
lib/http-client.js
Normal file
240
lib/http-client.js
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
/**
|
||||
* Shared HTTP Client Module
|
||||
*
|
||||
* Provides a unified HTTP/HTTPS request helper used by all Elektrine plugins.
|
||||
* Handles timeouts, error handling, and JSON parsing consistently.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const url = require('url');
|
||||
|
||||
/**
|
||||
* Make an HTTP/HTTPS request
|
||||
* @param {Object} options - Request options
|
||||
* @param {string} options.url - Full URL to request
|
||||
* @param {string} [options.method='POST'] - HTTP method
|
||||
* @param {Object} [options.data] - Data to send (will be JSON stringified)
|
||||
* @param {Object} [options.headers] - Additional headers
|
||||
* @param {number} [options.timeout=30000] - Request timeout in milliseconds
|
||||
* @param {string} [options.api_key] - API key to include in X-API-Key header
|
||||
* @param {Function} [options.logger] - Logger function for debug output
|
||||
* @returns {Promise<Object>} Response object with status, data, and headers
|
||||
*/
|
||||
// Connection pooling agents for keep-alive
|
||||
const http_agent = new http.Agent({ keepAlive: true, maxSockets: 10, keepAliveMsecs: 30000 });
|
||||
const https_agent = new https.Agent({ keepAlive: true, maxSockets: 10, keepAliveMsecs: 30000 });
|
||||
|
||||
function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const {
|
||||
url: request_url,
|
||||
method = 'POST',
|
||||
data = null,
|
||||
headers = {},
|
||||
timeout = 30000,
|
||||
api_key = null,
|
||||
logger = null
|
||||
} = options;
|
||||
|
||||
const parsed_url = url.parse(request_url);
|
||||
|
||||
// Validate URL scheme to prevent SSRF via non-HTTP protocols
|
||||
if (parsed_url.protocol !== 'http:' && parsed_url.protocol !== 'https:') {
|
||||
return reject(new Error(`Invalid URL scheme: ${parsed_url.protocol} (only http/https allowed)`));
|
||||
}
|
||||
|
||||
const payload = data ? JSON.stringify(data) : null;
|
||||
|
||||
const request_options = {
|
||||
hostname: parsed_url.hostname,
|
||||
port: parsed_url.port || (parsed_url.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed_url.path || '/',
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'elektrine-haraka/1.0',
|
||||
...headers
|
||||
},
|
||||
timeout: timeout
|
||||
};
|
||||
|
||||
if (
|
||||
parsed_url.protocol === 'http:' &&
|
||||
typeof parsed_url.hostname === 'string' &&
|
||||
parsed_url.hostname.endsWith('.internal') &&
|
||||
!request_options.headers['X-Forwarded-Proto'] &&
|
||||
!request_options.headers['x-forwarded-proto']
|
||||
) {
|
||||
request_options.headers['X-Forwarded-Proto'] = 'https';
|
||||
}
|
||||
|
||||
// Add content length for POST/PUT requests
|
||||
if (payload) {
|
||||
request_options.headers['Content-Length'] = Buffer.byteLength(payload);
|
||||
}
|
||||
|
||||
// Add API key if provided
|
||||
if (api_key) {
|
||||
request_options.headers['X-API-Key'] = api_key;
|
||||
}
|
||||
|
||||
// Use pooled agents for connection reuse
|
||||
const is_https = parsed_url.protocol === 'https:';
|
||||
const protocol = is_https ? https : http;
|
||||
request_options.agent = is_https ? https_agent : http_agent;
|
||||
|
||||
const req = protocol.request(request_options, (res) => {
|
||||
let response_data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
response_data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
const result = {
|
||||
status: res.statusCode,
|
||||
headers: res.headers,
|
||||
raw: response_data,
|
||||
data: null
|
||||
};
|
||||
|
||||
// Try to parse JSON response
|
||||
if (response_data) {
|
||||
try {
|
||||
result.data = JSON.parse(response_data);
|
||||
} catch (e) {
|
||||
result.data = response_data;
|
||||
}
|
||||
}
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(result);
|
||||
} else {
|
||||
const error = new Error(`HTTP ${res.statusCode}: ${response_data}`);
|
||||
error.status = res.statusCode;
|
||||
error.response = result;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
if (logger) logger(`HTTP request error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
const error = new Error('Request timed out');
|
||||
error.code = 'ETIMEDOUT';
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (payload) {
|
||||
req.write(payload);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a webhook to the Phoenix app
|
||||
* @param {string} webhook_url - Webhook URL
|
||||
* @param {Object} data - Data to send
|
||||
* @param {Object} options - Additional options
|
||||
* @param {string} [options.api_key] - API key
|
||||
* @param {number} [options.timeout=30000] - Timeout in milliseconds
|
||||
* @param {Function} [options.logger] - Logger function
|
||||
* @returns {Promise<Object>} Response data
|
||||
*/
|
||||
async function send_webhook(webhook_url, data, options = {}) {
|
||||
const result = await request({
|
||||
url: webhook_url,
|
||||
method: 'POST',
|
||||
data: data,
|
||||
headers: options.headers || {},
|
||||
api_key: options.api_key,
|
||||
timeout: options.timeout || 30000,
|
||||
logger: options.logger
|
||||
});
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a recipient with the Phoenix app
|
||||
* @param {string} verify_url - Verification URL
|
||||
* @param {string} email - Email address to verify
|
||||
* @param {Object} options - Additional options
|
||||
* @param {string} [options.api_key] - API key
|
||||
* @param {number} [options.timeout=5000] - Timeout in milliseconds
|
||||
* @param {Function} [options.logger] - Logger function
|
||||
* @returns {Promise<boolean>} True if recipient exists
|
||||
*/
|
||||
async function verify_recipient(verify_url, email, options = {}) {
|
||||
try {
|
||||
const result = await request({
|
||||
url: verify_url,
|
||||
method: 'POST',
|
||||
data: { email: email },
|
||||
api_key: options.api_key,
|
||||
timeout: options.timeout || 5000,
|
||||
logger: options.logger
|
||||
});
|
||||
|
||||
return result.data && result.data.exists === true;
|
||||
} catch (err) {
|
||||
// 404 means recipient does not exist
|
||||
if (err.status === 404) {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of valid domains from the Phoenix app
|
||||
* This includes configured local domains plus any custom domains
|
||||
* that have email enabled.
|
||||
* @param {string} domains_url - Domains API URL
|
||||
* @param {Object} options - Additional options
|
||||
* @param {string} [options.api_key] - API key
|
||||
* @param {number} [options.timeout=10000] - Timeout in milliseconds
|
||||
* @param {Function} [options.logger] - Logger function
|
||||
* @returns {Promise<string[]>} Array of domain names
|
||||
*/
|
||||
async function fetch_domains(domains_url, options = {}) {
|
||||
try {
|
||||
const result = await request({
|
||||
url: domains_url,
|
||||
method: 'GET',
|
||||
api_key: options.api_key,
|
||||
timeout: options.timeout || 10000,
|
||||
logger: options.logger
|
||||
});
|
||||
|
||||
if (result.data && Array.isArray(result.data.domains)) {
|
||||
return result.data.domains;
|
||||
}
|
||||
|
||||
if (options.logger) {
|
||||
options.logger('Unexpected domains response format, using empty list');
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
if (options.logger) {
|
||||
options.logger(`Failed to fetch domains: ${err.message}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request,
|
||||
send_webhook,
|
||||
verify_recipient,
|
||||
fetch_domains
|
||||
};
|
||||
19
lib/index.js
Normal file
19
lib/index.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Elektrine Haraka Library
|
||||
*
|
||||
* Central export for all shared modules used by Elektrine Haraka plugins.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
config: require('./config'),
|
||||
http: require('./http-client'),
|
||||
mime: require('./mime-parser'),
|
||||
domains: require('./domains'),
|
||||
email: require('./email-builder'),
|
||||
spam: require('./spam-extractor'),
|
||||
attachments: require('./attachment-handler'),
|
||||
bounce: require('./bounce-detector'),
|
||||
text: require('./text-normalizer')
|
||||
};
|
||||
234
lib/mime-parser.js
Normal file
234
lib/mime-parser.js
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* MIME parsing helpers with charset-decoding fallback behavior.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { simpleParser } = require('mailparser');
|
||||
const text_normalizer = require('./text-normalizer');
|
||||
|
||||
let cached_libmime = undefined;
|
||||
|
||||
let cached_iconv = undefined;
|
||||
|
||||
function get_iconv_constructor() {
|
||||
if (cached_iconv !== undefined) return cached_iconv;
|
||||
|
||||
try {
|
||||
cached_iconv = require('iconv').Iconv;
|
||||
} catch (err) {
|
||||
cached_iconv = null;
|
||||
}
|
||||
|
||||
return cached_iconv;
|
||||
}
|
||||
|
||||
function get_libmime() {
|
||||
if (cached_libmime !== undefined) return cached_libmime;
|
||||
|
||||
try {
|
||||
cached_libmime = require('libmime');
|
||||
} catch (err) {
|
||||
cached_libmime = null;
|
||||
}
|
||||
|
||||
return cached_libmime;
|
||||
}
|
||||
|
||||
function is_charset_decode_error(err) {
|
||||
if (!err || !err.message) return false;
|
||||
|
||||
const message = String(err.message).toLowerCase();
|
||||
return (
|
||||
message.includes('charset') ||
|
||||
message.includes('encoding') ||
|
||||
message.includes('iconv') ||
|
||||
message.includes('decode')
|
||||
);
|
||||
}
|
||||
|
||||
function count_mojibake_pairs(value) {
|
||||
if (typeof value !== 'string' || value.length < 2) return 0;
|
||||
|
||||
let count = 0;
|
||||
for (let i = 0; i < value.length - 1; i += 1) {
|
||||
const a = value.charCodeAt(i);
|
||||
const b = value.charCodeAt(i + 1);
|
||||
if (a >= 0x00C2 && a <= 0x00F4 && b >= 0x0080 && b <= 0x00BF) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function parsed_mojibake_score(parsed) {
|
||||
if (!parsed || typeof parsed !== 'object') return 0;
|
||||
|
||||
const fields = [
|
||||
parsed.subject,
|
||||
parsed.text,
|
||||
parsed.html,
|
||||
parsed.from && parsed.from.text,
|
||||
parsed.to && parsed.to.text,
|
||||
parsed.cc && parsed.cc.text
|
||||
];
|
||||
|
||||
let score = 0;
|
||||
for (const value of fields) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const sample = value.length > 4096 ? value.slice(0, 4096) : value;
|
||||
score += count_mojibake_pairs(sample);
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function text_quality_score(value) {
|
||||
if (typeof value !== 'string') return Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const mojibake_pairs = count_mojibake_pairs(value);
|
||||
const control_count = (value.match(/[\u0080-\u009F]/g) || []).length;
|
||||
const replacement_count = (value.match(/\uFFFD/g) || []).length;
|
||||
|
||||
// Lower is better.
|
||||
return mojibake_pairs * 5 + control_count * 3 + replacement_count * 8;
|
||||
}
|
||||
|
||||
function decode_subject_from_header_lines(parsed) {
|
||||
if (!parsed || !Array.isArray(parsed.headerLines)) return null;
|
||||
|
||||
const subject_line = parsed.headerLines.find((line) => line && line.key === 'subject');
|
||||
if (!subject_line || typeof subject_line.line !== 'string') return null;
|
||||
|
||||
const raw = subject_line.line.replace(/^subject\s*:/i, '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const libmime = get_libmime();
|
||||
|
||||
try {
|
||||
if (libmime && typeof libmime.decodeWords === 'function') {
|
||||
return String(libmime.decodeWords(raw));
|
||||
}
|
||||
} catch (_err) {
|
||||
// fall through to returning raw text for further normalization
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function choose_best_subject(parsed_subject, header_line_subject) {
|
||||
const parsed_candidate = text_normalizer.normalize_header(parsed_subject || '');
|
||||
const header_candidate = text_normalizer.normalize_header(header_line_subject || '');
|
||||
|
||||
const parsed_score = text_quality_score(parsed_candidate);
|
||||
const header_score = text_quality_score(header_candidate);
|
||||
|
||||
if (header_candidate && header_score < parsed_score) {
|
||||
return header_candidate;
|
||||
}
|
||||
|
||||
return parsed_candidate;
|
||||
}
|
||||
|
||||
function normalize_parsed_headers(parsed) {
|
||||
if (!parsed || typeof parsed !== 'object') return parsed;
|
||||
|
||||
const decoded_subject = decode_subject_from_header_lines(parsed);
|
||||
parsed.subject = choose_best_subject(parsed.subject || '', decoded_subject || '');
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parse_stream_data(arg1, arg2) {
|
||||
if (arg2 !== undefined) {
|
||||
if (arg1 instanceof Error) throw arg1;
|
||||
return arg2;
|
||||
}
|
||||
|
||||
return arg1;
|
||||
}
|
||||
|
||||
function read_message_stream(message_stream) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!message_stream) return resolve(Buffer.from(''));
|
||||
|
||||
const on_data = (arg1, arg2) => {
|
||||
try {
|
||||
const raw = parse_stream_data(arg1, arg2);
|
||||
if (Buffer.isBuffer(raw)) return resolve(raw);
|
||||
if (typeof raw === 'string') return resolve(Buffer.from(raw, 'binary'));
|
||||
return resolve(Buffer.from(String(raw || ''), 'utf8'));
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (typeof message_stream.get_data === 'function') {
|
||||
message_stream.get_data(on_data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof message_stream.get_data_string === 'function') {
|
||||
message_stream.get_data_string((raw) => {
|
||||
if (typeof raw === 'string') {
|
||||
resolve(Buffer.from(raw, 'binary'));
|
||||
} else {
|
||||
resolve(Buffer.from(''));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('message_stream does not support get_data/get_data_string'));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function parse_mime(input, options = {}) {
|
||||
const iconv = get_iconv_constructor();
|
||||
const logger = options.logger;
|
||||
|
||||
if (iconv) {
|
||||
try {
|
||||
const parsed_with_iconv = await simpleParser(input, { Iconv: iconv });
|
||||
const iconv_score = parsed_mojibake_score(parsed_with_iconv);
|
||||
|
||||
if (iconv_score === 0) {
|
||||
return normalize_parsed_headers(parsed_with_iconv);
|
||||
}
|
||||
|
||||
const parsed_with_iconv_lite = await simpleParser(input);
|
||||
const iconv_lite_score = parsed_mojibake_score(parsed_with_iconv_lite);
|
||||
|
||||
if (iconv_lite_score < iconv_score) {
|
||||
if (logger) {
|
||||
logger(
|
||||
'warn',
|
||||
`native iconv output looked mojibake-prone (score ${iconv_score}), using iconv-lite result (score ${iconv_lite_score})`
|
||||
);
|
||||
}
|
||||
return normalize_parsed_headers(parsed_with_iconv_lite);
|
||||
}
|
||||
|
||||
return normalize_parsed_headers(parsed_with_iconv);
|
||||
} catch (err) {
|
||||
if (!is_charset_decode_error(err)) throw err;
|
||||
if (logger) {
|
||||
logger('warn', `native iconv parsing failed, retrying with iconv-lite: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = await simpleParser(input);
|
||||
return normalize_parsed_headers(parsed);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parse_mime,
|
||||
read_message_stream,
|
||||
get_iconv_constructor
|
||||
};
|
||||
67
lib/queue-client.js
Normal file
67
lib/queue-client.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Redis-backed queue helper for async inbound processing.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { createClient } = require('redis');
|
||||
|
||||
class QueueClient {
|
||||
constructor(cfg, logger) {
|
||||
this.cfg = cfg;
|
||||
this.logger = logger;
|
||||
this.client = null;
|
||||
this.connecting = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.client && this.client.isReady) return;
|
||||
if (this.connecting) return this.connecting;
|
||||
|
||||
this.client = createClient({ url: this.cfg.redis_url });
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
this.logger.error('redis_client_error', { message: err.message });
|
||||
});
|
||||
|
||||
this.client.on('reconnecting', () => {
|
||||
this.logger.warn('redis_reconnecting');
|
||||
});
|
||||
|
||||
this.connecting = this.client.connect()
|
||||
.then(() => {
|
||||
this.logger.info('redis_connected', { redis_url: this.cfg.redis_url });
|
||||
})
|
||||
.finally(() => {
|
||||
this.connecting = null;
|
||||
});
|
||||
|
||||
return this.connecting;
|
||||
}
|
||||
|
||||
async enqueue(queue_name, payload) {
|
||||
await this.connect();
|
||||
await this.client.lPush(queue_name, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
async pop(queue_name, timeout_sec) {
|
||||
await this.connect();
|
||||
const result = await this.client.brPop(queue_name, timeout_sec);
|
||||
if (!result) return null;
|
||||
return result.element;
|
||||
}
|
||||
|
||||
async enqueue_dlq(queue_name, payload) {
|
||||
await this.enqueue(queue_name, payload);
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.client && this.client.isOpen) {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QueueClient
|
||||
};
|
||||
170
lib/spam-extractor.js
Normal file
170
lib/spam-extractor.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Spam Extractor Module
|
||||
*
|
||||
* Extracts spam information from Haraka connection/transaction notes
|
||||
* and email headers. Supports SpamAssassin results.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Default spam info structure
|
||||
* @returns {Object} Default spam info object
|
||||
*/
|
||||
function get_default_spam_info() {
|
||||
return {
|
||||
status: 'unknown',
|
||||
score: 0.0,
|
||||
threshold: 5.0,
|
||||
report: null,
|
||||
status_header: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract spam information from multiple sources
|
||||
* Checks transaction notes, connection notes, and headers in order of priority.
|
||||
*
|
||||
* @param {Object} connection - Haraka connection object
|
||||
* @param {Object} transaction - Haraka transaction object
|
||||
* @param {Object} headers - Parsed email headers (from mailparser)
|
||||
* @param {Object} [options] - Optional settings
|
||||
* @param {boolean} [options.debug=false] - Enable debug logging
|
||||
* @param {Function} [options.logger] - Logger function
|
||||
* @returns {Object} Spam info object
|
||||
*/
|
||||
function extract(connection, transaction, headers, options = {}) {
|
||||
const { debug = false, logger = console.log } = options;
|
||||
|
||||
if (debug) logger('Starting spam extraction');
|
||||
|
||||
const spam_info = get_default_spam_info();
|
||||
|
||||
// Method 1: Check transaction notes (highest priority)
|
||||
if (transaction && transaction.notes && transaction.notes.spamassassin) {
|
||||
if (debug) logger('Found spamassassin in transaction.notes');
|
||||
return parse_spamassassin_notes(transaction.notes.spamassassin, spam_info, debug, logger);
|
||||
}
|
||||
|
||||
// Method 2: Check connection notes
|
||||
if (connection && connection.notes && connection.notes.spamassassin) {
|
||||
if (debug) logger('Found spamassassin in connection.notes');
|
||||
return parse_spamassassin_notes(connection.notes.spamassassin, spam_info, debug, logger);
|
||||
}
|
||||
|
||||
// Method 3: Check email headers (fallback)
|
||||
if (headers) {
|
||||
const header_result = parse_spam_headers(headers, spam_info, debug, logger);
|
||||
if (header_result.status !== 'unknown') {
|
||||
return header_result;
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) logger('No spam info found in any source');
|
||||
return spam_info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SpamAssassin notes from Haraka
|
||||
* @param {Object} sa - SpamAssassin notes object
|
||||
* @param {Object} spam_info - Base spam info object
|
||||
* @param {boolean} debug - Debug mode
|
||||
* @param {Function} logger - Logger function
|
||||
* @returns {Object} Updated spam info
|
||||
*/
|
||||
function parse_spamassassin_notes(sa, spam_info, debug, logger) {
|
||||
if (debug) logger(`Parsing SpamAssassin notes: ${JSON.stringify(sa)}`);
|
||||
|
||||
if (sa.score !== undefined) {
|
||||
spam_info.score = parseFloat(sa.score) || 0.0;
|
||||
}
|
||||
|
||||
if (sa.reqd !== undefined) {
|
||||
spam_info.threshold = parseFloat(sa.reqd) || 5.0;
|
||||
}
|
||||
|
||||
if (sa.flag !== undefined) {
|
||||
spam_info.status = sa.flag === 'Yes' ? 'spam' : 'ham';
|
||||
}
|
||||
|
||||
if (sa.tests) {
|
||||
spam_info.report = sa.tests;
|
||||
}
|
||||
|
||||
return spam_info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse spam information from email headers
|
||||
* @param {Object} headers - Email headers (Map or object)
|
||||
* @param {Object} spam_info - Base spam info object
|
||||
* @param {boolean} debug - Debug mode
|
||||
* @param {Function} logger - Logger function
|
||||
* @returns {Object} Updated spam info
|
||||
*/
|
||||
function parse_spam_headers(headers, spam_info, debug, logger) {
|
||||
if (debug) logger('Checking headers for spam info');
|
||||
|
||||
// Get header values (handle both Map and object formats)
|
||||
const get_header = (name) => {
|
||||
if (headers instanceof Map) {
|
||||
return headers.get(name) || headers.get(name.toLowerCase());
|
||||
}
|
||||
return headers[name] || headers[name.toLowerCase()];
|
||||
};
|
||||
|
||||
const spam_status = get_header('X-Spam-Status') || get_header('x-spam-status');
|
||||
const spam_score = get_header('X-Spam-Score') || get_header('x-spam-score');
|
||||
const spam_report = get_header('X-Spam-Report') || get_header('x-spam-report');
|
||||
|
||||
if (spam_status || spam_score) {
|
||||
if (debug) logger(`Found spam headers - Status: ${spam_status}, Score: ${spam_score}`);
|
||||
|
||||
if (spam_status && typeof spam_status === 'string') {
|
||||
spam_info.status_header = spam_status;
|
||||
|
||||
// Parse "Yes, score=5.2, required=5.0" or "No, score=-1.6, required=5.0"
|
||||
const score_match = spam_status.match(/score=([-\d.]+)/);
|
||||
const req_match = spam_status.match(/required=([-\d.]+)/);
|
||||
|
||||
if (score_match) {
|
||||
spam_info.score = parseFloat(score_match[1]) || 0.0;
|
||||
}
|
||||
if (req_match) {
|
||||
spam_info.threshold = parseFloat(req_match[1]) || 5.0;
|
||||
}
|
||||
|
||||
spam_info.status = spam_status.startsWith('Yes') ? 'spam' : 'ham';
|
||||
}
|
||||
|
||||
// Override score if separate header exists
|
||||
if (spam_score && typeof spam_score === 'string') {
|
||||
spam_info.score = parseFloat(spam_score) || spam_info.score;
|
||||
}
|
||||
|
||||
// Add report if available
|
||||
if (spam_report && typeof spam_report === 'string') {
|
||||
spam_info.report = spam_report;
|
||||
}
|
||||
}
|
||||
|
||||
return spam_info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an email should be marked as spam based on score
|
||||
* @param {number} score - Spam score
|
||||
* @param {number} [threshold=5.0] - Spam threshold
|
||||
* @returns {boolean} True if spam
|
||||
*/
|
||||
function is_spam(score, threshold = 5.0) {
|
||||
return score >= threshold;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extract,
|
||||
get_default_spam_info,
|
||||
parse_spamassassin_notes,
|
||||
parse_spam_headers,
|
||||
is_spam
|
||||
};
|
||||
65
lib/telemetry.js
Normal file
65
lib/telemetry.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Minimal structured telemetry helper.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
function compact(fields) {
|
||||
const out = {};
|
||||
Object.entries(fields || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) out[key] = value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function emit(log_fn, level, scope, event, fields = {}) {
|
||||
const payload = compact({
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
scope,
|
||||
event,
|
||||
...fields
|
||||
});
|
||||
|
||||
const line = JSON.stringify(payload);
|
||||
log_fn(line);
|
||||
}
|
||||
|
||||
function create_plugin_logger(plugin, scope) {
|
||||
return {
|
||||
info(event, fields) {
|
||||
emit((line) => plugin.loginfo(line), 'info', scope, event, fields);
|
||||
},
|
||||
warn(event, fields) {
|
||||
emit((line) => plugin.logwarn(line), 'warn', scope, event, fields);
|
||||
},
|
||||
error(event, fields) {
|
||||
emit((line) => plugin.logerror(line), 'error', scope, event, fields);
|
||||
},
|
||||
debug(event, fields) {
|
||||
emit((line) => plugin.logdebug(line), 'debug', scope, event, fields);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function create_console_logger(scope) {
|
||||
return {
|
||||
info(event, fields) {
|
||||
emit((line) => console.log(line), 'info', scope, event, fields);
|
||||
},
|
||||
warn(event, fields) {
|
||||
emit((line) => console.warn(line), 'warn', scope, event, fields);
|
||||
},
|
||||
error(event, fields) {
|
||||
emit((line) => console.error(line), 'error', scope, event, fields);
|
||||
},
|
||||
debug(event, fields) {
|
||||
emit((line) => console.debug(line), 'debug', scope, event, fields);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create_plugin_logger,
|
||||
create_console_logger
|
||||
};
|
||||
73
lib/text-normalizer.js
Normal file
73
lib/text-normalizer.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Header text normalization helpers.
|
||||
*
|
||||
* Fixes common mojibake where UTF-8 bytes were interpreted as Latin-1.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
function count_replacement_chars(value) {
|
||||
if (typeof value !== 'string' || value.length === 0) return 0;
|
||||
return (value.match(/\uFFFD/g) || []).length;
|
||||
}
|
||||
|
||||
function has_c1_controls(value) {
|
||||
return /[\u0080-\u009F]/.test(value);
|
||||
}
|
||||
|
||||
function count_mojibake_utf8_latin1_pairs(value) {
|
||||
let count = 0;
|
||||
for (let i = 0; i < value.length - 1; i += 1) {
|
||||
const a = value.charCodeAt(i);
|
||||
const b = value.charCodeAt(i + 1);
|
||||
// Pattern of UTF-8 byte sequences misread as Latin-1 code points.
|
||||
if (a >= 0x00C2 && a <= 0x00F4 && b >= 0x0080 && b <= 0x00BF) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function try_repair_utf8_latin1_mojibake(value) {
|
||||
if (typeof value !== 'string' || value.length === 0) return value;
|
||||
|
||||
const cps = Array.from(value, (char) => char.codePointAt(0));
|
||||
if (cps.some((cp) => cp > 0xFF)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const repaired = Buffer.from(cps).toString('utf8');
|
||||
if (!repaired) return value;
|
||||
|
||||
const before_controls = has_c1_controls(value);
|
||||
const after_controls = has_c1_controls(repaired);
|
||||
const before_pairs = count_mojibake_utf8_latin1_pairs(value);
|
||||
const after_pairs = count_mojibake_utf8_latin1_pairs(repaired);
|
||||
const replacement_count = count_replacement_chars(repaired);
|
||||
|
||||
if (before_controls && !after_controls) {
|
||||
if (replacement_count === 0) return repaired;
|
||||
|
||||
// Allow partial recovery when controls disappear and mojibake pairs drop sharply.
|
||||
// This handles cases where one continuation byte was dropped upstream.
|
||||
if (before_pairs >= 3 && after_pairs < before_pairs && replacement_count <= 2) {
|
||||
return repaired;
|
||||
}
|
||||
}
|
||||
|
||||
if (before_pairs > 0 && after_pairs < before_pairs && replacement_count === 0) {
|
||||
return repaired;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalize_header(value) {
|
||||
if (typeof value !== 'string') return value;
|
||||
return try_repair_utf8_latin1_mojibake(value);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalize_header,
|
||||
try_repair_utf8_latin1_mojibake
|
||||
};
|
||||
959
package-lock.json
generated
Normal file
959
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,959 @@
|
|||
{
|
||||
"name": "elektrine-haraka",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "elektrine-haraka",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"haraka-plugin-dkim": "^1.0.11",
|
||||
"haraka-plugin-spf": "^1.2.10",
|
||||
"iconv": "^3.0.1",
|
||||
"mailparser": "^3.9.8",
|
||||
"redis": "^4.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@msimerson/stun": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@msimerson/stun/-/stun-3.0.2.tgz",
|
||||
"integrity": "sha512-Mn2Ls1xf5wmD92eM+hwFHhrm536u31pWYwPiCm12RzJiAfUJH8UbdfOGoi9sxnhlPL6wd+vj3QYLvWgUaQUPqw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"binary-data": "^0.6.0",
|
||||
"buffer-xor": "^2.0.2",
|
||||
"debug": "^4.3.7",
|
||||
"ip2buf": "^2.0.0",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"is-stun": "^2.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"turbo-crc32": "^1.0.1",
|
||||
"universalify": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"stun": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/client": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
"yallist": "4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/graph": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
||||
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/json": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
||||
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/search": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
||||
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/time-series": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
||||
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@selderee/plugin-htmlparser2": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
|
||||
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"selderee": "^0.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
|
||||
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
|
||||
"integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/address-rfc2821": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/address-rfc2821/-/address-rfc2821-2.1.4.tgz",
|
||||
"integrity": "sha512-4fj94T+zoQJiWVLpzjuj2MRuVs/pyTROe21l/nWW2qthbs0gV8M4VS6vCXyG2LI5lMANemhmmNUpUtsgNlf5TQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nearley": "^2.20.1",
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/address-rfc2822": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/address-rfc2822/-/address-rfc2822-2.2.3.tgz",
|
||||
"integrity": "sha512-M+tNumDEri308IBqo88uK5Ck2NEdvrCbrUwZfJXD0xkxMK0OrXY/OWyE6/xc1Z7MNBK7KCfZeZzz1QI4UMI+Mw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"email-addresses": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/binary-data": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-data/-/binary-data-0.6.0.tgz",
|
||||
"integrity": "sha512-HGiT0ir03tS1u7iWdW5xjJfbPpvxH2qJbPFxXW0I3P5iOzkbjN/cJy5GlpAwmjHW5CiayGOxZ/ytLzXmYgdgqQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"generate-function": "^2.3.1",
|
||||
"is-plain-object": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-xor": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-2.0.2.tgz",
|
||||
"integrity": "sha512-eHslX0bin3GB+Lx2p7lEYRShRewuNZL3fUl4qlVJGGiwoPGftmt8JQgk2Y9Ji5/01TnVDo33E5b5O3vUB1HdqQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/discontinuous-range": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
|
||||
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/email-addresses": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz",
|
||||
"integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encoding-japanese": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/generic-pool": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
||||
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/haraka-config": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/haraka-config/-/haraka-config-1.4.2.tgz",
|
||||
"integrity": "sha512-mlhnUqGLaXmIAVVo7/Ms3FPPQJrNWiwa/OVGIvoj0aZQDacLWIyDwHYZx35ka2zBy4iW0VGY/KjannbMEPD+7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"hjson": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/haraka-dsn": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/haraka-dsn/-/haraka-dsn-1.1.0.tgz",
|
||||
"integrity": "sha512-lo8qG+7gjcsfDgpd6/2VVfzAQbkbYlz1ApZCOTrPKygd8qxYxelWT/ZBVjddXwVeNNm0yzyua+vi5S5IiyEFFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= v14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/haraka-email-message": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/haraka-email-message/-/haraka-email-message-1.2.6.tgz",
|
||||
"integrity": "sha512-Ly1d/QhOEvKoO1Zn6TYSaMNYxoT61292oXx/iqsH9PV7DrcGUWOhgpM6TtdW8LRwR9TEOix1NRB8XX8V53waCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"haraka-config": "^1.4.2",
|
||||
"haraka-message-stream": "^1.3.1",
|
||||
"iconv-lite": "^0.7.1",
|
||||
"libmime": "^5.3.7",
|
||||
"libqp": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/haraka-message-stream": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/haraka-message-stream/-/haraka-message-stream-1.3.2.tgz",
|
||||
"integrity": "sha512-ssHDIWRN+mtPYjTh03m6aJU+Cy4OqKNPASIPaj3EHgEw/rd3YE2TI+PIBT6t7gGmmB6Umec6SwwBMOH/sLjiLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/haraka-net-utils": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/haraka-net-utils/-/haraka-net-utils-1.7.2.tgz",
|
||||
"integrity": "sha512-iQGu1dldfXC8JIrIb7Xnk9FAQ6QovXLSbRi/ZU/DBBG+ouoscCWUhRG5GKn7PgKK1aoOcekSDY+Sty9Q05hsQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"haraka-config": "^1.4.2",
|
||||
"haraka-tld": "^1.2.3",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"openssl-wrapper": "^0.3.4",
|
||||
"punycode.js": "^2.3.1",
|
||||
"sprintf-js": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= v14.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msimerson/stun": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/haraka-plugin-dkim": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/haraka-plugin-dkim/-/haraka-plugin-dkim-1.0.11.tgz",
|
||||
"integrity": "sha512-iRggkt5ZpsU0Cn3DgQX2O9u2WWL7lRgFrAOgNxXdnPWvx0/2QB5N/ztQZHiNtvlyJajLhOngicFBdu2p/u1oVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"address-rfc2821": "^2.1.3",
|
||||
"address-rfc2822": "^2.2.3",
|
||||
"async": "^3.2.6",
|
||||
"haraka-email-message": "^1.2.5",
|
||||
"haraka-utils": "^1.1.4",
|
||||
"nopt": "^8.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"dkim_key_gen.sh": "config/dkim_key_gen.sh",
|
||||
"dkimverify": "bin/dkimverify"
|
||||
}
|
||||
},
|
||||
"node_modules/haraka-plugin-spf": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/haraka-plugin-spf/-/haraka-plugin-spf-1.2.11.tgz",
|
||||
"integrity": "sha512-nFdQVTsJspVnY9ggpklwy2N/A0Zo7KlBL+waTnx3Q50AEOEBdwlpTAzVjAC3bDYOKJVdXWRfQDUj8bmvMdLkLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"haraka-dsn": "^1.1.0",
|
||||
"haraka-net-utils": "^1.7.2",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"nopt": "^9.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"spf": "bin/spf"
|
||||
}
|
||||
},
|
||||
"node_modules/haraka-plugin-spf/node_modules/abbrev": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
|
||||
"integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/haraka-plugin-spf/node_modules/nopt": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
|
||||
"integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"abbrev": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/haraka-tld": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/haraka-tld/-/haraka-tld-1.3.0.tgz",
|
||||
"integrity": "sha512-hAtq0qXVb1ogaDRfnpN2wLJT4NTOfEjJpNeR4JINXUZ9lZiHuecnrwllm9+i0qYay/QuFaZl9IvzPEUVhAem8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode.js": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/haraka-utils": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/haraka-utils/-/haraka-utils-1.1.4.tgz",
|
||||
"integrity": "sha512-bUggwf/biz9QCv+2l1Y0C1qLZfYuca51p6RAApk/qSY3dSAYr65B8VwPdI8Pq0Qpfk3heFGO0f6rBsyNERBjpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hjson": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/hjson/-/hjson-3.2.2.tgz",
|
||||
"integrity": "sha512-MkUeB0cTIlppeSsndgESkfFD21T2nXPRaBStLtf3cAYA2bVEFdXlodZB0TukwZiobPD1Ksax5DK4RTZeaXCI3Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"hjson": "bin/hjson"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "^0.11.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"htmlparser2": "^8.0.2",
|
||||
"selderee": "^0.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/iconv/-/iconv-3.0.1.tgz",
|
||||
"integrity": "sha512-lJnFLxVc0d82R7GfU7a9RujKVUQ3Eee19tPKWZWBJtAEGRHVEyFzCtbNl3GPKuDnHBBRT4/nDS4Ru9AIDT72qA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ip2buf": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ip2buf/-/ip2buf-2.0.0.tgz",
|
||||
"integrity": "sha512-ezW62UW6IPwpuS3mpsvOS3/3Jgx7aaNZT+uJo/+xVBxHCq7EA1ryuhzZw2MyC5GuGd1sAp3RDx7e4+nJCGt9vA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
|
||||
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"isobject": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/is-stun": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-stun/-/is-stun-2.0.0.tgz",
|
||||
"integrity": "sha512-3d3CI8nLmh2ATbjfvi5TkVcimMgXtFH7PGoXeT1prGguVK8eaO3CzynLbdFY8Ez9khVpLpP8HHFPxneqn0QYPw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/isobject": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
|
||||
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/libbase64": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
|
||||
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libmime": "5.3.8",
|
||||
"linkify-it": "5.0.0",
|
||||
"nodemailer": "8.0.5",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.261.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/libmime": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/moo": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
|
||||
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nearley": {
|
||||
"version": "2.20.1",
|
||||
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
|
||||
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^2.19.0",
|
||||
"moo": "^0.5.0",
|
||||
"railroad-diagrams": "^1.0.0",
|
||||
"randexp": "0.4.6"
|
||||
},
|
||||
"bin": {
|
||||
"nearley-railroad": "bin/nearley-railroad.js",
|
||||
"nearley-test": "bin/nearley-test.js",
|
||||
"nearley-unparse": "bin/nearley-unparse.js",
|
||||
"nearleyc": "bin/nearleyc.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://nearley.js.org/#give-to-nearley"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
||||
"integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"abbrev": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openssl-wrapper": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/openssl-wrapper/-/openssl-wrapper-0.3.4.tgz",
|
||||
"integrity": "sha512-iITsrx6Ho8V3/2OVtmZzzX8wQaKAaFXEJQdzoPUZDtyf5jWFlqo+h+OhGT4TATQ47f9ACKHua8nw7Qoy85aeKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"leac": "^0.6.0",
|
||||
"peberminta": "^0.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/railroad-diagrams": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
|
||||
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/randexp": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
|
||||
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"discontinuous-range": "1.0.0",
|
||||
"ret": "~0.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
||||
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@redis/bloom": "1.2.0",
|
||||
"@redis/client": "1.6.1",
|
||||
"@redis/graph": "1.1.1",
|
||||
"@redis/json": "1.0.7",
|
||||
"@redis/search": "1.2.0",
|
||||
"@redis/time-series": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ret": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
||||
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
|
||||
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parseley": "^0.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/tlds": {
|
||||
"version": "1.261.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
|
||||
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tlds": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/turbo-crc32": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/turbo-crc32/-/turbo-crc32-1.0.1.tgz",
|
||||
"integrity": "sha512-8yyRd1ZdNp+AQLGqi3lTaA2k81JjlIZOyFQEsi7GQWBgirnQOxjqVtDEbYHM2Z4yFdJ5AQw0fxBLLnDCl6RXoQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
package.json
Normal file
21
package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "elektrine-haraka",
|
||||
"description": "Elektrine SMTP Server - Haraka with custom plugins for email processing",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"haraka-plugin-dkim": "^1.0.11",
|
||||
"haraka-plugin-spf": "^1.2.10",
|
||||
"iconv": "^3.0.1",
|
||||
"mailparser": "^3.9.8",
|
||||
"redis": "^4.7.0"
|
||||
},
|
||||
"scripts": {
|
||||
"worker": "node ./scripts/elektrine-worker.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.elektrine.com/elektrine/elektrine-haraka.git"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
184
plugins/elektrine_async_queue.js
Normal file
184
plugins/elektrine_async_queue.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* Elektrine Async Queue Plugin
|
||||
*
|
||||
* Keeps SMTP transactions fast by enqueueing inbound messages to Redis,
|
||||
* then acknowledging the SMTP queue hook immediately.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const constants = require('haraka-constants');
|
||||
|
||||
const {
|
||||
config,
|
||||
domains
|
||||
} = require('../lib');
|
||||
const queue_lib = require('../lib/queue-client');
|
||||
const telemetry = require('../lib/telemetry');
|
||||
|
||||
function parse_stream_data(arg1, arg2) {
|
||||
if (arg2 !== undefined) {
|
||||
if (arg1 instanceof Error) throw arg1;
|
||||
return arg2;
|
||||
}
|
||||
|
||||
return arg1;
|
||||
}
|
||||
|
||||
function read_transaction_raw(transaction) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const message_stream = transaction && transaction.message_stream;
|
||||
if (!message_stream) {
|
||||
return resolve(Buffer.from(''));
|
||||
}
|
||||
|
||||
const on_data = (arg1, arg2) => {
|
||||
try {
|
||||
const raw = parse_stream_data(arg1, arg2);
|
||||
if (Buffer.isBuffer(raw)) return resolve(raw);
|
||||
if (typeof raw === 'string') return resolve(Buffer.from(raw, 'binary'));
|
||||
return resolve(Buffer.from(String(raw || ''), 'utf8'));
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (typeof message_stream.get_data === 'function') {
|
||||
message_stream.get_data(on_data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof message_stream.get_data_string === 'function') {
|
||||
message_stream.get_data_string((raw) => {
|
||||
if (typeof raw === 'string') {
|
||||
resolve(Buffer.from(raw, 'binary'));
|
||||
} else {
|
||||
resolve(Buffer.from(''));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('transaction.message_stream does not support get_data')); // eslint-disable-line max-len
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function transaction_spam_notes(transaction) {
|
||||
const notes = transaction && transaction.notes ? transaction.notes : {};
|
||||
const spamassassin = notes.spamassassin || null;
|
||||
|
||||
if (!spamassassin || typeof spamassassin !== 'object') return null;
|
||||
|
||||
return {
|
||||
score: spamassassin.score !== undefined ? spamassassin.score : null,
|
||||
required: spamassassin.reqd !== undefined ? spamassassin.reqd : null,
|
||||
flag: spamassassin.flag !== undefined ? spamassassin.flag : null,
|
||||
tests: spamassassin.tests || null
|
||||
};
|
||||
}
|
||||
|
||||
exports.register = function () {
|
||||
this.load_config();
|
||||
this.logger = telemetry.create_plugin_logger(this, 'async_queue');
|
||||
this.queue_client = new queue_lib.QueueClient(this.cfg, this.logger);
|
||||
|
||||
domains.init((msg) => this.logger.info('domains_init', { message: msg }))
|
||||
.then(() => {
|
||||
this.logger.info('domains_cache_ready');
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.warn('domains_cache_init_failed', { message: err.message });
|
||||
});
|
||||
|
||||
this.register_hook('queue', 'enqueue_inbound_message');
|
||||
};
|
||||
|
||||
exports.load_config = function () {
|
||||
const haraka_cfg = this.config.get('elektrine_queue.ini', {
|
||||
booleans: ['+main.enabled', '+main.include_headers', '+main.include_body', '+main.include_attachments']
|
||||
}, () => {
|
||||
this.load_config();
|
||||
});
|
||||
|
||||
this.cfg = config.load(haraka_cfg);
|
||||
};
|
||||
|
||||
exports.enqueue_inbound_message = function (next, connection) {
|
||||
const plugin = this;
|
||||
const transaction = connection.transaction;
|
||||
|
||||
if (!plugin.cfg.webhook_enabled) {
|
||||
plugin.logger.warn('queue_disabled');
|
||||
return next(constants.DENYSOFT, 'Inbound processing is temporarily disabled');
|
||||
}
|
||||
|
||||
if (!transaction) {
|
||||
return next(constants.OK, 'No transaction to enqueue');
|
||||
}
|
||||
|
||||
const rcpt_to = transaction.rcpt_to.map((rcpt) => rcpt.address());
|
||||
const has_local_recipient = transaction.rcpt_to.some((rcpt) => domains.is_local_domain(rcpt.host));
|
||||
|
||||
if (!has_local_recipient) {
|
||||
plugin.logger.warn('non_local_recipient_in_inbound_profile', {
|
||||
transaction_id: transaction.uuid,
|
||||
recipients: rcpt_to
|
||||
});
|
||||
return next(constants.DENY, 'Inbound role only accepts local recipients');
|
||||
}
|
||||
|
||||
read_transaction_raw(transaction)
|
||||
.then((raw_buffer) => {
|
||||
if (raw_buffer.length > plugin.cfg.queue_max_raw_bytes) {
|
||||
throw new Error(`Message exceeds queue_max_raw_bytes (${raw_buffer.length} > ${plugin.cfg.queue_max_raw_bytes})`); // eslint-disable-line max-len
|
||||
}
|
||||
|
||||
const message_id = transaction.uuid || crypto.randomUUID();
|
||||
const payload = {
|
||||
schema_version: 1,
|
||||
message_id,
|
||||
enqueued_at: new Date().toISOString(),
|
||||
mail_from: transaction.mail_from ? transaction.mail_from.address() : '',
|
||||
rcpt_to,
|
||||
data_bytes: transaction.data_bytes || raw_buffer.length,
|
||||
remote: {
|
||||
ip: connection.remote && connection.remote.ip,
|
||||
host: connection.remote && connection.remote.host,
|
||||
info: connection.remote && connection.remote.info
|
||||
},
|
||||
hello: connection.hello ? {
|
||||
host: connection.hello.host,
|
||||
verb: connection.hello.verb
|
||||
} : null,
|
||||
tls: Boolean(connection.tls),
|
||||
spamassassin: transaction_spam_notes(transaction),
|
||||
raw_rfc822_base64: raw_buffer.toString('base64')
|
||||
};
|
||||
|
||||
return plugin.queue_client.enqueue(plugin.cfg.queue_name, payload)
|
||||
.then(() => {
|
||||
plugin.logger.info('message_enqueued', {
|
||||
transaction_id: transaction.uuid,
|
||||
message_id,
|
||||
rcpt_count: rcpt_to.length,
|
||||
bytes: payload.data_bytes,
|
||||
queue: plugin.cfg.queue_name
|
||||
});
|
||||
|
||||
return next(constants.OK, 'Queued for async processing');
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
plugin.logger.error('enqueue_failed', {
|
||||
transaction_id: transaction.uuid,
|
||||
message: err.message
|
||||
});
|
||||
|
||||
return next(constants.DENYSOFT, 'Temporary queueing failure, please retry');
|
||||
});
|
||||
};
|
||||
948
plugins/elektrine_http_api.js
Normal file
948
plugins/elektrine_http_api.js
Normal file
|
|
@ -0,0 +1,948 @@
|
|||
/**
|
||||
* Elektrine HTTP API Plugin
|
||||
*
|
||||
* Provides a REST API endpoint for sending emails via Haraka.
|
||||
* Supports structured email data and raw MIME format.
|
||||
*
|
||||
* Endpoint: POST /api/v1/send
|
||||
* Authentication: X-API-Key header
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const net = require('net');
|
||||
const constants = require('haraka-constants');
|
||||
|
||||
// Shared library modules
|
||||
const { config, domains, email: emailBuilder } = require('../lib');
|
||||
|
||||
// Maximum request body size (50MB - handles large attachments)
|
||||
const MAX_BODY_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
// Request body read timeout (30 seconds)
|
||||
const BODY_READ_TIMEOUT_MS = 30000;
|
||||
|
||||
exports.register = function() {
|
||||
const plugin = this;
|
||||
|
||||
// Load configuration
|
||||
plugin.load_config();
|
||||
plugin.stats = {
|
||||
started_at: new Date().toISOString(),
|
||||
requests_total: 0,
|
||||
auth_failures: 0,
|
||||
rate_limited: 0,
|
||||
sent_ok: 0,
|
||||
sent_error: 0,
|
||||
dkim_sync_ok: 0,
|
||||
dkim_sync_error: 0,
|
||||
dkim_delete_ok: 0,
|
||||
dkim_delete_error: 0
|
||||
};
|
||||
|
||||
// Validate critical config at startup
|
||||
if (!plugin.cfg.http_api_key || plugin.cfg.http_api_key === 'elektrine_webhook_key_default') {
|
||||
plugin.logerror('CRITICAL: HTTP API key is not configured. Set HARAKA_HTTP_API_KEY environment variable.');
|
||||
}
|
||||
|
||||
// Initialize rate limiting storage
|
||||
plugin.rate_limit_storage = new Map();
|
||||
plugin.allowlists = {
|
||||
trusted_proxies: null,
|
||||
ops: null,
|
||||
metrics: null
|
||||
};
|
||||
plugin.rebuild_allowlists();
|
||||
|
||||
// Start HTTP server
|
||||
plugin.start_server();
|
||||
};
|
||||
|
||||
exports.load_config = function() {
|
||||
const plugin = this;
|
||||
|
||||
const haraka_cfg = plugin.config.get('elektrine.ini', {
|
||||
booleans: [
|
||||
'+main.enabled',
|
||||
'+main.include_headers',
|
||||
'+main.include_body',
|
||||
'+main.include_attachments'
|
||||
]
|
||||
}, function() {
|
||||
plugin.load_config();
|
||||
});
|
||||
|
||||
plugin.cfg = config.load(haraka_cfg);
|
||||
plugin.rebuild_allowlists();
|
||||
};
|
||||
|
||||
exports.start_server = function() {
|
||||
const plugin = this;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
plugin.handle_request(req, res);
|
||||
});
|
||||
|
||||
const port = plugin.cfg.http_port;
|
||||
const host = plugin.cfg.http_host;
|
||||
|
||||
server.listen(port, host, () => {
|
||||
plugin.loginfo(`HTTP API listening on ${host}:${port}`);
|
||||
});
|
||||
};
|
||||
|
||||
exports.handle_request = function(req, res) {
|
||||
const plugin = this;
|
||||
plugin.stats.requests_total += 1;
|
||||
const request_path = plugin.get_request_path(req);
|
||||
|
||||
if (req.method === 'GET' && (request_path === '/status' || request_path === '/healthz')) {
|
||||
if (!plugin.is_ops_request_allowed(req, request_path)) {
|
||||
return plugin.send_response(res, 403, { ok: false, error: 'Forbidden' });
|
||||
}
|
||||
return plugin.send_response(res, 200, {
|
||||
ok: true,
|
||||
role: plugin.cfg.role,
|
||||
started_at: plugin.stats.started_at
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && request_path === '/metrics') {
|
||||
if (!plugin.is_ops_request_allowed(req, request_path)) {
|
||||
return plugin.send_response(res, 403, { ok: false, error: 'Forbidden' });
|
||||
}
|
||||
return plugin.send_metrics(res);
|
||||
}
|
||||
|
||||
// CORS headers - only allow same-origin requests (API is internal)
|
||||
const allowed_origin = plugin.cfg.cors_origin || null;
|
||||
if (allowed_origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', allowed_origin);
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key');
|
||||
|
||||
// Handle preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const route = plugin.resolve_api_route(req.method, request_path);
|
||||
if (!route) {
|
||||
plugin.logwarn(
|
||||
`Unmatched HTTP route method=${req.method || 'UNKNOWN'} url=${req.url || ''} path=${request_path}`
|
||||
);
|
||||
return plugin.send_response(res, 404, { success: false, error: 'Not found' });
|
||||
}
|
||||
|
||||
if (!plugin.authenticate_api_request(req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!plugin.check_rate_limit_or_reject(req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.kind === 'send') {
|
||||
return plugin.read_request_body(req, res, (body) => {
|
||||
plugin.process_send_request(body, res);
|
||||
});
|
||||
}
|
||||
|
||||
if (route.kind === 'dkim_upsert') {
|
||||
return plugin.read_request_body(req, res, (body) => {
|
||||
plugin.process_dkim_upsert_request(route.domain, body, res);
|
||||
});
|
||||
}
|
||||
|
||||
if (route.kind === 'dkim_get') {
|
||||
return plugin.process_dkim_get_request(route.domain, res);
|
||||
}
|
||||
|
||||
if (route.kind === 'dkim_delete') {
|
||||
return plugin.process_dkim_delete_request(route.domain, res);
|
||||
}
|
||||
};
|
||||
|
||||
exports.resolve_api_route = function(method, request_path) {
|
||||
if (method === 'POST' && request_path === '/api/v1/send') {
|
||||
return { kind: 'send' };
|
||||
}
|
||||
|
||||
const dkim_domain = this.get_dkim_domain_from_path(request_path);
|
||||
if (!dkim_domain) return null;
|
||||
|
||||
if (method === 'GET') {
|
||||
return { kind: 'dkim_get', domain: dkim_domain };
|
||||
}
|
||||
|
||||
if (method === 'PUT') {
|
||||
return { kind: 'dkim_upsert', domain: dkim_domain };
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
return { kind: 'dkim_delete', domain: dkim_domain };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
exports.rebuild_allowlists = function() {
|
||||
const plugin = this;
|
||||
plugin.allowlists = {
|
||||
trusted_proxies: plugin.build_cidr_allowlist(plugin.cfg.trusted_proxy_cidrs || []),
|
||||
ops: plugin.build_cidr_allowlist(plugin.cfg.ops_allowed_cidrs || []),
|
||||
metrics: plugin.build_cidr_allowlist(plugin.cfg.metrics_allowed_cidrs || [])
|
||||
};
|
||||
};
|
||||
|
||||
exports.build_cidr_allowlist = function(cidr_values) {
|
||||
const plugin = this;
|
||||
const allowlist = new net.BlockList();
|
||||
|
||||
for (const raw_value of cidr_values) {
|
||||
if (!raw_value) continue;
|
||||
const value = String(raw_value).trim();
|
||||
if (!value) continue;
|
||||
|
||||
try {
|
||||
if (value.includes('/')) {
|
||||
const [raw_ip, raw_prefix] = value.split('/');
|
||||
const ip = plugin.parse_ip(raw_ip);
|
||||
const family = net.isIP(ip);
|
||||
const prefix = Number.parseInt(raw_prefix, 10);
|
||||
|
||||
if (!family || !Number.isInteger(prefix)) {
|
||||
plugin.logwarn(`Skipping invalid CIDR allowlist entry: ${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((family === 4 && (prefix < 0 || prefix > 32)) ||
|
||||
(family === 6 && (prefix < 0 || prefix > 128))) {
|
||||
plugin.logwarn(`Skipping CIDR allowlist entry with invalid prefix: ${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
allowlist.addSubnet(ip, prefix, family === 4 ? 'ipv4' : 'ipv6');
|
||||
continue;
|
||||
}
|
||||
|
||||
const ip = plugin.parse_ip(value);
|
||||
const family = net.isIP(ip);
|
||||
if (!family) {
|
||||
plugin.logwarn(`Skipping invalid allowlist IP entry: ${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
allowlist.addAddress(ip, family === 4 ? 'ipv4' : 'ipv6');
|
||||
} catch (err) {
|
||||
plugin.logwarn(`Skipping malformed allowlist entry (${value}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return allowlist;
|
||||
};
|
||||
|
||||
exports.get_request_path = function(req) {
|
||||
const raw_target = String(req.url || '').trim();
|
||||
|
||||
if (!raw_target) return '/';
|
||||
if (raw_target === '*') return '*';
|
||||
|
||||
let path_target = raw_target;
|
||||
|
||||
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(path_target)) {
|
||||
const scheme_sep = path_target.indexOf('://');
|
||||
const authority_start = scheme_sep >= 0 ? scheme_sep + 3 : 0;
|
||||
const first_slash = path_target.indexOf('/', authority_start);
|
||||
path_target = first_slash >= 0 ? path_target.slice(first_slash) : '/';
|
||||
} else if (!path_target.startsWith('/')) {
|
||||
// Some lightweight clients send a non-standard request target like
|
||||
// `127.0.0.1:8080/status`; normalize that into a path instead of
|
||||
// falling back to `/` and returning a misleading 404.
|
||||
const first_slash = path_target.indexOf('/');
|
||||
path_target = first_slash >= 0 ? path_target.slice(first_slash) : `/${path_target}`;
|
||||
}
|
||||
|
||||
const query_index = path_target.indexOf('?');
|
||||
const hash_index = path_target.indexOf('#');
|
||||
let cut_index = -1;
|
||||
|
||||
if (query_index >= 0 && hash_index >= 0) {
|
||||
cut_index = Math.min(query_index, hash_index);
|
||||
} else if (query_index >= 0) {
|
||||
cut_index = query_index;
|
||||
} else if (hash_index >= 0) {
|
||||
cut_index = hash_index;
|
||||
}
|
||||
|
||||
if (cut_index >= 0) {
|
||||
path_target = path_target.slice(0, cut_index);
|
||||
}
|
||||
|
||||
if (!path_target) return '/';
|
||||
return path_target.startsWith('/') ? path_target : `/${path_target}`;
|
||||
};
|
||||
|
||||
exports.get_header_value = function(req, key) {
|
||||
const value = req.headers[key];
|
||||
if (Array.isArray(value)) return value[0] || '';
|
||||
return value || '';
|
||||
};
|
||||
|
||||
exports.constant_time_equal = function(received, expected) {
|
||||
if (!received || !expected) return false;
|
||||
const received_buffer = Buffer.from(String(received));
|
||||
const expected_buffer = Buffer.from(String(expected));
|
||||
if (received_buffer.length !== expected_buffer.length) return false;
|
||||
return crypto.timingSafeEqual(received_buffer, expected_buffer);
|
||||
};
|
||||
|
||||
exports.authenticate_api_request = function(req, res) {
|
||||
const plugin = this;
|
||||
const api_key = plugin.get_header_value(req, 'x-api-key');
|
||||
const expected_key = plugin.cfg.http_api_key || '';
|
||||
|
||||
if (!plugin.constant_time_equal(api_key, expected_key)) {
|
||||
plugin.stats.auth_failures += 1;
|
||||
plugin.send_response(res, 401, { success: false, error: 'Unauthorized' });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
exports.check_rate_limit_or_reject = function(req, res) {
|
||||
const plugin = this;
|
||||
const client_ip = plugin.get_client_ip(req);
|
||||
|
||||
if (!plugin.check_rate_limit(client_ip)) {
|
||||
plugin.stats.rate_limited += 1;
|
||||
plugin.send_response(res, 429, { success: false, error: 'Rate limit exceeded' });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
exports.read_request_body = function(req, res, callback) {
|
||||
const plugin = this;
|
||||
let body = '';
|
||||
let body_size = 0;
|
||||
let finished = false;
|
||||
|
||||
const finish_once = (handler) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(body_timeout);
|
||||
handler();
|
||||
};
|
||||
|
||||
const body_timeout = setTimeout(() => {
|
||||
req.destroy();
|
||||
finish_once(() => {
|
||||
plugin.send_response(res, 408, { success: false, error: 'Request timeout' });
|
||||
});
|
||||
}, BODY_READ_TIMEOUT_MS);
|
||||
|
||||
req.on('data', (chunk) => {
|
||||
if (finished) return;
|
||||
|
||||
body_size += chunk.length;
|
||||
if (body_size > MAX_BODY_SIZE) {
|
||||
req.destroy();
|
||||
return finish_once(() => {
|
||||
plugin.send_response(res, 413, { success: false, error: 'Request body too large' });
|
||||
});
|
||||
}
|
||||
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
finish_once(() => callback(body));
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
finish_once(() => {});
|
||||
});
|
||||
};
|
||||
|
||||
exports.parse_ip = function(value) {
|
||||
if (!value) return '';
|
||||
|
||||
let candidate = String(value).trim();
|
||||
if (!candidate) return '';
|
||||
|
||||
if (candidate.startsWith('"') && candidate.endsWith('"') && candidate.length > 1) {
|
||||
candidate = candidate.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
if (candidate.startsWith('for=')) {
|
||||
candidate = candidate.slice(4).trim();
|
||||
}
|
||||
|
||||
if (candidate.startsWith('[')) {
|
||||
const closing_index = candidate.indexOf(']');
|
||||
if (closing_index > 0) {
|
||||
candidate = candidate.slice(1, closing_index);
|
||||
}
|
||||
} else {
|
||||
const ipv4_port_match = candidate.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/);
|
||||
if (ipv4_port_match) {
|
||||
candidate = ipv4_port_match[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.startsWith('::ffff:')) {
|
||||
candidate = candidate.slice('::ffff:'.length);
|
||||
}
|
||||
|
||||
return net.isIP(candidate) ? candidate : '';
|
||||
};
|
||||
|
||||
exports.is_ip_allowlisted = function(ip, allowlist) {
|
||||
if (!allowlist) return false;
|
||||
const parsed_ip = this.parse_ip(ip);
|
||||
const family = net.isIP(parsed_ip);
|
||||
if (!family) return false;
|
||||
return allowlist.check(parsed_ip, family === 4 ? 'ipv4' : 'ipv6');
|
||||
};
|
||||
|
||||
exports.get_forwarded_for_chain = function(req) {
|
||||
const forwarded_header = this.get_header_value(req, 'x-forwarded-for');
|
||||
if (!forwarded_header) return [];
|
||||
|
||||
return forwarded_header
|
||||
.split(',')
|
||||
.map((entry) => this.parse_ip(entry))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
exports.get_client_ip = function(req) {
|
||||
const plugin = this;
|
||||
const remote_ip = plugin.parse_ip(req.socket && req.socket.remoteAddress);
|
||||
const trusted_proxies = plugin.allowlists && plugin.allowlists.trusted_proxies;
|
||||
|
||||
if (!plugin.is_ip_allowlisted(remote_ip, trusted_proxies)) {
|
||||
return remote_ip || '0.0.0.0';
|
||||
}
|
||||
|
||||
const forwarded_chain = plugin.get_forwarded_for_chain(req);
|
||||
if (forwarded_chain.length > 0) {
|
||||
// Walk right-to-left to find the first untrusted hop.
|
||||
for (let i = forwarded_chain.length - 1; i >= 0; i -= 1) {
|
||||
const hop_ip = forwarded_chain[i];
|
||||
if (!plugin.is_ip_allowlisted(hop_ip, trusted_proxies)) {
|
||||
return hop_ip;
|
||||
}
|
||||
}
|
||||
|
||||
// If every forwarded hop is trusted, use the left-most original value.
|
||||
return forwarded_chain[0];
|
||||
}
|
||||
|
||||
const real_ip = plugin.parse_ip(plugin.get_header_value(req, 'x-real-ip'));
|
||||
if (real_ip && !plugin.is_ip_allowlisted(real_ip, trusted_proxies)) {
|
||||
return real_ip;
|
||||
}
|
||||
|
||||
return remote_ip || real_ip || '0.0.0.0';
|
||||
};
|
||||
|
||||
exports.is_ops_request_allowed = function(req, path) {
|
||||
const plugin = this;
|
||||
if (plugin.is_internal_api_request_authenticated(req)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const client_ip = plugin.get_client_ip(req);
|
||||
const allowlist = path === '/metrics'
|
||||
? plugin.allowlists.metrics
|
||||
: plugin.allowlists.ops;
|
||||
|
||||
const allowed = plugin.is_ip_allowlisted(client_ip, allowlist);
|
||||
if (!allowed) {
|
||||
plugin.logwarn(`Denied ${path} request from ${client_ip || 'unknown'}`);
|
||||
}
|
||||
return allowed;
|
||||
};
|
||||
|
||||
exports.is_internal_api_request_authenticated = function(req) {
|
||||
const api_key = this.get_header_value(req, 'x-api-key');
|
||||
const expected_key = this.cfg.http_api_key || '';
|
||||
return this.constant_time_equal(api_key, expected_key);
|
||||
};
|
||||
|
||||
exports.get_dkim_domain_from_path = function(request_path) {
|
||||
const match = request_path.match(/^\/api\/v1\/dkim\/domains\/([^/]+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
const decoded = decodeURIComponent(match[1]);
|
||||
return this.normalize_dkim_domain(decoded);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
exports.normalize_dkim_domain = function(value) {
|
||||
const domain = String(value || '').trim().toLowerCase().replace(/\.+$/, '');
|
||||
if (!domain) return null;
|
||||
|
||||
const labels = domain.split('.');
|
||||
if (labels.length < 2) return null;
|
||||
|
||||
for (const label of labels) {
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return domain;
|
||||
};
|
||||
|
||||
exports.normalize_dkim_selector = function(value) {
|
||||
const selector = String(value || '').trim();
|
||||
if (!selector) return null;
|
||||
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$/.test(selector)) {
|
||||
return null;
|
||||
}
|
||||
return selector;
|
||||
};
|
||||
|
||||
exports.normalize_private_key = function(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
const private_key = value.replace(/\r\n/g, '\n').trim();
|
||||
if (!private_key.includes('-----BEGIN') || !private_key.includes('PRIVATE KEY-----')) {
|
||||
return null;
|
||||
}
|
||||
if (private_key.includes('\u0000')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${private_key}\n`;
|
||||
};
|
||||
|
||||
exports.get_dkim_storage_dir = function() {
|
||||
const configured = String(this.cfg.dkim_storage_dir || '').trim();
|
||||
if (configured) return configured;
|
||||
|
||||
const haraka_dir = String(process.env.HARAKA || '').trim();
|
||||
if (haraka_dir) return path.resolve(haraka_dir, 'config', 'dkim');
|
||||
|
||||
const runtime_dir = '/tmp/haraka-config/config/dkim';
|
||||
if (fs.existsSync(runtime_dir)) {
|
||||
return runtime_dir;
|
||||
}
|
||||
|
||||
return path.resolve(process.cwd(), 'config', 'dkim');
|
||||
};
|
||||
|
||||
exports.process_dkim_upsert_request = function(domain, body, res) {
|
||||
const plugin = this;
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(body || '{}');
|
||||
} catch (err) {
|
||||
plugin.stats.dkim_sync_error += 1;
|
||||
return plugin.send_response(res, 400, { success: false, error: 'Invalid JSON' });
|
||||
}
|
||||
|
||||
const selector = plugin.normalize_dkim_selector(payload.selector);
|
||||
const private_key = plugin.normalize_private_key(payload.private_key);
|
||||
|
||||
if (!selector || !private_key) {
|
||||
plugin.stats.dkim_sync_error += 1;
|
||||
return plugin.send_response(res, 400, {
|
||||
success: false,
|
||||
error: 'selector and private_key are required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
plugin.upsert_dkim_domain(domain, selector, private_key);
|
||||
plugin.stats.dkim_sync_ok += 1;
|
||||
plugin.loginfo(`Installed DKIM key for ${domain} with selector ${selector}`);
|
||||
return plugin.send_response(res, 200, {
|
||||
success: true,
|
||||
domain,
|
||||
selector
|
||||
});
|
||||
} catch (err) {
|
||||
plugin.stats.dkim_sync_error += 1;
|
||||
plugin.logerror(`Failed to install DKIM key for ${domain}: ${err.message}`);
|
||||
return plugin.send_response(res, 500, {
|
||||
success: false,
|
||||
error: `Failed to install DKIM key: ${err.message}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.process_dkim_delete_request = function(domain, res) {
|
||||
const plugin = this;
|
||||
|
||||
try {
|
||||
const deleted = plugin.delete_dkim_domain(domain);
|
||||
plugin.stats.dkim_delete_ok += 1;
|
||||
plugin.loginfo(`${deleted ? 'Removed' : 'DKIM key not present for'} ${domain}`);
|
||||
return plugin.send_response(res, 200, {
|
||||
success: true,
|
||||
domain,
|
||||
deleted
|
||||
});
|
||||
} catch (err) {
|
||||
plugin.stats.dkim_delete_error += 1;
|
||||
plugin.logerror(`Failed to remove DKIM key for ${domain}: ${err.message}`);
|
||||
return plugin.send_response(res, 500, {
|
||||
success: false,
|
||||
error: `Failed to remove DKIM key: ${err.message}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.process_dkim_get_request = function(domain, res) {
|
||||
const plugin = this;
|
||||
|
||||
try {
|
||||
const dkim_domain = plugin.read_dkim_domain(domain);
|
||||
return plugin.send_response(res, 200, {
|
||||
success: true,
|
||||
domain,
|
||||
selector: dkim_domain.selector,
|
||||
public_key: dkim_domain.public_key_pem,
|
||||
value: dkim_domain.value,
|
||||
private_key_present: true
|
||||
});
|
||||
} catch (err) {
|
||||
const not_found = err && err.code === 'ENOENT';
|
||||
return plugin.send_response(res, not_found ? 404 : 500, {
|
||||
success: false,
|
||||
error: not_found ? `DKIM key not found for ${domain}` : `Failed to read DKIM key: ${err.message}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.upsert_dkim_domain = function(domain, selector, private_key) {
|
||||
const storage_dir = this.get_dkim_storage_dir();
|
||||
const domain_dir = path.join(storage_dir, domain);
|
||||
const private_path = path.join(domain_dir, 'private');
|
||||
const selector_path = path.join(domain_dir, 'selector');
|
||||
|
||||
fs.mkdirSync(domain_dir, { recursive: true, mode: 0o755 });
|
||||
fs.chmodSync(domain_dir, 0o755);
|
||||
|
||||
this.atomic_write_file(private_path, private_key, 0o600);
|
||||
this.atomic_write_file(selector_path, `${selector}\n`, 0o644);
|
||||
};
|
||||
|
||||
exports.delete_dkim_domain = function(domain) {
|
||||
const domain_dir = path.join(this.get_dkim_storage_dir(), domain);
|
||||
if (!fs.existsSync(domain_dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fs.rmSync(domain_dir, { recursive: true, force: true });
|
||||
return true;
|
||||
};
|
||||
|
||||
exports.read_dkim_domain = function(domain) {
|
||||
const domain_dir = path.join(this.get_dkim_storage_dir(), domain);
|
||||
const private_path = path.join(domain_dir, 'private');
|
||||
const selector_path = path.join(domain_dir, 'selector');
|
||||
|
||||
const private_key = fs.readFileSync(private_path, 'utf8');
|
||||
const selector = String(fs.readFileSync(selector_path, 'utf8') || '').trim();
|
||||
const public_key = this.derive_public_key_from_private(private_key);
|
||||
|
||||
return {
|
||||
selector,
|
||||
private_key,
|
||||
public_key_pem: public_key.pem,
|
||||
public_key_dns: public_key.dns,
|
||||
value: `v=DKIM1; k=rsa; p=${public_key.dns}`
|
||||
};
|
||||
};
|
||||
|
||||
exports.derive_public_key_from_private = function(private_key) {
|
||||
const private_object = crypto.createPrivateKey({ key: private_key, format: 'pem' });
|
||||
const public_object = crypto.createPublicKey(private_object);
|
||||
|
||||
const pem = public_object.export({ type: 'spki', format: 'pem' }).toString();
|
||||
const dns = public_object.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
|
||||
return { pem, dns };
|
||||
};
|
||||
|
||||
exports.atomic_write_file = function(target_path, contents, mode) {
|
||||
const directory = path.dirname(target_path);
|
||||
const temp_path = path.join(
|
||||
directory,
|
||||
`.${path.basename(target_path)}.${process.pid}.${Date.now()}.tmp`
|
||||
);
|
||||
|
||||
fs.writeFileSync(temp_path, contents, { mode });
|
||||
fs.renameSync(temp_path, target_path);
|
||||
fs.chmodSync(target_path, mode);
|
||||
};
|
||||
|
||||
exports.process_send_request = function(body, res) {
|
||||
const plugin = this;
|
||||
|
||||
try {
|
||||
const email_data = JSON.parse(body);
|
||||
|
||||
// Validate required fields
|
||||
if (!email_data.from || !email_data.to) {
|
||||
return plugin.send_response(res, 400, {
|
||||
success: false,
|
||||
error: 'Missing required fields: from and to'
|
||||
});
|
||||
}
|
||||
|
||||
// Subject required for non-raw emails
|
||||
if (!email_data.raw && !email_data.raw_base64 && email_data.subject === undefined) {
|
||||
return plugin.send_response(res, 400, {
|
||||
success: false,
|
||||
error: 'Missing required field: subject (required for non-raw emails)'
|
||||
});
|
||||
}
|
||||
|
||||
if (!email_data.raw && !email_data.raw_base64 && !plugin.has_structured_content(email_data)) {
|
||||
plugin.logwarn(
|
||||
`Rejecting structured send with no body or attachments from=${plugin.redact_email(email_data.from)} to_count=${emailBuilder.collect_recipients(email_data).length}`
|
||||
);
|
||||
return plugin.send_response(res, 400, {
|
||||
success: false,
|
||||
error: 'Missing message body: set text_body, text, body, html_body, html, attachments, raw, or raw_base64'
|
||||
});
|
||||
}
|
||||
|
||||
plugin.loginfo(
|
||||
`HTTP send payload summary ${plugin.payload_summary(email_data)}`
|
||||
);
|
||||
|
||||
// Queue email for delivery
|
||||
plugin.queue_email(email_data, (err, message_id) => {
|
||||
if (err) {
|
||||
plugin.stats.sent_error += 1;
|
||||
plugin.send_response(res, 400, { success: false, error: err.message });
|
||||
} else {
|
||||
plugin.stats.sent_ok += 1;
|
||||
plugin.send_response(res, 200, { success: true, message_id: message_id });
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
plugin.send_response(res, 400, { success: false, error: 'Invalid JSON' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.has_structured_content = function(email_data) {
|
||||
const body_fields = ['text_body', 'text', 'body', 'html_body', 'html'];
|
||||
const has_body = body_fields.some((field) => {
|
||||
const value = email_data[field];
|
||||
return typeof value === 'string' && value.trim() !== '';
|
||||
});
|
||||
|
||||
if (has_body) return true;
|
||||
|
||||
return Array.isArray(email_data.attachments) && email_data.attachments.length > 0;
|
||||
};
|
||||
|
||||
exports.redact_email = function(value) {
|
||||
const email = domains.extract_email(String(value || '').replace(/[\r\n]+/g, ' ').trim());
|
||||
if (!email || !email.includes('@')) return '<invalid>';
|
||||
|
||||
const [local, domain] = email.split('@');
|
||||
return `${local.slice(0, 2)}***@${domain}`;
|
||||
};
|
||||
|
||||
exports.payload_summary = function(email_data) {
|
||||
return [
|
||||
`from=${this.redact_email(email_data.from)}`,
|
||||
`to_count=${emailBuilder.collect_recipients(email_data).length}`,
|
||||
`text_body_bytes=${this.string_size(email_data.text_body)}`,
|
||||
`text_bytes=${this.string_size(email_data.text)}`,
|
||||
`body_bytes=${this.string_size(email_data.body)}`,
|
||||
`html_body_bytes=${this.string_size(email_data.html_body)}`,
|
||||
`html_bytes=${this.string_size(email_data.html)}`,
|
||||
`raw_bytes=${this.string_size(email_data.raw)}`,
|
||||
`raw_base64_bytes=${this.string_size(email_data.raw_base64)}`,
|
||||
`attachments=${Array.isArray(email_data.attachments) ? email_data.attachments.length : 0}`
|
||||
].join(' ');
|
||||
};
|
||||
|
||||
exports.string_size = function(value) {
|
||||
return typeof value === 'string' ? Buffer.byteLength(value) : 0;
|
||||
};
|
||||
|
||||
exports.message_body_summary = function(email_content) {
|
||||
const separator = '\r\n\r\n';
|
||||
const index = email_content.indexOf(separator);
|
||||
const body = index >= 0 ? email_content.slice(index + separator.length) : '';
|
||||
const canonical = body.replace(/(\r\n)*$/, '') + '\r\n';
|
||||
const hash = crypto.createHash('sha256').update(canonical, 'binary').digest('base64');
|
||||
return `message_bytes=${Buffer.byteLength(email_content)} body_bytes=${Buffer.byteLength(body)} body_hash=${hash}`;
|
||||
};
|
||||
|
||||
exports.ensure_terminal_crlf = function(email_content) {
|
||||
if (typeof email_content !== 'string' || email_content.endsWith('\r\n')) {
|
||||
return email_content;
|
||||
}
|
||||
|
||||
return email_content.replace(/\r?\n?$/, '') + '\r\n';
|
||||
};
|
||||
|
||||
exports.queue_email = function(email_data, callback) {
|
||||
const plugin = this;
|
||||
|
||||
try {
|
||||
const message_id = crypto.randomUUID();
|
||||
const all_recipients = emailBuilder.collect_recipients(email_data);
|
||||
if (all_recipients.length === 0) {
|
||||
throw new Error('Invalid recipient list');
|
||||
}
|
||||
|
||||
let email_content;
|
||||
let sender_email;
|
||||
|
||||
// Handle raw email formats
|
||||
if (email_data.raw_base64) {
|
||||
plugin.loginfo('Using base64-encoded raw email format');
|
||||
email_content = Buffer.from(email_data.raw_base64, 'base64').toString('binary');
|
||||
sender_email = domains.extract_email(String(email_data.from || '').replace(/[\r\n]+/g, ' ').trim());
|
||||
} else if (email_data.raw) {
|
||||
plugin.loginfo('Using raw email format');
|
||||
email_content = email_data.raw;
|
||||
sender_email = domains.extract_email(String(email_data.from || '').replace(/[\r\n]+/g, ' ').trim());
|
||||
} else {
|
||||
// Build email from structured data
|
||||
const built = emailBuilder.build(email_data, message_id);
|
||||
email_content = built.email_content;
|
||||
sender_email = built.sender_email;
|
||||
}
|
||||
|
||||
email_content = plugin.ensure_terminal_crlf(email_content);
|
||||
|
||||
plugin.loginfo(`Queued outbound MIME summary ${plugin.message_body_summary(email_content)}`);
|
||||
|
||||
if (!/^[^\s@<>]+@[A-Za-z0-9.-]+$/.test(sender_email)) {
|
||||
throw new Error('Invalid from address');
|
||||
}
|
||||
|
||||
// Always deliver through Haraka outbound.
|
||||
// Local domains are steered back to inbound-mx by elektrine_local_mx.
|
||||
if (domains.all_recipients_local(all_recipients)) {
|
||||
plugin.loginfo(`All recipients are local; routing via SMTP/local MX for message: ${message_id}`);
|
||||
}
|
||||
plugin.deliver_outbound(sender_email, all_recipients, email_content, message_id, callback);
|
||||
|
||||
} catch (err) {
|
||||
plugin.logerror(`Error building email: ${err.message}`);
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
exports.deliver_outbound = function(sender_email, recipients, email_content, message_id, callback) {
|
||||
const plugin = this;
|
||||
const outbound = require('./outbound');
|
||||
|
||||
plugin.loginfo(`Processing outbound delivery for: ${recipients.join(', ')}`);
|
||||
|
||||
outbound.send_email(sender_email, recipients, email_content, (code, msg) => {
|
||||
if (code === constants.cont || (msg && msg.includes('Message Queued'))) {
|
||||
plugin.loginfo(`Email queued successfully: ${message_id}`);
|
||||
callback(null, message_id);
|
||||
} else {
|
||||
plugin.logerror(`Email queueing failed: ${msg}`);
|
||||
callback(new Error(msg || 'Failed to queue email'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
exports.check_rate_limit = function(ip) {
|
||||
const plugin = this;
|
||||
const now = Date.now();
|
||||
const window_ms = plugin.cfg.rate_limit_window_ms;
|
||||
const max_requests = plugin.cfg.rate_limit_max_requests;
|
||||
|
||||
// Clean expired entries
|
||||
for (const [stored_ip, timestamps] of plugin.rate_limit_storage.entries()) {
|
||||
const valid = timestamps.filter(time => now - time < window_ms);
|
||||
if (valid.length === 0) {
|
||||
plugin.rate_limit_storage.delete(stored_ip);
|
||||
} else {
|
||||
plugin.rate_limit_storage.set(stored_ip, valid);
|
||||
}
|
||||
}
|
||||
|
||||
// Check current IP
|
||||
const timestamps = plugin.rate_limit_storage.get(ip) || [];
|
||||
const valid_timestamps = timestamps.filter(time => now - time < window_ms);
|
||||
|
||||
if (valid_timestamps.length >= max_requests) {
|
||||
plugin.logwarn(`Rate limit exceeded for IP: ${ip}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Record this request
|
||||
valid_timestamps.push(now);
|
||||
plugin.rate_limit_storage.set(ip, valid_timestamps);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
exports.send_response = function(res, status, data) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
exports.send_metrics = function(res) {
|
||||
const plugin = this;
|
||||
const uptime_seconds = Math.floor((Date.now() - Date.parse(plugin.stats.started_at)) / 1000);
|
||||
|
||||
const lines = [
|
||||
'# HELP elektrine_http_api_requests_total Total HTTP requests handled',
|
||||
'# TYPE elektrine_http_api_requests_total counter',
|
||||
`elektrine_http_api_requests_total ${plugin.stats.requests_total}`,
|
||||
'# HELP elektrine_http_api_auth_failures_total Authentication failures',
|
||||
'# TYPE elektrine_http_api_auth_failures_total counter',
|
||||
`elektrine_http_api_auth_failures_total ${plugin.stats.auth_failures}`,
|
||||
'# HELP elektrine_http_api_rate_limited_total Requests rejected by rate limit',
|
||||
'# TYPE elektrine_http_api_rate_limited_total counter',
|
||||
`elektrine_http_api_rate_limited_total ${plugin.stats.rate_limited}`,
|
||||
'# HELP elektrine_http_api_sent_ok_total Successful send requests',
|
||||
'# TYPE elektrine_http_api_sent_ok_total counter',
|
||||
`elektrine_http_api_sent_ok_total ${plugin.stats.sent_ok}`,
|
||||
'# HELP elektrine_http_api_sent_error_total Failed send requests',
|
||||
'# TYPE elektrine_http_api_sent_error_total counter',
|
||||
`elektrine_http_api_sent_error_total ${plugin.stats.sent_error}`,
|
||||
'# HELP elektrine_http_api_dkim_sync_ok_total Successful DKIM sync requests',
|
||||
'# TYPE elektrine_http_api_dkim_sync_ok_total counter',
|
||||
`elektrine_http_api_dkim_sync_ok_total ${plugin.stats.dkim_sync_ok}`,
|
||||
'# HELP elektrine_http_api_dkim_sync_error_total Failed DKIM sync requests',
|
||||
'# TYPE elektrine_http_api_dkim_sync_error_total counter',
|
||||
`elektrine_http_api_dkim_sync_error_total ${plugin.stats.dkim_sync_error}`,
|
||||
'# HELP elektrine_http_api_dkim_delete_ok_total Successful DKIM delete requests',
|
||||
'# TYPE elektrine_http_api_dkim_delete_ok_total counter',
|
||||
`elektrine_http_api_dkim_delete_ok_total ${plugin.stats.dkim_delete_ok}`,
|
||||
'# HELP elektrine_http_api_dkim_delete_error_total Failed DKIM delete requests',
|
||||
'# TYPE elektrine_http_api_dkim_delete_error_total counter',
|
||||
`elektrine_http_api_dkim_delete_error_total ${plugin.stats.dkim_delete_error}`,
|
||||
'# HELP elektrine_http_api_uptime_seconds Process uptime in seconds',
|
||||
'# TYPE elektrine_http_api_uptime_seconds gauge',
|
||||
`elektrine_http_api_uptime_seconds ${uptime_seconds}`
|
||||
];
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' });
|
||||
res.end(`${lines.join('\n')}\n`);
|
||||
};
|
||||
79
plugins/elektrine_local_mx.js
Normal file
79
plugins/elektrine_local_mx.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Elektrine Local MX Routing Plugin
|
||||
*
|
||||
* Routes local/protected domains to the internal inbound service instead of
|
||||
* external/public MX records. This keeps local delivery on the
|
||||
* inbound -> queue -> worker -> Phoenix path.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const constants = require('haraka-constants');
|
||||
const { domains } = require('../lib');
|
||||
|
||||
const DEFAULTS = {
|
||||
enabled: true,
|
||||
host: 'haraka-inbound',
|
||||
port: 25
|
||||
};
|
||||
|
||||
function to_int(value, fallback) {
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
exports.register = function () {
|
||||
this.load_config();
|
||||
this.register_hook('get_mx', 'route_local_mx');
|
||||
|
||||
domains.init((msg) => this.logdebug(`domains: ${msg}`))
|
||||
.catch((err) => {
|
||||
this.logwarn(`Domain cache init failed: ${err.message}`);
|
||||
});
|
||||
|
||||
if (this.cfg.enabled) {
|
||||
this.loginfo(`Local MX routing enabled -> ${this.cfg.host}:${this.cfg.port}`);
|
||||
} else {
|
||||
this.loginfo('Local MX routing disabled');
|
||||
}
|
||||
};
|
||||
|
||||
exports.load_config = function () {
|
||||
const plugin = this;
|
||||
const cfg = this.config.get('elektrine_local_mx.ini', {
|
||||
booleans: ['+main.enabled']
|
||||
}, () => {
|
||||
plugin.load_config();
|
||||
});
|
||||
|
||||
const main = (cfg && cfg.main) ? cfg.main : {};
|
||||
|
||||
this.cfg = {
|
||||
enabled: main.enabled !== undefined ? main.enabled : DEFAULTS.enabled,
|
||||
host: main.host || DEFAULTS.host,
|
||||
port: to_int(main.port, DEFAULTS.port)
|
||||
};
|
||||
|
||||
if (process.env.ELEKTRINE_LOCAL_MX_HOST) {
|
||||
this.cfg.host = process.env.ELEKTRINE_LOCAL_MX_HOST;
|
||||
}
|
||||
if (process.env.ELEKTRINE_LOCAL_MX_PORT) {
|
||||
this.cfg.port = to_int(process.env.ELEKTRINE_LOCAL_MX_PORT, this.cfg.port);
|
||||
}
|
||||
};
|
||||
|
||||
exports.route_local_mx = function (next, hmail, domain) {
|
||||
if (!this.cfg.enabled) return next();
|
||||
|
||||
const recipient_domain = String(domain || '').toLowerCase();
|
||||
if (!recipient_domain) return next();
|
||||
|
||||
if (!domains.is_local_domain(recipient_domain)) return next();
|
||||
|
||||
this.loginfo(`Routing local domain ${recipient_domain} to ${this.cfg.host}:${this.cfg.port}`);
|
||||
return next(constants.OK, {
|
||||
priority: 0,
|
||||
exchange: this.cfg.host,
|
||||
port: this.cfg.port
|
||||
});
|
||||
};
|
||||
86
plugins/elektrine_rcpt_verify.js
Normal file
86
plugins/elektrine_rcpt_verify.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Elektrine Recipient Verification Plugin
|
||||
*
|
||||
* Verifies that recipients exist before accepting email.
|
||||
* Prevents backscatter attacks from bouncing to spoofed senders.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const constants = require('haraka-constants');
|
||||
|
||||
// Shared library modules
|
||||
const { config, http: httpClient, domains } = require('../lib');
|
||||
|
||||
exports.register = function() {
|
||||
const plugin = this;
|
||||
|
||||
// Load configuration
|
||||
plugin.cfg = config.load();
|
||||
|
||||
// Log initial domains (may be updated dynamically from Phoenix)
|
||||
const local_domains = domains.get_local_domains();
|
||||
plugin.loginfo(`Recipient verification enabled for: ${local_domains.join(', ')}`);
|
||||
|
||||
// Log cache status periodically (every 10 minutes)
|
||||
setInterval(() => {
|
||||
const cache_status = domains.get_cache_status();
|
||||
if (cache_status.cached) {
|
||||
plugin.logdebug(`Domain cache: ${cache_status.domains.length} domains, age: ${Math.round(cache_status.age_ms / 1000)}s`);
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
};
|
||||
|
||||
exports.hook_rcpt = function(next, connection, params) {
|
||||
const plugin = this;
|
||||
const rcpt = params[0];
|
||||
|
||||
if (!rcpt || !rcpt.host) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const recipient_domain = rcpt.host.toLowerCase();
|
||||
const recipient_email = rcpt.address().toLowerCase();
|
||||
|
||||
// Check if this is for a local domain
|
||||
const is_local = domains.is_local_domain(recipient_domain);
|
||||
|
||||
if (!is_local) {
|
||||
// External domain - only allow if authenticated (relay)
|
||||
// Check if connection is authenticated (handled by auth/auth_proxy plugin)
|
||||
if (connection.relaying) {
|
||||
plugin.loginfo(`Authenticated relay to external domain: ${recipient_email}`);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Not authenticated and not local domain - reject (prevent open relay)
|
||||
plugin.logwarn(`Rejecting relay attempt to external domain: ${recipient_email}`);
|
||||
return next(constants.DENY, `Relay not permitted for ${recipient_domain}`);
|
||||
}
|
||||
|
||||
// Local domain - verify recipient exists in Phoenix
|
||||
plugin.loginfo(`Verifying recipient exists: ${recipient_email}`);
|
||||
|
||||
// Verify with Phoenix app
|
||||
httpClient.verify_recipient(plugin.cfg.verify_url, recipient_email, {
|
||||
api_key: plugin.cfg.phoenix_api_key,
|
||||
timeout: plugin.cfg.verify_timeout,
|
||||
logger: (msg) => plugin.logdebug(msg)
|
||||
})
|
||||
.then((exists) => {
|
||||
if (exists) {
|
||||
plugin.loginfo(`Recipient verified: ${recipient_email}`);
|
||||
return next(constants.OK);
|
||||
} else {
|
||||
plugin.logwarn(`Recipient does not exist: ${recipient_email}`);
|
||||
return next(constants.DENY, `Recipient ${recipient_email} does not exist`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
plugin.logerror(`Recipient verification failed: ${err.message}`);
|
||||
// On error, defer (temp fail) so the sending server retries later
|
||||
// This avoids accepting mail for potentially non-existent recipients
|
||||
// which would cause backscatter bounces
|
||||
return next(constants.DENYSOFT, 'Temporary recipient verification failure, please retry');
|
||||
});
|
||||
};
|
||||
85
plugins/elektrine_spf_enforcer.js
Normal file
85
plugins/elektrine_spf_enforcer.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Elektrine SPF Enforcer Plugin
|
||||
*
|
||||
* Strictly enforces SPF for emails claiming to be from protected domains.
|
||||
* Prevents spoofing of local domain addresses.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const constants = require('haraka-constants');
|
||||
|
||||
// Shared library modules
|
||||
const { domains } = require('../lib');
|
||||
|
||||
exports.register = function() {
|
||||
const plugin = this;
|
||||
|
||||
plugin.loginfo(`SPF Enforcer loaded - protecting: ${domains.get_local_domains().join(', ')}`);
|
||||
};
|
||||
|
||||
exports.hook_mail = function(next, connection, params) {
|
||||
const plugin = this;
|
||||
const mail_from = params[0];
|
||||
|
||||
if (!mail_from || !mail_from.host) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const from_domain = mail_from.host.toLowerCase();
|
||||
|
||||
// Check if email claims to be from one of our protected domains
|
||||
if (!domains.is_local_domain(from_domain)) {
|
||||
return next(); // Not our domain, let other plugins handle it
|
||||
}
|
||||
|
||||
// Store that this is from our domain for later checks
|
||||
connection.transaction.notes.from_protected_domain = true;
|
||||
connection.transaction.notes.protected_domain = from_domain;
|
||||
|
||||
plugin.loginfo(`Email claims to be from protected domain: ${from_domain}`);
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
exports.hook_data_post = function(next, connection) {
|
||||
const plugin = this;
|
||||
const transaction = connection.transaction;
|
||||
|
||||
// Only check emails claiming to be from our domains
|
||||
if (!transaction.notes.from_protected_domain) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const protected_domain = transaction.notes.protected_domain;
|
||||
|
||||
// Check SPF result
|
||||
const spf_result = transaction.results.get('spf');
|
||||
|
||||
if (!spf_result) {
|
||||
plugin.logwarn(`No SPF result for email from ${protected_domain} - accepting anyway`);
|
||||
return next();
|
||||
}
|
||||
|
||||
const spf_status = spf_result.result;
|
||||
|
||||
plugin.loginfo(`SPF check for ${protected_domain}: ${spf_status}`);
|
||||
|
||||
// Reject if SPF failed
|
||||
if (spf_status === 'Fail' || spf_status === 'fail') {
|
||||
plugin.logwarn(`Rejecting spoofed email from ${protected_domain} - SPF: ${spf_status}`);
|
||||
return next(constants.DENY,
|
||||
`Email rejected: SPF validation failed for ${protected_domain}. This email appears to be spoofed.`);
|
||||
}
|
||||
|
||||
// Reject on SPF errors (suspicious)
|
||||
if (spf_status === 'TempError' || spf_status === 'PermError') {
|
||||
plugin.logwarn(`Rejecting email from ${protected_domain} due to SPF error: ${spf_status}`);
|
||||
return next(constants.DENYSOFT,
|
||||
`Email temporarily rejected: SPF check failed for ${protected_domain}`);
|
||||
}
|
||||
|
||||
// Accept if SPF passed or softfail
|
||||
plugin.loginfo(`Accepting email from ${protected_domain} - SPF: ${spf_status}`);
|
||||
return next();
|
||||
};
|
||||
302
plugins/elektrine_webhook.js
Normal file
302
plugins/elektrine_webhook.js
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
/**
|
||||
* Elektrine Webhook Plugin
|
||||
*
|
||||
* Processes inbound emails and forwards them to the Phoenix application
|
||||
* via webhook. Handles email parsing, spam detection, and attachment extraction.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const constants = require('haraka-constants');
|
||||
|
||||
// Shared library modules
|
||||
const {
|
||||
config,
|
||||
http: httpClient,
|
||||
mime,
|
||||
domains,
|
||||
spam: spamExtractor,
|
||||
attachments: attachmentHandler,
|
||||
bounce: bounceDetector,
|
||||
text
|
||||
} = require('../lib');
|
||||
|
||||
// Retry configuration
|
||||
const MAX_RETRIES = 2;
|
||||
const RETRY_BASE_DELAY_MS = 1000;
|
||||
|
||||
exports.register = function() {
|
||||
const plugin = this;
|
||||
|
||||
// Load configuration from .ini file with environment variable overrides
|
||||
plugin.load_config();
|
||||
|
||||
// Validate critical config at startup
|
||||
if (!plugin.cfg.phoenix_api_key) {
|
||||
plugin.logerror('CRITICAL: Phoenix API key is not configured. Set PHOENIX_API_KEY environment variable.');
|
||||
}
|
||||
if (!plugin.cfg.webhook_url) {
|
||||
plugin.logerror('CRITICAL: Webhook URL is not configured. Set PHOENIX_WEBHOOK_URL environment variable.');
|
||||
}
|
||||
|
||||
// Initialize domain cache from Phoenix (async)
|
||||
// This fetches custom domains in addition to built-in domains
|
||||
domains.init((msg) => plugin.loginfo(`[domains] ${msg}`))
|
||||
.then(() => {
|
||||
plugin.loginfo('Domain cache initialized successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
plugin.logwarn(`Domain cache initialization failed: ${err.message}`);
|
||||
});
|
||||
|
||||
// Register for the queue event (when email is fully received)
|
||||
plugin.register_hook('queue', 'send_to_elektrine');
|
||||
};
|
||||
|
||||
exports.load_config = function() {
|
||||
const plugin = this;
|
||||
|
||||
// Load Haraka .ini config
|
||||
const haraka_cfg = plugin.config.get('elektrine_webhook.ini', {
|
||||
booleans: [
|
||||
'+main.enabled',
|
||||
'+main.include_headers',
|
||||
'+main.include_body',
|
||||
'+main.include_attachments'
|
||||
]
|
||||
}, function() {
|
||||
plugin.load_config();
|
||||
});
|
||||
|
||||
// Merge with centralized config
|
||||
plugin.cfg = config.load(haraka_cfg);
|
||||
};
|
||||
|
||||
exports.send_to_elektrine = function(next, connection) {
|
||||
const plugin = this;
|
||||
|
||||
if (!plugin.cfg.webhook_enabled) {
|
||||
return next(constants.ok, 'Webhook disabled');
|
||||
}
|
||||
|
||||
const transaction = connection.transaction;
|
||||
if (!transaction) {
|
||||
return next(constants.ok, 'No transaction');
|
||||
}
|
||||
|
||||
// Only process emails TO local domains (inbound emails)
|
||||
const is_inbound = transaction.rcpt_to.some(rcpt => {
|
||||
return domains.is_local_domain(rcpt.host);
|
||||
});
|
||||
|
||||
if (!is_inbound) {
|
||||
plugin.loginfo('Skipping outbound email - not destined for local domains');
|
||||
return next(constants.ok, 'Not inbound email');
|
||||
}
|
||||
|
||||
// Parse email and send webhook
|
||||
plugin.parse_and_send(transaction, connection, next);
|
||||
};
|
||||
|
||||
exports.parse_and_send = function(transaction, connection, next) {
|
||||
const plugin = this;
|
||||
|
||||
if (!transaction.message_stream) {
|
||||
return next(constants.ok, 'No message stream available');
|
||||
}
|
||||
|
||||
mime.read_message_stream(transaction.message_stream)
|
||||
.then((raw_buffer) => mime.parse_mime(raw_buffer, {
|
||||
logger: (level, message) => {
|
||||
if (level === 'warn') plugin.logwarn(message);
|
||||
}
|
||||
}))
|
||||
.then((parsed) => {
|
||||
// Build email data for webhook
|
||||
const email_data = plugin.build_webhook_data(transaction, connection, parsed);
|
||||
|
||||
// Check for bounce message
|
||||
if (email_data.is_bounce) {
|
||||
plugin.loginfo(`Skipping bounce message: ${email_data.subject}`);
|
||||
return next(constants.ok, 'Bounce message - not forwarding');
|
||||
}
|
||||
|
||||
// Send to Phoenix
|
||||
plugin.send_webhook(email_data, next);
|
||||
})
|
||||
.catch((err) => {
|
||||
plugin.logerror(`Email parsing failed: ${err.message}`);
|
||||
return next(constants.ok, 'Message queued despite parsing error');
|
||||
});
|
||||
};
|
||||
|
||||
function get_local_recipients(recipients) {
|
||||
const unique = new Set();
|
||||
|
||||
return (Array.isArray(recipients) ? recipients : [])
|
||||
.map((recipient) => text.normalize_header(recipient || ''))
|
||||
.filter((recipient) => recipient && domains.is_local_domain(domains.extract_domain(recipient)))
|
||||
.filter((recipient) => !unique.has(recipient) && unique.add(recipient));
|
||||
}
|
||||
|
||||
exports.build_webhook_data = function(transaction, connection, parsed) {
|
||||
const plugin = this;
|
||||
|
||||
const message_id = transaction.uuid || crypto.randomUUID();
|
||||
const mail_from = transaction.mail_from ? transaction.mail_from.address() : '';
|
||||
const rcpt_to = transaction.rcpt_to.map(rcpt => rcpt.address());
|
||||
const local_recipients = get_local_recipients(rcpt_to);
|
||||
|
||||
// Extract from email
|
||||
const from_email = text.normalize_header(parsed.from ? parsed.from.text : mail_from);
|
||||
const subject = text.normalize_header(parsed.subject || '');
|
||||
const text_body = parsed.text || '';
|
||||
const html_body = parsed.html || '';
|
||||
const to_email = text.normalize_header(parsed.to ? parsed.to.text : local_recipients[0] || rcpt_to[0] || '');
|
||||
|
||||
// Check if bounce
|
||||
const is_bounce = bounceDetector.is_bounce(from_email, subject, text_body, {
|
||||
envelope_from: mail_from
|
||||
});
|
||||
|
||||
// Extract spam info
|
||||
const spam_data = spamExtractor.extract(connection, transaction, parsed.headers);
|
||||
|
||||
// Extract attachments
|
||||
const attachment_data = attachmentHandler.extract(transaction, parsed, {
|
||||
include_content: plugin.cfg.include_attachments
|
||||
});
|
||||
|
||||
return {
|
||||
message_id: message_id,
|
||||
from: from_email,
|
||||
to: to_email,
|
||||
rcpt_to: local_recipients[0] || text.normalize_header(rcpt_to[0] || ''),
|
||||
all_local_rcpt_to: local_recipients,
|
||||
mail_from: text.normalize_header(mail_from),
|
||||
subject: subject,
|
||||
text_body: text_body,
|
||||
html_body: html_body,
|
||||
headers: plugin.cfg.include_headers ? parsed.headers : undefined,
|
||||
spam_status: spam_data.status,
|
||||
spam_score: spam_data.score,
|
||||
spam_threshold: spam_data.threshold,
|
||||
spam_report: spam_data.report,
|
||||
spam_status_header: spam_data.status_header,
|
||||
attachments: attachment_data.attachments,
|
||||
attachment_count: attachment_data.count,
|
||||
has_attachments: attachment_data.has_attachments,
|
||||
size: transaction.data_bytes,
|
||||
timestamp: new Date().toISOString(),
|
||||
is_bounce: is_bounce
|
||||
};
|
||||
};
|
||||
|
||||
exports.send_webhook = function(email_data, next) {
|
||||
const plugin = this;
|
||||
|
||||
plugin.send_webhook_payloads(plugin.expand_webhook_payloads(email_data), 0, next);
|
||||
};
|
||||
|
||||
exports.expand_webhook_payloads = function(email_data) {
|
||||
const local_recipients = get_local_recipients(email_data.all_local_rcpt_to || [email_data.rcpt_to]);
|
||||
|
||||
if (local_recipients.length <= 1) {
|
||||
return [email_data];
|
||||
}
|
||||
|
||||
return local_recipients.map((recipient) => ({
|
||||
...email_data,
|
||||
rcpt_to: recipient
|
||||
}));
|
||||
};
|
||||
|
||||
exports.send_webhook_payloads = function(payloads, index, next) {
|
||||
const plugin = this;
|
||||
|
||||
if (index >= payloads.length) {
|
||||
return next(constants.ok, 'Message accepted');
|
||||
}
|
||||
|
||||
return plugin.send_webhook_with_retry(payloads[index], 0, (code, message) => {
|
||||
if (code === constants.ok) {
|
||||
return plugin.send_webhook_payloads(payloads, index + 1, next);
|
||||
}
|
||||
|
||||
return next(code, message);
|
||||
});
|
||||
};
|
||||
|
||||
exports.send_webhook_with_retry = function(email_data, attempt, next) {
|
||||
const plugin = this;
|
||||
|
||||
httpClient.send_webhook(plugin.cfg.webhook_url, email_data, {
|
||||
api_key: plugin.cfg.phoenix_api_key,
|
||||
timeout: plugin.cfg.webhook_timeout,
|
||||
logger: (msg) => plugin.loginfo(msg)
|
||||
})
|
||||
.then((result) => {
|
||||
plugin.loginfo(`Email sent to Elektrine successfully: ${result.message_id || email_data.message_id}`);
|
||||
return next(constants.ok, 'Message accepted');
|
||||
})
|
||||
.catch((err) => {
|
||||
plugin.logerror(`Webhook failed (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${err.message}`);
|
||||
|
||||
// Don't retry client errors (4xx) - they won't succeed on retry
|
||||
if (err.status && err.status >= 400 && err.status < 500) {
|
||||
return plugin.handle_webhook_error(err.status, next);
|
||||
}
|
||||
|
||||
// Retry on server errors (5xx) and network errors
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
|
||||
plugin.loginfo(`Retrying webhook in ${delay}ms...`);
|
||||
setTimeout(() => {
|
||||
plugin.send_webhook_with_retry(email_data, attempt + 1, next);
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
if (err.status) {
|
||||
return plugin.handle_webhook_error(err.status, next);
|
||||
}
|
||||
|
||||
// Network failure after all retries - defer so sending server retries later
|
||||
plugin.logwarn('Webhook failed after all retries, deferring');
|
||||
return next(constants.denysoft, 'Temporary delivery failure, please retry');
|
||||
});
|
||||
};
|
||||
|
||||
exports.handle_webhook_error = function(status_code, next) {
|
||||
const plugin = this;
|
||||
|
||||
if (status_code === 404 || status_code === 400) {
|
||||
// Mailbox does not exist or invalid recipient - hard bounce
|
||||
plugin.loginfo('Bouncing email - mailbox does not exist or invalid recipient');
|
||||
return next(constants.deny, 'Mailbox does not exist');
|
||||
}
|
||||
|
||||
if (status_code === 401 || status_code === 403 || status_code === 429) {
|
||||
// Auth/rate-limit errors are usually operational and should not hard-bounce inbound mail.
|
||||
plugin.logwarn(`Deferring email - upstream auth/rate-limit issue (${status_code})`);
|
||||
return next(constants.denysoft, 'Temporary upstream authentication/rate-limit failure');
|
||||
}
|
||||
|
||||
if (status_code === 507) {
|
||||
// Storage limit exceeded - soft bounce
|
||||
plugin.loginfo('Deferring email - mailbox storage limit exceeded');
|
||||
return next(constants.denysoft, 'Mailbox storage limit exceeded');
|
||||
}
|
||||
|
||||
if (status_code >= 500) {
|
||||
// 5xx errors - soft bounce (temporary failure)
|
||||
plugin.loginfo('Deferring email - temporary server error');
|
||||
return next(constants.denysoft, 'Temporary server error');
|
||||
}
|
||||
|
||||
// Other 4xx errors - hard bounce
|
||||
plugin.loginfo('Bouncing email - client error');
|
||||
return next(constants.deny, 'Client error');
|
||||
};
|
||||
50
scripts/check-queues.sh
Executable file
50
scripts/check-queues.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DEPLOY_DIR="${SCRIPT_DIR}/../deployment"
|
||||
|
||||
if [ ! -d "$DEPLOY_DIR" ]; then
|
||||
echo "ERROR: deployment directory not found at ${DEPLOY_DIR}"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
compose() { docker compose "$@"; }
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
compose() { docker-compose "$@"; }
|
||||
else
|
||||
echo "ERROR: docker compose or docker-compose is required"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
QUEUE_NAME="${ELEKTRINE_QUEUE_NAME:-elektrine:inbound}"
|
||||
DLQ_NAME="${ELEKTRINE_DLQ_NAME:-elektrine:inbound:dlq}"
|
||||
QUEUE_WARN_THRESHOLD="${QUEUE_WARN_THRESHOLD:-1000}"
|
||||
DLQ_CRIT_THRESHOLD="${DLQ_CRIT_THRESHOLD:-10}"
|
||||
|
||||
inbound_depth=$(compose exec -T redis redis-cli LLEN "$QUEUE_NAME" | tr -d '\r')
|
||||
dlq_depth=$(compose exec -T redis redis-cli LLEN "$DLQ_NAME" | tr -d '\r')
|
||||
|
||||
if ! [[ "$inbound_depth" =~ ^[0-9]+$ ]] || ! [[ "$dlq_depth" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: failed to read queue depth (inbound=${inbound_depth}, dlq=${dlq_depth})"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
echo "inbound_queue=${QUEUE_NAME} depth=${inbound_depth}"
|
||||
echo "dlq_queue=${DLQ_NAME} depth=${dlq_depth}"
|
||||
|
||||
if [ "$dlq_depth" -ge "$DLQ_CRIT_THRESHOLD" ]; then
|
||||
echo "CRITICAL: DLQ depth ${dlq_depth} >= ${DLQ_CRIT_THRESHOLD}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$inbound_depth" -ge "$QUEUE_WARN_THRESHOLD" ]; then
|
||||
echo "WARNING: inbound queue depth ${inbound_depth} >= ${QUEUE_WARN_THRESHOLD}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: queue depths are within thresholds"
|
||||
exit 0
|
||||
39
scripts/configure-tls.sh
Executable file
39
scripts/configure-tls.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Configure Haraka TLS with Caddy certificates
|
||||
set -e
|
||||
|
||||
DOMAIN=${HARAKA_DOMAIN:-mail.example.com}
|
||||
CERT_PATH="/app/ssl/cert.crt"
|
||||
KEY_PATH="/app/ssl/cert.key"
|
||||
|
||||
echo "Setting up TLS for $DOMAIN"
|
||||
|
||||
# Wait for certificates
|
||||
echo "Waiting for SSL certificates..."
|
||||
timeout=300
|
||||
elapsed=0
|
||||
until [ -f "$CERT_PATH" ] && [ -f "$KEY_PATH" ]; do
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout - running without SMTP TLS"
|
||||
exit 0
|
||||
fi
|
||||
echo "Still waiting... ($elapsed/${timeout}s)"
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
echo "Certificates found, updating TLS config..."
|
||||
|
||||
# Write TLS config
|
||||
cat > /app/config/tls.ini << EOF
|
||||
[main]
|
||||
key=$KEY_PATH
|
||||
cert=$CERT_PATH
|
||||
|
||||
ciphers=ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256
|
||||
requestCert=false
|
||||
rejectUnauthorized=false
|
||||
EOF
|
||||
|
||||
echo "TLS configured - SMTP encryption enabled"
|
||||
4
scripts/deploy/baremetal_deploy.sh
Executable file
4
scripts/deploy/baremetal_deploy.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/docker_deploy.sh" "$@"
|
||||
51
scripts/deploy/docker_deploy.sh
Executable file
51
scripts/deploy/docker_deploy.sh
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DEPLOY_DIR="$ROOT_DIR/deployment"
|
||||
COMPOSE_FILE="$DEPLOY_DIR/docker-compose.same-server.yml"
|
||||
ENV_FILE="$DEPLOY_DIR/.env"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/deploy/docker_deploy.sh [--env-file deployment/.env] [docker compose args...]
|
||||
|
||||
Deploys elektrine-haraka for the same-host Docker layout.
|
||||
EOF
|
||||
}
|
||||
|
||||
ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--env-file)
|
||||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config >/dev/null
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d redis clamav spamassassin
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d haraka-inbound haraka-submission haraka-outbound haraka-worker cert-copier
|
||||
|
||||
if [[ "${#ARGS[@]}" -gt 0 ]]; then
|
||||
exec docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "${ARGS[@]}"
|
||||
fi
|
||||
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps
|
||||
304
scripts/elektrine-worker.js
Executable file
304
scripts/elektrine-worker.js
Executable file
|
|
@ -0,0 +1,304 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Elektrine async worker.
|
||||
*
|
||||
* Consumes queued inbound RFC822 messages from Redis, parses them,
|
||||
* and delivers normalized payloads to the Phoenix webhook endpoint.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
config,
|
||||
http,
|
||||
mime,
|
||||
spam,
|
||||
attachments,
|
||||
bounce,
|
||||
text,
|
||||
domains
|
||||
} = require('../lib');
|
||||
const queue = require('../lib/queue-client');
|
||||
const telemetry = require('../lib/telemetry');
|
||||
|
||||
const cfg = config.load();
|
||||
const logger = telemetry.create_console_logger('worker');
|
||||
const queue_client = new queue.QueueClient(cfg, logger);
|
||||
|
||||
let shutting_down = false;
|
||||
const counters = {
|
||||
consumed: 0,
|
||||
delivered: 0,
|
||||
skipped_bounce: 0,
|
||||
retried: 0,
|
||||
failed: 0,
|
||||
dlq: 0
|
||||
};
|
||||
|
||||
function as_header_object(headers) {
|
||||
if (!(headers instanceof Map)) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const out = {};
|
||||
headers.forEach((value, key) => {
|
||||
if (value === undefined || value === null) return;
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
out[key] = typeof value === 'string' ? text.normalize_header(value) : value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
out[key] = value.map((item) => String(item)).join(', ');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value.text === 'string') {
|
||||
out[key] = text.normalize_header(value.text);
|
||||
return;
|
||||
}
|
||||
|
||||
out[key] = String(value);
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function build_spam_context(envelope) {
|
||||
const notes = {};
|
||||
if (envelope.spamassassin) {
|
||||
notes.spamassassin = {
|
||||
score: envelope.spamassassin.score,
|
||||
reqd: envelope.spamassassin.required,
|
||||
flag: envelope.spamassassin.flag,
|
||||
tests: envelope.spamassassin.tests
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
transaction: { notes },
|
||||
connection: { notes: {} }
|
||||
};
|
||||
}
|
||||
|
||||
function get_local_recipients(recipients) {
|
||||
const unique = new Set();
|
||||
|
||||
return (Array.isArray(recipients) ? recipients : [])
|
||||
.map((recipient) => text.normalize_header(recipient || ''))
|
||||
.filter((recipient) => recipient && domains.is_local_domain(domains.extract_domain(recipient)))
|
||||
.filter((recipient) => !unique.has(recipient) && unique.add(recipient));
|
||||
}
|
||||
|
||||
function build_webhook_data(envelope, parsed, recipient = null) {
|
||||
const from_email = text.normalize_header(parsed.from ? parsed.from.text : envelope.mail_from);
|
||||
const subject = text.normalize_header(parsed.subject || '');
|
||||
const text_body = cfg.include_body ? (parsed.text || '') : '';
|
||||
const html_body = cfg.include_body ? (parsed.html || '') : '';
|
||||
const local_recipients = get_local_recipients(envelope.rcpt_to);
|
||||
const effective_recipient = text.normalize_header(recipient || local_recipients[0] || (envelope.rcpt_to && envelope.rcpt_to[0]) || '');
|
||||
const to_email = text.normalize_header(parsed.to ? parsed.to.text : effective_recipient);
|
||||
const rcpt_to = effective_recipient;
|
||||
const mail_from = text.normalize_header(envelope.mail_from || '');
|
||||
|
||||
const spam_context = build_spam_context(envelope);
|
||||
const spam_data = spam.extract(spam_context.connection, spam_context.transaction, parsed.headers);
|
||||
const attachment_data = attachments.extract(null, parsed, {
|
||||
include_content: cfg.include_attachments
|
||||
});
|
||||
|
||||
return {
|
||||
message_id: envelope.message_id,
|
||||
from: from_email,
|
||||
to: to_email,
|
||||
rcpt_to: rcpt_to,
|
||||
mail_from: mail_from,
|
||||
subject,
|
||||
text_body,
|
||||
html_body,
|
||||
headers: cfg.include_headers ? as_header_object(parsed.headers) : undefined,
|
||||
spam_status: spam_data.status,
|
||||
spam_score: spam_data.score,
|
||||
spam_threshold: spam_data.threshold,
|
||||
spam_report: spam_data.report,
|
||||
spam_status_header: spam_data.status_header,
|
||||
attachments: attachment_data.attachments,
|
||||
attachment_count: attachment_data.count,
|
||||
has_attachments: attachment_data.has_attachments,
|
||||
size: envelope.data_bytes,
|
||||
timestamp: new Date().toISOString(),
|
||||
is_bounce: bounce.is_bounce(from_email, subject, text_body, {
|
||||
envelope_from: mail_from
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function send_webhook_with_retry(payload) {
|
||||
let attempt = 0;
|
||||
while (attempt <= cfg.webhook_max_retries) {
|
||||
try {
|
||||
await http.send_webhook(cfg.webhook_url, payload, {
|
||||
api_key: cfg.phoenix_api_key,
|
||||
timeout: cfg.webhook_timeout,
|
||||
headers: {
|
||||
'X-Message-Id': payload.message_id,
|
||||
'X-Idempotency-Key': payload.message_id
|
||||
},
|
||||
logger: (message) => logger.debug('http_client', { message })
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
const status = err.status || null;
|
||||
const is_permanent_4xx = status >= 400 && status < 500 && status !== 429;
|
||||
const exhausted = attempt >= cfg.webhook_max_retries;
|
||||
|
||||
if (is_permanent_4xx || exhausted) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
counters.retried += 1;
|
||||
const delay_ms = cfg.webhook_retry_base_delay_ms * Math.pow(2, attempt);
|
||||
logger.warn('webhook_retry', {
|
||||
message_id: payload.message_id,
|
||||
attempt: attempt + 1,
|
||||
status,
|
||||
delay_ms,
|
||||
message: err.message
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay_ms));
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function push_dlq(envelope, error) {
|
||||
const dlq_payload = {
|
||||
failed_at: new Date().toISOString(),
|
||||
message_id: envelope.message_id,
|
||||
error: {
|
||||
status: error.status || null,
|
||||
message: error.message
|
||||
},
|
||||
payload: envelope
|
||||
};
|
||||
|
||||
await queue_client.enqueue_dlq(cfg.queue_dlq_name, dlq_payload);
|
||||
counters.dlq += 1;
|
||||
}
|
||||
|
||||
async function process_message(raw_element) {
|
||||
counters.consumed += 1;
|
||||
|
||||
let envelope;
|
||||
try {
|
||||
envelope = JSON.parse(raw_element);
|
||||
} catch (err) {
|
||||
counters.failed += 1;
|
||||
logger.error('invalid_queue_payload', { message: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw_buffer = Buffer.from(envelope.raw_rfc822_base64 || '', 'base64');
|
||||
const parsed = await mime.parse_mime(raw_buffer, {
|
||||
logger: (level, message) => {
|
||||
if (level === 'warn') logger.warn('mime_parse_fallback', { message });
|
||||
}
|
||||
});
|
||||
const local_recipients = get_local_recipients(envelope.rcpt_to);
|
||||
const target_recipients = local_recipients.length > 0 ? local_recipients : [null];
|
||||
const sample_payload = build_webhook_data(envelope, parsed, target_recipients[0]);
|
||||
|
||||
if (sample_payload.is_bounce) {
|
||||
counters.skipped_bounce += 1;
|
||||
logger.info('skip_bounce', {
|
||||
message_id: sample_payload.message_id,
|
||||
subject: sample_payload.subject
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const recipient of target_recipients) {
|
||||
const webhook_payload = build_webhook_data(envelope, parsed, recipient);
|
||||
|
||||
await send_webhook_with_retry(webhook_payload);
|
||||
counters.delivered += 1;
|
||||
logger.info('webhook_delivered', {
|
||||
message_id: webhook_payload.message_id,
|
||||
rcpt_to: webhook_payload.rcpt_to,
|
||||
attachment_count: webhook_payload.attachment_count
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
counters.failed += 1;
|
||||
logger.error('process_failed', {
|
||||
message_id: envelope.message_id,
|
||||
status: err.status || null,
|
||||
message: err.message
|
||||
});
|
||||
|
||||
try {
|
||||
await push_dlq(envelope, err);
|
||||
} catch (dlq_err) {
|
||||
logger.error('dlq_write_failed', {
|
||||
message_id: envelope.message_id,
|
||||
message: dlq_err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!cfg.phoenix_api_key || !cfg.webhook_url) {
|
||||
logger.error('missing_required_config', {
|
||||
has_api_key: Boolean(cfg.phoenix_api_key),
|
||||
webhook_url: cfg.webhook_url || null
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('worker_start', {
|
||||
queue_name: cfg.queue_name,
|
||||
dlq_name: cfg.queue_dlq_name,
|
||||
redis_url: cfg.redis_url,
|
||||
webhook_url: cfg.webhook_url,
|
||||
max_retries: cfg.webhook_max_retries
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
logger.info('worker_stats', { ...counters });
|
||||
}, 60000);
|
||||
|
||||
while (!shutting_down) {
|
||||
try {
|
||||
const raw_element = await queue_client.pop(cfg.queue_name, cfg.queue_pop_timeout_sec);
|
||||
if (!raw_element) continue;
|
||||
await process_message(raw_element);
|
||||
} catch (err) {
|
||||
logger.error('worker_loop_error', { message: err.message });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
await queue_client.close();
|
||||
logger.info('worker_stop');
|
||||
}
|
||||
|
||||
function start_shutdown(signal) {
|
||||
if (shutting_down) return;
|
||||
shutting_down = true;
|
||||
logger.warn('shutdown_signal', { signal });
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => start_shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => start_shutdown('SIGINT'));
|
||||
|
||||
run().catch((err) => {
|
||||
logger.error('worker_fatal', { message: err.message, stack: err.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
51
scripts/generate-dkim-keys.sh
Normal file
51
scripts/generate-dkim-keys.sh
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
domains=("$@")
|
||||
else
|
||||
domains=(
|
||||
example.com
|
||||
)
|
||||
fi
|
||||
|
||||
echo "Generating DKIM keys for: ${domains[*]}"
|
||||
|
||||
mkdir -p config/dkim
|
||||
|
||||
for domain in "${domains[@]}"; do
|
||||
echo "Generating keys for ${domain}..."
|
||||
openssl genrsa -out "config/dkim/${domain}.key" 2048
|
||||
openssl rsa -in "config/dkim/${domain}.key" -pubout -out "config/dkim/${domain}.pub"
|
||||
done
|
||||
|
||||
echo "Keys generated successfully!"
|
||||
echo ""
|
||||
echo "DNS Records to add:"
|
||||
echo "==================="
|
||||
|
||||
for domain in "${domains[@]}"; do
|
||||
pubkey=$(openssl rsa -in "config/dkim/${domain}.key" -pubout -outform DER 2>/dev/null | openssl base64 -A | tr -d '\n')
|
||||
echo ""
|
||||
echo "For ${domain}:"
|
||||
echo "default._domainkey.${domain}. TXT \"v=DKIM1; k=rsa; p=${pubkey}\""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "SPF Records:"
|
||||
for domain in "${domains[@]}"; do
|
||||
echo "${domain}. TXT \"v=spf1 ip4:YOUR_VPS_IP ~all\""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "DMARC Records:"
|
||||
for domain in "${domains[@]}"; do
|
||||
echo "_dmarc.${domain}. TXT \"v=DMARC1; p=quarantine; rua=mailto:dmarc@${domain}\""
|
||||
done
|
||||
|
||||
chmod 600 config/dkim/*.key
|
||||
chmod 644 config/dkim/*.pub
|
||||
|
||||
echo ""
|
||||
echo "Key permissions set. Private keys are now readable only by owner."
|
||||
64
scripts/init-ssl.sh
Executable file
64
scripts/init-ssl.sh
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
#!/bin/bash
|
||||
|
||||
# SSL Certificate Initialization Script for elektrine-haraka
|
||||
# This script runs inside Docker and sets up Let's Encrypt SSL certificates
|
||||
|
||||
set -e
|
||||
|
||||
DOMAIN="${HARAKA_DOMAIN:-mail.example.com}"
|
||||
EMAIL="${SSL_EMAIL:-admin@example.com}"
|
||||
STAGING=${STAGING:-0}
|
||||
|
||||
echo "🔐 Setting up SSL certificates for $DOMAIN"
|
||||
|
||||
# Check if certificates already exist
|
||||
if [ -d "/etc/letsencrypt/live/$DOMAIN" ]; then
|
||||
echo "✅ SSL certificates already exist for $DOMAIN"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📋 Starting SSL certificate setup..."
|
||||
|
||||
# Wait for nginx to be ready
|
||||
sleep 10
|
||||
|
||||
# Request certificate using certbot
|
||||
echo "📄 Requesting SSL certificate..."
|
||||
if [ "$STAGING" = "1" ]; then
|
||||
echo "⚠️ Using Let's Encrypt staging environment"
|
||||
STAGING_FLAG="--staging"
|
||||
else
|
||||
STAGING_FLAG=""
|
||||
fi
|
||||
|
||||
# Use certbot with webroot method
|
||||
certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path=/var/www/certbot \
|
||||
--email $EMAIL \
|
||||
--agree-tos \
|
||||
--no-eff-email \
|
||||
--non-interactive \
|
||||
$STAGING_FLAG \
|
||||
-d $DOMAIN
|
||||
|
||||
echo "✅ SSL certificate obtained successfully!"
|
||||
|
||||
# Update Haraka TLS configuration to use the certificates
|
||||
echo "🔧 Updating Haraka TLS configuration..."
|
||||
cat > /etc/letsencrypt/haraka-tls.ini << EOF
|
||||
[main]
|
||||
# Let's Encrypt certificates (mounted from Docker volume)
|
||||
key=/app/ssl/live/$DOMAIN/privkey.pem
|
||||
cert=/app/ssl/live/$DOMAIN/fullchain.pem
|
||||
|
||||
# Security settings
|
||||
ciphers=ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256
|
||||
requestCert=false
|
||||
rejectUnauthorized=false
|
||||
|
||||
# TLS versions
|
||||
# secureProtocol=TLSv1_2_method
|
||||
EOF
|
||||
|
||||
echo "🎉 SSL setup complete! Certificates ready for use."
|
||||
64
scripts/setup-firewall.sh
Normal file
64
scripts/setup-firewall.sh
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Firewall Setup for elektrine-haraka
|
||||
# Run this script on your host server (not in Docker)
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔥 Setting up UFW firewall rules for elektrine-haraka..."
|
||||
|
||||
# Reset UFW to defaults
|
||||
sudo ufw --force reset
|
||||
|
||||
# Default policies
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
|
||||
# SSH (adjust port if needed)
|
||||
sudo ufw allow ssh
|
||||
echo "✅ SSH access allowed"
|
||||
|
||||
# HTTP/HTTPS (port 80 needed for Let's Encrypt challenges)
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
echo "✅ HTTP/HTTPS traffic allowed (port 80 required for SSL certificates)"
|
||||
|
||||
# SMTP ports
|
||||
sudo ufw allow 25/tcp
|
||||
sudo ufw allow 587/tcp
|
||||
echo "✅ SMTP traffic allowed"
|
||||
|
||||
# Rate limiting for SMTP (prevent abuse)
|
||||
sudo ufw limit 25/tcp
|
||||
sudo ufw limit 587/tcp
|
||||
echo "✅ SMTP rate limiting enabled"
|
||||
|
||||
# Allow localhost connections (for API)
|
||||
sudo ufw allow from 127.0.0.1
|
||||
sudo ufw allow from ::1
|
||||
echo "✅ Localhost traffic allowed"
|
||||
|
||||
# Optional: Allow specific IP ranges for management
|
||||
# sudo ufw allow from 192.168.1.0/24
|
||||
# sudo ufw allow from 10.0.0.0/8
|
||||
|
||||
# Enable UFW
|
||||
sudo ufw --force enable
|
||||
|
||||
# Show status
|
||||
sudo ufw status verbose
|
||||
|
||||
echo ""
|
||||
echo "🎉 Firewall setup complete!"
|
||||
echo ""
|
||||
echo "Open ports:"
|
||||
echo " 22/tcp - SSH"
|
||||
echo " 25/tcp - SMTP (rate limited)"
|
||||
echo " 80/tcp - HTTP (required for Let's Encrypt SSL certificates)"
|
||||
echo " 443/tcp - HTTPS"
|
||||
echo " 587/tcp - SMTP Submission (rate limited)"
|
||||
echo ""
|
||||
echo "All other ports are blocked by default."
|
||||
echo ""
|
||||
echo "To allow additional IPs for management:"
|
||||
echo " sudo ufw allow from YOUR.IP.ADDRESS.HERE"
|
||||
55
scripts/setup.sh
Executable file
55
scripts/setup.sh
Executable file
|
|
@ -0,0 +1,55 @@
|
|||
#!/bin/bash
|
||||
|
||||
# elektrine-haraka Setup Script
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/../deployment"
|
||||
|
||||
echo "=== elektrine-haraka Setup ==="
|
||||
|
||||
# Check if .env file exists, if not create a template
|
||||
if [ ! -f .env ]; then
|
||||
echo "Creating .env file template..."
|
||||
cat > .env << EOF_INNER
|
||||
HARAKA_DOMAIN=your-domain.com
|
||||
PHOENIX_API_KEY=your-haraka-to-phoenix-key
|
||||
HARAKA_HTTP_API_KEY=your-phoenix-to-haraka-key
|
||||
PHOENIX_WEBHOOK_URL=https://your-app.com/api/haraka/inbound
|
||||
PHOENIX_VERIFY_URL=https://your-app.com/api/haraka/verify-recipient
|
||||
PHOENIX_DOMAINS_URL=https://your-app.com/api/haraka/domains
|
||||
REDIS_URL=redis://redis:6379
|
||||
ELEKTRINE_QUEUE_NAME=elektrine:inbound
|
||||
ELEKTRINE_DLQ_NAME=elektrine:inbound:dlq
|
||||
EOF_INNER
|
||||
echo ""
|
||||
echo "Please edit .env file with your actual values:"
|
||||
echo " - HARAKA_DOMAIN: Your email server domain"
|
||||
echo " - PHOENIX_API_KEY: Key used by Haraka to call Phoenix endpoints"
|
||||
echo " - HARAKA_HTTP_API_KEY: Key required by Haraka HTTP API for send calls"
|
||||
echo " - PHOENIX_WEBHOOK_URL: Your Phoenix app webhook endpoint"
|
||||
echo " - PHOENIX_VERIFY_URL: Recipient verification endpoint"
|
||||
echo " - PHOENIX_DOMAINS_URL: Domain cache endpoint"
|
||||
echo ""
|
||||
echo "Then run this script again."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Starting elektrine-haraka services..."
|
||||
docker compose up -d
|
||||
|
||||
echo "Services starting up..."
|
||||
sleep 5
|
||||
|
||||
echo "Setup complete!"
|
||||
echo ""
|
||||
echo "Your email server is running at: https://$(grep HARAKA_DOMAIN .env | cut -d= -f2)"
|
||||
echo "Status endpoint: https://$(grep HARAKA_DOMAIN .env | cut -d= -f2)/status"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " docker compose logs -f haraka-inbound # View inbound logs"
|
||||
echo " docker compose logs -f haraka-submission # View submission logs"
|
||||
echo " docker compose logs -f haraka-outbound # View outbound API logs"
|
||||
echo " docker compose logs -f haraka-worker # View worker logs"
|
||||
echo " docker compose logs -f caddy # View Caddy logs"
|
||||
echo " docker compose ps # Check service status"
|
||||
echo " docker compose down # Stop all services"
|
||||
198
scripts/start-haraka.sh
Executable file
198
scripts/start-haraka.sh
Executable file
|
|
@ -0,0 +1,198 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROLE="${HARAKA_ROLE:-inbound-mx}"
|
||||
SOURCE_CONFIG_DIR="/app/config"
|
||||
SOURCE_PLUGINS_DIR="/app/plugins"
|
||||
SOURCE_LIB_DIR="/app/lib"
|
||||
SOURCE_NODE_MODULES_DIR="/app/node_modules"
|
||||
RUNTIME_ROOT_DIR="/tmp/haraka-config-${ROLE}"
|
||||
RUNTIME_CONFIG_DIR="$RUNTIME_ROOT_DIR/config"
|
||||
RUNTIME_PLUGINS_DIR="$RUNTIME_ROOT_DIR/plugins"
|
||||
RUNTIME_LIB_DIR="$RUNTIME_ROOT_DIR/lib"
|
||||
RUNTIME_NODE_MODULES_DIR="$RUNTIME_ROOT_DIR/node_modules"
|
||||
PERSISTENT_DKIM_DIR="${HARAKA_DKIM_DIR:-}"
|
||||
|
||||
rm -rf "$RUNTIME_ROOT_DIR"
|
||||
mkdir -p "$RUNTIME_CONFIG_DIR"
|
||||
cp -R "$SOURCE_CONFIG_DIR"/. "$RUNTIME_CONFIG_DIR"/
|
||||
|
||||
escape_sed() {
|
||||
printf '%s' "$1" | sed 's/[\/&]/\\&/g'
|
||||
}
|
||||
|
||||
trim_value() {
|
||||
printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
append_domain() {
|
||||
candidate="$(trim_value "$1")"
|
||||
[ -n "$candidate" ] || return 0
|
||||
|
||||
case " $BOOTSTRAP_DKIM_DOMAINS " in
|
||||
*" $candidate "*) ;;
|
||||
*) BOOTSTRAP_DKIM_DOMAINS="$BOOTSTRAP_DKIM_DOMAINS $candidate" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
generate_dkim_private_key() {
|
||||
domain="$1"
|
||||
domain_dir="$2"
|
||||
private_path="$domain_dir/private"
|
||||
selector_path="$domain_dir/selector"
|
||||
|
||||
[ -f "$private_path" ] && [ -f "$selector_path" ] && return 0
|
||||
|
||||
mkdir -p "$domain_dir"
|
||||
|
||||
if [ ! -f "$private_path" ]; then
|
||||
openssl genrsa -out "$private_path" 2048 >/dev/null 2>&1
|
||||
chmod 600 "$private_path"
|
||||
fi
|
||||
|
||||
if [ ! -f "$selector_path" ]; then
|
||||
printf 'default\n' > "$selector_path"
|
||||
chmod 644 "$selector_path"
|
||||
fi
|
||||
|
||||
echo "Generated DKIM key for $domain" >&2
|
||||
}
|
||||
|
||||
HARAKA_DOMAIN_VALUE="${HARAKA_DOMAIN:-mail.example.com}"
|
||||
PHOENIX_WEBHOOK_URL_VALUE="${PHOENIX_WEBHOOK_URL:-}"
|
||||
PHOENIX_VERIFY_URL_VALUE="${PHOENIX_VERIFY_URL:-}"
|
||||
PHOENIX_DOMAINS_URL_VALUE="${PHOENIX_DOMAINS_URL:-}"
|
||||
PRIMARY_DOMAIN_VALUE="${PRIMARY_DOMAIN:-}"
|
||||
EMAIL_DOMAIN_VALUE="${EMAIL_DOMAIN:-}"
|
||||
SUPPORTED_DOMAINS_VALUE="${SUPPORTED_DOMAINS:-${EMAIL_SUPPORTED_DOMAINS:-}}"
|
||||
BOOTSTRAP_DKIM_DOMAINS=""
|
||||
|
||||
append_domain "$PRIMARY_DOMAIN_VALUE"
|
||||
append_domain "$EMAIL_DOMAIN_VALUE"
|
||||
|
||||
if [ -n "$SUPPORTED_DOMAINS_VALUE" ]; then
|
||||
OLD_IFS="$IFS"
|
||||
IFS=','
|
||||
for raw_domain in $SUPPORTED_DOMAINS_VALUE; do
|
||||
append_domain "$raw_domain"
|
||||
done
|
||||
IFS="$OLD_IFS"
|
||||
fi
|
||||
|
||||
sed -i "s/example\.com/$(escape_sed "$HARAKA_DOMAIN_VALUE")/g" "$RUNTIME_CONFIG_DIR/elektrine.ini"
|
||||
sed -i "s/app\.example\.com/host.docker.internal/g" "$RUNTIME_CONFIG_DIR/auth_proxy.ini"
|
||||
|
||||
if [ -n "$PHOENIX_WEBHOOK_URL_VALUE" ]; then
|
||||
sed -i "s#https://app\.example\.com/api/haraka/inbound#$(escape_sed "$PHOENIX_WEBHOOK_URL_VALUE")#g" "$RUNTIME_CONFIG_DIR/elektrine.ini"
|
||||
fi
|
||||
|
||||
if [ -n "$PHOENIX_VERIFY_URL_VALUE" ]; then
|
||||
sed -i "s#https://app\.example\.com/api/haraka/verify-recipient#$(escape_sed "$PHOENIX_VERIFY_URL_VALUE")#g" "$RUNTIME_CONFIG_DIR/elektrine.ini"
|
||||
fi
|
||||
|
||||
if [ -n "$PHOENIX_DOMAINS_URL_VALUE" ]; then
|
||||
sed -i "s#https://app\.example\.com/api/haraka/domains#$(escape_sed "$PHOENIX_DOMAINS_URL_VALUE")#g" "$RUNTIME_CONFIG_DIR/elektrine.ini"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$HARAKA_DOMAIN_VALUE" > "$RUNTIME_CONFIG_DIR/host_list"
|
||||
|
||||
if [ -n "$PERSISTENT_DKIM_DIR" ]; then
|
||||
mkdir -p "$PERSISTENT_DKIM_DIR"
|
||||
if [ -d "$SOURCE_CONFIG_DIR/dkim" ] && [ -z "$(ls -A "$PERSISTENT_DKIM_DIR" 2>/dev/null)" ]; then
|
||||
cp -R "$SOURCE_CONFIG_DIR"/dkim/. "$PERSISTENT_DKIM_DIR"/
|
||||
fi
|
||||
rm -rf "$RUNTIME_CONFIG_DIR/dkim"
|
||||
ln -s "$PERSISTENT_DKIM_DIR" "$RUNTIME_CONFIG_DIR/dkim"
|
||||
fi
|
||||
|
||||
if [ -d "$RUNTIME_CONFIG_DIR/dkim" ]; then
|
||||
for domain in $BOOTSTRAP_DKIM_DOMAINS; do
|
||||
generate_dkim_private_key "$domain" "$RUNTIME_CONFIG_DIR/dkim/$domain"
|
||||
done
|
||||
|
||||
for key_file in "$RUNTIME_CONFIG_DIR"/dkim/*.key; do
|
||||
[ -f "$key_file" ] || break
|
||||
domain="$(basename "$key_file" .key)"
|
||||
domain_dir="$RUNTIME_CONFIG_DIR/dkim/$domain"
|
||||
mkdir -p "$domain_dir"
|
||||
if [ ! -f "$domain_dir/private" ]; then
|
||||
cp "$key_file" "$domain_dir/private"
|
||||
fi
|
||||
if [ ! -f "$domain_dir/selector" ]; then
|
||||
printf 'default\n' > "$domain_dir/selector"
|
||||
fi
|
||||
done
|
||||
|
||||
find "$RUNTIME_CONFIG_DIR/dkim" -mindepth 1 -maxdepth 1 -type d -exec chmod 755 {} +
|
||||
find "$RUNTIME_CONFIG_DIR/dkim" -type f -name private -exec chmod 644 {} +
|
||||
find "$RUNTIME_CONFIG_DIR/dkim" -type f -name selector -exec chmod 644 {} +
|
||||
fi
|
||||
|
||||
# Haraka resolves custom plugins and local requires relative to the runtime root
|
||||
# passed with `-c`. Link shared code/dependencies there so local plugins load.
|
||||
ln -s "$SOURCE_PLUGINS_DIR" "$RUNTIME_PLUGINS_DIR"
|
||||
ln -s "$SOURCE_LIB_DIR" "$RUNTIME_LIB_DIR"
|
||||
if [ -d "$SOURCE_NODE_MODULES_DIR" ]; then
|
||||
ln -s "$SOURCE_NODE_MODULES_DIR" "$RUNTIME_NODE_MODULES_DIR"
|
||||
fi
|
||||
|
||||
PLUGINS_PROFILE="$RUNTIME_CONFIG_DIR/plugins.${ROLE}"
|
||||
SMTP_PROFILE="$RUNTIME_CONFIG_DIR/smtp.${ROLE}.ini"
|
||||
LOG_PROFILE="$RUNTIME_CONFIG_DIR/log.${ROLE}.ini"
|
||||
PLUGINS_FILE="$RUNTIME_CONFIG_DIR/plugins"
|
||||
|
||||
if [ -f "$PLUGINS_PROFILE" ]; then
|
||||
cp "$PLUGINS_PROFILE" "$PLUGINS_FILE"
|
||||
else
|
||||
echo "WARN: Role plugins profile not found: $PLUGINS_PROFILE" >&2
|
||||
if [ ! -f "$PLUGINS_FILE" ]; then
|
||||
echo "ERROR: No fallback plugins file found at $PLUGINS_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "WARN: Falling back to $PLUGINS_FILE" >&2
|
||||
fi
|
||||
|
||||
# The karma plugin version in this image is incompatible with Haraka 3.1.1.
|
||||
if grep -Eq '^[[:space:]]*karma[[:space:]]*$' "$PLUGINS_FILE"; then
|
||||
echo "WARN: Removing incompatible plugin 'karma' from runtime plugins list" >&2
|
||||
awk '!/^[[:space:]]*karma[[:space:]]*$/' "$PLUGINS_FILE" > "$PLUGINS_FILE.tmp"
|
||||
mv "$PLUGINS_FILE.tmp" "$PLUGINS_FILE"
|
||||
fi
|
||||
|
||||
if [ -f "$SMTP_PROFILE" ]; then
|
||||
cp "$SMTP_PROFILE" "$RUNTIME_CONFIG_DIR/smtp.ini"
|
||||
fi
|
||||
|
||||
if [ -f "$LOG_PROFILE" ]; then
|
||||
cp "$LOG_PROFILE" "$RUNTIME_CONFIG_DIR/log.ini"
|
||||
fi
|
||||
|
||||
TLS_KEY_PATH="/app/ssl/cert.key"
|
||||
TLS_CERT_PATH="/app/ssl/cert.crt"
|
||||
|
||||
if [ ! -f "$TLS_KEY_PATH" ] || [ ! -f "$TLS_CERT_PATH" ]; then
|
||||
TLS_KEY_PATH="$RUNTIME_CONFIG_DIR/tls_key.pem"
|
||||
TLS_CERT_PATH="$RUNTIME_CONFIG_DIR/tls_cert.pem"
|
||||
TLS_DOMAIN="${HARAKA_DOMAIN:-localhost}"
|
||||
|
||||
echo "WARN: TLS certificates not found, generating temporary self-signed certificate for role=$ROLE" >&2
|
||||
openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 30 \
|
||||
-subj "/CN=$TLS_DOMAIN" \
|
||||
-keyout "$TLS_KEY_PATH" \
|
||||
-out "$TLS_CERT_PATH" >/dev/null 2>&1
|
||||
|
||||
cat > "$RUNTIME_CONFIG_DIR/tls.ini" <<EOF
|
||||
[main]
|
||||
key=$TLS_KEY_PATH
|
||||
cert=$TLS_CERT_PATH
|
||||
minVersion=TLSv1.2
|
||||
maxVersion=TLSv1.3
|
||||
requestCert=false
|
||||
rejectUnauthorized=false
|
||||
secureRenegotiation=true
|
||||
honorCipherOrder=true
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Starting Haraka with role=$ROLE"
|
||||
exec haraka -c "$RUNTIME_ROOT_DIR"
|
||||
41
scripts/sync-caddy-certs.sh
Normal file
41
scripts/sync-caddy-certs.sh
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
DOMAIN="${HARAKA_DOMAIN:-mail.example.com}"
|
||||
SSL_DIR="/app/ssl"
|
||||
mkdir -p "$SSL_DIR"
|
||||
|
||||
find_cert_dir() {
|
||||
for base in \
|
||||
"/root/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory" \
|
||||
"/root/.local/share/caddy/certificates/acme-staging-v02.api.letsencrypt.org-directory" \
|
||||
"/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory" \
|
||||
"/data/caddy/certificates/acme-staging-v02.api.letsencrypt.org-directory"
|
||||
do
|
||||
if [ -d "$base/$DOMAIN" ]; then
|
||||
printf '%s\n' "$base/$DOMAIN"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
copy_if_present() {
|
||||
cert_dir="$(find_cert_dir || true)"
|
||||
|
||||
if [ -n "$cert_dir" ] && [ -f "$cert_dir/$DOMAIN.crt" ] && [ -f "$cert_dir/$DOMAIN.key" ]; then
|
||||
cp "$cert_dir/$DOMAIN.crt" "$SSL_DIR/cert.crt"
|
||||
cp "$cert_dir/$DOMAIN.key" "$SSL_DIR/cert.key"
|
||||
chmod 644 "$SSL_DIR/cert.crt"
|
||||
chmod 600 "$SSL_DIR/cert.key"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
while true; do
|
||||
copy_if_present || true
|
||||
sleep 15
|
||||
done
|
||||
173
test/email-builder.test.js
Normal file
173
test/email-builder.test.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('crypto');
|
||||
const { simpleParser } = require('mailparser');
|
||||
const libmime = require('libmime');
|
||||
|
||||
const builder = require('../lib/email-builder');
|
||||
|
||||
const BASE = {
|
||||
from: 'sender@elektrine.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'test',
|
||||
text_body: 'hello'
|
||||
};
|
||||
|
||||
// Every physical line of a built message must respect the RFC 5322 hard limit.
|
||||
function assert_line_limit(email_content) {
|
||||
for (const line of email_content.split('\r\n')) {
|
||||
assert.ok(
|
||||
Buffer.byteLength(line, 'utf8') <= 998,
|
||||
`line exceeds 998 octets: ${line.slice(0, 80)}...`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('round-trips display names, unicode subject and bodies', async () => {
|
||||
const { email_content, message_id } = builder.build({
|
||||
from: '"Maxfield" <maxfield@elektrine.com>',
|
||||
to: 'Ada Lovelace <ada@example.com>',
|
||||
cc: 'Grüße Müller <mueller@example.com>',
|
||||
subject: 'Grüße aus Zürich — 日本語テスト',
|
||||
text_body: 'Plain body with unicode ✓ and a long stretch ' + 'x'.repeat(300),
|
||||
html_body: '<p>HTML body ✓ — ünïcödé</p>'
|
||||
});
|
||||
|
||||
assert_line_limit(email_content);
|
||||
const parsed = await simpleParser(email_content);
|
||||
|
||||
assert.equal(parsed.from.value[0].name, 'Maxfield');
|
||||
assert.equal(parsed.from.value[0].address, 'maxfield@elektrine.com');
|
||||
assert.equal(parsed.to.value[0].name, 'Ada Lovelace');
|
||||
assert.equal(parsed.to.value[0].address, 'ada@example.com');
|
||||
assert.equal(parsed.cc.value[0].name, 'Grüße Müller');
|
||||
assert.equal(parsed.subject, 'Grüße aus Zürich — 日本語テスト');
|
||||
assert.equal(parsed.text.trim(), 'Plain body with unicode ✓ and a long stretch ' + 'x'.repeat(300));
|
||||
assert.equal(parsed.html.trim(), '<p>HTML body ✓ — ünïcödé</p>');
|
||||
assert.ok(parsed.messageId.includes(message_id));
|
||||
assert.ok(parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime()));
|
||||
});
|
||||
|
||||
test('drops custom headers that could override structure or identity', async () => {
|
||||
const { email_content } = builder.build({
|
||||
...BASE,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/mixed; boundary=evil',
|
||||
'Content-Transfer-Encoding': '7bit',
|
||||
'Reply-To': 'attacker@evil.example',
|
||||
'Sender': 'attacker@evil.example',
|
||||
'Return-Path': 'attacker@evil.example',
|
||||
'X-Campaign': 'summer-2026'
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(!email_content.includes('boundary=evil'));
|
||||
assert.ok(!email_content.includes('attacker@evil.example'));
|
||||
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.headers.get('x-campaign'), 'summer-2026');
|
||||
// The body's real Content-Type must be the only one.
|
||||
assert.match(parsed.headers.get('content-type').value, /^text\/plain$/);
|
||||
});
|
||||
|
||||
test('MIME-encodes non-ASCII custom header values', async () => {
|
||||
const { email_content } = builder.build({
|
||||
...BASE,
|
||||
headers: { 'X-Note': 'wörld ünïcödé' }
|
||||
});
|
||||
|
||||
// The raw header block (before the blank line) must be pure ASCII.
|
||||
const header_block = email_content.split('\r\n\r\n')[0];
|
||||
assert.match(header_block, /^[\x00-\x7F]*$/);
|
||||
|
||||
// mailparser leaves unknown headers raw; decode the encoded-word ourselves.
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(libmime.decodeWords(parsed.headers.get('x-note')), 'wörld ünïcödé');
|
||||
});
|
||||
|
||||
test('neutralizes CRLF header injection in subject and display names', async () => {
|
||||
const { email_content } = builder.build({
|
||||
...BASE,
|
||||
from: 'Evil\r\nBcc: hidden@evil.example\r\n <sender@elektrine.com>',
|
||||
subject: 'Hello\r\nX-Injected: 1'
|
||||
});
|
||||
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.headers.get('x-injected'), undefined);
|
||||
assert.equal(parsed.bcc, undefined);
|
||||
assert.equal(parsed.from.value[0].address, 'sender@elektrine.com');
|
||||
});
|
||||
|
||||
test('folds long recipient lists under the line limit', async () => {
|
||||
const to = Array.from({ length: 40 }, (_, i) =>
|
||||
`Recipient Number ${i} With A Fairly Long Display Name <recipient${i}@example.com>`);
|
||||
const { email_content } = builder.build({ ...BASE, to });
|
||||
|
||||
assert_line_limit(email_content);
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.to.value.length, 40);
|
||||
assert.equal(parsed.to.value[39].address, 'recipient39@example.com');
|
||||
});
|
||||
|
||||
test('re-wraps attachment base64 and round-trips the payload', async () => {
|
||||
const payload = crypto.randomBytes(3000);
|
||||
const { email_content } = builder.build({
|
||||
...BASE,
|
||||
html_body: '<p>with attachment</p>',
|
||||
attachments: [{
|
||||
filename: 'data.bin',
|
||||
content_type: 'application/octet-stream',
|
||||
data: payload.toString('base64')
|
||||
}]
|
||||
});
|
||||
|
||||
assert_line_limit(email_content);
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.attachments.length, 1);
|
||||
assert.equal(parsed.attachments[0].filename, 'data.bin');
|
||||
assert.ok(parsed.attachments[0].content.equals(payload));
|
||||
});
|
||||
|
||||
test('rejects missing or non-base64 attachment data', () => {
|
||||
assert.throws(
|
||||
() => builder.build({ ...BASE, attachments: [{ filename: 'x.bin' }] }),
|
||||
/Invalid attachment data/
|
||||
);
|
||||
assert.throws(
|
||||
() => builder.build({ ...BASE, attachments: [{ filename: 'x.bin', data: 'not base64!!' }] }),
|
||||
/Invalid attachment data/
|
||||
);
|
||||
});
|
||||
|
||||
test('encodes non-ASCII attachment filenames per RFC 2231', async () => {
|
||||
const { email_content } = builder.build({
|
||||
...BASE,
|
||||
attachments: [{
|
||||
filename: 'résumé 2026.pdf',
|
||||
content_type: 'application/pdf',
|
||||
data: Buffer.from('%PDF-fake').toString('base64')
|
||||
}]
|
||||
});
|
||||
|
||||
assert.ok(email_content.includes("filename*=UTF-8''"));
|
||||
const header_block = email_content.split('\r\n\r\n')[0];
|
||||
assert.match(header_block, /^[\x00-\x7F]*$/);
|
||||
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.attachments[0].filename, 'résumé 2026.pdf');
|
||||
});
|
||||
|
||||
test('quoted-printable bodies stay within soft line limits and round-trip', async () => {
|
||||
const text = '日本語のテキスト mixed with ASCII words. '.repeat(120);
|
||||
const { email_content } = builder.build({ ...BASE, text_body: text });
|
||||
|
||||
const body = email_content.split('\r\n\r\n').slice(1).join('\r\n\r\n');
|
||||
for (const line of body.split('\r\n')) {
|
||||
assert.ok(line.length <= 76, `QP line exceeds 76 chars: ${line.length}`);
|
||||
}
|
||||
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.text.replace(/\n$/, ''), text);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue