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:
@@ -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