Files
vmis/backend/app/services/mail_client.py
VMIS Developer 62baadb06f feat(vmis): 租戶自動開通完整流程 + Admin Portal SSO + NC 行事曆訂閱
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>
2026-03-15 15:31:37 +08:00

110 lines
4.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 的 mailboxdocker-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 不需要顯式建立 domainmailbox 新增時自動處理)。
新增一個 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