Pinbox24 — Mailgun Inbound Email Flow Fix Plan
Created: 2026-06-29
Source issue: #2048
Status: Plan — code fixes execute in external GitLab repos (see “Scope & ownership”)
Affected microservices: p24-ms-mailgun (→ mailgun-v42-prod), pinbox24-ms-s3-v2 (→ s3-v2-v42-prod)
Production host: bms-1 (OVH ns367522, 94.23.26.113)
Image registry: AWS ECR 563740926945.dkr.ecr.eu-central-1.amazonaws.com (region eu-central-1)
TL;DR
The inbound email pipeline (Mailgun → p24-ms-mailgun / pinbox24-ms-s3-v2 → MongoDB + Wasabi
S3) is silently broken. Both microservices return a success HTTP status to Mailgun before
attachments finish processing, so Mailgun receives a 2xx and never retries — files are lost
with no error surfaced. There are also several await-less async bugs and undefined-variable bugs
in the mailgun service, plus production credentials hardcoded in docker-deploy-prod.sh in both
repos (a deploy blocker that must be rotated first).
This document is the engineering plan. The line-level code fixes land in the two external GitLab
repos; this infra repo owns the plan, the deploy playbook
(docs/playbooks/pinbox24-ms-deploy.md), and the credential
rotation tracking.
Scope & ownership
| Work item | Repo / system | Owner |
|---|---|---|
pinbox24-ms-s3-v2 async-bug fixes | GitLab pinbox24-ms-s3-v2 | App dev (external repo) |
p24-ms-mailgun async + undefined-var fixes | GitLab p24-ms-mailgun | App dev (external repo) |
| Credential rotation (hardcoded prod secrets) | both repos + SOPS + GitLab CI/CD vars | Human — security blocker |
| Staging verification | bms-3 staging stacks | App dev + infra |
| Production deploy (s3-v2 first, then mailgun) | bms-1 via ECR / GitLab CI | Human — see deploy playbook |
| This plan + deploy playbook | p24-infra (this repo) | infra agent |
Why the code fixes are not in this PR: the source for both microservices lives in separate GitLab repositories, not in
p24-infra. A p24-infra worker has no checkout of those repos. The infra-repo deliverable is this plan and the deploy/rollback playbook; the code changes below are the exact spec for whoever edits the GitLab repos.
Root causes
A. pinbox24-ms-s3-v2 — CRITICAL (silent data loss)
Promise.all()not awaited —scanque.controller.ts:43. The handler responds201before the upload promises resolve. The HTTP 201 races ahead of the actual S3/Mongo writes.forEach+asynccallback —storage.controller.ts:115.Array.prototype.forEachdoes not await async callbacks, so the response fires after the first file and the rest are a fire-and-forget race.- Outer catch returns
201on any error — failures are swallowed and reported to Mailgun as success, so Mailgun never retries a genuinely failed webhook.
B. p24-ms-mailgun — HIGH
mailConfirmation.create()not awaited —integration.js:32andintegration.js:64. Dedup record is written fire-and-forget, so duplicate-detection is unreliable.bodyMail.emailSenderis undefined — Mailgun posts the sender asfrom/sender, notemailSender. The field-name mismatch means sender is always undefined.checkAddressbook()called but never defined — throwsReferenceErrorforaddressbookupdateactions.- No try/catch around
JSON.parse(attachments)— a malformedattachmentspayload throws and takes down the request handler. - No timeout / circuit-breaker on the MongoDB call in the
integration/addhandler (surfaced by incident #3688, 2026-07-10). When the MongoDB connection pool is dead — e.g.mailgun-v42-prodrunning a stalew4_appcredential because the container wasdocker restarted (which does not re-read the env file) instead of fully recreated after a rotation — every webhook hangs forever on a connection that will never be granted, instead of failing fast with a 5xx that Mailgun would retry. 100% of ~450 inbound emails produced zero MongoDB records with no error surfaced anywhere. This is the same class of bug as the “return 500 not 201 so Mailgun retries” fix in §A/§B: the DB layer must fail fast (bounded timeout → 5xx) rather than hang. The infra/process side of #3688 (make container recreate mandatory after anyw4_app/w3_approtation, plus a “webhooks received but zero regRecords created” alert) is handled in p24-infra; the app-level bounded-timeout fix belongs in the externalp24-ms-mailgunGitLab repo alongside the fixes above. Full incident + root cause:docs/playbooks/mailgun-mongodb-stale-credential-hang.md.
C. Security — BLOCKER for deploy
- Production credentials hardcoded in
docker-deploy-prod.shin both repos. - These must be rotated and moved to GitLab CI/CD variables before any deploy. Treat the exposed values as compromised (they are in git history). See “Credential rotation” below.
Fix specification
pinbox24-ms-s3-v2
- Add
awaitbeforePromise.all()in the scanque controller (line ~43) so the response only fires after all uploads resolve. - Replace
forEachwithfor...ofin the storage controller (line ~115) so each async upload is awaited sequentially (or collect promises andawait Promise.all). - Return
500from catch blocks instead of201, so Mailgun retries failed webhooks. - Fix Mailgun URL parsing — replace
slice(8)with a realURLparser. - Add
/tmpfile cleanup after upload, and filename sanitization before writing to S3.
p24-ms-mailgun
- Add
awaitonmailConfirmation.create()(lines ~32 and ~64). - Fix sender resolution:
bodyMail['from'] || bodyMail.senderinstead ofbodyMail.emailSender. - Guard the
checkAddressbook()call (function is not implemented — either implement or no-op guard soaddressbookupdatedoes not throw). - Wrap
JSON.parse(attachments)in try/catch. - Add input validation for required Mailgun fields before processing.
Both repos
- Move credentials out of
docker-deploy-prod.shinto GitLab CI/CD variables. - Rotate every exposed key (see rotation playbook reference below).
Dependency graph
Parallel (independent):
[A] s3-v2: await Promise.all, for..of, 500-on-error, URL parser, tmp cleanup, sanitize
[B] mailgun: await create(), sender field, guard checkAddressbook, JSON.parse guard, validation
[C] Credential rotation (hardcoded secrets removed from git, moved to GitLab CI/CD vars)
Then (A + B + C all done):
[D] Staging verification
[E] Production deploy — s3-v2 FIRST, then mailgun
[A], [B], [C] touch disjoint repos/systems and can proceed in parallel. [D]/[E] are gated
on all three.
Credential rotation (security blocker)
Hardcoded production credentials in docker-deploy-prod.sh (both repos) are exposed in git history
and must be treated as compromised. Rotation is a human action and follows the established
incident-rotation playbook:
docs/playbooks/static-api-key-incident-rotation.md.
Distribution on rotation is mandatory — update the new values in all stores: SOPS
(secrets/*.env.sops), GitLab CI/CD variables, the live container env on bms-1, and any dependent
service. Never echo a secret value into chat, a commit, or a log — reference the key name only.
A dedicated tracking issue is opened for this work: #2052 (human-action, security).
Rollback strategy
| Service | Registry | Rollback |
|---|---|---|
p24-ms-mailgun (mailgun-v42-prod) | AWS ECR | ECR preserves an old- image tag → instant rollback to the previous image |
pinbox24-ms-s3-v2 (s3-v2-v42-prod) | private registry (note: private-registry.dev.pinbox24.com is currently DOWN — confirm before relying on it) | No automatic old- tag — must manually tag the current image before each deploy |
| Both | GitLab pipeline history | Shows the exact image digest for any past deploy — recoverable reference |
The asymmetry is important: s3-v2 has no automatic previous-image tag, so the deploy operator must tag the running image before replacing it. The deploy playbook enforces this as a pre-flight step.
Staging endpoints
| Service | Staging URL |
|---|---|
p24-ms-mailgun | mailgun-v42-stage.dev.pinbox24.com |
pinbox24-ms-s3-v2 | s3-v2-test.dev.pinbox24.com |
Staging stacks run on bms-3 (51.68.155.224).
Verification (staging, before prod)
- Send a test inbound email through the staging Mailgun route to a
*.dev.pinbox24.comrecipient that lands on the staged services. - Confirm the attachment is written to Wasabi S3 and the metadata record to MongoDB.
- Force an error path (e.g. an unreachable S3 bucket) and confirm the service now returns
500(so Mailgun would retry) instead of201. - Confirm no
ReferenceErrorinaddressbookupdateand that the sender field is populated.
Related docs
- Deploy & rollback steps:
docs/playbooks/pinbox24-ms-deploy.md - Mailgun inbound routing (MX, routes, webhooks):
docs/playbooks/mailgun-receiving.md - Mailgun API key rotation:
docs/playbooks/mailgun-api-key-rotation.md - Silent stale-credential hang incident (#3688) — recreate-vs-restart root cause + the app-level no-timeout bug on the
integration/addDB call:docs/playbooks/mailgun-mongodb-stale-credential-hang.md - Pinbox24 architecture & DR audit:
docs/evaluation/04-pinbox24-map-dr-audit.md - bms-1 host operations:
docs/servers/p4-ovh-bms-1-ns367522-operations.md