Skip to content

Add email retention sweep and harden untrusted attachment metadata - #46

Merged
Rowee13 merged 2 commits into
mainfrom
chore/email-retention-and-attachment-hardening
Aug 18, 2026
Merged

Rowee13 merged 2 commits into
mainfrom
chore/email-retention-and-attachment-hardening

Conversation

@Rowee13

@Rowee13 Rowee13 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Summary

Two related changes to the API's email handling.

Email retention. Captured emails were kept forever, so storage grew without bound. A daily job now deletes emails older than EMAIL_RETENTION_DAYS (default 7). Attachment rows cascade with the email, but their files on the mounted volume do not, so the job unlinks those first; a missing file is logged and skipped rather than aborting the sweep.

Scheduling cleanup. The demo cleanup cron drops from every 5 minutes to every 30. The demo TTL is 60 minutes, so cleanup stays timely, while the old interval kept a database connection busy around the clock for a table that is almost always empty. ScheduleModule.forRoot() also moves from DemoModule to AppModule — every cron in the app depended on it being registered inside the demo module, so disabling that module would have silently stopped all scheduled jobs.

Attachment metadata hardening. Attachment filenames and content types come from the MIME headers of inbound mail, which anyone can send to the public SMTP listener, so they are untrusted input:

  • Filenames were interpolated directly into the storage path. The timestamp prefix only absorbed the first path segment, so a crafted name could resolve outside the uploads directory. safeAttachmentPath() now keeps only the basename, rejects null bytes, and verifies the result stays within uploadsDir before anything is written.
  • The download endpoint reflected the same values into response headers. Content types are constrained to well-formed, non-renderable mime types (falling back to application/octet-stream), the filename is stripped of quotes, backslashes and control characters so it cannot escape the Content-Disposition parameter, and X-Content-Type-Options: nosniff is set.

Testing

  • 55/55 tests pass, 24 of them new (attachment-path.spec.ts, attachment-headers.spec.ts, email-retention.service.spec.ts)
  • nest build clean
  • New and modified files lint clean

Notes

Configuration adds one variable, documented in .env.example:

EMAIL_RETENTION_DAYS=7   # 0 disables the daily retention sweep

Pre-existing prettier errors in src/main.ts and at emails.service.ts:189 are left untouched to keep the diff focused.

🤖 Generated with Claude Code

Rowee13 and others added 2 commits August 18, 2026 17:58
Captured emails were kept forever, so the database grew without bound.
Add a daily retention job that deletes emails older than
EMAIL_RETENTION_DAYS (default 7). Attachment rows cascade with the email
but their files on the mounted volume do not, so the job unlinks those
first; a missing file is logged and skipped rather than aborting the sweep.

Also drop the demo cleanup cron from every 5 minutes to every 30. The
demo TTL is 60 minutes, so 30 is still timely, and the old interval kept
a connection busy around the clock for a table that is almost always
empty.

Move ScheduleModule.forRoot() from DemoModule to AppModule. Every cron in
the app depended on it being registered there, so disabling the demo
module would have silently stopped all scheduled jobs.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Attachment filenames and content types come from the MIME headers of
inbound mail, which anyone can send to the public SMTP listener, so they
must be treated as untrusted input.

Filenames were interpolated straight into the storage path. The
timestamp prefix only absorbed the first path segment, so a crafted name
could still resolve outside the uploads directory. safeAttachmentPath()
now keeps only the basename, rejects null bytes, and verifies the result
stays inside uploadsDir before anything is written.

The download endpoint reflected the same values into response headers.
The content type is now constrained to a well-formed, non-renderable mime
type (falling back to application/octet-stream) and the filename is
stripped of quotes, backslashes and control characters so it cannot
escape the Content-Disposition parameter. Added X-Content-Type-Options:
nosniff.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@Rowee13
Rowee13 merged commit 4981c35 into main Aug 18, 2026
5 checks passed
@Rowee13
Rowee13 deleted the chore/email-retention-and-attachment-hardening branch August 18, 2026 10:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant