Publish Alpine musl distroless Postfix as the sole latest image
Build and Push Docker Image / build (push) Successful in 1m25s

This commit is contained in:
Ketan Patel committed 2026-10-03 00:17:09 -04:00
1 parent 2a9a7c6baa
commit 7ead00647b
12 files changed
+772 -1270

No files matched your search

+37 -53
View File
@@ -1,58 +1,42 @@
FROM docker.io/library/alpine:3.24 # Compile the launcher and collect Alpine/musl Postfix runtime dependencies.
FROM docker.io/library/alpine:3.24 AS build
RUN apk add --no-cache postfix postfix-pcre cyrus-sasl cyrus-sasl-login ca-certificates tzdata \
go lddtreepax python3
COPY install/postfix-entrypoint.go /build/postfix-entrypoint.go
COPY tests/smoke.go /build/smoke.go
RUN go vet /build/postfix-entrypoint.go && go vet /build/smoke.go \
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /usr/local/bin/postfix-entrypoint /build/postfix-entrypoint.go \
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /build/smoke /build/smoke.go \
&& newaliases
COPY build/runtime-root.py /build/runtime-root.py
RUN python3 /build/runtime-root.py /runtime /usr/local/bin/postfix-entrypoint \
/usr/libexec/postfix/master /usr/libexec/postfix/anvil /usr/libexec/postfix/bounce \
/usr/libexec/postfix/cleanup /usr/libexec/postfix/discard /usr/libexec/postfix/error \
/usr/libexec/postfix/flush /usr/libexec/postfix/local /usr/libexec/postfix/lmtp \
/usr/libexec/postfix/pickup /usr/libexec/postfix/proxymap /usr/libexec/postfix/qmgr \
/usr/libexec/postfix/scache /usr/libexec/postfix/showq /usr/libexec/postfix/smtp \
/usr/libexec/postfix/smtpd /usr/libexec/postfix/tlsmgr /usr/libexec/postfix/trivial-rewrite \
/usr/libexec/postfix/verify /usr/libexec/postfix/virtual /usr/libexec/postfix/postlogd \
/usr/sbin/postalias /usr/sbin/postcat /usr/sbin/postconf /usr/sbin/postdrop \
/usr/sbin/postkick /usr/sbin/postlock /usr/sbin/postlog /usr/sbin/postmap \
/usr/sbin/postqueue /usr/sbin/postsuper /usr/sbin/sendmail
# Variables for Labels # Test in the exact shell-free filesystem; publication depends on this stage.
ARG VENDOR="k2patel" FROM scratch AS test
ARG COMPONENT="postfix-relay" COPY --from=build /runtime/ /
ARG BUILD_DATE ENV PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ARG GIT_REPO="https://git.k2patel.in/k2patel/docker-postfix" RUN --mount=type=bind,from=build,source=/build/smoke,target=/ci-smoke ["/ci-smoke"]
ARG VCS_REF
ARG VERSION="2.0"
ARG NAME="dockerized-${COMPONENT}"
ARG DESCRIPTION="Postfix SMTP relay supporting multiple providers (Maileroo, Mailtrap, SendGrid)"
ARG DOCUMENTATION="https://git.k2patel.in/k2patel/docker-postfix"
ARG AUTHOR="k2patel"
ARG LICENSE="MIT"
# Labels # Export the pristine runtime, excluding smoke-test credentials, queues and logs.
LABEL org.label-schema.build-date="${BUILD_DATE}" \ FROM scratch
org.label-schema.name="${NAME}" \ COPY --from=build /runtime/ /
org.label-schema.description="${DESCRIPTION}" \ COPY --from=test /tmp/smoke-passed /usr/share/postfix-build-check
org.label-schema.vcs-ref="${VCS_REF}" \ LABEL org.opencontainers.image.source="https://git.k2patel.in/k2patel/docker-postfix" \
org.label-schema.vcs-url="${GIT_REPO}" \ org.opencontainers.image.description="Alpine/musl distroless Postfix SMTP relay" \
org.label-schema.vendor="${VENDOR}" \ org.opencontainers.image.licenses="MIT"
org.label-schema.version="${VERSION}" ENV PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Install required packages
RUN apk add --no-cache \
postfix \
postfix-pcre \
cyrus-sasl \
cyrus-sasl-login \
libsasl \
ca-certificates \
bash \
tzdata \
mailx
# Create necessary directories
RUN mkdir -p /var/spool/postfix /etc/postfix/sasl
# Copy entrypoint script
COPY run.sh /run.sh
RUN chmod +x /run.sh
# Initialize postfix
RUN newaliases
# Expose SMTP port
EXPOSE 25 EXPOSE 25
# Volumes for persistence
VOLUME ["/var/spool/postfix"] VOLUME ["/var/spool/postfix"]
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \ HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
CMD postfix status || exit 1 CMD ["/usr/local/bin/postfix-entrypoint", "--healthcheck"]
ENTRYPOINT ["/usr/local/bin/postfix-entrypoint"]
# Run postfix in foreground
ENTRYPOINT ["/run.sh"]
+94 -427
View File
@@ -1,465 +1,132 @@
# Docker Postfix SMTP Relay # Docker Postfix SMTP Relay
A lightweight Postfix SMTP relay container supporting multiple email service providers including Maileroo, Mailtrap, SendGrid, and any generic SMTP server. Source: https://git.k2patel.in/k2patel/docker-postfix
The image uses Alpine 3.24 (musl libc) and is published as `k2patel/postfix:latest`. `k2patel/postfix:latest` is the single maintained image: a distroless SMTP relay
Builds run on Gitea for pushes to `main`, monthly, or by manual dispatch. built from Alpine 3.24/musl into a `scratch` runtime. It supports Maileroo,
Mailtrap, SendGrid, Gmail, Office 365, and generic upstream SMTP servers.
There is no shell, BusyBox, package manager, Supervisor, or compiler in the image.
## Features A native launcher configures Postfix, creates queue directories on fresh volumes,
builds the SASL credential map, and starts Postfix in the foreground. Logs go to
container stdout. Native Postfix tools remain available for queue administration.
- 🚀 Multi-provider support (Maileroo, Mailtrap, SendGrid, Gmail, Office365, any SMTP) ## Quick start
- 🔒 TLS/SSL encryption
- 🌐 Auto-detects /16 subnet for relay
- 📝 All logs to stderr (`docker logs`)
- 🏥 Built-in health checks
- 🔧 Simple Makefile-based operations
## Quick Start ```sh
### 1. Clone and Configure
```bash
git clone https://git.k2patel.in/k2patel/docker-postfix.git git clone https://git.k2patel.in/k2patel/docker-postfix.git
cd docker-postfix cd docker-postfix
cp env.sample .env cp env.sample .env
nano .env # Edit with your SMTP provider details # Edit .env with your provider credentials and domain.
docker compose pull
docker compose up -d
``` ```
### 2. Build and Start To build locally, run `docker compose build` first. Builds include isolated
SMTP relay tests; no test messages are sent to external mailboxes.
```bash Configure applications to connect to `postfix-relay:25` on the same Docker
cd test network. Only trusted networks should be allowed to relay.
make build
make up
```
### 3. Test
```bash
cd test
make test TO=your@email.com
```
## Configuration ## Configuration
### Environment Variables | Variable | Default | Purpose |
|----------|---------|---------|
| `SMTP_SERVER` | Required | Upstream SMTP hostname |
| `SMTP_PORT` | `587` | Upstream SMTP port |
| `SMTP_USERNAME` | Required | Upstream SMTP username |
| `SMTP_PASSWORD` | Required | Upstream SMTP password or token |
| `DOMAIN` | Hostname suffix, otherwise `localdomain` | Domain for outgoing mail; set explicitly |
| `SERVER_HOSTNAME` | Container hostname | Relay hostname; set to a valid FQDN |
| `TIMEZONE` | `America/New_York` | IANA timezone |
| `LOCAL_NETWORK` | Detected IPv4 interface subnet | Trusted local CIDR |
| `SMTP_NETWORKS` | Empty | Additional trusted IPv4 CIDRs, comma-separated |
| `SMTP_HEADER_TAG` | Empty | Optional `RelayTag` header value |
| `SMTP_LISTEN_PORT` | `25` | Host port in Compose |
| `DATA_FOLDER` | Current directory | Compose bind-mount base for logs/mail/spool |
| `DEBUG` | `no` | Enable Postfix debug level 2 |
| Variable | Required | Default | Description | Localhost and the local subnet are trusted. Invalid CIDRs and multiline
|----------|----------|---------|-------------| configuration values are rejected. Credentials are stored in root-only files;
| `SMTP_SERVER` | Yes | - | SMTP server hostname | the launcher does not print their contents.
| `SMTP_PORT` | Yes | 587 | SMTP server port |
| `SMTP_USERNAME` | Yes | - | SMTP username |
| `SMTP_PASSWORD` | Yes | - | SMTP password |
| `DOMAIN` | Yes | - | Domain for outgoing mail |
| `SERVER_HOSTNAME` | No | Auto | Server FQDN |
| `TIMEZONE` | No | America/New_York | Timezone |
| `SMTP_NETWORKS` | No | Auto /16 | Additional networks (comma-separated CIDR) |
| `SMTP_LISTEN_PORT` | No | 25 | Port to expose on host |
| `LOCAL_NETWORK` | No | Auto-detected | Override local network CIDR (e.g., 192.168.0.0/16) |
| `DEBUG` | No | no | Enable debug logging |
### Provider Examples Maileroo, Mailtrap, and SendGrid require STARTTLS. Generic providers retain
opportunistic STARTTLS (`smtp_tls_security_level=may`). Use the provider's
STARTTLS submission port, typically 587; implicit TLS on port 465 is not configured.
#### Maileroo Provider examples are included in `env.sample`. For SendGrid, use the literal
```bash username `apikey`. Gmail requires an app password. Keep credentials in `.env`,
SMTP_SERVER=smtp.maileroo.com which is excluded from Git and the Docker build context.
SMTP_PORT=587
SMTP_USERNAME=noreply@example.com
SMTP_PASSWORD=your_password
DOMAIN=example.com
```
#### Mailtrap ## Updating an existing deployment
```bash
SMTP_SERVER=live.smtp.mailtrap.io
SMTP_PORT=587
SMTP_USERNAME=your_username
SMTP_PASSWORD=your_password
DOMAIN=example.com
```
#### SendGrid Use `k2patel/postfix:latest`, pull, and recreate the container. Existing environment
```bash settings and `/var/spool/postfix` persistence are retained. The default Compose
SMTP_SERVER=smtp.sendgrid.net file supplies the native health check. Replace an old custom `postfix status`
SMTP_PORT=587 or shell-based health check with:
SMTP_USERNAME=apikey
SMTP_PASSWORD=your_api_key
DOMAIN=example.com
```
**Note**: For SendGrid, username must be `apikey` (literal string).
#### Gmail
```bash
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=your_email@gmail.com
SMTP_PASSWORD=your_app_password
DOMAIN=gmail.com
```
## Makefile Commands
All operations are handled through the Makefile in the `test/` directory.
### Setup Commands
```bash
cd test
make build # Build the Docker image
make up # Start the container
make down # Stop the container
make restart # Restart the container
```
### Monitoring Commands
```bash
make logs # View logs (follow mode)
make logs-tail # View last 100 lines
make status # Show container and Postfix status
make queue # View mail queue
make health # Check container health
```
### Maintenance Commands
```bash
make shell # Open shell in container
make config # Show Postfix configuration
make test # Send test email (auto-detects container)
make flush # Flush mail queue
make check # Validate Postfix configuration
```
### Cleanup Commands
```bash
make clean # Remove container and volumes
make clean-all # Remove everything including images
```
### Advanced Commands
```bash
make debug # Start with debug mode enabled
make validate # Validate .env file
make queue-delete # Delete all messages in queue
make stats # Show real-time container stats
```
### Full Command List
Run `make help` or just `make` to see all available commands:
```bash
cd test
make
```
## Network Configuration
The container automatically allows relay from:
- Localhost (127.0.0.0/8)
- Auto-detected /16 subnet
To add custom networks:
```bash
SMTP_NETWORKS=192.168.1.0/24,10.0.0.0/8
```
## Using with Applications
Configure your application to use the relay:
```yaml ```yaml
services: healthcheck:
your-app: test: ["CMD", "/usr/local/bin/postfix-entrypoint", "--healthcheck"]
environment:
MAIL_HOST: postfix-relay
MAIL_PORT: 25
depends_on:
- postfix
``` ```
Connection details: The check connects to localhost SMTP and requires a `220` greeting. The old
- **Host**: `postfix-relay` `postfix status`, `postfix check`, shell access, and `mail`/`mailx` commands are
- **Port**: `25` not part of this image. Use native commands below. Custom pipe/alias delivery
- **Authentication**: Not required for local network commands that need a shell or external utilities are unsupported.
## Testing ## Operations
### Quick Test (Recommended) ```sh
docker logs -f postfix-relay
docker exec postfix-relay /usr/local/bin/postfix-entrypoint --healthcheck
docker exec postfix-relay /usr/local/bin/postfix-entrypoint --check
docker exec postfix-relay postconf -n
docker exec postfix-relay postqueue -p
docker exec postfix-relay postqueue -f
docker compose restart postfix
```
The easiest way to test your Postfix relay: `--check` parses and displays the active main and master configuration; startup
also checks queue structure using `postsuper`. It does not validate upstream
credentials or external delivery.
```bash Host-side helpers work without a shell inside the container:
```sh
cd test cd test
make test TO=recipient@example.com make help
```
**That's it!** No configuration needed.
### What Gets Auto-Detected?
The test script automatically finds and configures:
| Feature | What Happens | Override Option |
|---------|--------------|-----------------|
| **Container Name** | Finds containers using `docker-postfix-postfix` image | Specify as first arg |
| **FROM Address** | Uses `noreply@DOMAIN` from container env | `FROM=email@domain.com` |
| **SMTP Port** | Detects mapped port (e.g., `25` or custom) | N/A (always detected) |
| **Host IP** | Converts `0.0.0.0` → `127.0.0.1` for local testing | N/A (always detected) |
| **Subject** | Defaults to "Test Email from Postfix Relay" | `SUBJECT="Your Subject"` |
**No manual configuration required** - just provide the recipient email!
### Advanced Testing Options
**With custom FROM address:**
```bash
make test TO=user@example.com FROM=noreply@mydomain.com
```
**With custom subject:**
```bash
make test TO=user@example.com FROM=sender@domain.com SUBJECT="My Test Email"
```
**Using the test script directly:**
```bash
./test-email.sh user@example.com
./test-email.sh user@example.com sender@mydomain.com
./test-email.sh user@example.com sender@mydomain.com "Custom Subject"
```
**Manual container specification (if auto-detection fails):**
```bash
./test-email.sh my-container user@example.com sender@domain.com
```
### Test Script Features
The `test-email.sh` script automatically:
- Finds containers using `docker-postfix-postfix` image
- Detects port mapping and converts `0.0.0.0` to `127.0.0.1`
- Uses proper SMTP protocol via sendmail
- Falls back to mail command if needed
- Shows queue status after sending
- Provides colored output for easy reading
### Validation Tests
```bash
cd test
make check # Validate Postfix configuration
make validate # Validate .env file
make queue # View mail queue
make config # Show current Postfix config
```
## Troubleshooting
### Email Not Received
**1. Check container logs:**
```bash
cd test
make logs-tail # Last 100 lines
make logs # Follow in real-time
```
**2. Check mail queue:**
```bash
make queue # View queued messages
make flush # Force processing
```
**3. Verify configuration:**
```bash
make config # Show Postfix config
make check # Validate config
make validate # Validate .env file
```
### SMTP Protocol Errors
If you see "improper command pipelining" errors:
**Use the test script** (handles protocol correctly):
```bash
./test-email.sh user@example.com sender@domain.com
```
**Verify FROM domain matches DOMAIN setting:**
```bash
# In .env file
DOMAIN=mydomain.com
# Use matching FROM address
make test TO=user@example.com FROM=noreply@mydomain.com
```
**Check mynetworks configuration:**
```bash
docker exec postfix-relay postconf mynetworks
```
### Container Not Auto-Detected
**1. Verify container is running:**
```bash
docker ps | grep postfix
```
**2. Start the container:**
```bash
cd test
make up
```
**3. Manually specify container:**
```bash
./test-email.sh my-container-name user@example.com
```
### Enable Debug Mode
For detailed SMTP transaction logs:
```bash
cd test
make debug
```
### Emails Stuck in Queue
```bash
cd test
make queue # View queue
make flush # Force processing
make queue-delete # Delete all (with confirmation)
```
### Network Configuration Issues
**Override auto-detected network:**
```bash
# In .env file
LOCAL_NETWORK=192.168.1.0/24
```
**Add additional networks:**
```bash
SMTP_NETWORKS=10.0.0.0/8,172.16.0.0/12
```
### Container Health Issues
```bash
cd test
make status # Check status
make health # Check health status
make restart # Restart container
make down # Stop container
make up # Start fresh
```
## Examples
### Testing Examples
**Simple test with Gmail:**
```bash
cd test
make test TO=youraddress@gmail.com
# FROM will auto-detect from DOMAIN in .env
```
**Test with custom FROM and subject:**
```bash
make test TO=client@example.com FROM=support@mycompany.com SUBJECT="Production Test"
```
**Test from command line:**
```bash
./test-email.sh user@example.com noreply@mydomain.com "Hello World"
```
**Verify email was sent:**
```bash
make queue # Should show empty queue if sent
make logs-tail # Check for "status=sent"
```
### Basic Workflow
```bash
# Initial setup
cd docker-postfix
cp env.sample .env
nano .env
# Build and start
cd test
make build
make up
# Check status
make status make status
make logs
# Test email delivery
make test TO=your@email.com
# Stop
make down
```
### Development Workflow
```bash
cd test
# Start with debug
make debug
# Check logs in another terminal
make logs
# Restart after changes
make restart
# Clean up
make clean
```
### Monitoring Workflow
```bash
cd test
# Check everything
make status
make health
make queue make queue
make logs-tail make check
# Sends a real email only when you explicitly run this command:
# Continuous monitoring make test TO=recipient@example.com
make logs # Optional sender, subject, or container:
make test TO=recipient@example.com FROM=sender@example.com SUBJECT='Relay test' CONTAINER=postfix-relay
``` ```
## Security Submission to the queue is not proof of delivery. Check logs for `status=sent`
and inspect deferred messages with `postqueue -p`.
- Store credentials in `.env` (never commit to git) ## Build and CI
- Limit relay to trusted networks only
- TLS automatically enabled for known providers
- Use app-specific passwords for Gmail
## License Gitea Actions builds `linux/amd64` on the Kubernetes runner for pushes to `main`,
monthly on the first day, and manual dispatch. Configure repository secrets
`DOCKER_USER` and `DOCKER_TOKEN` for Docker Hub publishing. Each build uses a
temporary Buildx builder and removes its cache afterward.
MIT License - See [LICENSE](LICENSE) file for details. The final filesystem contains Postfix and its musl/shared-library dependencies,
SASL and database plugins, ICU data, CA certificates, timezone data, and the
native launcher. Go, Python, and packaging tools are confined to the build stage.
## Support Before publication, a separate stage tests the exact shell-free filesystem:
startup with an empty queue and stale PID file, native health checks, SMTP
submission, STARTTLS and authenticated relay to a local mock server, header
insertion, LMDB/PCRE plugins, queue draining, restart, and SIGTERM shutdown.
The final image is copied from the pristine runtime, excluding test credentials
and queue contents.
For issues and questions, please open an issue on [Gitea](https://git.k2patel.in/k2patel/docker-postfix/issues). ## License and support
MIT License; see [LICENSE](LICENSE).
Issues: https://git.k2patel.in/k2patel/docker-postfix/issues
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Assemble an Alpine-derived, shell-free runtime from an explicit ELF allowlist."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
root = Path(sys.argv[1])
root.mkdir(parents=True, exist_ok=True)
copied = set()
def copy(source):
source = Path(os.path.normpath(source))
if source in copied:
return
copied.add(source)
target = root / str(source).lstrip('/')
target.parent.mkdir(parents=True, exist_ok=True)
if source.is_symlink():
link = os.readlink(source)
if not target.is_symlink():
target.symlink_to(link)
# Preserve every hop, not just the final file in a multi-link chain.
copy(link if os.path.isabs(link) else source.parent / link)
elif source.is_dir():
target.mkdir(exist_ok=True)
for child in source.iterdir():
copy(child)
else:
shutil.copy2(source, target)
for executable in sys.argv[2:]:
result = subprocess.run(['lddtreepax', '-l', executable], check=True, text=True, capture_output=True)
paths = result.stdout.splitlines()
if not paths or any(not p.startswith('/') or not Path(p).exists() for p in paths):
raise SystemExit(f'Unresolved runtime dependency for {executable}: {result.stdout}')
copy(executable)
for path in paths:
copy(path)
# musl uses this file for nonstandard library directories (for example Lua).
for search_path in Path('/etc').glob('ld-musl-*.path'):
copy(search_path)
# Postfix loads database maps and SASL mechanisms dynamically.
for folder in ['/usr/lib/postfix', '/usr/lib/sasl2']:
for plugin in Path(folder).glob('*.so*'):
result = subprocess.run(['lddtreepax', '-l', str(plugin)], check=True,
text=True, capture_output=True,
env={**os.environ, 'LD_LIBRARY_PATH': '/lib:/usr/lib:/usr/lib/postfix'})
for dependency in result.stdout.splitlines():
if not dependency.startswith('/') or not Path(dependency).exists():
raise SystemExit(f'Unresolved plugin dependency: {dependency}')
copy(dependency)
copy(plugin)
for data in ['/etc/postfix', '/etc/passwd', '/etc/group', '/etc/aliases',
'/etc/aliases.lmdb', '/etc/ssl/certs/ca-certificates.crt', '/etc/ssl/cert.pem',
'/etc/ssl/openssl.cnf', '/usr/lib/ossl-modules', '/usr/share/zoneinfo',
'/usr/share/icu', '/usr/lib/icu', '/etc/services', '/etc/protocols', '/etc/alpine-release', '/etc/os-release']:
if Path(data).exists():
copy(data)
for directory in ['tmp', 'root', 'dev', 'var/mail', 'var/lib/postfix', 'var/spool/postfix']:
(root / directory).mkdir(parents=True, exist_ok=True)
os.chmod(root / 'tmp', 0o1777)
# Preserve the Postfix command group and setgid bits for queue submission.
for command in ['postdrop', 'postqueue', 'postlog']:
source = Path('/usr/sbin') / command
target = root / str(source).lstrip('/')
stat = source.stat()
os.chown(target, stat.st_uid, stat.st_gid)
os.chmod(target, stat.st_mode & 0o7777)
for forbidden in ['bin/sh', 'bin/bash', 'bin/busybox', 'sbin/apk', 'usr/bin/apk',
'usr/bin/python3', 'usr/bin/go', 'usr/sbin/postfix']:
assert not (root / forbidden).exists(), f'Unexpected runtime tool: {forbidden}'
+3 -3
View File
@@ -1,7 +1,6 @@
version: "3.8"
services: services:
postfix: postfix:
image: docker.io/k2patel/postfix:latest
build: . build: .
container_name: postfix-relay container_name: postfix-relay
hostname: ${SERVER_HOSTNAME:-postfix-relay} hostname: ${SERVER_HOSTNAME:-postfix-relay}
@@ -17,6 +16,7 @@ services:
- DOMAIN=${DOMAIN} - DOMAIN=${DOMAIN}
- SERVER_HOSTNAME=${SERVER_HOSTNAME} - SERVER_HOSTNAME=${SERVER_HOSTNAME}
- SMTP_NETWORKS=${SMTP_NETWORKS} - SMTP_NETWORKS=${SMTP_NETWORKS}
- LOCAL_NETWORK=${LOCAL_NETWORK:-}
- SMTP_HEADER_TAG=${SMTP_HEADER_TAG} - SMTP_HEADER_TAG=${SMTP_HEADER_TAG}
- DEBUG=${DEBUG:-no} - DEBUG=${DEBUG:-no}
volumes: volumes:
@@ -32,7 +32,7 @@ services:
max-file: "3" max-file: "3"
labels: "service=postfix-relay" labels: "service=postfix-relay"
healthcheck: healthcheck:
test: ["CMD", "postfix", "status"] test: ["CMD", "/usr/local/bin/postfix-entrypoint", "--healthcheck"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
+3 -3
View File
@@ -38,7 +38,7 @@ DEBUG=no
# ============================================ # ============================================
# -------------------------------------------- # --------------------------------------------
# Option 1: Maileroo (Current Configuration) # Option 1: Maileroo
# -------------------------------------------- # --------------------------------------------
SMTP_SERVER='smtp.maileroo.com' SMTP_SERVER='smtp.maileroo.com'
SMTP_PORT=587 SMTP_PORT=587
@@ -95,7 +95,7 @@ SMTP_PASSWORD=your_maileroo_password_here
# Notes # Notes
# ============================================ # ============================================
# #
# 1. The container automatically detects /16 subnet for relay # 1. The container automatically detects the local IPv4 subnet for relay
# Additional networks can be added via SMTP_NETWORKS # Additional networks can be added via SMTP_NETWORKS
# #
# 2. All logs are sent to stderr and visible via: # 2. All logs are sent to stderr and visible via:
@@ -104,7 +104,7 @@ SMTP_PASSWORD=your_maileroo_password_here
# 3. TLS is automatically enabled for known providers # 3. TLS is automatically enabled for known providers
# (Maileroo, Mailtrap, SendGrid) # (Maileroo, Mailtrap, SendGrid)
# #
# 4. Health check monitors Postfix status every 30 seconds # 4. Native health check verifies the SMTP greeting every 30 seconds
# #
# 5. For SendGrid, username must be "apikey" (literal string) # 5. For SendGrid, username must be "apikey" (literal string)
# #
-11
View File
@@ -1,11 +0,0 @@
[supervisord]
nodaemon=true
user=root
[program:rsyslogd]
command = /usr/sbin/rsyslogd -n
[program:postfix]
command=/run.sh
autorestart=true
+230
View File
@@ -0,0 +1,230 @@
// Native configuration and process launcher for the Alpine-derived runtime.
package main
import (
"bufio"
"errors"
"fmt"
"net"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
func fail(err error) { fmt.Fprintln(os.Stderr, "postfix-entrypoint:", err); os.Exit(1) }
func check(err error) {
if err != nil {
fail(err)
}
}
func env(name, fallback string) string {
value := os.Getenv(name)
if value == "" {
value = fallback
}
if strings.ContainsAny(value, "\r\n\x00") {
fail(fmt.Errorf("%s must be a single line", name))
}
return value
}
func run(command string, args ...string) {
c := exec.Command(command, args...)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
check(c.Run())
}
func health() error {
c, err := net.DialTimeout("tcp4", "127.0.0.1:25", 3*time.Second)
if err != nil {
return err
}
defer c.Close()
if err = c.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
return err
}
line, err := bufio.NewReader(c).ReadString('\n')
if err != nil {
return err
}
if !strings.HasPrefix(line, "220 ") {
return fmt.Errorf("unexpected SMTP greeting: %s", line)
}
_, err = c.Write([]byte("QUIT\r\n"))
return err
}
func directory(path string, uid, gid int, mode os.FileMode) {
check(os.MkdirAll(path, mode))
info, err := os.Lstat(path)
check(err)
if !info.IsDir() {
fail(fmt.Errorf("%s must be a directory, not a symlink", path))
}
check(os.Chown(path, uid, gid))
check(os.Chmod(path, mode))
}
func initQueue() {
account, err := user.Lookup("postfix")
check(err)
group, err := user.LookupGroup("postdrop")
check(err)
uid, err := strconv.Atoi(account.Uid)
check(err)
gid, err := strconv.Atoi(group.Gid)
check(err)
directory("/var/lib/postfix", uid, 0, 0700)
directory("/var/spool/postfix", 0, 0, 0755)
directory("/var/spool/postfix/pid", 0, 0, 0755)
for _, name := range strings.Fields("active bounce corrupt defer deferred flush hold incoming private saved trace") {
directory("/var/spool/postfix/"+name, uid, 0, 0700)
}
directory("/var/spool/postfix/maildrop", uid, gid, 0730)
directory("/var/spool/postfix/public", uid, gid, 0710)
// Keep the pid file: master locks it and safely handles stale contents itself.
// Unlinking a live lock could permit two masters on a shared queue.
run("/usr/sbin/postsuper")
}
func networks() string {
local := env("LOCAL_NETWORK", "")
if local == "" {
addresses, err := net.InterfaceAddrs()
check(err)
for _, address := range addresses {
n, ok := address.(*net.IPNet)
if ok && n.IP.To4() != nil && !n.IP.IsLoopback() {
local = (&net.IPNet{IP: n.IP.Mask(n.Mask), Mask: n.Mask}).String()
break
}
}
if local == "" {
fail(errors.New("cannot detect local network; set LOCAL_NETWORK"))
}
}
values := []string{"127.0.0.0/8", "[::1]/128"}
for _, item := range append([]string{local}, strings.Split(env("SMTP_NETWORKS", ""), ",")...) {
item = strings.TrimSpace(item)
if item == "" {
continue
}
ip, network, err := net.ParseCIDR(item)
if err != nil || ip.To4() == nil {
fail(fmt.Errorf("invalid IPv4 network: %s", item))
}
values = append(values, network.String())
}
return strings.Join(values, ", ")
}
func configure() {
server := env("SMTP_SERVER", "")
username := env("SMTP_USERNAME", "")
password := env("SMTP_PASSWORD", "")
if server == "" || username == "" || password == "" {
fail(errors.New("SMTP_SERVER, SMTP_USERNAME and SMTP_PASSWORD are required"))
}
if strings.ContainsAny(server, " \t[]") {
fail(errors.New("SMTP_SERVER must be a hostname or IP address"))
}
port := env("SMTP_PORT", "587")
number, err := strconv.Atoi(port)
if err != nil || number < 1 || number > 65535 {
fail(errors.New("SMTP_PORT must be between 1 and 65535"))
}
hostname, err := os.Hostname()
check(err)
hostname = env("SERVER_HOSTNAME", hostname)
domain := env("DOMAIN", "")
if domain == "" {
_, suffix, ok := strings.Cut(hostname, ".")
if ok {
domain = suffix
} else {
domain = "localdomain"
}
}
timezone := env("TIMEZONE", "America/New_York")
if filepath.IsAbs(timezone) || strings.Contains(timezone, "..") {
fail(errors.New("invalid TIMEZONE"))
}
zone, err := os.ReadFile(filepath.Join("/usr/share/zoneinfo", timezone))
check(err)
check(os.WriteFile("/etc/localtime", zone, 0644))
check(os.WriteFile("/etc/timezone", []byte(timezone+"\n"), 0644))
relay := fmt.Sprintf("[%s]:%s", server, port)
config := map[string]string{
"myhostname": hostname, "mydomain": domain, "mydestination": "$myhostname", "myorigin": "$mydomain",
"mynetworks": networks(), "inet_interfaces": "all", "inet_protocols": "ipv4", "relayhost": relay,
"relay_domains": "*", "smtp_sasl_auth_enable": "yes", "smtp_sasl_password_maps": "lmdb:/etc/postfix/sasl_passwd",
"smtp_sasl_security_options": "noanonymous", "smtp_sasl_tls_security_options": "noanonymous",
"smtp_sasl_mechanism_filter": "plain, login", "smtp_tls_security_level": "may",
"smtp_tls_CAfile": "/etc/ssl/certs/ca-certificates.crt",
"header_size_limit": "4096000", "mailbox_size_limit": "0", "message_size_limit": "52428800",
"recipient_delimiter": "+", "smtpd_relay_restrictions": "permit_mynetworks, reject_unauth_destination",
"smtpd_recipient_restrictions": "permit_mynetworks, reject_unauth_destination", "smtpd_sasl_local_domain": domain,
"maillog_file": "/dev/stdout", "maillog_file_prefixes": "/var, /dev", "maximal_queue_lifetime": "1d",
"bounce_queue_lifetime": "1d", "queue_run_delay": "300s", "minimal_backoff_time": "300s", "maximal_backoff_time": "4000s",
}
for _, provider := range []string{"mailtrap.io", "sendgrid", "maileroo"} {
if strings.Contains(strings.ToLower(server), provider) {
config["smtp_tls_security_level"] = "encrypt"
config["smtp_tls_session_cache_database"] = "lmdb:${data_directory}/smtp_scache"
config["smtp_tls_loglevel"] = "1"
}
}
if env("DEBUG", "no") == "yes" {
config["debug_peer_level"] = "2"
}
tag := env("SMTP_HEADER_TAG", "")
if tag != "" {
check(os.WriteFile("/etc/postfix/header_tag", []byte("/^MIME-Version:/i PREPEND RelayTag: "+tag+"\n/^Content-Transfer-Encoding:/i PREPEND RelayTag: "+tag+"\n"), 0644))
config["header_checks"] = "regexp:/etc/postfix/header_tag"
} else {
config["header_checks"] = ""
}
// Remove the obsolete setting even when reusing an existing configuration.
run("/usr/sbin/postconf", "-X", "smtp_use_tls")
// Pass arguments directly; no shell expansion or secret-bearing command line.
args := []string{"-e"}
for key, value := range config {
args = append(args, key+" = "+value)
}
run("/usr/sbin/postconf", args...)
// Restrict both the source and LMDB files, including any pre-existing files.
syscall.Umask(0077)
check(os.WriteFile("/etc/postfix/sasl_passwd", []byte(relay+" "+username+":"+password+"\n"), 0600))
check(os.Chmod("/etc/postfix/sasl_passwd", 0600))
run("/usr/sbin/postmap", "lmdb:/etc/postfix/sasl_passwd")
check(os.Chmod("/etc/postfix/sasl_passwd.lmdb", 0600))
syscall.Umask(0022)
check(os.Unsetenv("SMTP_PASSWORD"))
check(os.Unsetenv("SMTP_USERNAME"))
initQueue()
fmt.Fprintf(os.Stderr, "Postfix configured: hostname=%s relay=%s\n", hostname, relay)
}
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "--healthcheck":
check(health())
return
case "--check":
run("/usr/sbin/postconf", "-n")
run("/usr/sbin/postconf", "-M")
return
default:
path, err := exec.LookPath(os.Args[1])
check(err)
check(syscall.Exec(path, os.Args[1:], os.Environ()))
return
}
}
configure()
mode := "-s"
if os.Getpid() == 1 {
mode = "-i"
}
check(syscall.Exec("/usr/libexec/postfix/master", []string{"master", mode}, os.Environ()))
}
-219
View File
@@ -1,219 +0,0 @@
#!/bin/bash
set -e
# Redirect all output to stderr for docker logs
exec 1>&2
echo "========================================"
echo "Postfix SMTP Relay Configuration"
echo "========================================"
[ "${DEBUG}" == "yes" ] && set -x
# Set timezone
if [ -n "${TIMEZONE}" ]; then
echo "Setting timezone to: ${TIMEZONE}"
cp /usr/share/zoneinfo/${TIMEZONE} /etc/localtime
echo "${TIMEZONE}" > /etc/timezone
fi
# Initialize postfix directories (Alpine-specific)
# Postfix on Alpine doesn't use post-install in the same way
# The directories are already created during package installation
function add_config_value() {
local key=${1}
local value=${2}
local config_file=${3:-/etc/postfix/main.cf}
[ "${key}" == "" ] && echo "ERROR: No key set !!" && exit 1
[ "${value}" == "" ] && echo "ERROR: No value set !!" && exit 1
echo " Setting: ${key} = ${value}"
postconf -e "${key} = ${value}"
}
# Validate required environment variables
[ -z "${SMTP_SERVER}" ] && echo "ERROR: SMTP_SERVER is not set" && exit 1
[ -z "${SMTP_PORT}" ] && echo "ERROR: SMTP_PORT is not set" && exit 1
[ -z "${SMTP_USERNAME}" ] && echo "ERROR: SMTP_USERNAME is not set" && exit 1
[ -z "${SMTP_PASSWORD}" ] && echo "ERROR: SMTP_PASSWORD is not set" && exit 1
# Detect SMTP provider and set defaults
SMTP_PROVIDER="generic"
if [[ "${SMTP_SERVER}" == *"mailtrap.io"* ]]; then
SMTP_PROVIDER="mailtrap"
echo "Detected provider: Mailtrap"
elif [[ "${SMTP_SERVER}" == *"sendgrid"* ]]; then
SMTP_PROVIDER="sendgrid"
echo "Detected provider: SendGrid"
elif [[ "${SMTP_SERVER}" == *"maileroo"* ]]; then
SMTP_PROVIDER="maileroo"
echo "Detected provider: Maileroo"
else
echo "Using generic SMTP provider: ${SMTP_SERVER}"
fi
# Get local network subnet - allow override via environment variable
if [ -z "${LOCAL_NETWORK}" ]; then
# Auto-detect and use /16 by default
LOCAL_NETWORK=$(ip route | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+' | head -1 | awk '{print $1}')
if [ -z "$LOCAL_NETWORK" ]; then
LOCAL_IP=$(hostname -i | grep -E -o '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1)
if [ -n "$LOCAL_IP" ]; then
# Extract first two octets for /16 network
NETWORK_PREFIX=$(echo $LOCAL_IP | cut -d. -f1-2)
LOCAL_NETWORK="${NETWORK_PREFIX}.0.0/16"
else
LOCAL_NETWORK="172.16.0.0/16"
fi
fi
echo "Auto-detected local network: ${LOCAL_NETWORK}"
else
echo "Using provided LOCAL_NETWORK: ${LOCAL_NETWORK}"
fi
# Build mynetworks - always include localhost and detected local network
nets="127.0.0.0/8, [::1]/128, ${LOCAL_NETWORK}"
# Add custom networks if specified
if [ ! -z "${SMTP_NETWORKS}" ]; then
echo "Adding custom networks..."
for i in $(sed 's/,/ /g' <<<$SMTP_NETWORKS); do
if grep -Eq "[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}" <<<$i ; then
nets="${nets}, $i"
echo " Added network: $i"
else
echo " WARNING: $i is not in proper IPv4 subnet format. Ignoring."
fi
done
fi
echo ""
echo "Allowed networks: ${nets}"
echo ""
# Set hostname and domain defaults
SERVER_HOSTNAME_DEFAULT=$(hostname -f)
DOMAIN_DEFAULT=$(hostname -d)
# Use provided values or defaults
SERVER_HOSTNAME=${SERVER_HOSTNAME:-${SERVER_HOSTNAME_DEFAULT}}
DOMAIN=${DOMAIN:-${DOMAIN_DEFAULT}}
echo "Configuring Postfix..."
echo "----------------------------------------"
# Basic configuration
add_config_value "myhostname" "${SERVER_HOSTNAME}"
add_config_value "mydomain" "${DOMAIN}"
add_config_value "mydestination" '$myhostname'
add_config_value "myorigin" '$mydomain'
add_config_value "mynetworks" "${nets}"
add_config_value "inet_interfaces" "all"
add_config_value "inet_protocols" "ipv4"
# Relay configuration
add_config_value "relayhost" "[${SMTP_SERVER}]:${SMTP_PORT}"
add_config_value "relay_domains" "*"
# SASL Authentication
add_config_value "smtp_sasl_auth_enable" "yes"
add_config_value "smtp_sasl_password_maps" "lmdb:/etc/postfix/sasl_passwd"
add_config_value "smtp_sasl_security_options" "noanonymous"
add_config_value "smtp_sasl_tls_security_options" "noanonymous"
add_config_value "smtp_sasl_mechanism_filter" "plain, login"
# TLS configuration
if [ "${SMTP_PROVIDER}" == "sendgrid" ] || [ "${SMTP_PROVIDER}" == "mailtrap" ] || [ "${SMTP_PROVIDER}" == "maileroo" ]; then
add_config_value "smtp_use_tls" "yes"
add_config_value "smtp_tls_security_level" "encrypt"
add_config_value "smtp_tls_CAfile" "/etc/ssl/certs/ca-certificates.crt"
add_config_value "smtp_tls_session_cache_database" "lmdb:\${data_directory}/smtp_scache"
add_config_value "smtp_tls_loglevel" "1"
else
add_config_value "smtp_tls_security_level" "may"
fi
# Size limits
add_config_value "header_size_limit" "4096000"
add_config_value "mailbox_size_limit" "0"
add_config_value "message_size_limit" "52428800"
add_config_value "recipient_delimiter" "+"
# Relay restrictions
add_config_value "smtpd_relay_restrictions" "permit_mynetworks, reject_unauth_destination"
add_config_value "smtpd_recipient_restrictions" "permit_mynetworks, reject_unauth_destination"
add_config_value "smtpd_sasl_local_domain" "${DOMAIN}"
# Logging configuration - send logs to stdout/stderr
add_config_value "maillog_file" "/dev/stdout"
add_config_value "maillog_file_prefixes" "/var, /dev"
# Queue settings
add_config_value "maximal_queue_lifetime" "1d"
add_config_value "bounce_queue_lifetime" "1d"
add_config_value "queue_run_delay" "300s"
add_config_value "minimal_backoff_time" "300s"
add_config_value "maximal_backoff_time" "4000s"
# Debug settings (enable if DEBUG=yes)
if [ "${DEBUG}" == "yes" ]; then
add_config_value "debug_peer_level" "2"
fi
echo "----------------------------------------"
echo ""
# Create SASL password file
echo "Configuring SASL authentication..."
echo "[${SMTP_SERVER}]:${SMTP_PORT} ${SMTP_USERNAME}:${SMTP_PASSWORD}" > /etc/postfix/sasl_passwd
chmod 600 /etc/postfix/sasl_passwd
postmap /etc/postfix/sasl_passwd
# Set permissions on the generated database file (may have different extensions)
chmod 600 /etc/postfix/sasl_passwd.* 2>/dev/null || true
echo " SASL credentials configured for ${SMTP_SERVER}:${SMTP_PORT}"
echo ""
# Set header tag if specified
if [ ! -z "${SMTP_HEADER_TAG}" ]; then
echo "Setting SMTP header tag: ${SMTP_HEADER_TAG}"
postconf -e "header_checks = regexp:/etc/postfix/header_tag"
echo -e "/^MIME-Version:/i PREPEND RelayTag: $SMTP_HEADER_TAG\n/^Content-Transfer-Encoding:/i PREPEND RelayTag: $SMTP_HEADER_TAG" > /etc/postfix/header_tag
echo ""
fi
# Clean up old PID file if mounting /var/spool/postfix
echo "Cleaning up old PID files..."
rm -f /var/spool/postfix/pid/master.pid
# Validate postfix configuration
echo "Validating Postfix configuration..."
postconf -c /etc/postfix/
if [[ $? != 0 ]]; then
echo ""
echo "========================================"
echo "ERROR: Postfix configuration error!"
echo "========================================"
exit 1
fi
echo ""
echo "========================================"
echo "Postfix configuration completed successfully"
echo "========================================"
echo "Provider: ${SMTP_PROVIDER}"
echo "Relay host: ${SMTP_SERVER}:${SMTP_PORT}"
echo "Hostname: ${SERVER_HOSTNAME}"
echo "Domain: ${DOMAIN}"
echo "Networks: ${nets}"
echo "========================================"
echo ""
# Start postfix in foreground mode
echo "Starting Postfix in foreground mode..."
echo ""
# Run postfix in foreground - this will handle all logging to stdout/stderr
exec postfix start-fg
+9 -14
View File
@@ -1,7 +1,7 @@
.PHONY: help build up down restart logs shell test clean status queue config .PHONY: help build up down restart logs test clean status queue config
# Change to parent directory for docker-compose commands # Change to parent directory for docker-compose commands
COMPOSE=cd .. && docker-compose COMPOSE=cd .. && docker compose
# Default target # Default target
help: help:
@@ -22,7 +22,6 @@ help:
@echo " make health - Check container health" @echo " make health - Check container health"
@echo "" @echo ""
@echo "Maintenance:" @echo "Maintenance:"
@echo " make shell - Open shell in container"
@echo " make config - Show Postfix configuration" @echo " make config - Show Postfix configuration"
@echo " make test - Send test email (auto-detects container)" @echo " make test - Send test email (auto-detects container)"
@echo " Usage: make test TO=user@example.com [FROM=sender@domain.com]" @echo " Usage: make test TO=user@example.com [FROM=sender@domain.com]"
@@ -68,10 +67,6 @@ logs:
logs-tail: logs-tail:
@$(COMPOSE) logs --tail=100 @$(COMPOSE) logs --tail=100
# Open shell in container
shell:
@$(COMPOSE) exec postfix bash
# Show container and Postfix status # Show container and Postfix status
status: status:
@echo "Container Status:" @echo "Container Status:"
@@ -80,7 +75,7 @@ status:
@echo "" @echo ""
@echo "Postfix Status:" @echo "Postfix Status:"
@echo "===============" @echo "==============="
@$(COMPOSE) exec postfix postfix status || echo "Container not running" @$(COMPOSE) exec postfix /usr/local/bin/postfix-entrypoint --healthcheck || echo "Container not running"
# View mail queue # View mail queue
queue: queue:
@@ -103,7 +98,7 @@ config:
# Check Postfix configuration # Check Postfix configuration
check: check:
@echo "Checking Postfix configuration..." @echo "Checking Postfix configuration..."
@$(COMPOSE) exec postfix postfix check @$(COMPOSE) exec postfix /usr/local/bin/postfix-entrypoint --check
@$(COMPOSE) exec postfix postconf -c /etc/postfix/ @$(COMPOSE) exec postfix postconf -c /etc/postfix/
@echo "Configuration check complete" @echo "Configuration check complete"
@@ -117,14 +112,14 @@ test:
echo " make test TO=user@example.com FROM=noreply@mydomain.com"; \ echo " make test TO=user@example.com FROM=noreply@mydomain.com"; \
echo " make test TO=user@example.com FROM=sender@domain.com SUBJECT='My Test'"; \ echo " make test TO=user@example.com FROM=sender@domain.com SUBJECT='My Test'"; \
echo ""; \ echo ""; \
echo "Note: Container is auto-detected from docker-postfix-postfix image"; \ echo "Note: Container defaults to postfix-relay; override with CONTAINER=name"; \
echo " FROM defaults to noreply@<DOMAIN> if not specified"; \ echo " FROM defaults to noreply@<DOMAIN> if not specified"; \
exit 1; \ exit 1; \
fi fi
@if [ -n "$(CONTAINER)" ]; then \ @if [ -n "$(CONTAINER)" ]; then \
./test-email.sh $(CONTAINER) $(TO) $(FROM) $(SUBJECT); \ ./test-email.sh "$(CONTAINER)" "$(TO)" "$(FROM)" "$(SUBJECT)"; \
else \ else \
./test-email.sh $(TO) $(FROM) $(SUBJECT); \ ./test-email.sh "$(TO)" "$(FROM)" "$(SUBJECT)"; \
fi fi
# Validate environment file # Validate environment file
@@ -159,7 +154,7 @@ clean-all: clean
queue-detail: queue-detail:
@echo "Detailed Queue Information:" @echo "Detailed Queue Information:"
@echo "==========================" @echo "=========================="
@$(COMPOSE) exec postfix sh -c "postqueue -p | tail -n 1" @$(COMPOSE) exec -T postfix postqueue -p | tail -n 1
# Delete all queued messages # Delete all queued messages
queue-delete: queue-delete:
@@ -176,7 +171,7 @@ queue-delete:
debug: debug:
@echo "Restarting with DEBUG mode enabled..." @echo "Restarting with DEBUG mode enabled..."
@$(COMPOSE) down @$(COMPOSE) down
@cd .. && DEBUG=yes docker-compose up @cd .. && DEBUG=yes docker compose up
# Show real-time statistics # Show real-time statistics
stats: stats:
+29 -191
View File
@@ -1,200 +1,38 @@
#!/bin/bash #!/usr/bin/env bash
# Host-side helper; the container only executes Postfix's native sendmail.
set -euo pipefail
set -e if [[ $# -lt 1 ]]; then
echo "Usage: $0 [container] recipient [sender] [subject]" >&2
# Colors for output exit 1
RED='\033[0;31m' fi
GREEN='\033[0;32m' if [[ $1 == *@* ]]; then
YELLOW='\033[1;33m' container=${CONTAINER:-postfix-relay}
NC='\033[0m' # No Color
# Parse arguments flexibly
# Supports: recipient [from] [subject]
# Or: container recipient [from] [subject]
CONTAINER_NAME=""
RECIPIENT=""
FROM_EMAIL=""
SUBJECT="Test Email from Postfix Relay"
# Check if first argument looks like an email
if [[ "$1" =~ @.*\. ]]; then
# First arg is email - it's the recipient
RECIPIENT="${1}"
FROM_EMAIL="${2}"
SUBJECT="${3:-Test Email from Postfix Relay}"
else else
# First arg is not email - assume it's container name container=$1
if [ -n "$1" ]; then shift
CONTAINER_NAME="${1}"
RECIPIENT="${2}"
FROM_EMAIL="${3}"
SUBJECT="${4:-Test Email from Postfix Relay}"
fi fi
fi recipient=${1:?Recipient required}
from=${2:-}
# Function to print colored messages subject=${3:-Test Email from Postfix Relay}
print_error() { if [[ ! $recipient =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$ ]]; then
echo -e "${RED}ERROR: $1${NC}" >&2 echo "Invalid recipient" >&2
}
print_success() {
echo -e "${GREEN}SUCCESS: $1${NC}"
}
print_info() {
echo -e "${YELLOW}INFO: $1${NC}"
}
# Show usage if recipient is not provided
if [ -z "$RECIPIENT" ]; then
echo "Usage: $0 RECIPIENT_EMAIL [FROM_EMAIL] [SUBJECT]"
echo " or: $0 CONTAINER_NAME RECIPIENT_EMAIL [FROM_EMAIL] [SUBJECT]"
echo ""
echo "Arguments:"
echo " RECIPIENT_EMAIL - Email address to send test email to (required)"
echo " FROM_EMAIL - From email address (optional, default: noreply@<domain>)"
echo " SUBJECT - Email subject (default: 'Test Email from Postfix Relay')"
echo " CONTAINER_NAME - Docker container name (optional, auto-detected)"
echo ""
echo "Examples:"
echo " $0 user@example.com"
echo " $0 user@example.com sender@mydomain.com"
echo " $0 user@example.com sender@mydomain.com 'My Test Email'"
echo " $0 my-postfix user@example.com sender@mydomain.com"
echo ""
exit 1 exit 1
fi fi
if [[ -z $from ]]; then
# Auto-detect container name if not provided domain=$(docker exec "$container" postconf -h mydomain)
if [ -z "$CONTAINER_NAME" ]; then from="noreply@$domain"
print_info "Auto-detecting container..."
# Try to find container by image name pattern
CONTAINER_NAME=$(docker ps --filter "ancestor=docker-postfix-postfix" --format '{{.Names}}' | head -n 1)
# Fallback: try common naming patterns
if [ -z "$CONTAINER_NAME" ]; then
CONTAINER_NAME=$(docker ps --filter "name=postfix" --format '{{.Names}}' | head -n 1)
fi fi
if [[ ! $from =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$ || $subject == *$'\r'* || $subject == *$'\n'* ]]; then
if [ -z "$CONTAINER_NAME" ]; then echo "Invalid sender or subject" >&2
print_error "Could not auto-detect container. Please specify container name."
echo ""
echo "Available containers:"
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
echo ""
echo "Usage: $0 CONTAINER_NAME RECIPIENT_EMAIL"
exit 1 exit 1
fi fi
docker exec -i "$container" sendmail -i -f "$from" "$recipient" <<MAIL
From: $from
To: $recipient
Subject: $subject
print_success "Detected container: ${CONTAINER_NAME}" Test email from the Postfix relay, submitted at $(date -u).
fi MAIL
echo "Message submitted; inspect delivery status with docker logs $container."
# Check if container exists docker exec "$container" postqueue -p
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
print_error "Container '${CONTAINER_NAME}' is not running"
echo ""
echo "Available containers:"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
exit 1
fi
print_info "Container: ${CONTAINER_NAME}"
# Get the port mapping for the container
PORT_MAPPING=$(docker port "${CONTAINER_NAME}" 25 2>/dev/null | head -n 1)
if [ -z "$PORT_MAPPING" ]; then
print_error "Could not determine port mapping for container '${CONTAINER_NAME}'"
exit 1
fi
# Extract host and port from mapping (format: 0.0.0.0:25 or 127.0.0.1:25)
HOST=$(echo "$PORT_MAPPING" | cut -d: -f1)
PORT=$(echo "$PORT_MAPPING" | cut -d: -f2)
# Convert 0.0.0.0 to 127.0.0.1 for local communication
if [ "$HOST" = "0.0.0.0" ]; then
HOST="127.0.0.1"
fi
# Set default FROM email if not provided
if [ -z "$FROM_EMAIL" ]; then
# Try to get domain from container environment or use hostname
DOMAIN=$(docker exec "$CONTAINER_NAME" printenv DOMAIN 2>/dev/null || echo "relay.local")
FROM_EMAIL="noreply@${DOMAIN}"
fi
print_info "SMTP Server: ${HOST}:${PORT}"
print_info "From: ${FROM_EMAIL}"
print_info "Recipient: ${RECIPIENT}"
print_info "Subject: ${SUBJECT}"
echo ""
# Generate email body with timestamp
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S %Z")
EMAIL_BODY="This is a test email sent from the Postfix SMTP relay.
Container: ${CONTAINER_NAME}
Sent at: ${TIMESTAMP}
SMTP Server: ${HOST}:${PORT}
If you received this email, your Postfix relay is working correctly!
---
Automated test email"
print_info "Sending test email..."
echo ""
# Method 1: Try using sendmail directly (most reliable for Postfix)
print_info "Attempting to send via sendmail..."
SENDMAIL_OUTPUT=$(docker exec -i "$CONTAINER_NAME" sendmail -v -f "$FROM_EMAIL" "$RECIPIENT" 2>&1 << EOFMAIL
From: ${FROM_EMAIL}
To: ${RECIPIENT}
Subject: ${SUBJECT}
${EMAIL_BODY}
EOFMAIL
)
SENDMAIL_EXIT=$?
if [ $SENDMAIL_EXIT -eq 0 ]; then
print_success "Email queued successfully via sendmail"
echo "$SENDMAIL_OUTPUT" | grep -i "queued\|sent" || true
else
print_error "Sendmail method failed"
echo "Output: $SENDMAIL_OUTPUT"
echo ""
# Method 2: Try using mailx/mail command
print_info "Trying alternative method using mail command..."
MAIL_OUTPUT=$(docker exec "$CONTAINER_NAME" sh -c "
echo '${EMAIL_BODY}' | mail -v -s '${SUBJECT}' -r '${FROM_EMAIL}' '${RECIPIENT}' 2>&1
")
MAIL_EXIT=$?
if [ $MAIL_EXIT -eq 0 ]; then
print_success "Email queued successfully via mail command"
else
print_error "Mail command failed"
echo "Output: $MAIL_OUTPUT"
echo ""
print_info "Check container logs for more details:"
echo " docker logs $CONTAINER_NAME"
exit 1
fi
fi
echo ""
print_info "Checking mail queue..."
docker exec "$CONTAINER_NAME" postqueue -p 2>/dev/null || echo "Queue check not available"
echo ""
print_success "Test complete! Check the recipient mailbox for the test email."
echo ""
print_info "You can monitor the logs with:"
echo " docker logs -f ${CONTAINER_NAME}"
echo ""
+15 -349
View File
@@ -1,353 +1,19 @@
#!/bin/bash #!/usr/bin/env bash
# Host-side checks for the distroless runtime. Email is sent only with --email.
# Postfix SMTP Relay Test Script set -euo pipefail
# This script validates the Postfix relay configuration and sends test emails container=${CONTAINER:-postfix-relay}
case ${1:---check} in
set -e --check|-c|--full|-f)
docker exec "$container" /usr/local/bin/postfix-entrypoint --healthcheck
# Colors for output docker exec "$container" /usr/local/bin/postfix-entrypoint --check
RED='\033[0;31m' docker exec "$container" postqueue -p
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Functions
print_header() {
echo -e "\n${BLUE}========================================${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}========================================${NC}\n"
}
print_success() {
echo -e "${GREEN}✓ $1${NC}"
}
print_error() {
echo -e "${RED}✗ $1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠ $1${NC}"
}
print_info() {
echo -e "${BLUE}ℹ $1${NC}"
}
# Check if .env file exists
check_env() {
print_header "Checking Environment Configuration"
if [ ! -f .env ]; then
print_error ".env file not found"
print_info "Copy env.sample to .env and configure it"
exit 1
fi
print_success ".env file found"
# Source the .env file
source .env
# Check required variables
local required_vars=("SMTP_SERVER" "SMTP_PORT" "SMTP_USERNAME" "SMTP_PASSWORD" "DOMAIN")
local missing_vars=0
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
print_error "$var is not set"
missing_vars=$((missing_vars + 1))
else
print_success "$var is set"
fi
done
if [ $missing_vars -gt 0 ]; then
print_error "$missing_vars required variable(s) missing"
exit 1
fi
print_info "Provider: $SMTP_SERVER:$SMTP_PORT"
print_info "Domain: $DOMAIN"
}
# Check if Docker is running
check_docker() {
print_header "Checking Docker"
if ! command -v docker &> /dev/null; then
print_error "Docker is not installed"
exit 1
fi
print_success "Docker is installed"
if ! docker info &> /dev/null; then
print_error "Docker daemon is not running"
exit 1
fi
print_success "Docker daemon is running"
if ! command -v docker-compose &> /dev/null; then
print_error "Docker Compose is not installed"
exit 1
fi
print_success "Docker Compose is installed"
}
# Check container status
check_container() {
print_header "Checking Container Status"
if ! docker ps --filter name=postfix-relay --format '{{.Names}}' | grep -q postfix-relay; then
print_warning "Container is not running"
print_info "Starting container..."
docker-compose up -d
sleep 10
fi
if docker ps --filter name=postfix-relay --format '{{.Names}}' | grep -q postfix-relay; then
print_success "Container is running"
else
print_error "Failed to start container"
exit 1
fi
# Check health status
health_status=$(docker inspect postfix-relay --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown")
if [ "$health_status" = "healthy" ]; then
print_success "Container is healthy"
elif [ "$health_status" = "starting" ]; then
print_warning "Container is starting..."
print_info "Waiting for health check..."
sleep 10
else
print_warning "Health status: $health_status"
fi
}
# Check Postfix status
check_postfix() {
print_header "Checking Postfix Status"
if docker exec postfix-relay postfix status &> /dev/null; then
print_success "Postfix is running"
else
print_error "Postfix is not running"
print_info "Checking logs..."
docker logs --tail 50 postfix-relay
exit 1
fi
}
# Check network connectivity
check_network() {
print_header "Checking Network Connectivity"
source .env
# Check if nc (netcat) is available in container
if docker exec postfix-relay which nc &> /dev/null; then
if docker exec postfix-relay nc -zv ${SMTP_SERVER} ${SMTP_PORT} 2>&1 | grep -q succeeded; then
print_success "Can connect to ${SMTP_SERVER}:${SMTP_PORT}"
else
print_error "Cannot connect to ${SMTP_SERVER}:${SMTP_PORT}"
print_info "Check firewall and network settings"
fi
else
print_warning "netcat not available, skipping connectivity test"
fi
}
# Check Postfix configuration
check_config() {
print_header "Validating Postfix Configuration"
if docker exec postfix-relay postfix check 2>&1 | grep -q error; then
print_error "Postfix configuration has errors"
docker exec postfix-relay postfix check
exit 1
else
print_success "Postfix configuration is valid"
fi
# Show key configuration
print_info "Key Configuration Values:"
docker exec postfix-relay postconf -n | grep -E "^(myhostname|mydomain|relayhost|mynetworks)" | while read line; do
echo " $line"
done
}
# Check mail queue
check_queue() {
print_header "Checking Mail Queue"
queue_output=$(docker exec postfix-relay postqueue -p)
if echo "$queue_output" | grep -q "Mail queue is empty"; then
print_success "Mail queue is empty"
else
print_warning "Mail queue has messages:"
echo "$queue_output"
fi
}
# Send test email
send_test_email() {
print_header "Sending Test Email"
if [ -z "$1" ]; then
read -p "Enter recipient email address: " recipient
else
recipient="$1"
fi
if [ -z "$recipient" ]; then
print_error "No recipient provided"
return 1
fi
# Validate email format
if ! echo "$recipient" | grep -qE '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'; then
print_error "Invalid email address format"
return 1
fi
print_info "Sending test email to: $recipient"
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
hostname=$(docker exec postfix-relay hostname)
test_message="Test email from Postfix SMTP Relay
Timestamp: $timestamp
Hostname: $hostname
Container: postfix-relay
If you receive this email, your Postfix relay is working correctly!
"
if docker exec postfix-relay sh -c "echo '$test_message' | mail -s 'Postfix Relay Test - $timestamp' $recipient" 2>&1; then
print_success "Test email sent to $recipient"
print_info "Check the mail queue and logs:"
echo ""
docker exec postfix-relay postqueue -p
else
print_error "Failed to send test email"
return 1
fi
}
# Show logs
show_logs() {
print_header "Recent Logs"
docker logs --tail 50 postfix-relay
}
# Full test suite
run_full_test() {
print_header "Running Full Test Suite"
check_env
check_docker
check_container
check_postfix
check_network
check_config
check_queue
print_header "Test Summary"
print_success "All checks passed!"
print_info "Container is ready to relay emails"
echo ""
read -p "Do you want to send a test email? (y/n): " send_test
if [ "$send_test" = "y" ] || [ "$send_test" = "Y" ]; then
send_test_email
fi
}
# Main menu
show_menu() {
echo ""
echo -e "${BLUE}Postfix SMTP Relay Test Menu${NC}"
echo "================================"
echo "1) Run full test suite"
echo "2) Check environment"
echo "3) Check container status"
echo "4) Check Postfix configuration"
echo "5) Check mail queue"
echo "6) Send test email"
echo "7) Show logs"
echo "8) Exit"
echo ""
read -p "Select option: " option
case $option in
1) run_full_test ;;
2) check_env ;;
3) check_container && check_postfix ;;
4) check_config ;;
5) check_queue ;;
6) send_test_email ;;
7) show_logs ;;
8) exit 0 ;;
*) print_error "Invalid option"; show_menu ;;
esac
}
# Parse command line arguments
if [ $# -eq 0 ]; then
# No arguments, show menu
show_menu
else
case "$1" in
--full|-f)
run_full_test
;; ;;
--email|-e) --email|-e)
if [ -n "$2" ]; then shift
send_test_email "$2" exec "$(dirname "$0")/test-email.sh" "$container" "$@"
else
send_test_email
fi
;;
--check|-c)
check_env
check_docker
check_container
check_postfix
check_config
;;
--logs|-l)
show_logs
;;
--queue|-q)
check_queue
;;
--help|-h)
echo "Postfix SMTP Relay Test Script"
echo ""
echo "Usage: $0 [option] [arguments]"
echo ""
echo "Options:"
echo " -f, --full Run full test suite"
echo " -c, --check Run configuration checks"
echo " -e, --email [address] Send test email"
echo " -q, --queue Check mail queue"
echo " -l, --logs Show recent logs"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " $0 # Interactive menu"
echo " $0 --full # Run all tests"
echo " $0 --email user@example.com # Send test email"
echo " $0 --check # Check configuration"
echo ""
;;
*)
print_error "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;; ;;
--logs|-l) docker logs --tail 50 "$container" ;;
--queue|-q) docker exec "$container" postqueue -p ;;
--help|-h) echo "Usage: $0 [--check|--full|--email recipient [sender] [subject]|--logs|--queue]" ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac esac
fi
+276
View File
@@ -0,0 +1,276 @@
// Runs in the exact scratch filesystem during the image build. No external mail.
package main
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/smtp"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func command(args ...string) []byte {
c := exec.Command(args[0], args[1:]...)
out, err := c.CombinedOutput()
if err != nil {
panic(fmt.Sprintf("%v: %v\n%s", args, err, out))
}
return out
}
func certificate() tls.Certificate {
key, err := rsa.GenerateKey(rand.Reader, 2048)
must(err)
template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
must(err)
cert, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
must(err)
return cert
}
func serve(conn net.Conn, cert tls.Certificate, mechanism string, delivered chan<- string) {
defer conn.Close()
must(conn.SetDeadline(time.Now().Add(25 * time.Second)))
reader := bufio.NewReader(conn)
secured, authenticated := false, false
send := func(s string) { _, err := fmt.Fprint(conn, s+"\r\n"); must(err) }
send("220 localhost test relay")
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
switch strings.ToUpper(fields[0]) {
case "EHLO":
if secured {
send("250-localhost\r\n250 AUTH " + mechanism)
} else {
send("250-localhost\r\n250 STARTTLS")
}
case "STARTTLS":
send("220 Ready for TLS")
secure := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})
must(secure.Handshake())
conn = secure
reader = bufio.NewReader(conn)
secured = true
case "AUTH":
if !secured || len(fields) < 2 {
send("535 TLS required")
continue
}
decode := func(encoded string) string {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
must(err)
return string(decoded)
}
if fields[1] == "LOGIN" {
encoded := ""
if len(fields) > 2 {
encoded = fields[2]
} else {
send("334 VXNlcm5hbWU6")
encoded, err = reader.ReadString('\n')
must(err)
}
if decode(encoded) != "test-user" {
panic("incorrect SASL username")
}
send("334 UGFzc3dvcmQ6")
encoded, err = reader.ReadString('\n')
must(err)
if decode(encoded) != "test-password" {
panic("incorrect SASL password")
}
} else if fields[1] == "PLAIN" {
encoded := ""
if len(fields) > 2 {
encoded = fields[2]
} else {
send("334 ")
encoded, err = reader.ReadString('\n')
must(err)
}
if decode(encoded) != "\x00test-user\x00test-password" {
panic("incorrect SASL credentials")
}
} else {
panic("unexpected SASL mechanism: " + fields[1])
}
authenticated = true
send("235 Authentication successful")
case "MAIL", "RCPT":
if !authenticated {
send("530 Authentication required")
} else {
send("250 OK")
}
case "DATA":
if !authenticated {
send("530 Authentication required")
continue
}
send("354 End with dot")
var body strings.Builder
for {
part, err := reader.ReadString('\n')
must(err)
if part == ".\r\n" {
break
}
body.WriteString(part)
}
send("250 Queued")
delivered <- body.String()
case "QUIT":
send("221 Bye")
return
case "RSET", "NOOP":
send("250 OK")
default:
panic("unexpected SMTP command: " + line)
}
}
}
func start() *exec.Cmd {
cmd := exec.Command("/usr/local/bin/postfix-entrypoint")
cmd.Env = append(os.Environ(), "SMTP_SERVER=127.0.0.1", "SMTP_PORT=2525", "SMTP_USERNAME=test-user", "SMTP_PASSWORD=test-password",
"SERVER_HOSTNAME=relay.example.test", "DOMAIN=example.test", "LOCAL_NETWORK=127.0.0.0/8", "SMTP_HEADER_TAG=ci-test")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
must(cmd.Start())
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if exec.Command("/usr/local/bin/postfix-entrypoint", "--healthcheck").Run() == nil {
return cmd
}
time.Sleep(200 * time.Millisecond)
}
panic("Postfix did not become healthy")
}
func stop(cmd *exec.Cmd) {
must(cmd.Process.Signal(syscall.SIGTERM))
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-done:
case <-time.After(10 * time.Second):
cmd.Process.Kill()
panic("Postfix did not stop on SIGTERM")
}
}
func main() {
// Guarantee a bounded build even if a daemon misbehaves.
go func() { time.Sleep(80 * time.Second); panic("smoke test timeout") }()
for _, path := range []string{"/bin/sh", "/bin/bash", "/bin/busybox", "/sbin/apk", "/usr/bin/python3", "/usr/bin/go"} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
panic("unexpected runtime tool: " + path)
}
}
missing := exec.Command("/usr/local/bin/postfix-entrypoint")
missing.Env = []string{"PATH=/usr/sbin:/usr/bin:/bin"}
if missing.Run() == nil {
panic("missing credentials accepted")
}
listener, err := net.Listen("tcp4", "127.0.0.1:2525")
must(err)
defer listener.Close()
delivered := make(chan string, 4)
cert := certificate()
go func() {
count := 0
for {
conn, err := listener.Accept()
if err != nil {
return
}
mechanism := "PLAIN"
if count%2 == 1 {
mechanism = "LOGIN"
}
count++
go serve(conn, cert, mechanism, delivered)
}
}()
// An empty queue volume, including stale PID contents, must initialize safely.
must(os.MkdirAll("/var/spool/postfix/pid", 0755))
must(os.WriteFile("/var/spool/postfix/pid/master.pid", []byte("999999\n"), 0644))
// Reproduce the deprecated setting from the previous image.
command("/usr/sbin/postconf", "-e", "smtp_use_tls = yes")
cmd := start()
if strings.Contains(string(command("/usr/sbin/postconf", "-n")), "smtp_use_tls") {
panic("deprecated smtp_use_tls setting survived migration")
}
for _, path := range []string{"/etc/postfix/sasl_passwd", "/etc/postfix/sasl_passwd.lmdb"} {
info, err := os.Stat(path)
must(err)
if info.Mode().Perm() != 0600 {
panic("unsafe credential mode")
}
}
command("/usr/local/bin/postfix-entrypoint", "--check")
// Ensure dynamically loaded PCRE and LMDB maps both work.
must(os.WriteFile("/tmp/test.pcre", []byte("/^test$/ OK\n"), 0644))
if strings.TrimSpace(string(command("/usr/sbin/postmap", "-q", "test", "pcre:/tmp/test.pcre"))) != "OK" {
panic("PCRE plugin failed")
}
for round := 0; round < 2; round++ {
message := "From: sender@example.test\r\nTo: recipient@example.net\r\nSubject: distroless smoke\r\nMIME-Version: 1.0\r\n\r\nLocal smoke message\r\n"
must(smtp.SendMail("127.0.0.1:25", nil, "sender@example.test", []string{"recipient@example.net"}, []byte(message)))
select {
case body := <-delivered:
if !strings.Contains(body, "Local smoke message") || !strings.Contains(body, "RelayTag: ci-test") {
panic("message/header missing")
}
case <-time.After(25 * time.Second):
panic("SASL/TLS relay delivery timed out")
}
// Wait for qmgr to remove the successfully delivered message.
for i := 0; i < 30; i++ {
if strings.Contains(string(command("/usr/sbin/postqueue", "-p")), "Mail queue is empty") {
break
}
time.Sleep(100 * time.Millisecond)
}
if !strings.Contains(string(command("/usr/sbin/postqueue", "-p")), "Mail queue is empty") {
panic("queue did not drain")
}
stop(cmd)
if round == 0 {
cmd = start()
}
}
if exec.Command("/usr/local/bin/postfix-entrypoint", "--healthcheck").Run() == nil {
panic("healthcheck succeeded after shutdown")
}
// No source or test binary is copied into the final runtime.
must(os.MkdirAll(filepath.Dir("/tmp/smoke-passed"), 0755))
must(os.WriteFile("/tmp/smoke-passed", []byte("startup, healthcheck, empty queue, restart, SMTP, STARTTLS, SASL, LMDB, PCRE, header tag, SIGTERM: passed\n"), 0644))
fmt.Println("Distroless Postfix smoke tests passed")
}