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
+6
View File
@@ -194,6 +194,12 @@ IMAP2_SSL=true
IMAP2_POLL_INTERVAL_MINUTES=10
IMAP2_DELETE_AFTER_PROCESS=false
# IMAP Readonly Mode (Feature Flag)
# When true, IMAP processing will fetch and process attachments but will NOT modify
# the mailbox state (no starring, labeling, deleting, or flag changes).
# Use for pre-production instances that share a mailbox with production.
IMAP_READONLY_MODE=false
# **Storage/Document Services**
# Amazon S3
AWS_REGION=us-east-1
+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",
+1
View File
@@ -99,6 +99,7 @@ DocuElevate can monitor multiple IMAP mailboxes for document attachments. Each m
| `IMAP1_PASSWORD` | IMAP password (first mailbox). | `*******` |
| `IMAP1_SSL` | Use SSL (`true`/`false`). | `true` |
| `IMAP1_POLL_INTERVAL_MINUTES` | Frequency in minutes to poll for new mail. | `5` |
| `IMAP_READONLY_MODE` | When `true`, fetches and processes attachments but does **not** modify the mailbox (no starring, labeling, deleting, or flag changes). Use for pre-production instances sharing a mailbox with production. Default: `false`. | `false` |
### Authentication
+139
View File
@@ -566,6 +566,7 @@ class TestPullInbox:
):
"""Test processing messages and marking them as read."""
mock_settings.workdir = "/tmp"
mock_settings.imap_readonly_mode = False
mock_load.return_value = {}
mock_mail = MagicMock()
mock_imap_class.return_value = mock_mail
@@ -605,6 +606,7 @@ class TestPullInbox:
def test_delete_after_process(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
"""Test deleting messages after processing."""
mock_settings.workdir = "/tmp"
mock_settings.imap_readonly_mode = False
mock_load.return_value = {}
mock_mail = MagicMock()
mock_imap_class.return_value = mock_mail
@@ -655,6 +657,7 @@ class TestPullInbox:
):
"""Test that Gmail messages are starred and labeled."""
mock_settings.workdir = "/tmp"
mock_settings.imap_readonly_mode = False
mock_load.return_value = {}
mock_has_label.return_value = False
mock_mail = MagicMock()
@@ -830,6 +833,142 @@ class TestPullInbox:
delete_after_process=False,
)
@patch("app.tasks.imap_tasks.email_already_has_label")
@patch("app.tasks.imap_tasks.mark_as_processed_with_label")
@patch("app.tasks.imap_tasks.mark_as_processed_with_star")
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
@patch("app.tasks.imap_tasks.save_processed_emails")
@patch("app.tasks.imap_tasks.settings")
def test_readonly_mode_skips_gmail_modifications(
self,
mock_settings,
mock_save,
mock_load,
mock_imap_class,
mock_fetch,
mock_star,
mock_label,
mock_has_label,
):
"""Test that readonly mode skips starring and labeling Gmail messages."""
mock_settings.workdir = "/tmp"
mock_settings.imap_readonly_mode = True
mock_load.return_value = {}
mock_has_label.return_value = False
mock_mail = MagicMock()
mock_imap_class.return_value = mock_mail
import email
msg = email.message.EmailMessage()
msg["Message-ID"] = "<test@gmail.com>"
raw_email = msg.as_bytes()
mock_mail.login.return_value = ("OK", [])
mock_mail.select.return_value = ("OK", [])
mock_mail.search.return_value = ("OK", [b"1"])
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
pull_inbox(
mailbox_key="imap2",
host="imap.gmail.com",
port=993,
username="user@gmail.com",
password=_TEST_CREDENTIAL,
use_ssl=True,
delete_after_process=False,
)
# Attachments should still be processed
mock_fetch.assert_called_once()
# But no mailbox modifications
mock_star.assert_not_called()
mock_label.assert_not_called()
mock_mail.store.assert_not_called()
# Processed emails cache should still be updated
mock_save.assert_called()
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
@patch("app.tasks.imap_tasks.save_processed_emails")
@patch("app.tasks.imap_tasks.settings")
def test_readonly_mode_skips_delete(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
"""Test that readonly mode skips deletion even when delete_after_process is True."""
mock_settings.workdir = "/tmp"
mock_settings.imap_readonly_mode = True
mock_load.return_value = {}
mock_mail = MagicMock()
mock_imap_class.return_value = mock_mail
import email
msg = email.message.EmailMessage()
msg["Message-ID"] = "<test@example.com>"
raw_email = msg.as_bytes()
mock_mail.login.return_value = ("OK", [])
mock_mail.select.return_value = ("OK", [])
mock_mail.search.return_value = ("OK", [b"1"])
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
pull_inbox(
mailbox_key="imap1",
host="imap.example.com",
port=993,
username="user",
password=_TEST_CREDENTIAL,
use_ssl=True,
delete_after_process=True,
)
# Attachments should still be processed
mock_fetch.assert_called_once()
# But no mailbox modifications (no delete, no flag changes)
mock_mail.store.assert_not_called()
mock_mail.expunge.assert_not_called()
# Processed emails cache should still be updated
mock_save.assert_called()
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
@patch("app.tasks.imap_tasks.save_processed_emails")
@patch("app.tasks.imap_tasks.settings")
def test_readonly_mode_skips_unseen_flag(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
"""Test that readonly mode skips removing Seen flag on non-Gmail."""
mock_settings.workdir = "/tmp"
mock_settings.imap_readonly_mode = True
mock_load.return_value = {}
mock_mail = MagicMock()
mock_imap_class.return_value = mock_mail
import email
msg = email.message.EmailMessage()
msg["Message-ID"] = "<test@example.com>"
raw_email = msg.as_bytes()
mock_mail.login.return_value = ("OK", [])
mock_mail.select.return_value = ("OK", [])
mock_mail.search.return_value = ("OK", [b"1"])
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
pull_inbox(
mailbox_key="imap1",
host="imap.example.com",
port=993,
username="user",
password=_TEST_CREDENTIAL,
use_ssl=True,
delete_after_process=False,
)
# No flag changes in readonly mode
mock_mail.store.assert_not_called()
@pytest.mark.unit
class TestFetchAttachmentsExtended: