From f8c5dd539d627569231bbed828acab96be9aa485 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:28:51 +0000 Subject: [PATCH] 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> --- .env.demo | 6 + app/config.py | 9 ++ app/tasks/imap_tasks.py | 23 +-- .../config_validator/settings_display.py | 1 + app/utils/settings_service.py | 12 ++ docs/ConfigurationGuide.md | 1 + tests/test_imap_tasks.py | 139 ++++++++++++++++++ 7 files changed, 181 insertions(+), 10 deletions(-) diff --git a/.env.demo b/.env.demo index 19ef9de1..5d60097b 100644 --- a/.env.demo +++ b/.env.demo @@ -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 diff --git a/app/config.py b/app/config.py index c45f651b..8dce1995 100644 --- a/app/config.py +++ b/app/config.py @@ -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( diff --git a/app/tasks/imap_tasks.py b/app/tasks/imap_tasks.py index cc380d88..981fdbcd 100644 --- a/app/tasks/imap_tasks.py +++ b/app/tasks/imap_tasks.py @@ -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() diff --git a/app/utils/config_validator/settings_display.py b/app/utils/config_validator/settings_display.py index 44015461..1e417166 100644 --- a/app/utils/config_validator/settings_display.py +++ b/app/utils/config_validator/settings_display.py @@ -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"], diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index c27642a0..ea63f017 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -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", diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index dba9c2ae..212e3365 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -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 diff --git a/tests/test_imap_tasks.py b/tests/test_imap_tasks.py index 015cdbf9..3cfe0e25 100644 --- a/tests/test_imap_tasks.py +++ b/tests/test_imap_tasks.py @@ -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"] = "" + 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"] = "" + 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"] = "" + 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: