feat(imap): add attachment type filter for IMAP ingestion

Add a configurable switch to control which attachment types are ingested
via IMAP. Images are excluded by default; office files and PDFs are ingested.

- Add global `IMAP_ATTACHMENT_FILTER` config setting (default: `documents_only`)
- Add `attachment_filter` column to `UserImapAccount` model for per-user override
- Migration 032 adds the column to `user_imap_accounts` table
- Update `fetch_attachments_and_enqueue()` to respect filter (documents_only/all)
- Update `pull_inbox()`, `_pull_user_imap_accounts()`, and
  `_pull_user_integration_imap()` to pass the resolved filter
- Update IMAP accounts API (schemas, create/update handlers, response serializer)
- Update IMAP accounts UI to show attachment filter dropdown in modal and
  display filter badges on account cards
- Add 6 new tests covering attachment filter behaviour
- Update ConfigurationGuide.md, EmailIngestion.md, and .env.demo

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-12 00:59:54 +00:00
parent 5e45b68cc1
commit 554bb21d32
11 changed files with 300 additions and 22 deletions
+26
View File
@@ -110,6 +110,15 @@ class ImapAccountCreate(BaseModel):
use_ssl: bool = Field(default=True, description="Use SSL/TLS connection")
delete_after_process: bool = Field(default=False, description="Delete emails from mailbox after processing")
is_active: bool = Field(default=True, description="Whether to poll this mailbox")
attachment_filter: str | None = Field(
default=None,
description=(
"Controls which attachment types to ingest. "
"'documents_only' PDFs and office files only (default when None). "
"'all' all supported types including images. "
"Null inherits the global imap_attachment_filter setting."
),
)
class ImapAccountUpdate(BaseModel):
@@ -123,6 +132,15 @@ class ImapAccountUpdate(BaseModel):
use_ssl: bool | None = None
delete_after_process: bool | None = None
is_active: bool | None = None
attachment_filter: str | None = Field(
default=None,
description=(
"Controls which attachment types to ingest. "
"'documents_only' PDFs and office files only. "
"'all' all supported types including images. "
"Null or empty string clears the override (inherits global setting)."
),
)
class ImapTestRequest(BaseModel):
@@ -155,6 +173,7 @@ def _to_response(acct: UserImapAccount) -> dict[str, Any]:
"use_ssl": acct.use_ssl,
"delete_after_process": acct.delete_after_process,
"is_active": acct.is_active,
"attachment_filter": acct.attachment_filter,
"last_checked_at": acct.last_checked_at.isoformat() if acct.last_checked_at else None,
"last_error": acct.last_error,
"created_at": acct.created_at.isoformat() if acct.created_at else None,
@@ -222,6 +241,7 @@ def create_imap_account(
use_ssl=body.use_ssl,
delete_after_process=body.delete_after_process,
is_active=body.is_active,
attachment_filter=body.attachment_filter or None,
)
try:
db.add(acct)
@@ -277,6 +297,12 @@ def update_imap_account(
acct.delete_after_process = body.delete_after_process
if body.is_active is not None:
acct.is_active = body.is_active
# attachment_filter uses a sentinel check: the field is always present in the
# model (defaulting to None in Pydantic) so we update it unconditionally when
# the caller sends any value (including explicit null to clear the override).
# An empty string is normalised to None to avoid storing a non-meaningful value.
if "attachment_filter" in body.model_fields_set:
acct.attachment_filter = body.attachment_filter or None
# Reset last_error so the next poll gives a fresh result
acct.last_error = None