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

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