feat(imap): add IMAP_READONLY_MODE feature flag to safeguard shared mailboxes

When enabled, IMAP processing will fetch and process attachments but
will NOT modify the mailbox state (no starring, labeling, deleting,
or flag changes). This allows preprod instances to safely share a
Gmail inbox with production without interfering with production
email processing.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-01 14:28:51 +00:00
parent 88cd8c4098
commit f8c5dd539d
7 changed files with 181 additions and 10 deletions
+9
View File
@@ -221,6 +221,15 @@ class Settings(BaseSettings):
# Feature flags
allow_file_delete: bool = True # Default to allowing file deletion from database
imap_readonly_mode: bool = Field(
default=False,
description=(
"When enabled, IMAP processing will fetch and process attachments but will NOT modify "
"the mailbox state (no starring, labeling, deleting, or flag changes). "
"Use this for pre-production instances that share a mailbox with production to prevent "
"preprod from interfering with production email processing."
),
)
# Batch processing settings
processall_throttle_threshold: int = Field(
+13 -10
View File
@@ -218,20 +218,23 @@ def pull_inbox(mailbox_key, host, port, username, password, use_ssl, delete_afte
# We call the function without assigning its return value since it is not used.
fetch_attachments_and_enqueue(email_message)
if is_gmail_host:
mark_as_processed_with_star(mail, num)
mark_as_processed_with_label(mail, num, label="Ingested")
if settings.imap_readonly_mode:
logger.info("Readonly mode: skipping mailbox modifications for %s in %s", msg_id, mailbox_key)
else:
if is_gmail_host:
mark_as_processed_with_star(mail, num)
mark_as_processed_with_label(mail, num, label="Ingested")
if delete_after_process:
logger.info("Deleting message %s from %s", num.decode(), mailbox_key)
mail.store(num, "+FLAGS", "\\Deleted")
else:
mail.store(num, "-FLAGS", "\\Seen")
processed_emails[msg_id] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
save_processed_emails(processed_emails)
if delete_after_process:
logger.info("Deleting message %s from %s", num.decode(), mailbox_key)
mail.store(num, "+FLAGS", "\\Deleted")
else:
mail.store(num, "-FLAGS", "\\Seen")
if delete_after_process:
if not settings.imap_readonly_mode and delete_after_process:
mail.expunge()
mail.close()
@@ -121,6 +121,7 @@ def get_settings_for_display(show_values: bool = False) -> dict[str, list[dict[s
"imap2_ssl",
"imap2_poll_interval_minutes",
"imap2_delete_after_process",
"imap_readonly_mode",
],
"Dropbox": ["dropbox_app_key", "dropbox_app_secret", "dropbox_folder", "dropbox_refresh_token"],
"NextCloud": ["nextcloud_upload_url", "nextcloud_username", "nextcloud_password", "nextcloud_folder"],
+12
View File
@@ -1015,6 +1015,18 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
"imap_readonly_mode": {
"category": "IMAP",
"description": (
"When enabled, IMAP processing fetches and processes attachments but does NOT modify "
"the mailbox (no starring, labeling, deleting, or flag changes). "
"Use for preprod instances sharing a mailbox with production."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Monitoring - Uptime Kuma
"uptime_kuma_url": {
"category": "Monitoring",