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: