Initial commit on Forgejo
Some checks are pending
CI and deploy / Test (push) Waiting to run
CI and deploy / Build, push, and deploy (push) Blocked by required conditions

Fresh repository history for elektrine/elektrine-haraka hosted at
https://git.elektrine.com/elektrine/elektrine-haraka.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:42 -04:00
commit 67c1d41a0c
82 changed files with 8713 additions and 0 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
node_modules
npm-debug.log*
.git
.gitignore
README
docs/
logs/
ansible/
nginx/
*.md
.DS_Store
Thumbs.db

View 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
View 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
View 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
View 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
View 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
View 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
View 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

View file

@ -0,0 +1,3 @@
# Blacklist malicious IPs/networks (one per line)
# Example: 192.168.1.100
# Example: 10.0.0.0/8

View 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
View 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
View 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
View 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
View 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

View 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

View 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

View 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
View 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
View file

@ -0,0 +1,4 @@
elektrine.com
elektrine.net
elektrine.org
z.org

9
config/http.ini Normal file
View 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
View file

@ -0,0 +1 @@
f1c4e6ff4c90a71d4e26eddb5a38fcf7f0b176946e18197914421018fd2a91e2

36
config/karma.ini Normal file
View 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

View 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
View 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

View file

@ -0,0 +1,7 @@
[main]
level=info
timestamps=true
format=default
[file]
filename=/app/logs/haraka-outbound.log

View file

@ -0,0 +1,7 @@
[main]
level=info
timestamps=true
format=default
[file]
filename=/app/logs/haraka-submission.log

1
config/me Normal file
View file

@ -0,0 +1 @@
mail.elektrine.com

10
config/outbound.ini Normal file
View 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
View 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
View 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

View 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
View 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

View 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
View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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.

View 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:

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}

View 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');
});
};

View 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`);
};

View 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
});
};

View 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');
});
};

View 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();
};

View 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
View 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
View 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"

View 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
View 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
View 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);
});

View 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
View 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
View 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
View 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
View 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"

View 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
View 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);
});