diff --git a/Dockerfile b/Dockerfile index 1bb00e0..e802303 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 -ARG VENDOR="k2patel" -ARG COMPONENT="postfix-relay" -ARG BUILD_DATE -ARG GIT_REPO="https://git.k2patel.in/k2patel/docker-postfix" -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" +# Test in the exact shell-free filesystem; publication depends on this stage. +FROM scratch AS test +COPY --from=build /runtime/ / +ENV PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +RUN --mount=type=bind,from=build,source=/build/smoke,target=/ci-smoke ["/ci-smoke"] -# Labels -LABEL org.label-schema.build-date="${BUILD_DATE}" \ - org.label-schema.name="${NAME}" \ - org.label-schema.description="${DESCRIPTION}" \ - org.label-schema.vcs-ref="${VCS_REF}" \ - org.label-schema.vcs-url="${GIT_REPO}" \ - org.label-schema.vendor="${VENDOR}" \ - org.label-schema.version="${VERSION}" - -# 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 +# Export the pristine runtime, excluding smoke-test credentials, queues and logs. +FROM scratch +COPY --from=build /runtime/ / +COPY --from=test /tmp/smoke-passed /usr/share/postfix-build-check +LABEL org.opencontainers.image.source="https://git.k2patel.in/k2patel/docker-postfix" \ + org.opencontainers.image.description="Alpine/musl distroless Postfix SMTP relay" \ + org.opencontainers.image.licenses="MIT" +ENV PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin EXPOSE 25 - -# Volumes for persistence VOLUME ["/var/spool/postfix"] - -# Health check HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \ - CMD postfix status || exit 1 - -# Run postfix in foreground -ENTRYPOINT ["/run.sh"] + CMD ["/usr/local/bin/postfix-entrypoint", "--healthcheck"] +ENTRYPOINT ["/usr/local/bin/postfix-entrypoint"] diff --git a/README.md b/README.md index 3761ef3..43301bb 100644 --- a/README.md +++ b/README.md @@ -1,465 +1,132 @@ # 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`. -Builds run on Gitea for pushes to `main`, monthly, or by manual dispatch. +`k2patel/postfix:latest` is the single maintained image: a distroless SMTP relay +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) -- 🔒 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 -## Quick Start - -### 1. Clone and Configure - -```bash +```sh git clone https://git.k2patel.in/k2patel/docker-postfix.git cd docker-postfix 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 -cd test -make build -make up -``` - -### 3. Test - -```bash -cd test -make test TO=your@email.com -``` +Configure applications to connect to `postfix-relay:25` on the same Docker +network. Only trusted networks should be allowed to relay. ## 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 | -|----------|----------|---------|-------------| -| `SMTP_SERVER` | Yes | - | SMTP server hostname | -| `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 | +Localhost and the local subnet are trusted. Invalid CIDRs and multiline +configuration values are rejected. Credentials are stored in root-only files; +the launcher does not print their contents. -### 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 -```bash -SMTP_SERVER=smtp.maileroo.com -SMTP_PORT=587 -SMTP_USERNAME=noreply@example.com -SMTP_PASSWORD=your_password -DOMAIN=example.com -``` +Provider examples are included in `env.sample`. For SendGrid, use the literal +username `apikey`. Gmail requires an app password. Keep credentials in `.env`, +which is excluded from Git and the Docker build context. -#### Mailtrap -```bash -SMTP_SERVER=live.smtp.mailtrap.io -SMTP_PORT=587 -SMTP_USERNAME=your_username -SMTP_PASSWORD=your_password -DOMAIN=example.com -``` +## Updating an existing deployment -#### SendGrid -```bash -SMTP_SERVER=smtp.sendgrid.net -SMTP_PORT=587 -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: +Use `k2patel/postfix:latest`, pull, and recreate the container. Existing environment +settings and `/var/spool/postfix` persistence are retained. The default Compose +file supplies the native health check. Replace an old custom `postfix status` +or shell-based health check with: ```yaml -services: - your-app: - environment: - MAIL_HOST: postfix-relay - MAIL_PORT: 25 - depends_on: - - postfix +healthcheck: + test: ["CMD", "/usr/local/bin/postfix-entrypoint", "--healthcheck"] ``` -Connection details: -- **Host**: `postfix-relay` -- **Port**: `25` -- **Authentication**: Not required for local network +The check connects to localhost SMTP and requires a `220` greeting. The old +`postfix status`, `postfix check`, shell access, and `mail`/`mailx` commands are +not part of this image. Use native commands below. Custom pipe/alias delivery +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 -make test TO=recipient@example.com -``` - -**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 help 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 logs-tail - -# Continuous monitoring -make logs +make check +# Sends a real email only when you explicitly run this command: +make test TO=recipient@example.com +# 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) -- Limit relay to trusted networks only -- TLS automatically enabled for known providers -- Use app-specific passwords for Gmail +## Build and CI -## 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 diff --git a/build/runtime-root.py b/build/runtime-root.py new file mode 100644 index 0000000..dfb3c75 --- /dev/null +++ b/build/runtime-root.py @@ -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}' diff --git a/docker-compose.yml b/docker-compose.yml index 0f5a998..f198617 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ -version: "3.8" - services: postfix: + image: docker.io/k2patel/postfix:latest build: . container_name: postfix-relay hostname: ${SERVER_HOSTNAME:-postfix-relay} @@ -17,6 +16,7 @@ services: - DOMAIN=${DOMAIN} - SERVER_HOSTNAME=${SERVER_HOSTNAME} - SMTP_NETWORKS=${SMTP_NETWORKS} + - LOCAL_NETWORK=${LOCAL_NETWORK:-} - SMTP_HEADER_TAG=${SMTP_HEADER_TAG} - DEBUG=${DEBUG:-no} volumes: @@ -32,7 +32,7 @@ services: max-file: "3" labels: "service=postfix-relay" healthcheck: - test: ["CMD", "postfix", "status"] + test: ["CMD", "/usr/local/bin/postfix-entrypoint", "--healthcheck"] interval: 30s timeout: 10s retries: 3 diff --git a/env.sample b/env.sample index d5d165d..014055c 100644 --- a/env.sample +++ b/env.sample @@ -38,7 +38,7 @@ DEBUG=no # ============================================ # -------------------------------------------- -# Option 1: Maileroo (Current Configuration) +# Option 1: Maileroo # -------------------------------------------- SMTP_SERVER='smtp.maileroo.com' SMTP_PORT=587 @@ -95,7 +95,7 @@ SMTP_PASSWORD=your_maileroo_password_here # 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 # # 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 # (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) # diff --git a/etc/supervisor/supervisord.conf b/etc/supervisor/supervisord.conf deleted file mode 100644 index 8bea475..0000000 --- a/etc/supervisor/supervisord.conf +++ /dev/null @@ -1,11 +0,0 @@ -[supervisord] -nodaemon=true -user=root - -[program:rsyslogd] -command = /usr/sbin/rsyslogd -n - -[program:postfix] -command=/run.sh -autorestart=true - diff --git a/install/postfix-entrypoint.go b/install/postfix-entrypoint.go new file mode 100644 index 0000000..0ebb7aa --- /dev/null +++ b/install/postfix-entrypoint.go @@ -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())) +} diff --git a/run.sh b/run.sh deleted file mode 100755 index e660a64..0000000 --- a/run.sh +++ /dev/null @@ -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 diff --git a/test/Makefile b/test/Makefile index 7004ded..0cb0cdc 100644 --- a/test/Makefile +++ b/test/Makefile @@ -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 -COMPOSE=cd .. && docker-compose +COMPOSE=cd .. && docker compose # Default target help: @@ -22,7 +22,6 @@ help: @echo " make health - Check container health" @echo "" @echo "Maintenance:" - @echo " make shell - Open shell in container" @echo " make config - Show Postfix configuration" @echo " make test - Send test email (auto-detects container)" @echo " Usage: make test TO=user@example.com [FROM=sender@domain.com]" @@ -68,10 +67,6 @@ logs: logs-tail: @$(COMPOSE) logs --tail=100 -# Open shell in container -shell: - @$(COMPOSE) exec postfix bash - # Show container and Postfix status status: @echo "Container Status:" @@ -80,7 +75,7 @@ status: @echo "" @echo "Postfix Status:" @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 queue: @@ -103,7 +98,7 @@ config: # Check Postfix configuration check: @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/ @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=sender@domain.com SUBJECT='My Test'"; \ 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@ if not specified"; \ exit 1; \ fi @if [ -n "$(CONTAINER)" ]; then \ - ./test-email.sh $(CONTAINER) $(TO) $(FROM) $(SUBJECT); \ + ./test-email.sh "$(CONTAINER)" "$(TO)" "$(FROM)" "$(SUBJECT)"; \ else \ - ./test-email.sh $(TO) $(FROM) $(SUBJECT); \ + ./test-email.sh "$(TO)" "$(FROM)" "$(SUBJECT)"; \ fi # Validate environment file @@ -159,7 +154,7 @@ clean-all: clean queue-detail: @echo "Detailed Queue Information:" @echo "==========================" - @$(COMPOSE) exec postfix sh -c "postqueue -p | tail -n 1" + @$(COMPOSE) exec -T postfix postqueue -p | tail -n 1 # Delete all queued messages queue-delete: @@ -176,7 +171,7 @@ queue-delete: debug: @echo "Restarting with DEBUG mode enabled..." @$(COMPOSE) down - @cd .. && DEBUG=yes docker-compose up + @cd .. && DEBUG=yes docker compose up # Show real-time statistics stats: diff --git a/test/test-email.sh b/test/test-email.sh index d8361e3..db0c703 100755 --- a/test/test-email.sh +++ b/test/test-email.sh @@ -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 - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -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}" +if [[ $# -lt 1 ]]; then + echo "Usage: $0 [container] recipient [sender] [subject]" >&2 + exit 1 +fi +if [[ $1 == *@* ]]; then + container=${CONTAINER:-postfix-relay} else - # First arg is not email - assume it's container name - if [ -n "$1" ]; then - CONTAINER_NAME="${1}" - RECIPIENT="${2}" - FROM_EMAIL="${3}" - SUBJECT="${4:-Test Email from Postfix Relay}" - fi + container=$1 + shift fi - -# Function to print colored messages -print_error() { - echo -e "${RED}ERROR: $1${NC}" >&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@)" - 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 "" +recipient=${1:?Recipient required} +from=${2:-} +subject=${3:-Test Email from Postfix Relay} +if [[ ! $recipient =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$ ]]; then + echo "Invalid recipient" >&2 exit 1 fi - -# Auto-detect container name if not provided -if [ -z "$CONTAINER_NAME" ]; then - 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 - - if [ -z "$CONTAINER_NAME" ]; then - 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 - fi - - print_success "Detected container: ${CONTAINER_NAME}" +if [[ -z $from ]]; then + domain=$(docker exec "$container" postconf -h mydomain) + from="noreply@$domain" fi - -# Check if container exists -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}}" +if [[ ! $from =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$ || $subject == *$'\r'* || $subject == *$'\n'* ]]; then + echo "Invalid sender or subject" >&2 exit 1 fi +docker exec -i "$container" sendmail -i -f "$from" "$recipient" </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 "" +Test email from the Postfix relay, submitted at $(date -u). +MAIL +echo "Message submitted; inspect delivery status with docker logs $container." +docker exec "$container" postqueue -p diff --git a/test/test.sh b/test/test.sh index 6d73ff0..04ecdde 100755 --- a/test/test.sh +++ b/test/test.sh @@ -1,353 +1,19 @@ -#!/bin/bash - -# Postfix SMTP Relay Test Script -# This script validates the Postfix relay configuration and sends test emails - -set -e - -# Colors for output -RED='\033[0;31m' -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) - if [ -n "$2" ]; then - send_test_email "$2" - 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 - ;; - esac -fi +#!/usr/bin/env bash +# Host-side checks for the distroless runtime. Email is sent only with --email. +set -euo pipefail +container=${CONTAINER:-postfix-relay} +case ${1:---check} in + --check|-c|--full|-f) + docker exec "$container" /usr/local/bin/postfix-entrypoint --healthcheck + docker exec "$container" /usr/local/bin/postfix-entrypoint --check + docker exec "$container" postqueue -p + ;; + --email|-e) + shift + exec "$(dirname "$0")/test-email.sh" "$container" "$@" + ;; + --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 diff --git a/tests/smoke.go b/tests/smoke.go new file mode 100644 index 0000000..9dca492 --- /dev/null +++ b/tests/smoke.go @@ -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") +}