A small SMTP catch-all that accepts any scan-to-email from a network MFP/scanner and dumps the attachments as plain files on disk. Single container, no auth, no TLS, no queue — a LAN-only dump bucket for scanners that don't speak SMB/FTP.
Built on aiosmtpd + ripmime.
Not a mailserver. Don't expose this to the internet. There is no authentication, no sender validation, no rate limit, and no queue.
Content-Disposition: attachment part using Python's stdlib
email module and writes it to /scans/ (or a per-sender subdirectory).ripmime when the stdlib parser finds nothing (rare, but
happens with weirdly-encoded multipart/signed messages)..eml is saved as
unparsed-YYYYMMDD-HHMMSS.eml so a scan is never silently lost.250 OK on success, 451 on transient internal error so the
scanner's own retry takes over. Never 5xxs the scanner permanently.mkdir -p scans
docker compose up -d
Edit docker-compose.yml to point at the published image (or set
image: explicitly):
services:
scan-sink:
image: nopenix/scan-to-folder-mail-sink:latest
# ...
git clone https://github.com/NopeNix/Scan-to-Folder-Mail-Sink.git
cd Scan-to-Folder-Mail-Sink
mkdir -p scans
docker compose build
docker compose up -d
docker compose logs -f
In your MFP/scanner's scan-to-email settings:
25 (or whatever SCAN_SMTP_PORT is set to on the host)scan@anything)[email protected])The sink doesn't care what you put in From/To.
docker run (without compose)docker run -d \
--name scan-sink \
--restart unless-stopped \
-p 25:25 \
-v "$PWD/scans:/scans" \
-e SMTP_HOST=0.0.0.0 \
-e SMTP_PORT=25 \
-e SCAN_DIR=/scans \
-e SUBDIR_BY_FROM=true \
nopenix/scan-to-folder-mail-sink:latest
Send a test message from any LAN machine:
python3 - <<'EOF'
import socket
eml = (
b"From: scan@anything\r\n"
b"To: [email protected]\r\n"
b"Subject: test\r\n"
b"MIME-Version: 1.0\r\n"
b'Content-Type: multipart/mixed; boundary="BOUND"\r\n\r\n'
b"--BOUND\r\n"
b'Content-Type: application/pdf\r\n'
b'Content-Disposition: attachment; filename="test.pdf"\r\n\r\n'
b"%PDF-1.4\n%fake pdf\r\n"
b"--BOUND--\r\n"
)
s = socket.create_connection(("127.0.0.1", 25), timeout=10)
f = s.makefile("rwb", buffering=0)
def cmd(b): f.write(b)
def expect():
line = f.readline()
while line and not (line.startswith(b"250 ") or line.startswith(b"354 ") or line.startswith(b"220 ")):
line = f.readline()
return line.decode().strip()
expect() # 220 banner
cmd(b"EHLO test\r\n"); expect()
cmd(b"MAIL FROM:<scan@anything>\r\n"); expect()
cmd(b"RCPT TO:<[email protected]>\r\n"); expect()
cmd(b"DATA\r\n"); expect()
f.write(eml + b".\r\n"); print(expect())
cmd(b"QUIT\r\n")
EOF
ls -la scans/
# expect scans/scan/test.pdf (subdir = local-part of sender)
Logs should contain:
INFO DATA from=scan@anything [email protected] bytes=...
INFO saved 1 attachment(s) (fallback=False)
All knobs are environment variables. None are required.
| Variable | Default | Description |
|---|---|---|
SMTP_HOST | 0.0.0.0 | Listen address inside the container. |
SMTP_PORT | 25 | SMTP port inside the container. Always 25 unless you change this. |
SCAN_DIR | /scans | Output directory. Must match the bind mount target. |
SUBDIR_BY_FROM | true | Group saved files into a subdirectory named after the sender local-part. Set false to flatten. |
WATCH_HTTP_PORT | (empty) | Reserved for a future HTTP file-server mode. Empty = disabled. |
SCAN_SMTP_PORT | 25 | Host-side port published by docker-compose. Edit docker-compose.yml to change. |
True / false for SUBDIR_BY_FROM accepts: 1, true, yes, on (case-insensitive).
/, \, and any character outside [A-Za-z0-9._-] is
replaced with _. Empty results become attachment. Path traversal in the
filename (../../etc/passwd) is killed by os.path.basename first.<scans>/<file> already exists, the new file becomes
<stem>-<unix_ts><ext> (e.g. dup.pdf → dup-1753041012.pdf). Never
overwrites..eml is dumped as unparsed-YYYYMMDD-HHMMSS.eml so you can
recover the message manually./scans survive because it's a bind mount.docker-compose.yml caps logs at 10 MB × 3 files (json-file
driver) so a misbehaving scanner can't fill the disk.sudo chown -R $USER:$USER scans/.linux/amd64 and linux/arm64 (covers
Raspberry Pi scanners).--restart unless-stopped brings it back on failure.pip install -r requirements.txt
ripmime # install via your package manager if missing
SMTP_PORT=2525 SCAN_DIR=./scans python sink.py
.
├── Dockerfile # python:3.12-slim + ripmime
├── docker-compose.yml # service scan-sink, bind mount, healthcheck
├── sink.py # aiosmtpd Controller + SinkHandler (199 lines)
├── requirements.txt # aiosmtpd, watchfiles
├── .github/workflows/build.yml # multi-arch build + push to Docker Hub
├── .gitignore
└── README.md
.github/workflows/build.yml builds the image for linux/amd64 +
linux/arm64 on every push to main, every v* tag, and on PRs (PRs build
without pushing). Requires the GitHub secrets:
DOCKERHUB_USERNAME — your Docker Hub usernameDOCKERHUB_TOKEN — a Docker Hub access token (not your password)Tags produced: latest (default branch), main, semver from v* tags, plus
the short SHA.
Out of scope on purpose. Don't ask for them as features:
If you need any of those, point your scanner at a real mailserver. This is the opposite of that.
MIT. See LICENSE.
Content type
Image
Digest
sha256:8234000b9…
Size
46 MB
Last updated
2 months ago
docker pull nopenix/scan-to-folder-mail-sink