Backend: - schedule_tenant: NC 新容器自動 pgsql 安裝 (_nc_db_check 全新容器處理) - schedule_tenant: NC 初始化加入 Redis + APCu memcache 設定 (修正 OIDC invalid_state) - schedule_tenant: 新租戶 KC realm 自動設定 accessCodeLifespan=600s (修正 authentication_expired) - schedule_account: NC Mail 帳號自動設定 (nc_mail_result/nc_mail_done_at) - schedule_account: NC 台灣國定假日行事曆自動訂閱 (CalDAV MKCALENDAR) - nextcloud_client: 新增 subscribe_calendar() CalDAV 訂閱方法 - settings: 新增系統設定 API (site_title/version/timezone/SSO/Keycloak) - models/result: 新增 nc_mail_result, nc_mail_done_at 欄位 - alembic: 遷移 002(system_settings) 003(keycloak_admin) 004(nc_mail_result) Frontend (Admin Portal): - 新增完整管理後台 (index/tenants/accounts/servers/schedules/logs/settings/system-status) - api.js: Keycloak JS Adapter SSO 整合 (PKCE/S256, fallback KC JS 來源, 自動 token 更新) - index.html: Promise.allSettled 取代 Promise.all,防止單一 API 失敗影響整頁 - 所有頁面加入 try/catch + toast 錯誤處理 - 新增品牌 LOGO 與 favicon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
110 lines
4.3 KiB
Python
110 lines
4.3 KiB
Python
"""
|
||
MailClient — 透過 SSH + docker exec mailserver 管理 docker-mailserver。
|
||
domain 在 docker-mailserver 中是隱式的(由 mailbox 決定),
|
||
所以 domain_exists 檢查是否有任何 @domain 的 mailbox。
|
||
"""
|
||
import logging
|
||
from typing import Optional
|
||
import dns.resolver
|
||
|
||
from app.core.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
MAILSERVER_CONTAINER = "mailserver"
|
||
|
||
|
||
class MailClient:
|
||
|
||
def _ssh_exec(self, cmd: str) -> tuple[int, str]:
|
||
"""SSH 到 10.1.0.254 執行指令,回傳 (exit_code, stdout)"""
|
||
import paramiko
|
||
client = paramiko.SSHClient()
|
||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||
client.connect(
|
||
settings.DOCKER_SSH_HOST,
|
||
username=settings.DOCKER_SSH_USER,
|
||
timeout=15,
|
||
)
|
||
_, stdout, _ = client.exec_command(cmd)
|
||
output = stdout.read().decode().strip()
|
||
exit_code = stdout.channel.recv_exit_status()
|
||
client.close()
|
||
return exit_code, output
|
||
|
||
def check_mx_dns(self, domain: str) -> bool:
|
||
"""驗證 domain 的 MX record 是否指向正確的 mail server"""
|
||
try:
|
||
answers = dns.resolver.resolve(domain, "MX")
|
||
for rdata in answers:
|
||
if settings.MAIL_MX_HOST in str(rdata.exchange).rstrip("."):
|
||
return True
|
||
return False
|
||
except Exception as e:
|
||
logger.warning(f"MX DNS check failed for {domain}: {e}")
|
||
return False
|
||
|
||
def domain_exists(self, domain: str) -> bool:
|
||
"""檢查 mailserver 是否有任何 @domain 的 mailbox(docker-mailserver 的 domain 由 mailbox 決定)"""
|
||
try:
|
||
code, output = self._ssh_exec(
|
||
f"docker exec {MAILSERVER_CONTAINER} setup email list 2>/dev/null | grep -q '@{domain}' && echo yes || echo no"
|
||
)
|
||
return output.strip() == "yes"
|
||
except Exception as e:
|
||
logger.warning(f"domain_exists({domain}) SSH failed: {e}")
|
||
return False
|
||
|
||
def create_domain(self, domain: str) -> bool:
|
||
"""
|
||
docker-mailserver 不需要顯式建立 domain(mailbox 新增時自動處理)。
|
||
新增一個 postmaster@ 系統帳號來確保 domain 被識別。
|
||
"""
|
||
try:
|
||
import secrets
|
||
passwd = secrets.token_urlsafe(16)
|
||
code, output = self._ssh_exec(
|
||
f"docker exec {MAILSERVER_CONTAINER} setup email add postmaster@{domain} {passwd} 2>&1"
|
||
)
|
||
if code == 0:
|
||
logger.info(f"create_domain({domain}): postmaster account created")
|
||
return True
|
||
# 若帳號已存在視為成功
|
||
if "already exists" in output.lower():
|
||
return True
|
||
logger.error(f"create_domain({domain}) failed (exit {code}): {output}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"create_domain({domain}) SSH failed: {e}")
|
||
return False
|
||
|
||
def mailbox_exists(self, email: str) -> bool:
|
||
try:
|
||
code, output = self._ssh_exec(
|
||
f"docker exec {MAILSERVER_CONTAINER} setup email list 2>/dev/null | grep -q '{email}' && echo yes || echo no"
|
||
)
|
||
return output.strip() == "yes"
|
||
except Exception as e:
|
||
logger.warning(f"mailbox_exists({email}) SSH failed: {e}")
|
||
return False
|
||
|
||
def create_mailbox(self, email: str, password: Optional[str], quota_gb: int = 20) -> bool:
|
||
try:
|
||
import secrets
|
||
passwd = password or secrets.token_urlsafe(16)
|
||
quota_mb = quota_gb * 1024
|
||
code, output = self._ssh_exec(
|
||
f"docker exec {MAILSERVER_CONTAINER} setup email add {email} {passwd} 2>&1"
|
||
)
|
||
if code != 0 and "already exists" not in output.lower():
|
||
logger.error(f"create_mailbox({email}) failed (exit {code}): {output}")
|
||
return False
|
||
# 設定配額
|
||
self._ssh_exec(
|
||
f"docker exec {MAILSERVER_CONTAINER} setup quota set {email} {quota_mb}M 2>/dev/null"
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"create_mailbox({email}) SSH failed: {e}")
|
||
return False
|