Sign inSign up

nopenix/scan-to-folder-mail-sink

By nopenix

Updated 2 months ago

Image
0

336

nopenix/scan-to-folder-mail-sink repository overview

Scan-to-Folder Mail Sink

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.


What it does

  1. Listens on SMTP port 25 (configurable).
  2. Accepts mail for any sender and any recipient, no questions asked.
  3. Extracts every Content-Disposition: attachment part using Python's stdlib email module and writes it to /scans/ (or a per-sender subdirectory).
  4. Falls back to ripmime when the stdlib parser finds nothing (rare, but happens with weirdly-encoded multipart/signed messages).
  5. Last-resort: if both fail, the entire raw .eml is saved as unparsed-YYYYMMDD-HHMMSS.eml so a scan is never silently lost.
  6. Returns 250 OK on success, 451 on transient internal error so the scanner's own retry takes over. Never 5xxs the scanner permanently.

Quick start

Using the prebuilt image from Docker Hub
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
    # ...
Building locally from source
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
Pointing the scanner at the sink

In your MFP/scanner's scan-to-email settings:

  • SMTP server: the IP/hostname of the host running the container
  • Port: 25 (or whatever SCAN_SMTP_PORT is set to on the host)
  • TLS/STARTTLS: off
  • Authentication: none
  • From: anything (scan@anything)
  • To: 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

Verifying it works

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)

Configuration

All knobs are environment variables. None are required.

VariableDefaultDescription
SMTP_HOST0.0.0.0Listen address inside the container.
SMTP_PORT25SMTP port inside the container. Always 25 unless you change this.
SCAN_DIR/scansOutput directory. Must match the bind mount target.
SUBDIR_BY_FROMtrueGroup 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_PORT25Host-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).

Filename handling
  • Sanitization: /, \, 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.
  • Collisions: when <scans>/<file> already exists, the new file becomes <stem>-<unix_ts><ext> (e.g. dup.pdfdup-1753041012.pdf). Never overwrites.
  • Unparsed: when both the stdlib parser and ripmime find zero attachments, the entire raw .eml is dumped as unparsed-YYYYMMDD-HHMMSS.eml so you can recover the message manually.

Operational notes

  • No persistence layer. If you lose the container, you only lose pending in-flight mail (which never happens — accepted DATA is written to disk before the 250). Scans already written to /scans survive because it's a bind mount.
  • Log rotation. docker-compose.yml caps logs at 10 MB × 3 files (json-file driver) so a misbehaving scanner can't fill the disk.
  • Permissions. The container runs as root and writes files as root. The bind mount on the host inherits whatever UID/GID the host filesystem allows. If you need to read the files as a non-root user, chown the host directory: sudo chown -R $USER:$USER scans/.
  • Multi-arch image. Pushed for linux/amd64 and linux/arm64 (covers Raspberry Pi scanners).
  • Healthcheck. Tries a TCP connect to localhost:25 every 30s. --restart unless-stopped brings it back on failure.

Development

Run without Docker
pip install -r requirements.txt
ripmime  # install via your package manager if missing
SMTP_PORT=2525 SCAN_DIR=./scans python sink.py
Project layout
.
├── 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
CI

.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 username
  • DOCKERHUB_TOKEN — a Docker Hub access token (not your password)

Tags produced: latest (default branch), main, semver from v* tags, plus the short SHA.


Non-goals

Out of scope on purpose. Don't ask for them as features:

  • TLS / STARTTLS, SASL/PLAIN auth, certificates
  • Multiple virtual hosts, sender/recipient whitelists
  • Queue persistence, retry-with-disk-on-failure
  • Web UI, REST API, admin interface of any kind
  • Virus scanning, MIME filtering, attachment-type limits
  • Database for tracking scans
  • DKIM / SPF / DMARC verification

If you need any of those, point your scanner at a real mailserver. This is the opposite of that.

License

MIT. See LICENSE.

Tag summary

Content type

Image

Digest

sha256:8234000b9

Size

46 MB

Last updated

2 months ago

docker pull nopenix/scan-to-folder-mail-sink