campaign sending prototype
This commit is contained in:
@@ -7,11 +7,11 @@ GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile m
|
|||||||
This repository owns:
|
This repository owns:
|
||||||
|
|
||||||
- backend module manifest `mail`
|
- backend module manifest `mail`
|
||||||
- mail permissions such as `mail:profile:read`, `mail:profile:write`, `mail:profile:use`, and `mail:profile:test`
|
- mail permissions such as `mail:profile:read`, `mail:profile:write`, `mail:profile:use`, `mail:profile:test`, and `mail:mailbox:read`
|
||||||
- SMTP/IMAP profile models, policy checks, encrypted credential storage, and profile resolution
|
- SMTP/IMAP profile models, policy checks, encrypted credential storage, and profile resolution
|
||||||
- SMTP send and IMAP append adapters, including mock transports for development
|
- SMTP send and IMAP append adapters, including mock transports for development
|
||||||
- development mock mailbox endpoints used by test-send flows
|
- development mock mailbox endpoints used by test-send flows
|
||||||
- WebUI package `@govoplan/mail-webui` with profile and policy management components
|
- WebUI package `@govoplan/mail-webui` with profile management, policy management, and read-only mailbox components
|
||||||
|
|
||||||
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
||||||
|
|
||||||
@@ -46,6 +46,6 @@ Frontend package:
|
|||||||
@govoplan/mail-webui
|
@govoplan/mail-webui
|
||||||
```
|
```
|
||||||
|
|
||||||
The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and upcoming read-only mailbox inspection.
|
The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection.
|
||||||
|
|
||||||
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, PermissionDefinition, RoleTemplate
|
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ PERMISSIONS = (
|
|||||||
_permission("mail:profile:read", "View mail profiles", "Inspect reusable SMTP/IMAP profile metadata."),
|
_permission("mail:profile:read", "View mail profiles", "Inspect reusable SMTP/IMAP profile metadata."),
|
||||||
_permission("mail:profile:use", "Use mail profiles", "Select an approved mail profile for delivery."),
|
_permission("mail:profile:use", "Use mail profiles", "Select an approved mail profile for delivery."),
|
||||||
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."),
|
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."),
|
||||||
|
_permission("mail:mailbox:read", "Read mailboxes", "List IMAP folders and inspect messages without mutating mailbox state."),
|
||||||
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
|
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
|
||||||
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."),
|
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."),
|
||||||
)
|
)
|
||||||
@@ -38,6 +39,7 @@ ROLE_TEMPLATES = (
|
|||||||
"mail:profile:read",
|
"mail:profile:read",
|
||||||
"mail:profile:use",
|
"mail:profile:use",
|
||||||
"mail:profile:test",
|
"mail:profile:test",
|
||||||
|
"mail:mailbox:read",
|
||||||
"mail:profile:write",
|
"mail:profile:write",
|
||||||
"mail:secret:manage",
|
"mail:secret:manage",
|
||||||
),
|
),
|
||||||
@@ -46,7 +48,7 @@ ROLE_TEMPLATES = (
|
|||||||
slug="mail_profile_user",
|
slug="mail_profile_user",
|
||||||
name="Mail profile user",
|
name="Mail profile user",
|
||||||
description="Use and test approved mail profiles without reading secrets.",
|
description="Use and test approved mail profiles without reading secrets.",
|
||||||
permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test"),
|
permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test", "mail:mailbox:read"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,7 +71,12 @@ manifest = ModuleManifest(
|
|||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_mail_router,
|
route_factory=_mail_router,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
frontend=FrontendModule(module_id="mail", package_name="@govoplan/mail-webui"),
|
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), order=50),),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="mail",
|
||||||
|
package_name="@govoplan/mail-webui",
|
||||||
|
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), order=50),),
|
||||||
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id="mail",
|
module_id="mail",
|
||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from govoplan_mail.backend.schemas import (
|
|||||||
MailImapFolderListResponse,
|
MailImapFolderListResponse,
|
||||||
MailImapFolderResponse,
|
MailImapFolderResponse,
|
||||||
MailImapTestRequest,
|
MailImapTestRequest,
|
||||||
|
MailMailboxAttachmentResponse,
|
||||||
|
MailMailboxMessageDetailResponse,
|
||||||
|
MailMailboxMessageListResponse,
|
||||||
|
MailMailboxMessageResponse,
|
||||||
|
MailMailboxMessageSummaryResponse,
|
||||||
MailProfilePolicyResponse,
|
MailProfilePolicyResponse,
|
||||||
MailProfilePolicyUpdateRequest,
|
MailProfilePolicyUpdateRequest,
|
||||||
MailServerProfileCreateRequest,
|
MailServerProfileCreateRequest,
|
||||||
@@ -30,7 +35,7 @@ from govoplan_mail.backend.mail_profiles import (
|
|||||||
smtp_config_from_profile,
|
smtp_config_from_profile,
|
||||||
update_mail_server_profile,
|
update_mail_server_profile,
|
||||||
)
|
)
|
||||||
from govoplan_mail.backend.sending.imap import list_imap_folders, test_imap_login
|
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login
|
||||||
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -61,6 +66,11 @@ def _require_profile_credentials_scope(principal: ApiPrincipal, scope_type: str)
|
|||||||
_require_scope(principal, "mail:secret:manage")
|
_require_scope(principal, "mail:secret:manage")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mailbox_read_scope(principal: ApiPrincipal) -> None:
|
||||||
|
_require_scope(principal, "mail:mailbox:read")
|
||||||
|
_require_scope(principal, "mail:profile:use")
|
||||||
|
|
||||||
|
|
||||||
def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||||
if scope_type == "system":
|
if scope_type == "system":
|
||||||
_require_scope(principal, "system:settings:read")
|
_require_scope(principal, "system:settings:read")
|
||||||
@@ -83,6 +93,51 @@ def _profile_response(profile) -> MailServerProfileResponse:
|
|||||||
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
|
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
|
||||||
|
|
||||||
|
|
||||||
|
def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse:
|
||||||
|
return MailMailboxMessageSummaryResponse(
|
||||||
|
uid=message.uid,
|
||||||
|
folder=message.folder,
|
||||||
|
subject=message.subject,
|
||||||
|
from_header=message.from_header,
|
||||||
|
to_header=message.to_header,
|
||||||
|
cc_header=message.cc_header,
|
||||||
|
date=message.date,
|
||||||
|
message_id=message.message_id,
|
||||||
|
flags=message.flags,
|
||||||
|
size_bytes=message.size_bytes,
|
||||||
|
body_preview=message.body_preview,
|
||||||
|
attachment_count=message.attachment_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mailbox_detail_response(message) -> MailMailboxMessageDetailResponse:
|
||||||
|
return MailMailboxMessageDetailResponse(
|
||||||
|
uid=message.uid,
|
||||||
|
folder=message.folder,
|
||||||
|
subject=message.subject,
|
||||||
|
from_header=message.from_header,
|
||||||
|
to_header=message.to_header,
|
||||||
|
cc_header=message.cc_header,
|
||||||
|
date=message.date,
|
||||||
|
message_id=message.message_id,
|
||||||
|
flags=message.flags,
|
||||||
|
size_bytes=message.size_bytes,
|
||||||
|
body_preview=message.body_preview,
|
||||||
|
body_text=message.body_text,
|
||||||
|
body_html=message.body_html,
|
||||||
|
headers=message.headers,
|
||||||
|
attachments=[MailMailboxAttachmentResponse(filename=item.filename, content_type=item.content_type, size_bytes=item.size_bytes) for item in message.attachments],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: str):
|
||||||
|
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True)
|
||||||
|
imap = imap_config_from_profile(profile)
|
||||||
|
if imap is None:
|
||||||
|
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
||||||
|
return imap
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profiles", response_model=MailServerProfileListResponse)
|
@router.get("/profiles", response_model=MailServerProfileListResponse)
|
||||||
def list_profiles(
|
def list_profiles(
|
||||||
include_inactive: bool = False,
|
include_inactive: bool = False,
|
||||||
@@ -311,6 +366,83 @@ def list_profile_imap_folders(
|
|||||||
return MailImapFolderListResponse(ok=False, message=_safe_error_message(exc), folders=[], detected_sent_folder=None, details={"error_type": exc.__class__.__name__})
|
return MailImapFolderListResponse(ok=False, message=_safe_error_message(exc), folders=[], detected_sent_folder=None, details={"error_type": exc.__class__.__name__})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse)
|
||||||
|
def list_profile_mailbox_folders(
|
||||||
|
profile_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_mailbox_read_scope(principal)
|
||||||
|
try:
|
||||||
|
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
|
result = list_imap_folders(imap_config=imap)
|
||||||
|
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
||||||
|
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
|
||||||
|
except MailProfileError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except ImapConfigurationError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
except ImapAppendError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles/{profile_id}/mailbox/messages", response_model=MailMailboxMessageListResponse)
|
||||||
|
def list_profile_mailbox_messages(
|
||||||
|
profile_id: str,
|
||||||
|
folder: str = Query(default="INBOX", min_length=1, max_length=255),
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_mailbox_read_scope(principal)
|
||||||
|
try:
|
||||||
|
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
|
result = list_imap_messages(imap_config=imap, folder=folder, limit=limit)
|
||||||
|
return MailMailboxMessageListResponse(
|
||||||
|
profile_id=profile_id,
|
||||||
|
folder=result.folder,
|
||||||
|
host=result.host,
|
||||||
|
port=result.port,
|
||||||
|
security=result.security,
|
||||||
|
messages=[_mailbox_summary_response(message) for message in result.messages],
|
||||||
|
)
|
||||||
|
except MailProfileError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except ImapConfigurationError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
except ImapAppendError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles/{profile_id}/mailbox/messages/{message_uid}", response_model=MailMailboxMessageResponse)
|
||||||
|
def get_profile_mailbox_message(
|
||||||
|
profile_id: str,
|
||||||
|
message_uid: str,
|
||||||
|
folder: str = Query(default="INBOX", min_length=1, max_length=255),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_mailbox_read_scope(principal)
|
||||||
|
try:
|
||||||
|
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
|
result = get_imap_message(imap_config=imap, folder=folder, uid=message_uid)
|
||||||
|
return MailMailboxMessageResponse(
|
||||||
|
profile_id=profile_id,
|
||||||
|
folder=result.folder,
|
||||||
|
host=result.host,
|
||||||
|
port=result.port,
|
||||||
|
security=result.security,
|
||||||
|
message=_mailbox_detail_response(result.message),
|
||||||
|
)
|
||||||
|
except MailProfileError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except ImapConfigurationError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
except ImapAppendError as exc:
|
||||||
|
status_code = status.HTTP_404_NOT_FOUND if "not found" in str(exc).casefold() else status.HTTP_502_BAD_GATEWAY
|
||||||
|
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def _safe_error_message(exc: Exception) -> str:
|
def _safe_error_message(exc: Exception) -> str:
|
||||||
text = str(exc).strip()
|
text = str(exc).strip()
|
||||||
return text or exc.__class__.__name__
|
return text or exc.__class__.__name__
|
||||||
|
|||||||
@@ -125,3 +125,49 @@ class MailImapFolderListResponse(BaseModel):
|
|||||||
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
||||||
detected_sent_folder: str | None = None
|
detected_sent_folder: str | None = None
|
||||||
details: dict[str, Any] = Field(default_factory=dict)
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
class MailMailboxAttachmentResponse(BaseModel):
|
||||||
|
filename: str | None = None
|
||||||
|
content_type: str
|
||||||
|
size_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
class MailMailboxMessageSummaryResponse(BaseModel):
|
||||||
|
uid: str
|
||||||
|
folder: str
|
||||||
|
subject: str | None = None
|
||||||
|
from_header: str | None = None
|
||||||
|
to_header: str | None = None
|
||||||
|
cc_header: str | None = None
|
||||||
|
date: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
flags: list[str] = Field(default_factory=list)
|
||||||
|
size_bytes: int | None = None
|
||||||
|
body_preview: str | None = None
|
||||||
|
attachment_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class MailMailboxMessageDetailResponse(MailMailboxMessageSummaryResponse):
|
||||||
|
body_text: str | None = None
|
||||||
|
body_html: str | None = None
|
||||||
|
headers: dict[str, str] = Field(default_factory=dict)
|
||||||
|
attachments: list[MailMailboxAttachmentResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class MailMailboxMessageListResponse(BaseModel):
|
||||||
|
profile_id: str
|
||||||
|
folder: str
|
||||||
|
host: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
security: str | None = None
|
||||||
|
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class MailMailboxMessageResponse(BaseModel):
|
||||||
|
profile_id: str
|
||||||
|
folder: str
|
||||||
|
host: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
security: str | None = None
|
||||||
|
message: MailMailboxMessageDetailResponse
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ import socket
|
|||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from email import policy
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from govoplan_mail.backend.config import ImapConfig, TransportSecurity
|
from govoplan_mail.backend.config import ImapConfig, TransportSecurity
|
||||||
from govoplan_mail.backend.dev.mock_mailbox import (
|
from govoplan_mail.backend.dev.mock_mailbox import (
|
||||||
MOCK_IMAP_FOLDERS,
|
MOCK_IMAP_FOLDERS,
|
||||||
consume_fail_next_imap,
|
consume_fail_next_imap,
|
||||||
|
get_record,
|
||||||
is_mock_imap_host,
|
is_mock_imap_host,
|
||||||
|
list_records,
|
||||||
record_imap_append,
|
record_imap_append,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,6 +61,66 @@ class ImapFolderListResult:
|
|||||||
detected_sent_folder: str | None = None
|
detected_sent_folder: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImapMailboxAttachmentInfo:
|
||||||
|
filename: str | None
|
||||||
|
content_type: str
|
||||||
|
size_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImapMailboxMessageSummary:
|
||||||
|
uid: str
|
||||||
|
folder: str
|
||||||
|
subject: str | None
|
||||||
|
from_header: str | None
|
||||||
|
to_header: str | None
|
||||||
|
cc_header: str | None
|
||||||
|
date: str | None
|
||||||
|
message_id: str | None
|
||||||
|
flags: list[str]
|
||||||
|
size_bytes: int | None
|
||||||
|
body_preview: str | None
|
||||||
|
attachment_count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImapMailboxMessageDetail:
|
||||||
|
uid: str
|
||||||
|
folder: str
|
||||||
|
subject: str | None
|
||||||
|
from_header: str | None
|
||||||
|
to_header: str | None
|
||||||
|
cc_header: str | None
|
||||||
|
date: str | None
|
||||||
|
message_id: str | None
|
||||||
|
flags: list[str]
|
||||||
|
size_bytes: int | None
|
||||||
|
body_preview: str | None
|
||||||
|
body_text: str | None
|
||||||
|
body_html: str | None
|
||||||
|
headers: dict[str, str]
|
||||||
|
attachments: list[ImapMailboxAttachmentInfo]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImapMailboxMessageListResult:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
security: str
|
||||||
|
folder: str
|
||||||
|
messages: list[ImapMailboxMessageSummary]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImapMailboxMessageResult:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
security: str
|
||||||
|
folder: str
|
||||||
|
message: ImapMailboxMessageDetail
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ImapAppendResult:
|
class ImapAppendResult:
|
||||||
host: str
|
host: str
|
||||||
@@ -283,6 +349,226 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_message(raw: bytes) -> EmailMessage:
|
||||||
|
return BytesParser(policy=policy.default).parsebytes(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _header_text(message: EmailMessage, name: str) -> str | None:
|
||||||
|
value = message.get(name)
|
||||||
|
return str(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _body_content(message: EmailMessage, prefer: tuple[str, ...]) -> str | None:
|
||||||
|
try:
|
||||||
|
body = message.get_body(preferencelist=prefer) if message.is_multipart() else message
|
||||||
|
if body is None:
|
||||||
|
return None
|
||||||
|
content = body.get_content()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
text = str(content).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _body_preview(message: EmailMessage) -> str | None:
|
||||||
|
text = _body_content(message, ("plain", "html"))
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
preview = re.sub(r"\s+", " ", text).strip()
|
||||||
|
return preview[:600] or None
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]:
|
||||||
|
attachments: list[ImapMailboxAttachmentInfo] = []
|
||||||
|
for part in message.iter_attachments():
|
||||||
|
payload = part.get_payload(decode=True) or b""
|
||||||
|
attachments.append(
|
||||||
|
ImapMailboxAttachmentInfo(
|
||||||
|
filename=part.get_filename(),
|
||||||
|
content_type=part.get_content_type(),
|
||||||
|
size_bytes=len(payload),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return attachments
|
||||||
|
|
||||||
|
|
||||||
|
def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
|
||||||
|
message = _parse_message(raw)
|
||||||
|
attachments = _attachment_infos(message)
|
||||||
|
return ImapMailboxMessageSummary(
|
||||||
|
uid=uid,
|
||||||
|
folder=folder,
|
||||||
|
subject=_header_text(message, "Subject"),
|
||||||
|
from_header=_header_text(message, "From"),
|
||||||
|
to_header=_header_text(message, "To"),
|
||||||
|
cc_header=_header_text(message, "Cc"),
|
||||||
|
date=_header_text(message, "Date"),
|
||||||
|
message_id=_header_text(message, "Message-ID"),
|
||||||
|
flags=flags,
|
||||||
|
size_bytes=size_bytes if size_bytes is not None else len(raw),
|
||||||
|
body_preview=_body_preview(message),
|
||||||
|
attachment_count=len(attachments),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_detail_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageDetail:
|
||||||
|
message = _parse_message(raw)
|
||||||
|
attachments = _attachment_infos(message)
|
||||||
|
return ImapMailboxMessageDetail(
|
||||||
|
uid=uid,
|
||||||
|
folder=folder,
|
||||||
|
subject=_header_text(message, "Subject"),
|
||||||
|
from_header=_header_text(message, "From"),
|
||||||
|
to_header=_header_text(message, "To"),
|
||||||
|
cc_header=_header_text(message, "Cc"),
|
||||||
|
date=_header_text(message, "Date"),
|
||||||
|
message_id=_header_text(message, "Message-ID"),
|
||||||
|
flags=flags,
|
||||||
|
size_bytes=size_bytes if size_bytes is not None else len(raw),
|
||||||
|
body_preview=_body_preview(message),
|
||||||
|
body_text=_body_content(message, ("plain",)),
|
||||||
|
body_html=_body_content(message, ("html",)),
|
||||||
|
headers={str(key): str(value) for key, value in message.items()},
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]:
|
||||||
|
metadata_parts: list[str] = []
|
||||||
|
raw: bytes | None = None
|
||||||
|
for item in data or []:
|
||||||
|
if isinstance(item, tuple):
|
||||||
|
metadata_parts.append(_decode_item(item[0]))
|
||||||
|
if isinstance(item[1], bytes):
|
||||||
|
raw = item[1]
|
||||||
|
else:
|
||||||
|
metadata_parts.append(_decode_item(item))
|
||||||
|
metadata = " ".join(part for part in metadata_parts if part)
|
||||||
|
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE)
|
||||||
|
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
|
||||||
|
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
|
||||||
|
flags = flags_match.group(1).split() if flags_match else []
|
||||||
|
if raw is None:
|
||||||
|
raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True)
|
||||||
|
return (
|
||||||
|
uid_match.group(1) if uid_match else None,
|
||||||
|
flags,
|
||||||
|
int(size_match.group(1)) if size_match else None,
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_readonly(client: imaplib.IMAP4, folder: str) -> None:
|
||||||
|
typ, data = client.select(folder, readonly=True)
|
||||||
|
if typ != "OK":
|
||||||
|
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
|
||||||
|
typ, data = client.uid("fetch", str(uid), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
|
||||||
|
if typ != "OK":
|
||||||
|
raise ImapAppendError(f"IMAP message {uid!r} fetch failed: {data!r}", temporary=True)
|
||||||
|
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
|
||||||
|
return fetched_uid or str(uid), flags, size_bytes, raw
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_record_folder(record: dict[str, Any]) -> str:
|
||||||
|
folder = str(record.get("folder") or "").strip()
|
||||||
|
if folder:
|
||||||
|
return folder
|
||||||
|
return "Sent" if record.get("kind") == "imap_append" else "INBOX"
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_folder_matches(record: dict[str, Any], folder: str) -> bool:
|
||||||
|
return _mock_record_folder(record).casefold() == folder.casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
|
||||||
|
raw = record.get("raw_eml")
|
||||||
|
if isinstance(raw, str) and raw:
|
||||||
|
return raw.encode("utf-8", errors="replace")
|
||||||
|
headers = record.get("headers") if isinstance(record.get("headers"), dict) else {}
|
||||||
|
lines = [f"{key}: {value}" for key, value in headers.items()]
|
||||||
|
lines.append("")
|
||||||
|
lines.append(str(record.get("body_preview") or ""))
|
||||||
|
return "\n".join(lines).encode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50) -> ImapMailboxMessageListResult:
|
||||||
|
"""List mailbox messages without mutating read/unread state."""
|
||||||
|
|
||||||
|
host, port = _require_imap_config(imap_config)
|
||||||
|
folder = (folder or "INBOX").strip() or "INBOX"
|
||||||
|
limit = max(1, min(limit, 100))
|
||||||
|
if is_mock_imap_host(imap_config.host):
|
||||||
|
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
|
||||||
|
messages = [
|
||||||
|
_message_summary_from_raw(
|
||||||
|
uid=str(record.get("id") or ""),
|
||||||
|
folder=_mock_record_folder(record),
|
||||||
|
raw=_mock_raw_bytes(record),
|
||||||
|
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
|
||||||
|
size_bytes=int(record.get("size_bytes") or 0) or None,
|
||||||
|
)
|
||||||
|
for record in records[:limit]
|
||||||
|
]
|
||||||
|
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages)
|
||||||
|
|
||||||
|
client = _open_imap(imap_config)
|
||||||
|
try:
|
||||||
|
_select_readonly(client, folder)
|
||||||
|
typ, data = client.uid("search", None, "ALL")
|
||||||
|
if typ != "OK":
|
||||||
|
raise ImapAppendError(f"IMAP search failed for folder {folder!r}: {data!r}", temporary=True)
|
||||||
|
uid_bytes = data[0] if data else b""
|
||||||
|
uids = _decode_item(uid_bytes).split()
|
||||||
|
messages: list[ImapMailboxMessageSummary] = []
|
||||||
|
for uid in reversed(uids[-limit:]):
|
||||||
|
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
|
||||||
|
messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
|
||||||
|
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult:
|
||||||
|
"""Fetch one message body using BODY.PEEK in a read-only selected mailbox."""
|
||||||
|
|
||||||
|
host, port = _require_imap_config(imap_config)
|
||||||
|
folder = (folder or "INBOX").strip() or "INBOX"
|
||||||
|
uid = str(uid).strip()
|
||||||
|
if not uid:
|
||||||
|
raise ImapConfigurationError("Message UID is required")
|
||||||
|
|
||||||
|
if is_mock_imap_host(imap_config.host):
|
||||||
|
record = get_record(uid, include_raw=True)
|
||||||
|
if not record or not _mock_folder_matches(record, folder):
|
||||||
|
raise ImapAppendError(f"Mock mailbox message {uid!r} was not found in folder {folder!r}", temporary=False)
|
||||||
|
message = _message_detail_from_raw(
|
||||||
|
uid=uid,
|
||||||
|
folder=_mock_record_folder(record),
|
||||||
|
raw=_mock_raw_bytes(record),
|
||||||
|
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
|
||||||
|
size_bytes=int(record.get("size_bytes") or 0) or None,
|
||||||
|
)
|
||||||
|
return ImapMailboxMessageResult(host=host, port=port, security=imap_config.security.value, folder=folder, message=message)
|
||||||
|
|
||||||
|
client = _open_imap(imap_config)
|
||||||
|
try:
|
||||||
|
_select_readonly(client, folder)
|
||||||
|
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
|
||||||
|
message = _message_detail_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes)
|
||||||
|
return ImapMailboxMessageResult(host=host, port=port, security=imap_config.security.value, folder=folder, message=message)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def append_message_to_sent(
|
def append_message_to_sent(
|
||||||
message_bytes: bytes,
|
message_bytes: bytes,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -21,18 +21,23 @@ def _redis_client() -> Redis:
|
|||||||
return Redis.from_url(settings.redis_url, decode_responses=True)
|
return Redis.from_url(settings.redis_url, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _distributed_rate_limit_enabled() -> bool:
|
||||||
|
return bool(getattr(settings, "celery_enabled", False))
|
||||||
|
|
||||||
|
|
||||||
def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = True) -> RateLimitDecision:
|
def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = True) -> RateLimitDecision:
|
||||||
"""Throttle sends across worker processes using Redis when available.
|
"""Throttle sends across worker processes using Redis when available.
|
||||||
|
|
||||||
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. If Redis is unavailable, it falls back to no distributed wait;
|
the same time. Direct local development runs do not have a broker, so Redis
|
||||||
the per-container Celery concurrency still protects local development.
|
is only used when Celery/worker mode is explicitly enabled. If Redis is
|
||||||
|
unavailable, it falls back to no distributed wait.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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:
|
if not enabled or not _distributed_rate_limit_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)
|
||||||
|
|
||||||
redis_key = f"multimailer:ratelimit:{key}:next_allowed"
|
redis_key = f"multimailer:ratelimit:{key}:next_allowed"
|
||||||
|
|||||||
@@ -46,6 +46,52 @@ export type MailImapFolderListResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MailMailboxAttachment = {
|
||||||
|
filename?: string | null;
|
||||||
|
content_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailMailboxMessageSummary = {
|
||||||
|
uid: string;
|
||||||
|
folder: string;
|
||||||
|
subject?: string | null;
|
||||||
|
from_header?: string | null;
|
||||||
|
to_header?: string | null;
|
||||||
|
cc_header?: string | null;
|
||||||
|
date?: string | null;
|
||||||
|
message_id?: string | null;
|
||||||
|
flags?: string[];
|
||||||
|
size_bytes?: number | null;
|
||||||
|
body_preview?: string | null;
|
||||||
|
attachment_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailMailboxMessageDetail = MailMailboxMessageSummary & {
|
||||||
|
body_text?: string | null;
|
||||||
|
body_html?: string | null;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
attachments?: MailMailboxAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailMailboxMessageListResponse = {
|
||||||
|
profile_id: string;
|
||||||
|
folder: string;
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
security?: MailSecurity | string | null;
|
||||||
|
messages: MailMailboxMessageSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailMailboxMessageResponse = {
|
||||||
|
profile_id: string;
|
||||||
|
folder: string;
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
security?: MailSecurity | string | null;
|
||||||
|
message: MailMailboxMessageDetail;
|
||||||
|
};
|
||||||
|
|
||||||
export type MailServerProfile = {
|
export type MailServerProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id?: string | null;
|
tenant_id?: string | null;
|
||||||
@@ -179,6 +225,30 @@ export async function listMailProfileImapFolders(settings: ApiSettings, profileI
|
|||||||
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
|
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMailboxFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
||||||
|
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMailboxMessages(
|
||||||
|
settings: ApiSettings,
|
||||||
|
profileId: string,
|
||||||
|
folder = "INBOX",
|
||||||
|
limit = 50
|
||||||
|
): Promise<MailMailboxMessageListResponse> {
|
||||||
|
const params = new URLSearchParams({ folder, limit: String(limit) });
|
||||||
|
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMailboxMessage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
profileId: string,
|
||||||
|
folder: string,
|
||||||
|
uid: string
|
||||||
|
): Promise<MailMailboxMessageResponse> {
|
||||||
|
const params = new URLSearchParams({ folder });
|
||||||
|
return apiFetch<MailMailboxMessageResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function testSmtpSettings(
|
export async function testSmtpSettings(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
payload: MailSmtpTestPayload
|
payload: MailSmtpTestPayload
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { MailServerSettingsPanel, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } 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 {
|
||||||
createMailServerProfile,
|
createMailServerProfile,
|
||||||
deactivateMailServerProfile,
|
deactivateMailServerProfile,
|
||||||
getMailProfilePolicy,
|
getMailProfilePolicy,
|
||||||
|
listImapFolders,
|
||||||
|
listMailProfileImapFolders,
|
||||||
listMailServerProfiles,
|
listMailServerProfiles,
|
||||||
mailProfilePatternKeys,
|
mailProfilePatternKeys,
|
||||||
updateMailProfilePolicy,
|
updateMailProfilePolicy,
|
||||||
|
testImapSettings,
|
||||||
|
testMailProfileImap,
|
||||||
|
testMailProfileSmtp,
|
||||||
|
testSmtpSettings,
|
||||||
updateMailServerProfile,
|
updateMailServerProfile,
|
||||||
type MailCredentialPolicy,
|
type MailCredentialPolicy,
|
||||||
type MailImapTestPayload,
|
type MailImapTestPayload,
|
||||||
@@ -28,7 +35,6 @@ import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
|||||||
import Dialog from "../../components/Dialog";
|
import Dialog from "../../components/Dialog";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
|
||||||
|
|
||||||
export type MailProfileTargetOption = {
|
export type MailProfileTargetOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -290,7 +296,7 @@ export function MailProfileScopeManager({
|
|||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "Saving…" : "Save profile"}</Button></>}
|
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "Saving…" : "Save profile"}</Button></>}
|
||||||
>
|
>
|
||||||
<ProfileForm draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} />
|
<ProfileForm settings={settings} draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -492,6 +498,7 @@ export function MailProfilePolicyEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProfileForm({
|
function ProfileForm({
|
||||||
|
settings,
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
editing,
|
editing,
|
||||||
@@ -499,6 +506,7 @@ function ProfileForm({
|
|||||||
canWrite,
|
canWrite,
|
||||||
canManageCredentials
|
canManageCredentials
|
||||||
}: {
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
draft: ProfileDraft;
|
draft: ProfileDraft;
|
||||||
setDraft: (draft: ProfileDraft) => void;
|
setDraft: (draft: ProfileDraft) => void;
|
||||||
editing: EditingProfile;
|
editing: EditingProfile;
|
||||||
@@ -506,10 +514,101 @@ function ProfileForm({
|
|||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
canManageCredentials: boolean;
|
canManageCredentials: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
|
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
|
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | 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;
|
||||||
const existingHasImap = editing !== "new" && Boolean(editing?.imap);
|
const existingProfile = editing !== "new" ? editing : null;
|
||||||
|
const existingHasImap = Boolean(existingProfile?.imap);
|
||||||
const imapToggleDisabled = disabled || (!canManageCredentials && existingHasImap && draft.imapEnabled);
|
const imapToggleDisabled = disabled || (!canManageCredentials && existingHasImap && draft.imapEnabled);
|
||||||
|
const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured);
|
||||||
|
const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSmtpTestResult(null);
|
||||||
|
setImapTestResult(null);
|
||||||
|
setFolderResult(null);
|
||||||
|
setMailActionState(null);
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
function patchSmtpSettings(patch: Partial<MailServerSmtpSettings>) {
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
smtpHost: patch.host !== undefined ? String(patch.host ?? "") : draft.smtpHost,
|
||||||
|
smtpPort: patch.port !== undefined ? String(patch.port ?? "") : draft.smtpPort,
|
||||||
|
smtpUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.smtpUsername,
|
||||||
|
smtpPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.smtpPassword,
|
||||||
|
smtpSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "starttls"), "starttls") : draft.smtpSecurity,
|
||||||
|
smtpTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.smtpTimeout
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchImapSettings(patch: Partial<MailServerImapSettings>) {
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
imapHost: patch.host !== undefined ? String(patch.host ?? "") : draft.imapHost,
|
||||||
|
imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort,
|
||||||
|
imapUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.imapUsername,
|
||||||
|
imapPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.imapPassword,
|
||||||
|
imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity,
|
||||||
|
imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder,
|
||||||
|
imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSmtpTest() {
|
||||||
|
setMailActionState("smtp");
|
||||||
|
setSmtpTestResult(null);
|
||||||
|
try {
|
||||||
|
setSmtpTestResult(useSavedSmtpTest && existingProfile
|
||||||
|
? await testMailProfileSmtp(settings, existingProfile.id)
|
||||||
|
: await testSmtpSettings(settings, smtpPayload(draft, false)));
|
||||||
|
} catch (err) {
|
||||||
|
setSmtpTestResult({ ok: false, protocol: "smtp", message: errorMessage(err), details: {} });
|
||||||
|
} finally {
|
||||||
|
setMailActionState(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImapTest() {
|
||||||
|
if (!draft.imapEnabled) return;
|
||||||
|
setMailActionState("imap");
|
||||||
|
setImapTestResult(null);
|
||||||
|
try {
|
||||||
|
setImapTestResult(useSavedImapTest && existingProfile
|
||||||
|
? await testMailProfileImap(settings, existingProfile.id)
|
||||||
|
: await testImapSettings(settings, imapPayload(draft, false)));
|
||||||
|
} catch (err) {
|
||||||
|
setImapTestResult({ ok: false, protocol: "imap", message: errorMessage(err), details: {} });
|
||||||
|
} finally {
|
||||||
|
setMailActionState(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFolderLookup() {
|
||||||
|
if (!draft.imapEnabled) return;
|
||||||
|
setMailActionState("folders");
|
||||||
|
setFolderResult(null);
|
||||||
|
try {
|
||||||
|
setFolderResult(useSavedImapTest && existingProfile
|
||||||
|
? await listMailProfileImapFolders(settings, existingProfile.id)
|
||||||
|
: await listImapFolders(settings, imapPayload(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 || !draft.imapEnabled) return;
|
||||||
|
setDraft({ ...draft, imapSentFolder: folder });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mail-profile-form">
|
<div className="mail-profile-form">
|
||||||
<div className="admin-form-grid two-columns">
|
<div className="admin-form-grid two-columns">
|
||||||
@@ -519,29 +618,32 @@ function ProfileForm({
|
|||||||
<FormField label="Description"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="Description"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>SMTP</h3>
|
<MailServerSettingsPanel
|
||||||
<div className="admin-form-grid three-columns">
|
smtp={{ host: draft.smtpHost, port: draft.smtpPort, username: draft.smtpUsername, password: draft.smtpPassword, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
|
||||||
<FormField label="Host"><input value={draft.smtpHost} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpHost: event.target.value })} /></FormField>
|
imap={{ enabled: draft.imapEnabled, host: draft.imapHost, port: draft.imapPort, username: draft.imapUsername, password: draft.imapPassword, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
|
||||||
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.smtpPort} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpPort: event.target.value })} /></FormField>
|
onSmtpChange={patchSmtpSettings}
|
||||||
<FormField label="Security"><SecuritySelect value={draft.smtpSecurity} disabled={disabled} onChange={(smtpSecurity) => setDraft({ ...draft, smtpSecurity })} /></FormField>
|
onImapChange={patchImapSettings}
|
||||||
<FormField label="Username"><input value={draft.smtpUsername} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpUsername: event.target.value })} /></FormField>
|
onImapEnabledChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })}
|
||||||
<FormField label="Password" help={editing === "new" ? undefined : "Leave empty to keep the current password."}><input type="password" value={draft.smtpPassword} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, smtpPassword: event.target.value })} /></FormField>
|
smtpDisabled={disabled}
|
||||||
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.smtpTimeout} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpTimeout: event.target.value })} /></FormField>
|
smtpCredentialDisabled={credentialDisabled}
|
||||||
</div>
|
imapToggleDisabled={imapToggleDisabled}
|
||||||
|
imapServerDisabled={disabled || !draft.imapEnabled}
|
||||||
<div className="mail-profile-imap-heading">
|
imapCredentialDisabled={credentialDisabled || !draft.imapEnabled}
|
||||||
<h3>IMAP</h3>
|
imapActionDisabled={disabled || !draft.imapEnabled}
|
||||||
<ToggleSwitch label="Enable IMAP" checked={draft.imapEnabled} disabled={imapToggleDisabled} onChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })} />
|
smtpTitle="SMTP"
|
||||||
</div>
|
imapTitle="IMAP"
|
||||||
<div className="admin-form-grid three-columns">
|
smtpTestLabel={useSavedSmtpTest ? "Test saved SMTP" : "Test SMTP"}
|
||||||
<FormField label="Host"><input value={draft.imapHost} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapHost: event.target.value })} /></FormField>
|
imapTestLabel={useSavedImapTest ? "Test saved IMAP" : "Test IMAP"}
|
||||||
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.imapPort} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapPort: event.target.value })} /></FormField>
|
busyAction={mailActionState}
|
||||||
<FormField label="Security"><SecuritySelect value={draft.imapSecurity} disabled={disabled || !draft.imapEnabled} onChange={(imapSecurity) => setDraft({ ...draft, imapSecurity })} /></FormField>
|
onTestSmtp={() => void runSmtpTest()}
|
||||||
<FormField label="Username"><input value={draft.imapUsername} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapUsername: event.target.value })} /></FormField>
|
onTestImap={() => void runImapTest()}
|
||||||
<FormField label="Password" help={editing === "new" ? undefined : "Leave empty to keep the current password."}><input type="password" value={draft.imapPassword} disabled={credentialDisabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapPassword: event.target.value })} /></FormField>
|
onLookupFolders={() => void runFolderLookup()}
|
||||||
<FormField label="Sent folder"><input value={draft.imapSentFolder} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapSentFolder: event.target.value })} /></FormField>
|
smtpTestResult={smtpTestResult}
|
||||||
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.imapTimeout} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapTimeout: event.target.value })} /></FormField>
|
imapTestResult={imapTestResult}
|
||||||
</div>
|
folderLookupResult={folderResult}
|
||||||
|
onUseDetectedFolder={useDetectedSentFolder}
|
||||||
|
useDetectedFolderDisabled={disabled || !draft.imapEnabled}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -586,10 +688,6 @@ function PatternTextarea({ label, value, disabled, onChange }: { label: string;
|
|||||||
return <FormField label={label}><textarea rows={3} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} placeholder="*.example.org" /></FormField>;
|
return <FormField label={label}><textarea rows={3} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} placeholder="*.example.org" /></FormField>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SecuritySelect({ value, disabled, onChange }: { value: MailSecurity; disabled: boolean; onChange: (value: MailSecurity) => void }) {
|
|
||||||
return <select value={value} disabled={disabled} onChange={(event) => onChange(readSecurity(event.target.value, value))}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TransportCell({ profile, protocol }: { profile: MailServerProfile; protocol: "smtp" | "imap" }) {
|
function TransportCell({ profile, protocol }: { profile: MailServerProfile; protocol: "smtp" | "imap" }) {
|
||||||
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
|
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
|
||||||
if (!transport) return <span className="muted">Not configured</span>;
|
if (!transport) return <span className="muted">Not configured</span>;
|
||||||
|
|||||||
390
webui/src/features/mail/MailboxPage.tsx
Normal file
390
webui/src/features/mail/MailboxPage.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DismissibleAlert,
|
||||||
|
ExplorerTree,
|
||||||
|
LoadingIndicator,
|
||||||
|
MessageDisplayPanel,
|
||||||
|
formatDateTime,
|
||||||
|
type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
getMailboxMessage,
|
||||||
|
listMailboxFolders,
|
||||||
|
listMailboxMessages,
|
||||||
|
listMailServerProfiles,
|
||||||
|
type MailImapFolderResponse,
|
||||||
|
type MailMailboxMessageDetail,
|
||||||
|
type MailMailboxMessageSummary,
|
||||||
|
type MailServerProfile
|
||||||
|
} from "../../api/mail";
|
||||||
|
|
||||||
|
type MailFolderNode = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
folderName: string | null;
|
||||||
|
flags: string[];
|
||||||
|
children: MailFolderNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||||
|
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||||
|
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||||
|
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
||||||
|
const [selectedFolder, setSelectedFolder] = useState("INBOX");
|
||||||
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||||
|
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
||||||
|
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
|
||||||
|
const [loadingProfiles, setLoadingProfiles] = useState(true);
|
||||||
|
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||||
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
|
const [loadingMessage, setLoadingMessage] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||||
|
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
||||||
|
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
|
||||||
|
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
|
||||||
|
const busy = loadingProfiles || loadingFolders || loadingMessages || loadingMessage;
|
||||||
|
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
|
||||||
|
|
||||||
|
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
|
||||||
|
useEffect(() => { if (selectedProfileId && selectedFolder) void loadMessages(selectedProfileId, selectedFolder); }, [selectedProfileId, selectedFolder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
|
||||||
|
if (ancestorIds.length === 0) return;
|
||||||
|
setExpandedFolders((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
ancestorIds.forEach((id) => next.add(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [folderTree, selectedFolder]);
|
||||||
|
|
||||||
|
async function loadProfiles() {
|
||||||
|
setLoadingProfiles(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const loaded = await listMailServerProfiles(settings);
|
||||||
|
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
|
||||||
|
setProfiles(loaded);
|
||||||
|
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
|
||||||
|
if (usable.length === 0) {
|
||||||
|
setFolders([]);
|
||||||
|
setMessages([]);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setLoadingProfiles(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFolders(profileId = selectedProfileId) {
|
||||||
|
if (!profileId) return;
|
||||||
|
setLoadingFolders(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await listMailboxFolders(settings, profileId);
|
||||||
|
if (!response.ok) throw new Error(response.message || "Mailbox folders could not be loaded.");
|
||||||
|
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }];
|
||||||
|
setFolders(loaded);
|
||||||
|
setExpandedFolders(new Set());
|
||||||
|
setSelectedFolder((current) => {
|
||||||
|
if (current && loaded.some((folder) => folder.name === current)) return current;
|
||||||
|
if (loaded.some((folder) => folder.name === "INBOX")) return "INBOX";
|
||||||
|
return response.detected_sent_folder || loaded[0]?.name || "INBOX";
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
setFolders([]);
|
||||||
|
setMessages([]);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingFolders(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) {
|
||||||
|
if (!profileId || !folder) return;
|
||||||
|
setLoadingMessages(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await listMailboxMessages(settings, profileId, folder, 50);
|
||||||
|
setMessages(response.messages ?? []);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
setMessages([]);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingMessages(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMessage(message: MailMailboxMessageSummary) {
|
||||||
|
if (!selectedProfileId || loadingMessage) return;
|
||||||
|
setLoadingMessage(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await getMailboxMessage(settings, selectedProfileId, selectedFolder, message.uid);
|
||||||
|
setSelectedMessage(response.message);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setLoadingMessage(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProfile(profileId: string) {
|
||||||
|
setSelectedProfileId(profileId);
|
||||||
|
setFolders([]);
|
||||||
|
setMessages([]);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
setExpandedFolders(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFolderNode(node: MailFolderNode) {
|
||||||
|
if (node.folderName) {
|
||||||
|
setSelectedFolder(node.folderName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toggleFolderNode(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFolderNode(node: MailFolderNode) {
|
||||||
|
setExpandedFolders((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(node.id)) next.delete(node.id);
|
||||||
|
else next.add(node.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
|
|
||||||
|
<div className={`file-manager-shell mailbox-shell ${busy ? "is-loading" : ""}`}>
|
||||||
|
<aside className="file-tree-panel" aria-label="Mailbox folders">
|
||||||
|
<div className="file-tree-heading">Folders</div>
|
||||||
|
<div className="file-tree-list">
|
||||||
|
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">No folders available.</p>}
|
||||||
|
<ExplorerTree
|
||||||
|
nodes={folderTree}
|
||||||
|
getNodeId={(node) => node.id}
|
||||||
|
getNodeLabel={(node) => node.label}
|
||||||
|
getNodeChildren={(node) => node.children}
|
||||||
|
activeId={selectedFolderNodeId}
|
||||||
|
expandedIds={expandedFolders}
|
||||||
|
onOpen={openFolderNode}
|
||||||
|
onToggle={toggleFolderNode}
|
||||||
|
disabled={busy}
|
||||||
|
renderNodeContent={(node) => (
|
||||||
|
<span className="mailbox-tree-node-label">
|
||||||
|
<span>{node.label}</span>
|
||||||
|
{node.flags.length > 0 && <small>{node.flags.join(" ")}</small>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="file-list-panel mailbox-message-list-panel" aria-label="Mailbox messages">
|
||||||
|
<div className="file-list-sticky">
|
||||||
|
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
|
||||||
|
<label className="mailbox-profile-field">
|
||||||
|
<span>Profile</span>
|
||||||
|
<select value={selectedProfileId} disabled={busy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||||
|
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
|
||||||
|
<Button onClick={() => void loadProfiles()} disabled={busy}>
|
||||||
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="file-breadcrumbs" aria-label="Current mailbox folder">
|
||||||
|
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "Mail"}</span>
|
||||||
|
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="file-list-meta">
|
||||||
|
<span>{messages.length} message{messages.length === 1 ? "" : "s"}</span>
|
||||||
|
<span>{selectedFolder}</span>
|
||||||
|
{busy && <span>Working...</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="file-list-table-head mailbox-message-head" role="row">
|
||||||
|
<span>Subject</span>
|
||||||
|
<span>Date</span>
|
||||||
|
<span>Size</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="file-list-drop-target mailbox-message-scroll">
|
||||||
|
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
|
||||||
|
{messages.length === 0 && <div className="file-list-empty">No messages in this folder.</div>}
|
||||||
|
{messages.map((message) => {
|
||||||
|
const selected = message.uid === selectedMessage?.uid;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.uid}
|
||||||
|
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""}`}
|
||||||
|
role="row"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => void openMessage(message)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
void openMessage(message);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="file-list-name-cell">
|
||||||
|
<div className="file-list-name">
|
||||||
|
<Mail className="file-row-icon" size={20} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{message.subject || "(no subject)"}</strong>
|
||||||
|
<small>{message.from_header || "-"}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
|
||||||
|
<span className="file-row-tail mailbox-message-tail">
|
||||||
|
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
|
||||||
|
<span>{formatBytes(message.size_bytes)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mailbox-preview-panel" aria-label="Message preview">
|
||||||
|
<div className="file-tree-heading">Preview</div>
|
||||||
|
<div className="mailbox-preview-scroll">
|
||||||
|
<MessageDisplayPanel
|
||||||
|
title={selectedMessage?.subject}
|
||||||
|
fields={selectedMessage ? [
|
||||||
|
{ label: "From", value: selectedMessage.from_header || "-" },
|
||||||
|
{ label: "To", value: selectedMessage.to_header || "-" },
|
||||||
|
{ label: "Cc", value: selectedMessage.cc_header || null },
|
||||||
|
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }
|
||||||
|
] : []}
|
||||||
|
bodyText={selectedMessage?.body_text}
|
||||||
|
bodyHtml={selectedMessage?.body_html}
|
||||||
|
bodyPreview={selectedMessage?.body_preview}
|
||||||
|
headers={selectedMessage?.headers}
|
||||||
|
attachments={selectedMessage?.attachments?.map((attachment) => ({
|
||||||
|
filename: attachment.filename,
|
||||||
|
contentType: attachment.content_type,
|
||||||
|
sizeBytes: attachment.size_bytes
|
||||||
|
}))}
|
||||||
|
emptyText="Select a message to inspect its content."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{busy && (
|
||||||
|
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
|
||||||
|
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{loadingLabel}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMailboxFolderTree(folders: MailImapFolderResponse[]): MailFolderNode[] {
|
||||||
|
const roots: MailFolderNode[] = [];
|
||||||
|
const byId = new Map<string, MailFolderNode>();
|
||||||
|
|
||||||
|
function ensureNode(parts: string[], index: number): MailFolderNode | null {
|
||||||
|
if (index < 0 || index >= parts.length) return null;
|
||||||
|
const id = parts.slice(0, index + 1).join("/");
|
||||||
|
const existing = byId.get(id);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const node: MailFolderNode = { id, label: parts[index], folderName: null, flags: [], children: [] };
|
||||||
|
byId.set(id, node);
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
roots.push(node);
|
||||||
|
} else {
|
||||||
|
ensureNode(parts, index - 1)?.children.push(node);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const folder of folders) {
|
||||||
|
const parts = folderParts(folder.name);
|
||||||
|
const node = ensureNode(parts, parts.length - 1);
|
||||||
|
if (node) {
|
||||||
|
node.folderName = folder.name;
|
||||||
|
node.flags = folder.flags ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortFolderNodes(roots);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderParts(folderName: string): string[] {
|
||||||
|
const normalized = folderName.trim();
|
||||||
|
if (!normalized) return [];
|
||||||
|
if (normalized.includes("/")) return normalized.split("/").filter(Boolean);
|
||||||
|
if (/^INBOX[.]/i.test(normalized)) return ["INBOX", ...normalized.slice(6).split(".").filter(Boolean)];
|
||||||
|
return [normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFolderNodes(nodes: MailFolderNode[]) {
|
||||||
|
nodes.sort((left, right) => folderSortKey(left.label).localeCompare(folderSortKey(right.label)));
|
||||||
|
nodes.forEach((node) => sortFolderNodes(node.children));
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderSortKey(label: string): string {
|
||||||
|
return label.toUpperCase() === "INBOX" ? "0" : `1:${label.toLocaleLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFolderNodeId(nodes: MailFolderNode[], folderName: string): string | null {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.folderName === folderName) return node.id;
|
||||||
|
const child = findFolderNodeId(node.children, folderName);
|
||||||
|
if (child) return child;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents: string[] = []): string[] {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const path = [...parents, node.id];
|
||||||
|
if (node.folderName === folderName) return path;
|
||||||
|
const child = folderAncestorIds(node.children, folderName, path);
|
||||||
|
if (child.length > 0) return child;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transportLabel(profile: MailServerProfile): string {
|
||||||
|
const imap = profile.imap;
|
||||||
|
if (!imap?.host) return "IMAP not configured";
|
||||||
|
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value?: number | null): string {
|
||||||
|
if (!value) return "-";
|
||||||
|
if (value < 1024) return `${value} B`;
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ export * from "./api/mail";
|
|||||||
export { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
export { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||||
export type { MailProfileTargetOption } from "./features/mail/MailProfileManagement";
|
export type { MailProfileTargetOption } from "./features/mail/MailProfileManagement";
|
||||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||||
|
export { default as MailboxPage } from "./features/mail/MailboxPage";
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
||||||
|
const mailboxRead = ["mail:mailbox:read"];
|
||||||
|
|
||||||
export const mailModule: PlatformWebModule = {
|
export const mailModule: PlatformWebModule = {
|
||||||
id: "mail",
|
id: "mail",
|
||||||
label: "Mail",
|
label: "Mail",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["access"]
|
dependencies: ["access"],
|
||||||
|
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||||
|
routes: [
|
||||||
|
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
export default mailModule;
|
export default mailModule;
|
||||||
|
|||||||
@@ -193,3 +193,397 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.mailbox-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-toolbar,
|
||||||
|
.mailbox-message-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-toolbar label {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: min(340px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-toolbar label span,
|
||||||
|
.mailbox-preview-header span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-toolbar-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(190px, 240px) minmax(0, 1.25fr) minmax(320px, .75fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-folder-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-folder-list button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 9px 10px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-folder-list button:hover,
|
||||||
|
.mailbox-folder-list button:focus-visible,
|
||||||
|
.mailbox-folder-list button.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-folder-list small {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-table {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-table .data-grid-cell.is-selected {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-subject-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-subject-button span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-attachment-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-preview-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-preview-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-preview-header div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 70px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-preview-header strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-body {
|
||||||
|
max-height: 420px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--text);
|
||||||
|
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-attachments {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-attachments h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-attachments div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-attachments small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-headers dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-headers dl div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-headers dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-headers dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.mailbox-layout {
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-layout > .card:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.mailbox-toolbar,
|
||||||
|
.mailbox-message-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Mailbox work surface: shared explorer/list pattern with mail-specific columns. */
|
||||||
|
.mailbox-page.file-manager-fullscreen {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
height: calc(100vh - 115px);
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-shell.file-manager-shell {
|
||||||
|
position: relative;
|
||||||
|
grid-template-columns: minmax(230px, 290px) minmax(390px, 1fr) minmax(340px, .82fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-shell.is-loading .mailbox-preview-panel {
|
||||||
|
filter: blur(1.5px);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-tree-empty {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-tree-node-label {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-tree-node-label > span,
|
||||||
|
.mailbox-tree-node-label small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-tree-node-label small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-toolbar.file-manager-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-profile-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-profile-field > span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-toolbar-meta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-breadcrumb-static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-list-panel {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-table {
|
||||||
|
min-width: 660px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-head,
|
||||||
|
.mailbox-message-row {
|
||||||
|
grid-template-columns: minmax(260px, 1fr) 190px 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-head span {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-row .file-list-name strong {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-date {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-tail {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-tail > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-preview-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-preview-scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1250px) {
|
||||||
|
.mailbox-shell.file-manager-shell {
|
||||||
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-preview-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 320px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.mailbox-toolbar.file-manager-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-table {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-tail {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user