Compare commits
4 Commits
b0337b2d26
...
658c1e6d5d
| Author | SHA1 | Date | |
|---|---|---|---|
| 658c1e6d5d | |||
| 764577dc23 | |||
| 2e906d9ddd | |||
| 79d3f45068 |
@@ -0,0 +1,67 @@
|
|||||||
|
"""repair mail mailbox index columns
|
||||||
|
|
||||||
|
Revision ID: 5f708192abcd
|
||||||
|
Revises: 4e5f708192ab
|
||||||
|
Create Date: 2026-07-14 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "5f708192abcd"
|
||||||
|
down_revision = "4e5f708192ab"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "mail_mailbox_folder_index" in tables:
|
||||||
|
columns = _columns(inspector, "mail_mailbox_folder_index")
|
||||||
|
indexes = _indexes(inspector, "mail_mailbox_folder_index")
|
||||||
|
if "message_indexed_at" not in columns:
|
||||||
|
op.add_column("mail_mailbox_folder_index", sa.Column("message_indexed_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
if "ix_mail_mailbox_folder_index_message_indexed_at" not in indexes:
|
||||||
|
op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False)
|
||||||
|
|
||||||
|
if "mail_mailbox_message_index" in tables:
|
||||||
|
columns = _columns(inspector, "mail_mailbox_message_index")
|
||||||
|
indexes = _indexes(inspector, "mail_mailbox_message_index")
|
||||||
|
if "sort_position" not in columns:
|
||||||
|
op.add_column("mail_mailbox_message_index", sa.Column("sort_position", sa.BigInteger(), nullable=False, server_default="0"))
|
||||||
|
if op.get_bind().dialect.name != "sqlite":
|
||||||
|
op.alter_column("mail_mailbox_message_index", "sort_position", server_default=None)
|
||||||
|
if "ix_mail_mailbox_message_index_sort_position" not in indexes:
|
||||||
|
op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False)
|
||||||
|
if "ix_mail_mailbox_message_index_page" not in indexes:
|
||||||
|
op.create_index("ix_mail_mailbox_message_index_page", "mail_mailbox_message_index", ["tenant_id", "profile_id", "folder", "sort_position"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "mail_mailbox_message_index" in tables:
|
||||||
|
indexes = _indexes(inspector, "mail_mailbox_message_index")
|
||||||
|
if "ix_mail_mailbox_message_index_page" in indexes:
|
||||||
|
op.drop_index("ix_mail_mailbox_message_index_page", table_name="mail_mailbox_message_index")
|
||||||
|
if "ix_mail_mailbox_message_index_sort_position" in indexes:
|
||||||
|
op.drop_index(op.f("ix_mail_mailbox_message_index_sort_position"), table_name="mail_mailbox_message_index")
|
||||||
|
if "sort_position" in _columns(inspector, "mail_mailbox_message_index"):
|
||||||
|
op.drop_column("mail_mailbox_message_index", "sort_position")
|
||||||
|
if "mail_mailbox_folder_index" in tables:
|
||||||
|
indexes = _indexes(inspector, "mail_mailbox_folder_index")
|
||||||
|
if "ix_mail_mailbox_folder_index_message_indexed_at" in indexes:
|
||||||
|
op.drop_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), table_name="mail_mailbox_folder_index")
|
||||||
|
if "message_indexed_at" in _columns(inspector, "mail_mailbox_folder_index"):
|
||||||
|
op.drop_column("mail_mailbox_folder_index", "message_indexed_at")
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(inspector, table_name: str) -> set[str]:
|
||||||
|
return {column["name"] for column in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _indexes(inspector, table_name: str) -> set[str]:
|
||||||
|
return {index["name"] for index in inspector.get_indexes(table_name)}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""repair mail mailbox index columns
|
||||||
|
|
||||||
|
Revision ID: 5f708192abcd
|
||||||
|
Revises: 4e5f708192ab
|
||||||
|
Create Date: 2026-07-14 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "5f708192abcd"
|
||||||
|
down_revision = "4e5f708192ab"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "mail_mailbox_folder_index" in tables:
|
||||||
|
columns = _columns(inspector, "mail_mailbox_folder_index")
|
||||||
|
indexes = _indexes(inspector, "mail_mailbox_folder_index")
|
||||||
|
if "message_indexed_at" not in columns:
|
||||||
|
op.add_column("mail_mailbox_folder_index", sa.Column("message_indexed_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
if "ix_mail_mailbox_folder_index_message_indexed_at" not in indexes:
|
||||||
|
op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False)
|
||||||
|
|
||||||
|
if "mail_mailbox_message_index" in tables:
|
||||||
|
columns = _columns(inspector, "mail_mailbox_message_index")
|
||||||
|
indexes = _indexes(inspector, "mail_mailbox_message_index")
|
||||||
|
if "sort_position" not in columns:
|
||||||
|
op.add_column("mail_mailbox_message_index", sa.Column("sort_position", sa.BigInteger(), nullable=False, server_default="0"))
|
||||||
|
if op.get_bind().dialect.name != "sqlite":
|
||||||
|
op.alter_column("mail_mailbox_message_index", "sort_position", server_default=None)
|
||||||
|
if "ix_mail_mailbox_message_index_sort_position" not in indexes:
|
||||||
|
op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False)
|
||||||
|
if "ix_mail_mailbox_message_index_page" not in indexes:
|
||||||
|
op.create_index("ix_mail_mailbox_message_index_page", "mail_mailbox_message_index", ["tenant_id", "profile_id", "folder", "sort_position"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "mail_mailbox_message_index" in tables:
|
||||||
|
indexes = _indexes(inspector, "mail_mailbox_message_index")
|
||||||
|
if "ix_mail_mailbox_message_index_page" in indexes:
|
||||||
|
op.drop_index("ix_mail_mailbox_message_index_page", table_name="mail_mailbox_message_index")
|
||||||
|
if "ix_mail_mailbox_message_index_sort_position" in indexes:
|
||||||
|
op.drop_index(op.f("ix_mail_mailbox_message_index_sort_position"), table_name="mail_mailbox_message_index")
|
||||||
|
if "sort_position" in _columns(inspector, "mail_mailbox_message_index"):
|
||||||
|
op.drop_column("mail_mailbox_message_index", "sort_position")
|
||||||
|
if "mail_mailbox_folder_index" in tables:
|
||||||
|
indexes = _indexes(inspector, "mail_mailbox_folder_index")
|
||||||
|
if "ix_mail_mailbox_folder_index_message_indexed_at" in indexes:
|
||||||
|
op.drop_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), table_name="mail_mailbox_folder_index")
|
||||||
|
if "message_indexed_at" in _columns(inspector, "mail_mailbox_folder_index"):
|
||||||
|
op.drop_column("mail_mailbox_folder_index", "message_indexed_at")
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(inspector, table_name: str) -> set[str]:
|
||||||
|
return {column["name"] for column in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _indexes(inspector, table_name: str) -> set[str]:
|
||||||
|
return {index["name"] for index in inspector.get_indexes(table_name)}
|
||||||
@@ -685,7 +685,7 @@ def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -
|
|||||||
|
|
||||||
|
|
||||||
def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]:
|
def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]:
|
||||||
typ, data = client.select(folder, readonly=True)
|
typ, data = client.select(_quote_mailbox_name(folder), readonly=True)
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
|
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
|
||||||
selected_count = _decode_item(data[0] if data else None).strip()
|
selected_count = _decode_item(data[0] if data else None).strip()
|
||||||
@@ -1017,7 +1017,7 @@ def append_message_to_sent(
|
|||||||
client = _open_imap(imap_config)
|
client = _open_imap(imap_config)
|
||||||
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
|
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
|
||||||
internal_date = imaplib.Time2Internaldate(time.time())
|
internal_date = imaplib.Time2Internaldate(time.time())
|
||||||
typ, data = client.append(target_folder, "\\Seen", internal_date, message_bytes)
|
typ, data = client.append(_quote_mailbox_name(target_folder), "\\Seen", internal_date, message_bytes)
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
|
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
|
||||||
response = "; ".join(_decode_item(item) for item in (data or [])) or None
|
response = "; ".join(_decode_item(item) for item in (data or [])) or None
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from redis import Redis
|
from redis import Redis
|
||||||
@@ -17,6 +18,10 @@ class RateLimitDecision:
|
|||||||
waited_seconds: float
|
waited_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
_local_rate_limit_lock = threading.Lock()
|
||||||
|
_local_next_allowed: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
def _redis_client() -> Redis:
|
def _redis_client() -> Redis:
|
||||||
return Redis.from_url(settings.redis_url, decode_responses=True)
|
return Redis.from_url(settings.redis_url, decode_responses=True)
|
||||||
|
|
||||||
@@ -30,15 +35,18 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T
|
|||||||
|
|
||||||
The implementation stores the next allowed send timestamp per key. A Redis
|
The implementation stores the next allowed send timestamp per key. A Redis
|
||||||
lock keeps multiple Celery processes from reading/updating the timestamp at
|
lock keeps multiple Celery processes from reading/updating the timestamp at
|
||||||
the same time. Direct local development runs do not have a broker, so Redis
|
the same time. Direct local development runs do not have a broker, so they
|
||||||
is only used when Celery/worker mode is explicitly enabled. If Redis is
|
use a process-local limiter. If Redis is unavailable in worker mode, the
|
||||||
unavailable, it falls back to no distributed wait.
|
process-local fallback still protects single-process sends.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
messages_per_minute = max(1, int(messages_per_minute or 1))
|
messages_per_minute = max(1, int(messages_per_minute or 1))
|
||||||
gap = 60.0 / messages_per_minute
|
gap = 60.0 / messages_per_minute
|
||||||
if not enabled or not _distributed_rate_limit_enabled():
|
if not enabled:
|
||||||
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0)
|
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0)
|
||||||
|
if not _distributed_rate_limit_enabled():
|
||||||
|
waited = _wait_for_local_rate_limit(key, gap)
|
||||||
|
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
|
||||||
|
|
||||||
redis_key = f"govoplan:ratelimit:{key}:next_allowed"
|
redis_key = f"govoplan:ratelimit:{key}:next_allowed"
|
||||||
lock_key = f"govoplan:ratelimit:{key}:lock"
|
lock_key = f"govoplan:ratelimit:{key}:lock"
|
||||||
@@ -56,7 +64,17 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
client.set(redis_key, now + gap, ex=max(60, int(gap * 10)))
|
client.set(redis_key, now + gap, ex=max(60, int(gap * 10)))
|
||||||
except (RedisError, TimeoutError, ValueError):
|
except (RedisError, TimeoutError, ValueError):
|
||||||
# Development fallback: do not fail sending because Redis is absent.
|
waited = _wait_for_local_rate_limit(key, gap)
|
||||||
waited = 0.0
|
|
||||||
|
|
||||||
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
|
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_local_rate_limit(key: str, gap: float) -> float:
|
||||||
|
with _local_rate_limit_lock:
|
||||||
|
now = time.time()
|
||||||
|
next_allowed = _local_next_allowed.get(key, now)
|
||||||
|
waited = max(0.0, next_allowed - now)
|
||||||
|
_local_next_allowed[key] = max(now, next_allowed) + gap
|
||||||
|
if waited > 0:
|
||||||
|
time.sleep(waited)
|
||||||
|
return waited
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_mail.backend.config import ImapConfig
|
||||||
from govoplan_mail.backend.sending.imap import (
|
from govoplan_mail.backend.sending.imap import (
|
||||||
_detect_sent_folder,
|
_detect_sent_folder,
|
||||||
_extract_mailbox_name,
|
_extract_mailbox_name,
|
||||||
_normalize_mailbox_page,
|
_normalize_mailbox_page,
|
||||||
_paged_descending_sequences,
|
_paged_descending_sequences,
|
||||||
_parse_fetch_sequence,
|
_parse_fetch_sequence,
|
||||||
|
_select_readonly,
|
||||||
_sequence_set,
|
_sequence_set,
|
||||||
|
append_message_to_sent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,5 +73,47 @@ class ImapMessagePaginationTests(unittest.TestCase):
|
|||||||
self.assertEqual(_normalize_mailbox_page(folder=" Sent ", limit=500, offset=3), ("Sent", 100, 3))
|
self.assertEqual(_normalize_mailbox_page(folder=" Sent ", limit=500, offset=3), ("Sent", 100, 3))
|
||||||
|
|
||||||
|
|
||||||
|
class ImapMailboxCommandTests(unittest.TestCase):
|
||||||
|
def test_select_quotes_mailbox_name_with_spaces(self):
|
||||||
|
class Client:
|
||||||
|
untagged_responses = {"EXISTS": [b"0"], "UIDVALIDITY": [b"1"]}
|
||||||
|
|
||||||
|
def select(self, mailbox, readonly=False):
|
||||||
|
self.mailbox = mailbox
|
||||||
|
self.readonly = readonly
|
||||||
|
return "OK", [b"0"]
|
||||||
|
|
||||||
|
def response(self, code):
|
||||||
|
return "OK", [b"1"] if code == "UIDVALIDITY" else []
|
||||||
|
|
||||||
|
client = Client()
|
||||||
|
|
||||||
|
self.assertEqual(_select_readonly(client, "Gesendete Elemente"), (0, "1"))
|
||||||
|
self.assertEqual(client.mailbox, '"Gesendete Elemente"')
|
||||||
|
self.assertTrue(client.readonly)
|
||||||
|
|
||||||
|
def test_append_quotes_mailbox_name_with_spaces(self):
|
||||||
|
class Client:
|
||||||
|
def append(self, mailbox, flags, date_time, message):
|
||||||
|
self.mailbox = mailbox
|
||||||
|
self.flags = flags
|
||||||
|
self.date_time = date_time
|
||||||
|
self.message = message
|
||||||
|
return "OK", [b"APPEND completed"]
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
return "BYE", [b"logged out"]
|
||||||
|
|
||||||
|
client = Client()
|
||||||
|
config = ImapConfig(host="imap.example.org", username="user", password="secret", sent_folder="auto")
|
||||||
|
|
||||||
|
with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client):
|
||||||
|
result = append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config, folder="Gesendete Elemente")
|
||||||
|
|
||||||
|
self.assertEqual(client.mailbox, '"Gesendete Elemente"')
|
||||||
|
self.assertEqual(result.folder, "Gesendete Elemente")
|
||||||
|
self.assertEqual(result.bytes_appended, len(b"Subject: test\r\n\r\nBody"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
80
tests/test_rate_limit.py
Normal file
80
tests/test_rate_limit.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
from govoplan_mail.backend.sending import rate_limit
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
rate_limit._local_next_allowed.clear()
|
||||||
|
|
||||||
|
def test_local_fallback_waits_between_sends(self) -> None:
|
||||||
|
now = [100.0]
|
||||||
|
sleeps: list[float] = []
|
||||||
|
|
||||||
|
def fake_time() -> float:
|
||||||
|
return now[0]
|
||||||
|
|
||||||
|
def fake_sleep(seconds: float) -> None:
|
||||||
|
sleeps.append(seconds)
|
||||||
|
now[0] += seconds
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(rate_limit, "_distributed_rate_limit_enabled", return_value=False),
|
||||||
|
patch.object(rate_limit.time, "time", fake_time),
|
||||||
|
patch.object(rate_limit.time, "sleep", fake_sleep),
|
||||||
|
):
|
||||||
|
first = rate_limit.wait_for_rate_limit(key="tenant:t:campaign:c", messages_per_minute=4)
|
||||||
|
second = rate_limit.wait_for_rate_limit(key="tenant:t:campaign:c", messages_per_minute=4)
|
||||||
|
|
||||||
|
self.assertEqual(first.waited_seconds, 0.0)
|
||||||
|
self.assertEqual(second.waited_seconds, 15.0)
|
||||||
|
self.assertEqual(sleeps, [15.0])
|
||||||
|
|
||||||
|
def test_disabled_rate_limit_does_not_reserve_local_slot(self) -> None:
|
||||||
|
now = [100.0]
|
||||||
|
sleeps: list[float] = []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(rate_limit, "_distributed_rate_limit_enabled", return_value=False),
|
||||||
|
patch.object(rate_limit.time, "time", lambda: now[0]),
|
||||||
|
patch.object(rate_limit.time, "sleep", lambda seconds: sleeps.append(seconds)),
|
||||||
|
):
|
||||||
|
disabled = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=4, enabled=False)
|
||||||
|
enabled = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=4)
|
||||||
|
|
||||||
|
self.assertEqual(disabled.waited_seconds, 0.0)
|
||||||
|
self.assertEqual(enabled.waited_seconds, 0.0)
|
||||||
|
self.assertEqual(sleeps, [])
|
||||||
|
|
||||||
|
def test_redis_failure_falls_back_to_local_wait(self) -> None:
|
||||||
|
now = [100.0]
|
||||||
|
sleeps: list[float] = []
|
||||||
|
|
||||||
|
def fail_redis_client():
|
||||||
|
raise RedisError("redis unavailable")
|
||||||
|
|
||||||
|
def fake_sleep(seconds: float) -> None:
|
||||||
|
sleeps.append(seconds)
|
||||||
|
now[0] += seconds
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(rate_limit, "_distributed_rate_limit_enabled", return_value=True),
|
||||||
|
patch.object(rate_limit, "_redis_client", fail_redis_client),
|
||||||
|
patch.object(rate_limit.time, "time", lambda: now[0]),
|
||||||
|
patch.object(rate_limit.time, "sleep", fake_sleep),
|
||||||
|
):
|
||||||
|
first = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=60)
|
||||||
|
second = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=60)
|
||||||
|
|
||||||
|
self.assertEqual(first.waited_seconds, 0.0)
|
||||||
|
self.assertEqual(second.waited_seconds, 1.0)
|
||||||
|
self.assertEqual(sleeps, [1.0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { ConnectionTree, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
import { ConnectionTree, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
||||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import {
|
import {
|
||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
deactivateMailServerProfile,
|
deactivateMailServerProfile,
|
||||||
fetchMailSettingsDelta,
|
fetchMailSettingsDelta,
|
||||||
getMailProfilePolicy,
|
getMailProfilePolicy,
|
||||||
listImapFolders,
|
|
||||||
listMailProfileImapFolders,
|
|
||||||
mailProfilePatternKeys,
|
mailProfilePatternKeys,
|
||||||
mailProfilePolicyLimitKeys,
|
mailProfilePolicyLimitKeys,
|
||||||
updateMailProfilePolicy,
|
updateMailProfilePolicy,
|
||||||
@@ -804,8 +802,7 @@ function ProfileForm({
|
|||||||
}: {settings: ApiSettings;draft: ProfileDraft;setDraft: (draft: ProfileDraft) => void;editing: EditingProfile;busy: boolean;canWrite: boolean;canManageCredentials: boolean;effectivePolicy?: MailProfilePolicy | null;editTarget: MailProfileEditTarget;}) {
|
}: {settings: ApiSettings;draft: ProfileDraft;setDraft: (draft: ProfileDraft) => void;editing: EditingProfile;busy: boolean;canWrite: boolean;canManageCredentials: boolean;effectivePolicy?: MailProfilePolicy | null;editTarget: MailProfileEditTarget;}) {
|
||||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
|
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null);
|
||||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
|
||||||
|
|
||||||
const disabled = busy || !canWrite;
|
const disabled = busy || !canWrite;
|
||||||
const credentialDisabled = disabled || !canManageCredentials;
|
const credentialDisabled = disabled || !canManageCredentials;
|
||||||
@@ -826,7 +823,6 @@ function ProfileForm({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSmtpTestResult(null);
|
setSmtpTestResult(null);
|
||||||
setImapTestResult(null);
|
setImapTestResult(null);
|
||||||
setFolderResult(null);
|
|
||||||
setMailActionState(null);
|
setMailActionState(null);
|
||||||
}, [editing, editTarget]);
|
}, [editing, editTarget]);
|
||||||
|
|
||||||
@@ -851,8 +847,6 @@ function ProfileForm({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function patchSmtpCredentials(patch: Partial<MailServerCredentialSettings>) {
|
function patchSmtpCredentials(patch: Partial<MailServerCredentialSettings>) {
|
||||||
setDraft({
|
setDraft({
|
||||||
...draft,
|
...draft,
|
||||||
@@ -898,27 +892,6 @@ function ProfileForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runFolderLookup() {
|
|
||||||
if (!draftHasImap) return;
|
|
||||||
setMailActionState("folders");
|
|
||||||
setFolderResult(null);
|
|
||||||
try {
|
|
||||||
setFolderResult(useSavedImapTest && existingProfile ?
|
|
||||||
await listMailProfileImapFolders(settings, existingProfile.id) :
|
|
||||||
await listImapFolders(settings, rawImapPayload(draft, false)));
|
|
||||||
} catch (err) {
|
|
||||||
setFolderResult({ ok: false, message: errorMessage(err), folders: [] });
|
|
||||||
} finally {
|
|
||||||
setMailActionState(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDetectedSentFolder() {
|
|
||||||
const folder = folderResult?.detected_sent_folder;
|
|
||||||
if (!folder || disabled || !draftHasImap) return;
|
|
||||||
setDraft({ ...draft, imapSentFolder: folder });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mail-profile-form">
|
<div className="mail-profile-form">
|
||||||
{showProfileFields &&
|
{showProfileFields &&
|
||||||
@@ -963,12 +936,8 @@ function ProfileForm({
|
|||||||
busyAction={mailActionState}
|
busyAction={mailActionState}
|
||||||
onTestSmtp={() => void runSmtpTest()}
|
onTestSmtp={() => void runSmtpTest()}
|
||||||
onTestImap={() => void runImapTest()}
|
onTestImap={() => void runImapTest()}
|
||||||
onLookupFolders={() => void runFolderLookup()}
|
|
||||||
smtpTestResult={smtpTestResult}
|
smtpTestResult={smtpTestResult}
|
||||||
imapTestResult={imapTestResult}
|
imapTestResult={imapTestResult}
|
||||||
folderLookupResult={folderResult}
|
|
||||||
onUseDetectedFolder={useDetectedSentFolder}
|
|
||||||
useDetectedFolderDisabled={disabled || !draftHasImap}
|
|
||||||
initialSection={initialSection}
|
initialSection={initialSection}
|
||||||
visibleSections={visibleSections}
|
visibleSections={visibleSections}
|
||||||
mode={settingsPanelMode} />
|
mode={settingsPanelMode} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type MailProfileProtocol = "smtp" | "imap";
|
export type MailProfileProtocol = "smtp" | "imap";
|
||||||
export type MailProfileEditSection = MailProfileProtocol | "advanced";
|
export type MailProfileEditSection = MailProfileProtocol;
|
||||||
export type MailProfilePanelMode = "all" | "server" | "credentials";
|
export type MailProfilePanelMode = "all" | "server" | "credentials";
|
||||||
|
|
||||||
export type MailProfileEditTarget =
|
export type MailProfileEditTarget =
|
||||||
@@ -47,7 +47,7 @@ export function mailProfileEditTargetPanelMode(target: MailProfileEditTarget): M
|
|||||||
|
|
||||||
export function mailProfileEditTargetVisibleSections(target: MailProfileEditTarget): MailProfileEditSection[] {
|
export function mailProfileEditTargetVisibleSections(target: MailProfileEditTarget): MailProfileEditSection[] {
|
||||||
if (target.kind === "server" || target.kind === "credentials") return [target.protocol];
|
if (target.kind === "server" || target.kind === "credentials") return [target.protocol];
|
||||||
if (target.kind === "create") return ["smtp", "imap", "advanced"];
|
if (target.kind === "create") return ["smtp", "imap"];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ assertEqual(mailProfileEditTargetInitialSection({ kind: "server", protocol: "ima
|
|||||||
assertEqual(mailProfileEditTargetPanelMode({ kind: "server", protocol: "smtp" }), "server");
|
assertEqual(mailProfileEditTargetPanelMode({ kind: "server", protocol: "smtp" }), "server");
|
||||||
assertEqual(mailProfileEditTargetPanelMode({ kind: "credentials", protocol: "imap" }), "credentials");
|
assertEqual(mailProfileEditTargetPanelMode({ kind: "credentials", protocol: "imap" }), "credentials");
|
||||||
assertEqual(mailProfileEditTargetPanelMode({ kind: "profile" }), null);
|
assertEqual(mailProfileEditTargetPanelMode({ kind: "profile" }), null);
|
||||||
|
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "create" }), ["smtp", "imap"]);
|
||||||
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "credentials", protocol: "imap" }), ["imap"]);
|
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "credentials", protocol: "imap" }), ["imap"]);
|
||||||
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
|
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
|
||||||
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
|
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
|
||||||
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
|
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
|
||||||
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);
|
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user