Email Feeder¶
Standalone IMAP poller that auto-ingests emails, extracts attachments, creates cases via the REST API, and sends acknowledgements.
Email Feeder¶
The email feeder is a standalone Python service (Pydantic v2) that turns
forwarded emails into Suspicious submissions. It polls one or more IMAP
mailboxes, extracts the reported mail and its attachments, uploads them to
S3-compatible storage (RustFS), and sends an acknowledgement mail through SMTP.
Case creation and analysis are done by the Django backend, which picks the
uploaded submissions up (tasp.cron.fetch_emails).
Flow¶
- A user forwards a suspicious mail to the mailbox watched by the feeder
(
mail-connectorsinconfig.json). - The feeder fetches
UNSEENmessages, parses headers, body and attachments (includingmessage/rfc822and.msgattachments) and applies the size caps. - The submission is uploaded to the object store. A message is marked as seen
only once it was handled; a failed upload leaves it
UNSEENfor the next cycle. - An acknowledgement mail is sent to the reporter through the configured SMTP
server. Mails that cannot be processed get a "bad mail" acknowledgement
(
classes/templates/acknowledge_bad_mail.jinja2).
Upload layouts:
- Prefix contract (when
s3.feeder_bucketis set): one bucket, one prefix per submission (<submission_id>/...), with_status.jsonwritten last so the consumer never sees a partial submission. - Legacy mode (
feeder_bucketempty): one bucket per submission.
Configuration¶
The feeder reads a JSON file (default config.json in the working directory,
override with FEEDER_CONFIG_PATH). Start from config-sample.json.
Local file keys¶
| Key | Default | Meaning |
|---|---|---|
mail-connectors.imap.<name> |
none | Plain IMAP connectors |
mail-connectors.imaps.<name> |
none | Implicit-TLS IMAP connectors (use_ssl is forced on) |
working-path |
/app/case |
Scratch directory for submissions being processed |
timer-inbox-emails |
10 |
Seconds between mailbox refreshes (must be > 0, else the default is used) |
imap-idle |
true |
Wait with IMAP IDLE instead of sleeping (see below) |
caps.max_attachment_bytes |
26214400 (25 MiB) |
Per-attachment limit |
caps.max_attachments |
50 |
Attachment count limit |
caps.max_total_bytes |
52428800 (50 MiB) |
Total submission size limit |
Each connector takes enable, host, port, login, password,
mailbox_to_monitor, and for imaps the optional TLS files certfile,
keyfile, rootcafile. Disabled connectors are skipped.
imap-idle: when exactly one mailbox is enabled and the server advertises IDLE,
the loop blocks in IMAP IDLE so new mail wakes it immediately; the wait is capped
at timer-inbox-emails so the loop still polls and reacts to SIGHUP. With
several mailboxes, a server without IDLE, or "imap-idle": false, it falls back
to interval polling.
S3 and mail settings come from the backend¶
In a deployed stack the s3 and mail blocks are not in config.json. When
both BACKEND_URL and FEEDER_API_TOKEN are set, the feeder calls
GET {BACKEND_URL}/api/config/feeder/ with Authorization: Token <token>
(5 attempts, 2 s apart), builds the s3 and mail blocks from the response and
merges them with the local file (connectors, working path, timers, caps). The
merged result is cached next to the config file as .config-cache.json
(mode 0600) and used if the backend is unreachable at the next start or reload.
With no cache and no backend, startup fails.
Mapping from the backend settings.json (see CONFIG.md at the repository root):
| Feeder key | Source in settings |
|---|---|
s3.endpoint, access_key, secret_key, secure |
storage.s3.* |
mail.server, port, username, password, tls |
email.smtp.* |
mail.footer |
email.content.footer |
mail.group |
email.content.team_name |
mail.company_name |
email.content.global_domain |
mail.company_url |
email.content.website |
mail.suspicious_web |
email.links.submissions |
mail.glossary, inquiry, inquiry_text |
email.links.glossary, inquiry, inquiry_text |
mail.security, security_msg |
email.links.security_contact, security_text |
mail.socials, mail.logos |
email.socials, email.logos |
In API mode the whole s3 block is replaced by the API values, including
storage.s3.feeder_bucket: set it in settings.json to use the prefix
contract, leave it empty for legacy bucket-per-submission mode. A local
s3.feeder_bucket is ignored.
Without BACKEND_URL/FEEDER_API_TOKEN (standalone or development), put the
s3 and mail blocks in config.json yourself:
{
"s3": {
"endpoint": "localhost:9000",
"access_key": "minioadmin",
"secret_key": "minioadmin",
"secure": false,
"feeder_bucket": "suspicious-feeder"
},
"mail": {
"server": "localhost",
"port": 3025,
"username": "SUSPICIOUS",
"password": "imap_password",
"tls": false,
"footer": "Limited Distribution",
"group": "Your Cybersecurity Team Name",
"company_name": "test.com",
"company_url": "https://www.test.com/en",
"suspicious_web": "https://suspicious.test/submissions/",
"glossary": "https://glossary.example",
"inquiry": "mailto:inquiry@example.com",
"inquiry_text": "inquiry@example.com",
"security": "mailto:security@example.com",
"security_msg": "security@example.com",
"socials": {"linkedin": "https://www.linkedin.com/company/test"},
"logos": {
"company": "data:image/png;base64,...",
"acknowledge-badmail": "data:image/png;base64,..."
}
}
}
s3 requires endpoint, access_key, secret_key; mail requires server,
password and tls (the other keys have the defaults shown in
classes/models/configs/internals/mail.py).
Environment variables¶
| Variable | Default | Meaning |
|---|---|---|
BACKEND_URL |
unset | Backend base URL (http://suspicious:9020 in compose) |
FEEDER_API_TOKEN |
unset | Service token for /api/config/feeder/; create it with the create_service_token management command in the backend |
FEEDER_CONFIG_PATH |
config.json |
Path of the local config file |
FEEDER_CA_BUNDLE |
/etc/private/rootcafile.pem |
Compose-level variable, passed to the container as REQUESTS_CA_BUNDLE so the config call trusts the internal CA |
LOG_LEVEL |
INFO |
Log level |
LOG_FORMAT |
json |
Console format: json or plain (files stay JSON) |
LOG_DIR |
/app/log |
Directory for the log files |
FEEDER_TESTING |
unset | 1 disables file handlers (tests) |
OTEL_ENABLED |
true |
Set false to disable OpenTelemetry export |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://tempo:4318 |
OTLP target; failures never block startup |
Operations¶
- Health:
GET :9091/healthreturns JSON status (okordegraded) with IMAP and S3 freshness; it is the container healthcheck. - Reload:
kill -HUP <pid>(ordocker kill -s HUP feeder) reloads the config on the next cycle, so mailboxes can be added or disabled without a restart. - Logs: JSON on stdout plus rotating files (10 MiB x 5) in
LOG_DIR:feeder.log,imap.log,minio.log,smtp.log. Records carrytrace_idandspan_id. The compose stack mounts${FEEDER_PATH}/logson/app/log; create that directory yourself as the host user before the first start, otherwise Docker creates it as root and the feeder cannot write. - Non-root: the image runs as the
feederuser (UID/GID 1000, build argsAPP_UID/APP_GID), withno-new-privilegesand all capabilities dropped in compose. The mailbox content is attacker-controlled, so do not run it as root.working-pathand/app/logmust be writable by that UID.
Tests¶
cd email-feeder
pip install -r requirements-dev.txt
pytest tests
The default suite is offline. The end-to-end test
(tests/test_feeder_e2e_bucket.py) deposits a real report mail in GreenMail and
checks the prefix-contract layout in a real RustFS bucket. It needs the e2e stack
and is skipped unless SUSPICIOUS_E2E=1:
docker compose -f docker-compose.e2e.yaml up -d
SUSPICIOUS_E2E=1 pytest tests/test_feeder_e2e_bucket.py
docker compose -f docker-compose.e2e.yaml down
docker-compose.e2e.yaml publishes SMTP 3025, IMAP 3143 and the S3 API 9000 on
127.0.0.1 (user imap_user / imap_password, S3 minioadmin / minioadmin).
Manual testing¶
docker-compose.dev.yaml starts GreenMail and RustFS for running the feeder by
hand without the full stack:
docker compose -f docker-compose.dev.yaml up -d
| Port | Service |
|---|---|
| 3025 / 3465 | SMTP / SMTPS |
| 3110 / 3995 | POP3 / POP3S |
| 3143 / 3993 | IMAP / IMAPS |
| 9000 / 9001 | RustFS S3 API / admin console |
Default credentials: IMAP imap_user@localhost / imap_password; RustFS
minioadmin / minioadmin. Edit them in the compose file if needed. For this
setup, use the mail-connectors of config-sample.json and the s3/mail
blocks above, then run python3 main.py. Stop the services with
docker compose -f docker-compose.dev.yaml down.