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_POLL_INTERVAL_MINUTES=10
|
||||||
IMAP2_DELETE_AFTER_PROCESS=false
|
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**
|
# **Storage/Document Services**
|
||||||
# Amazon S3
|
# Amazon S3
|
||||||
AWS_REGION=us-east-1
|
AWS_REGION=us-east-1
|
||||||
|
|||||||
@@ -221,6 +221,15 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Feature flags
|
# Feature flags
|
||||||
allow_file_delete: bool = True # Default to allowing file deletion from database
|
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
|
# Batch processing settings
|
||||||
processall_throttle_threshold: int = Field(
|
processall_throttle_threshold: int = Field(
|
||||||
|
|||||||
@@ -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.
|
# We call the function without assigning its return value since it is not used.
|
||||||
fetch_attachments_and_enqueue(email_message)
|
fetch_attachments_and_enqueue(email_message)
|
||||||
|
|
||||||
|
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:
|
if is_gmail_host:
|
||||||
mark_as_processed_with_star(mail, num)
|
mark_as_processed_with_star(mail, num)
|
||||||
mark_as_processed_with_label(mail, num, label="Ingested")
|
mark_as_processed_with_label(mail, num, label="Ingested")
|
||||||
|
|
||||||
processed_emails[msg_id] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
|
||||||
save_processed_emails(processed_emails)
|
|
||||||
|
|
||||||
if delete_after_process:
|
if delete_after_process:
|
||||||
logger.info("Deleting message %s from %s", num.decode(), mailbox_key)
|
logger.info("Deleting message %s from %s", num.decode(), mailbox_key)
|
||||||
mail.store(num, "+FLAGS", "\\Deleted")
|
mail.store(num, "+FLAGS", "\\Deleted")
|
||||||
else:
|
else:
|
||||||
mail.store(num, "-FLAGS", "\\Seen")
|
mail.store(num, "-FLAGS", "\\Seen")
|
||||||
|
|
||||||
if delete_after_process:
|
processed_emails[msg_id] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
save_processed_emails(processed_emails)
|
||||||
|
|
||||||
|
if not settings.imap_readonly_mode and delete_after_process:
|
||||||
mail.expunge()
|
mail.expunge()
|
||||||
|
|
||||||
mail.close()
|
mail.close()
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ def get_settings_for_display(show_values: bool = False) -> dict[str, list[dict[s
|
|||||||
"imap2_ssl",
|
"imap2_ssl",
|
||||||
"imap2_poll_interval_minutes",
|
"imap2_poll_interval_minutes",
|
||||||
"imap2_delete_after_process",
|
"imap2_delete_after_process",
|
||||||
|
"imap_readonly_mode",
|
||||||
],
|
],
|
||||||
"Dropbox": ["dropbox_app_key", "dropbox_app_secret", "dropbox_folder", "dropbox_refresh_token"],
|
"Dropbox": ["dropbox_app_key", "dropbox_app_secret", "dropbox_folder", "dropbox_refresh_token"],
|
||||||
"NextCloud": ["nextcloud_upload_url", "nextcloud_username", "nextcloud_password", "nextcloud_folder"],
|
"NextCloud": ["nextcloud_upload_url", "nextcloud_username", "nextcloud_password", "nextcloud_folder"],
|
||||||
|
|||||||
@@ -1015,6 +1015,18 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_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
|
# Monitoring - Uptime Kuma
|
||||||
"uptime_kuma_url": {
|
"uptime_kuma_url": {
|
||||||
"category": "Monitoring",
|
"category": "Monitoring",
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ DocuElevate can monitor multiple IMAP mailboxes for document attachments. Each m
|
|||||||
| `IMAP1_PASSWORD` | IMAP password (first mailbox). | `*******` |
|
| `IMAP1_PASSWORD` | IMAP password (first mailbox). | `*******` |
|
||||||
| `IMAP1_SSL` | Use SSL (`true`/`false`). | `true` |
|
| `IMAP1_SSL` | Use SSL (`true`/`false`). | `true` |
|
||||||
| `IMAP1_POLL_INTERVAL_MINUTES` | Frequency in minutes to poll for new mail. | `5` |
|
| `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
|
### Authentication
|
||||||
|
|
||||||
|
|||||||
@@ -566,6 +566,7 @@ class TestPullInbox:
|
|||||||
):
|
):
|
||||||
"""Test processing messages and marking them as read."""
|
"""Test processing messages and marking them as read."""
|
||||||
mock_settings.workdir = "/tmp"
|
mock_settings.workdir = "/tmp"
|
||||||
|
mock_settings.imap_readonly_mode = False
|
||||||
mock_load.return_value = {}
|
mock_load.return_value = {}
|
||||||
mock_mail = MagicMock()
|
mock_mail = MagicMock()
|
||||||
mock_imap_class.return_value = mock_mail
|
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):
|
def test_delete_after_process(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
|
||||||
"""Test deleting messages after processing."""
|
"""Test deleting messages after processing."""
|
||||||
mock_settings.workdir = "/tmp"
|
mock_settings.workdir = "/tmp"
|
||||||
|
mock_settings.imap_readonly_mode = False
|
||||||
mock_load.return_value = {}
|
mock_load.return_value = {}
|
||||||
mock_mail = MagicMock()
|
mock_mail = MagicMock()
|
||||||
mock_imap_class.return_value = mock_mail
|
mock_imap_class.return_value = mock_mail
|
||||||
@@ -655,6 +657,7 @@ class TestPullInbox:
|
|||||||
):
|
):
|
||||||
"""Test that Gmail messages are starred and labeled."""
|
"""Test that Gmail messages are starred and labeled."""
|
||||||
mock_settings.workdir = "/tmp"
|
mock_settings.workdir = "/tmp"
|
||||||
|
mock_settings.imap_readonly_mode = False
|
||||||
mock_load.return_value = {}
|
mock_load.return_value = {}
|
||||||
mock_has_label.return_value = False
|
mock_has_label.return_value = False
|
||||||
mock_mail = MagicMock()
|
mock_mail = MagicMock()
|
||||||
@@ -830,6 +833,142 @@ class TestPullInbox:
|
|||||||
delete_after_process=False,
|
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
|
@pytest.mark.unit
|
||||||
class TestFetchAttachmentsExtended:
|
class TestFetchAttachmentsExtended:
|
||||||
|
|||||||
Reference in New Issue
Block a user