Publish Alpine musl distroless Postfix as the sole latest image
Build and Push Docker Image / build (push) Successful in 1m25s
Build and Push Docker Image / build (push) Successful in 1m25s
This commit is contained in:
1 parent
2a9a7c6baa
commit
7ead00647b
12 files changed
+773
-1271
No files matched your search
+37
-53
@@ -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"]
|
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
[supervisord]
|
|
||||||
nodaemon=true
|
|
||||||
user=root
|
|
||||||
|
|
||||||
[program:rsyslogd]
|
|
||||||
command = /usr/sbin/rsyslogd -n
|
|
||||||
|
|
||||||
[program:postfix]
|
|
||||||
command=/run.sh
|
|
||||||
autorestart=true
|
|
||||||
|
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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}
|
||||||
# Function to print colored messages
|
from=${2:-}
|
||||||
print_error() {
|
subject=${3:-Test Email from Postfix Relay}
|
||||||
echo -e "${RED}ERROR: $1${NC}" >&2
|
if [[ ! $recipient =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$ ]]; then
|
||||||
}
|
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
|
|
||||||
|
|
||||||
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}"
|
|
||||||
fi
|
fi
|
||||||
|
if [[ ! $from =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$ || $subject == *$'\r'* || $subject == *$'\n'* ]]; then
|
||||||
# Check if container exists
|
echo "Invalid sender or subject" >&2
|
||||||
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
docker exec -i "$container" sendmail -i -f "$from" "$recipient" <<MAIL
|
||||||
|
From: $from
|
||||||
|
To: $recipient
|
||||||
|
Subject: $subject
|
||||||
|
|
||||||
print_info "Container: ${CONTAINER_NAME}"
|
Test email from the Postfix relay, submitted at $(date -u).
|
||||||
|
MAIL
|
||||||
# Get the port mapping for the container
|
echo "Message submitted; inspect delivery status with docker logs $container."
|
||||||
PORT_MAPPING=$(docker port "${CONTAINER_NAME}" 25 2>/dev/null | head -n 1)
|
docker exec "$container" postqueue -p
|
||||||
|
|
||||||
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 ""
|
|
||||||
+16
-350
@@ -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)
|
--logs|-l) docker logs --tail 50 "$container" ;;
|
||||||
check_env
|
--queue|-q) docker exec "$container" postqueue -p ;;
|
||||||
check_docker
|
--help|-h) echo "Usage: $0 [--check|--full|--email recipient [sender] [subject]|--logs|--queue]" ;;
|
||||||
check_container
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||||
check_postfix
|
esac
|
||||||
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
|
|
||||||
+276
@@ -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")
|
||||||
|
}
|
||||||
Reference in new issue
Block a user