- Python 97.7%
- Dockerfile 2.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Mail2NC CI / Container smoke test (push) Successful in 46s
Mail2NC CI / Unit tests (push) Successful in 47s
Mail2NC CI / Publish tagged multi-arch image (push) Successful in 59s
Mail2NC CI / Security and release validation (push) Successful in 2m22s
Mail2NC CI / Promote release to minor channel and latest (push) Successful in 11s
|
||
| .github/workflows | ||
| examples | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| .metadata.json | ||
| Dockerfile | ||
| LICENSE | ||
| main.py | ||
| README.md | ||
| requirements.lock | ||
Mail2NC
Mail2NC polls an IMAP mailbox over TLS and uploads message attachments to Nextcloud through WebDAV. It is designed to run as a small, non-root, distroless container.
Requirements
- An IMAP account reachable over TLS.
- A Nextcloud account with write access to the destination folder. Use a dedicated Nextcloud app password when OIDC or two-factor authentication is enabled.
- A persistent, writable volume mounted at
/data. - Registry credentials with pull access to
git.bexsys.fr/clement/mail2nc.
Quick start
The hardened example uses Docker secrets and a persistent named volume:
install -d -m 0700 examples/secrets
printf '%s' 'imap-password' > examples/secrets/imap_password
printf '%s' 'nextcloud-app-password' > examples/secrets/nextcloud_password
sudo chown 65532:65532 examples/secrets/*
sudo chmod 0400 examples/secrets/*
docker compose -f examples/docker-compose.yml up -d
Never commit the files under examples/secrets/. Releases keep immutable
SemVer tags such as v2.0.1 and commit tags such as sha-<commit>. Runtime
deployments track the floating minor channel :v2.0: only an exact digest
which has passed both vulnerability scanners and the multi-architecture check
is promoted to :v2.0 and :latest. Serialized release guards refuse to move
the minor channel or :latest back to an older SemVer version.
Local Docker Compose implements file-backed secrets as bind mounts, so the source files must be readable by container UID 65532. Keep their containing directory restricted and their files owned by that UID as shown above.
Configuration
Credentials and service configuration support the _FILE suffix. For example,
IMAP_PASS_FILE=/run/secrets/imap_password reads the secret from that file.
If both forms are present, the file takes precedence. LOG_LEVEL, STATE_FILE
and HEALTH_FILE are read directly from the environment; path overrides must
therefore be set directly for both the process and its container health check.
Required settings
| Variable | Description |
|---|---|
IMAP_HOST |
IMAP server hostname. |
IMAP_USER |
IMAP login name. |
IMAP_PASS |
IMAP password. |
NEXTCLOUD_URL |
HTTPS Nextcloud base URL, without the WebDAV suffix. |
NEXTCLOUD_USER |
Nextcloud user used for WebDAV. |
NEXTCLOUD_PASS |
Nextcloud password or, preferably, app password. |
Optional settings
| Variable | Default | Description |
|---|---|---|
IMAP_PORT |
993 |
IMAP TLS port. |
IMAP_FOLDER |
INBOX |
Mailbox to monitor. |
LEGACY_UIDVALIDITY |
unset | Required only to migrate a legacy last_uid state; it must exactly match the selected mailbox. |
NEXTCLOUD_FOLDER |
/Scans |
Destination below the user's Nextcloud files root. |
POLL_INTERVAL |
30 |
Seconds between successful polling attempts. |
CONNECT_TIMEOUT |
10 |
Network connection timeout in seconds. |
READ_TIMEOUT |
60 |
Network read timeout in seconds. |
MAX_ATTACHMENT_BYTES |
52428800 |
Maximum decoded attachment size (50 MiB). |
MAX_MESSAGE_BYTES |
73400320 |
Maximum raw RFC 822 message size (70 MiB), checked before and after body fetch. |
MAX_TOTAL_ATTACHMENT_BYTES |
52428800 |
Maximum cumulative decoded attachment size per message (50 MiB). |
MAX_ATTACHMENTS |
20 |
Maximum number of attachments per message. |
NO_ATTACHMENT_POLICY |
halt |
halt leaves the message unread and stops the batch; mark_seen explicitly skips it. |
BACKOFF_MAX |
300 |
Maximum retry delay after consecutive poll failures. |
STATE_FILE |
/data/state.json |
Durable processing-state path. Keep it on the persistent volume. |
HEALTH_FILE |
/data/status.json |
Health-state path. Keep it on the persistent volume. |
HEALTH_MAX_AGE |
max(180, 3 × POLL_INTERVAL) |
Maximum age of the last successful poll. |
LOG_LEVEL |
INFO |
Python logging level. |
NEXTCLOUD_URL must use HTTPS. IMAP TLS and HTTPS certificates are validated
against the container trust store.
Processing guarantees
Mail2NC processes UIDs in ascending order and stops at the first message it cannot finish. It only advances its durable watermark after the message is handled successfully, preventing a later UID from permanently hiding an earlier failure.
Attachments with a filename are accepted whether they are declared as
attachment or inline. Filenames are sanitized before being encoded into
the WebDAV URL. Upload, folder-creation, IMAP search/fetch/store and login
results are checked explicitly.
A message with no usable attachment is fail-closed by default: it remains
unread, the watermark does not advance and the current batch stops. Set
NO_ATTACHMENT_POLICY=mark_seen only when silently skipping such messages is
the intended behavior.
Mail2NC uses the standard Nextcloud endpoint
/remote.php/dav/files/{user}/...; see the
Nextcloud WebDAV documentation.
State and recovery
The named volume contains two small JSON files:
/data/state.json: version 2 state with the mailboxUIDVALIDITYand last committed UID./data/status.json: last attempt, last successful poll and consecutive failure count.
{"last_uid": N} is considered ambiguous and is never migrated automatically.
Set LEGACY_UIDVALIDITY to the value independently verified on the selected
mailbox; migration proceeds only when the two values match exactly. Once the
state is version 2, that compatibility setting is ignored.
A changed UIDVALIDITY, invalid JSON or unreadable state stops processing
instead of resetting to UID zero. Back up /data, inspect the mailbox and
resolve the state deliberately; deleting the state can replay old messages and
attachments.
Health check
The image health check runs:
/usr/bin/python3 /app/main.py --healthcheck
It becomes unhealthy for fatal state errors, after three consecutive poll failures, or when the last successful poll is too old. A process that is alive but cannot reach IMAP or Nextcloud will therefore not remain falsely healthy.
Local validation
python3 -m pip install --require-hashes -r requirements.lock
python3 -m unittest discover --start-directory tests --verbose
docker build --tag mail2nc:dev .
The CI workflow runs unit tests and an amd64 smoke build for pull requests and
branch pushes. A stable SemVer tag such as v2.0.1 publishes both :v2.0.1
and :sha-<commit>. Trivy and Grype are pinned by digest, fail closed on scan
or parse errors, reject every fixable vulnerability and allow no count increase
above the reviewed upstream-only baseline. Repository misconfiguration and
secret scans run as part of the same gate. Only then is that exact scanned
multi-architecture digest promoted to the matching minor channel (for example
:v2.0) and to :latest. The serialized SemVer guard prevents an older patch
from moving its minor channel backwards and prevents any older stable release
from moving :latest backwards; pushing main never publishes.