Merge pull request #442 from christianlouis/copilot/add-feature-flag-gmail-processing
feat(imap): add IMAP_READONLY_MODE to prevent preprod from modifying shared mailboxes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user