Files
webmail-gateway/app.py
porsche5130 e1c2ea46f3 修正多租戶 Cookie 隔離與郵件查看功能
## 主要修正

1. **Cookie 隔離機制** (多租戶支援)
   - 改用租戶專屬 Cookie: `webmail_session_{tenant_code}`
   - 設定 Cookie path 為 `/{tenant_code}` 確保隔離
   - 解決兩個租戶共用 Cookie 導致互相覆蓋的問題

2. **郵件查看 API 修正**
   - 修正路由定義: `{{mail_id}}` → `{mail_id}` (FastAPI 路由語法錯誤)
   - 修正函數呼叫: `get_mail_detail` → `get_mail_by_id`

3. **Session 讀取機制更新**
   - Callback: 設定租戶專屬 Cookie
   - Inbox/Compose/API: 從 `request.cookies.get(f"webmail_session_{tenant_code}")` 讀取
   - 移除對 SessionMiddleware 的依賴 (改用手動 Cookie 管理)

4. **PKCE 錯誤訊息優化**
   - 增加 PKCE 驗證失敗的詳細錯誤訊息
   - 提示可能的失敗原因 (過期、舊連結、Redis 連線)

## 測試狀態
-  修正路由與函數呼叫錯誤
-  實作租戶專屬 Cookie 機制
- 🔧 待測試:兩個租戶同時登入不互相干擾

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-04 04:35:28 +08:00

3446 lines
132 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.
from fastapi import FastAPI, Request, HTTPException, Form, File, UploadFile
from fastapi.responses import RedirectResponse, HTMLResponse, JSONResponse
from starlette.middleware.sessions import SessionMiddleware
from authlib.integrations.starlette_client import OAuth
from sqlalchemy import create_engine, text
from typing import Optional
import redis
import json
import os
import secrets
import logging
import imaplib
import smtplib
import email
from email.header import decode_header
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
import html
import hashlib
import base64
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
SECRET_KEY = os.getenv("SECRET_KEY", secrets.token_urlsafe(32))
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
# Redis 配置
REDIS_HOST = os.getenv("REDIS_HOST", "10.1.0.20")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "DC1qaz2wsx")
REDIS_DB = int(os.getenv("REDIS_DB", "2"))
redis_client = redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASSWORD,
db=REDIS_DB,
decode_responses=True
)
# Virtual MIS 資料庫配置
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://admin:DC1qaz2wsx@10.1.0.20:5433/virtual_mis")
db_engine = create_engine(DATABASE_URL, pool_pre_ping=True)
# Keycloak OAuth 配置
KEYCLOAK_SERVER_URL = os.getenv("KEYCLOAK_SERVER_URL", "https://auth.lab.taipei")
KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "vmis-admin")
KEYCLOAK_CLIENT_ID = os.getenv("KEYCLOAK_CLIENT_ID", "vmis-services")
KEYCLOAK_CLIENT_SECRET = os.getenv("KEYCLOAK_CLIENT_SECRET", "VirtualMIS2026ServiceSecret12345")
REDIRECT_URI = os.getenv("REDIRECT_URI", "https://webmail.lab.taipei/callback")
# 郵件伺服器配置
MAIL_SERVER = os.getenv("MAIL_SERVER", "10.1.0.254")
SMTP_SERVER = os.getenv("SMTP_SERVER", "10.1.0.254")
oauth = OAuth()
oauth.register(
name="keycloak",
client_id=KEYCLOAK_CLIENT_ID,
client_secret=KEYCLOAK_CLIENT_SECRET,
server_metadata_url=f"{KEYCLOAK_SERVER_URL}/realms/{KEYCLOAK_REALM}/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"}
)
def get_account_by_sso_uuid(sso_uuid: str):
"""根據 Keycloak UUID 查詢帳號"""
try:
with db_engine.connect() as conn:
result = conn.execute(
text("""
SELECT a.email, a.default_password, a.sso_account, a.account_code
FROM accounts a
JOIN tenants t ON a.tenant_id = t.id
WHERE a.sso_uuid = :sso_uuid
AND t.keycloak_realm = :realm
LIMIT 1
"""),
{"sso_uuid": sso_uuid, "realm": KEYCLOAK_REALM}
).fetchone()
if result:
logger.info(f"Found account: {result[2]} ({result[0]})")
return {
"email": result[0],
"password": result[1],
"sso_account": result[2],
"account_code": result[3]
}
logger.warning(f"No account found for sso_uuid: {sso_uuid}")
return None
except Exception as e:
logger.error(f"Database error: {e}")
return None
def decode_header_value(header_value):
"""解碼郵件標頭"""
if not header_value:
return ''
decoded = decode_header(header_value)
parts = []
for content, charset in decoded:
if isinstance(content, bytes):
parts.append(content.decode(charset or 'utf-8', errors='replace'))
else:
parts.append(content)
return ''.join(parts)
def get_imap_folders(email_addr, password):
"""取得 IMAP 資料夾列表"""
try:
mail = imaplib.IMAP4(MAIL_SERVER, 143)
mail.login(email_addr, password)
# 列出所有資料夾(包括子資料夾)
status, folders = mail.list()
folder_list = []
if status == 'OK':
import re
for folder in folders:
# 解析資料夾名稱
folder_str = folder.decode('utf-8', errors='replace')
logger.info(f"Raw folder: {folder_str}")
# 格式可能是:
# 1. (\HasNoChildren) "." "INBOX" (有引號)
# 2. (\HasNoChildren) "." INBOX (無引號)
# 3. (\HasNoChildren \Sent) "." Sent (無引號 + 多個標誌)
# 改進的正則表達式:匹配有引號或無引號的資料夾名稱
# 格式: (flags) "delimiter" folder_name 或 "folder_name"
match = re.search(r'\([^)]*\)\s+"[^"]*"\s+(.+)$', folder_str)
if match:
folder_name = match.group(1).strip()
# 移除可能的引號
folder_name = folder_name.strip('"')
if folder_name:
folder_list.append(folder_name)
logger.info(f"Parsed folder: {folder_name}")
else:
logger.warning(f"Failed to parse folder: {folder_str}")
mail.logout()
# 記錄所有找到的資料夾
logger.info(f"Found folders: {folder_list}")
# 確保至少有 INBOX
if not folder_list:
folder_list = ['INBOX']
return folder_list
except Exception as e:
logger.error(f"IMAP folder list error: {e}")
import traceback
traceback.print_exc()
return ['INBOX'] # 至少返回收件匣
def get_imap_messages(email_addr, password, folder='INBOX', limit=50):
"""從 IMAP 取得郵件列表(含附件、標記等資訊)"""
try:
# 連接 IMAP
mail = imaplib.IMAP4(MAIL_SERVER, 143)
mail.login(email_addr, password)
# 選擇資料夾
status, _ = mail.select(folder)
if status != 'OK':
logger.warning(f"Failed to select folder: {folder}, using INBOX")
mail.select('INBOX')
# 搜尋所有郵件
status, messages = mail.search(None, 'ALL')
mail_ids = messages[0].split()
# 取得最新的 N 封
mail_ids = mail_ids[-limit:]
mail_ids.reverse()
messages_list = []
for mail_id in mail_ids:
# 取得郵件 FLAGS 和 RFC822
status, msg_data = mail.fetch(mail_id, '(FLAGS RFC822)')
flags = []
msg_bytes = None
for response_part in msg_data:
if isinstance(response_part, bytes):
# 解析 FLAGS
flags_match = response_part.decode('utf-8', errors='replace')
if '\\Seen' in flags_match:
flags.append('seen')
if '\\Flagged' in flags_match:
flags.append('flagged')
if '\\Answered' in flags_match:
flags.append('answered')
elif isinstance(response_part, tuple):
msg_bytes = response_part[1]
if msg_bytes:
msg = email.message_from_bytes(msg_bytes)
# 解析主旨
subject = decode_header_value(msg.get('Subject', ''))
# 解析寄件者
from_ = msg.get('From', '')
# 解析日期
date_str = msg.get('Date', '')
# 檢查是否有附件
has_attachment = False
if msg.is_multipart():
for part in msg.walk():
content_disposition = part.get('Content-Disposition', '')
if 'attachment' in content_disposition:
has_attachment = True
break
# 是否未讀
is_unread = 'seen' not in flags
messages_list.append({
'id': mail_id.decode(),
'subject': subject or '(無主旨)',
'from': from_,
'date': date_str,
'has_attachment': has_attachment,
'is_unread': is_unread,
'is_flagged': 'flagged' in flags,
'is_answered': 'answered' in flags
})
mail.close()
mail.logout()
return messages_list
except Exception as e:
logger.error(f"IMAP error: {e}")
import traceback
traceback.print_exc()
return []
def get_mail_by_id(email_addr, password, mail_id, folder='INBOX'):
"""讀取單封郵件內容"""
try:
mail = imaplib.IMAP4(MAIL_SERVER, 143)
mail.login(email_addr, password)
mail.select(folder)
status, msg_data = mail.fetch(mail_id, '(RFC822)')
for response_part in msg_data:
if isinstance(response_part, tuple):
msg = email.message_from_bytes(response_part[1])
# 解析標頭
subject = decode_header_value(msg.get('Subject', ''))
from_ = msg.get('From', '')
to_ = msg.get('To', '')
date_str = msg.get('Date', '')
# 解析郵件內容和附件
body = ""
attachments = []
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = part.get('Content-Disposition', '')
# 檢查是否為附件
if 'attachment' in content_disposition:
filename = part.get_filename()
if filename:
# 解碼檔名
decoded_filename = decode_header_value(filename)
# 取得檔案大小
file_size = len(part.get_payload(decode=True))
attachments.append({
'filename': decoded_filename,
'size': file_size,
'content_type': content_type
})
# 取得郵件本文
elif content_type == "text/plain" and not body:
body = part.get_payload(decode=True).decode('utf-8', errors='replace')
elif content_type == "text/html" and not body:
body = part.get_payload(decode=True).decode('utf-8', errors='replace')
else:
body = msg.get_payload(decode=True).decode('utf-8', errors='replace')
mail.close()
mail.logout()
return {
'id': mail_id,
'subject': subject or '(無主旨)',
'from': from_,
'to': to_,
'date': date_str,
'body': body,
'attachments': attachments
}
mail.close()
mail.logout()
return None
except Exception as e:
logger.error(f"IMAP read error: {e}")
import traceback
traceback.print_exc()
return None
def send_email_smtp(from_addr, password, to_addr, subject, body, content_type='plain', attachments=None):
"""使用 SMTP 發送郵件(支援附件)"""
try:
# 建立郵件
msg = MIMEMultipart('mixed') if attachments else MIMEMultipart('alternative')
msg['From'] = from_addr
msg['To'] = to_addr
msg['Subject'] = subject
# 建立郵件內容部分
if attachments:
# 有附件時,先建立 alternative 部分
msg_alternative = MIMEMultipart('alternative')
if content_type == 'html':
text_content = body.replace('<br>', '\n').replace('<[^>]*>', '')
msg_alternative.attach(MIMEText(text_content, 'plain', 'utf-8'))
msg_alternative.attach(MIMEText(body, 'html', 'utf-8'))
else:
msg_alternative.attach(MIMEText(body, 'plain', 'utf-8'))
msg.attach(msg_alternative)
else:
# 無附件時,直接附加內容
if content_type == 'html':
text_content = body.replace('<br>', '\n').replace('<[^>]*>', '')
msg.attach(MIMEText(text_content, 'plain', 'utf-8'))
msg.attach(MIMEText(body, 'html', 'utf-8'))
else:
msg.attach(MIMEText(body, 'plain', 'utf-8'))
# 附加檔案
if attachments:
from email.mime.base import MIMEBase
from email import encoders
import mimetypes
for attachment in attachments:
# 猜測 MIME 類型
mime_type, _ = mimetypes.guess_type(attachment['filename'])
if mime_type is None:
mime_type = 'application/octet-stream'
main_type, sub_type = mime_type.split('/', 1)
part = MIMEBase(main_type, sub_type)
part.set_payload(attachment['content'])
encoders.encode_base64(part)
part.add_header('Content-Disposition', f'attachment; filename="{attachment["filename"]}"')
msg.attach(part)
# 連接 SMTP (內部網路,不使用 TLS 和認證)
smtp = smtplib.SMTP(SMTP_SERVER, 25, timeout=10)
# 內部郵件伺服器不需要認證
smtp.send_message(msg)
smtp.quit()
attachment_count = len(attachments) if attachments else 0
logger.info(f"Email sent from {from_addr} to {to_addr} (type: {content_type}, attachments: {attachment_count})")
return True
except Exception as e:
logger.error(f"SMTP error: {e}")
import traceback
traceback.print_exc()
return False
@app.get("/")
async def root(request: Request, folder: str = "INBOX"):
session_id = request.session.get("session_id")
if not session_id:
logger.info("No session, redirecting to Keycloak")
return await oauth.keycloak.authorize_redirect(request, REDIRECT_URI)
user_data_json = redis_client.get(f"webmail:session:{session_id}")
if not user_data_json:
logger.info("Session expired, redirecting to login")
request.session.clear()
return await oauth.keycloak.authorize_redirect(request, REDIRECT_URI)
user_data = json.loads(user_data_json)
sso_uuid = user_data.get("sub")
name = user_data.get("name")
preferred_username = user_data.get("preferred_username")
# 從資料庫取得郵件帳號密碼
credentials = get_account_by_sso_uuid(sso_uuid)
if not credentials:
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>錯誤</title></head><body>
<h1>找不到郵件帳號</h1>
<p>SSO: {preferred_username}</p>
<a href="/logout">登出</a>
</body></html>""")
mail_email = credentials['email']
mail_password = credentials['password']
# 取得資料夾列表
folders = get_imap_folders(mail_email, mail_password)
# 取得郵件列表
messages = get_imap_messages(mail_email, mail_password, folder)
# 取得使用者主題設定
theme = redis_client.hget(f"webmail:settings:{session_id}", "theme") or "default"
# 定義主題樣式
themes = {
"default": {
"bg": "#f5f5f5",
"header_bg": "#1a73e8",
"header_text": "white",
"container_bg": "white",
"item_hover": "#f8f9fa",
"text_secondary": "#5f6368",
"btn_bg": "#1a73e8",
"btn_hover": "#1557b0"
},
"dark": {
"bg": "#1f1f1f",
"header_bg": "#2d2d2d",
"header_text": "#e8eaed",
"container_bg": "#2d2d2d",
"item_hover": "#3c4043",
"text_secondary": "#9aa0a6",
"btn_bg": "#8ab4f8",
"btn_hover": "#aecbfa"
},
"green": {
"bg": "#e8f5e9",
"header_bg": "#2e7d32",
"header_text": "white",
"container_bg": "white",
"item_hover": "#c8e6c9",
"text_secondary": "#558b2f",
"btn_bg": "#43a047",
"btn_hover": "#66bb6a"
},
"purple": {
"bg": "#f3e5f5",
"header_bg": "#6a1b9a",
"header_text": "white",
"container_bg": "white",
"item_hover": "#e1bee7",
"text_secondary": "#7b1fa2",
"btn_bg": "#8e24aa",
"btn_hover": "#ab47bc"
}
}
current_theme = themes.get(theme, themes["default"])
# 提取主題顏色變數(避免 f-string 中使用字典語法)
theme_bg = current_theme['bg']
theme_header_bg = current_theme['header_bg']
theme_header_text = current_theme['header_text']
theme_btn_bg = current_theme['btn_bg']
theme_btn_hover = current_theme['btn_hover']
theme_item_hover = current_theme['item_hover']
theme_container_bg = current_theme['container_bg']
theme_text_secondary = current_theme['text_secondary']
# 計算條件表達式的值
theme_text_color = theme_header_text if theme == 'dark' else '#202124'
# 產生資料夾列表 HTML
def get_folder_icon(folder_name):
"""根據資料夾名稱判斷圖示"""
folder_lower = folder_name.lower()
if 'inbox' in folder_lower:
return '📥'
elif 'sent' in folder_lower or 'sent items' in folder_lower or 'sent mail' in folder_lower:
return '📤'
elif 'draft' in folder_lower:
return '📝'
elif 'trash' in folder_lower or 'deleted' in folder_lower:
return '🗑️'
elif 'junk' in folder_lower or 'spam' in folder_lower:
return '🚫'
elif 'archive' in folder_lower:
return '📦'
elif 'star' in folder_lower or 'flagged' in folder_lower:
return ''
elif 'important' in folder_lower:
return ''
else:
return '📁'
def get_folder_display_name(folder_name):
"""根據資料夾名稱轉換為中文顯示名稱"""
folder_lower = folder_name.lower()
# 完全匹配
exact_match = {
'INBOX': '收件匣',
'Sent': '寄件備份',
'Sent Items': '寄件備份',
'Sent Mail': '寄件備份',
'Drafts': '草稿',
'Trash': '垃圾桶',
'Deleted Items': '垃圾桶',
'Junk': '垃圾郵件',
'Spam': '垃圾郵件',
'Archive': '封存',
'Starred': '已加星號',
'Flagged': '已標幟',
'Important': '重要郵件'
}
if folder_name in exact_match:
return exact_match[folder_name]
# 部分匹配(處理層級資料夾,例如 INBOX.Sent
if 'sent' in folder_lower:
return '寄件備份'
elif 'draft' in folder_lower:
return '草稿'
elif 'trash' in folder_lower or 'deleted' in folder_lower:
return '垃圾桶'
elif 'junk' in folder_lower or 'spam' in folder_lower:
return '垃圾郵件'
elif 'archive' in folder_lower:
return '封存'
elif 'star' in folder_lower or 'flagged' in folder_lower:
return '已加星號'
# 顯示原始名稱(處理層級,例如將 . 替換為 /
return folder_name.replace('.', ' / ')
def get_folder_indent_level(folder_name):
"""計算資料夾的層級(用於縮排)"""
return folder_name.count('.')
# 資料夾排序:收件匣和寄件備份在最上方
def get_folder_priority(folder_name):
"""定義資料夾顯示優先順序(數字越小越前面)"""
folder_lower = folder_name.lower()
if folder_name == 'INBOX' or 'inbox' in folder_lower:
return 1 # 收件匣最優先
elif 'sent' in folder_lower:
return 2 # 寄件備份第二
elif 'draft' in folder_lower:
return 3 # 草稿第三
elif 'trash' in folder_lower or 'deleted' in folder_lower:
return 98 # 垃圾桶倒數第二
elif 'junk' in folder_lower or 'spam' in folder_lower:
return 99 # 垃圾郵件最後
else:
return 50 # 其他資料夾中間
# 對資料夾進行排序
sorted_folders = sorted(folders, key=get_folder_priority)
folders_html = ""
for f in sorted_folders:
icon = get_folder_icon(f)
display_name = get_folder_display_name(f)
indent_level = get_folder_indent_level(f)
active_class = 'active' if f == folder else ''
# 計算縮排
indent_style = f"padding-left: {16 + indent_level * 20}px;" if indent_level > 0 else ""
folders_html += f"""
<li class="folder-item {active_class}" onclick="location.href='/?folder={f}'" style="{indent_style}">
<span class="folder-icon">{icon}</span>
<span class="folder-name">{display_name}</span>
</li>
"""
# 產生郵件列表 HTML
mail_items_html = ""
if messages:
for m in messages:
# 智慧型標記
markers = []
if m['is_flagged']:
markers.append('<span class="marker marker-star active" title="已加星號">⭐</span>')
else:
markers.append('<span class="marker marker-star" title="加星號" onclick="toggleStar(event, \'{m["id"]}\')">☆</span>')
# 重要標記(這裡先用 flagged 模擬,之後可擴展)
markers.append('<span class="marker marker-important" title="標記為重要" onclick="toggleImportant(event, \'{m["id"]}\')">❗</span>')
if m['is_answered']:
markers.append('<span class="marker marker-replied" title="已回覆">↩️</span>')
markers_html = ''.join(markers)
# 附件圖示
attachment_icon = '<span class="attachment-icon" title="有附件">📎</span>' if m['has_attachment'] else ''
# 未讀樣式
unread_class = 'unread' if m['is_unread'] else ''
mail_items_html += f"""
<li class="mail-item {unread_class}">
<div class="mail-row">
<div class="mail-checkbox">
<input type="checkbox" class="mail-select" value="{m['id']}" onclick="event.stopPropagation()">
</div>
<div class="mail-markers">{markers_html}</div>
<div class="mail-subject-col" onclick="openMailModal('{m['id']}', '{folder}')">
<span class="subject-text">{m['subject']}</span>
{attachment_icon}
</div>
<div class="mail-date-col" onclick="openMailModal('{m['id']}', '{folder}')">{m['date']}</div>
</div>
</li>
"""
else:
mail_items_html = '<div class="no-mail">目前沒有郵件</div>'
# 顯示郵件列表Gmail 風格兩欄式布局)
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>WebMail</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: 'Google Sans', 'Segoe UI', Arial, sans-serif; background: {theme_bg}; color: {theme_text_color}; overflow: hidden; height: 100vh; display: flex; flex-direction: column; }}
/* 頂部導航列 */
.header {{ background: {theme_header_bg}; color: {theme_header_text}; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); z-index: 100; }}
.header h1 {{ margin: 0; font-size: 22px; font-weight: 400; }}
.header-actions {{ display: flex; gap: 12px; align-items: center; }}
.user-info {{ font-size: 13px; }}
.user-info a {{ color: {theme_header_text}; text-decoration: none; }}
.btn {{ background: {theme_btn_bg}; color: white; border: none; padding: 8px 20px; border-radius: 24px; cursor: pointer; text-decoration: none; display: inline-block; font-size: 14px; transition: all 0.2s; }}
.btn:hover {{ background: {theme_btn_hover}; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }}
.btn-icon {{ background: transparent; padding: 8px; border-radius: 50%; }}
.btn-icon:hover {{ background: rgba(255,255,255,0.1); }}
/* 主要容器 - Gmail 風格兩欄 */
.main-layout {{ flex: 1; display: flex; overflow: hidden; }}
/* 左側資料夾面板 */
.sidebar {{ width: 256px; min-width: 180px; max-width: 400px; background: {theme_bg}; border-right: 2px solid {theme_item_hover}; display: flex; flex-direction: column; position: relative; }}
.sidebar-header {{ padding: 12px 16px; background: {theme_item_hover}; border-bottom: 2px solid {theme_bg}; display: flex; justify-content: space-between; align-items: center; }}
.sidebar-header h2 {{ font-size: 14px; font-weight: 600; margin: 0; }}
.folder-list {{ list-style: none; overflow-y: auto; flex: 1; }}
.folder-item {{ padding: 8px 12px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 10px; font-size: 13px; border-left: 3px solid transparent; }}
.folder-item:hover {{ background: {theme_item_hover}; }}
.folder-item.active {{ background: {theme_btn_bg}; color: white; font-weight: 500; border-left: 3px solid white; }}
.folder-icon {{ font-size: 20px; width: 24px; text-align: center; }}
.folder-name {{ flex: 1; }}
/* 調整大小手柄 */
.resize-handle {{ width: 4px; height: 100%; position: absolute; right: 0; top: 0; cursor: col-resize; background: transparent; }}
.resize-handle:hover, .resize-handle.resizing {{ background: {theme_btn_bg}; }}
/* 右側郵件列表 */
.content-area {{ flex: 1; display: flex; flex-direction: column; background: {theme_container_bg}; overflow: hidden; }}
.content-header {{ padding: 12px 16px; background: {theme_item_hover}; border-bottom: 2px solid {theme_bg}; display: flex; justify-content: space-between; align-items: center; }}
.content-header h2 {{ font-size: 16px; font-weight: 500; margin: 0; }}
.content-actions {{ display: flex; gap: 8px; align-items: center; }}
.btn-delete {{ background: #ea4335; }}
.btn-delete:hover {{ background: #d33828; }}
.btn-delete:disabled {{ background: #ccc; cursor: not-allowed; opacity: 0.5; }}
.mail-list-container {{ flex: 1; overflow-y: auto; }}
.mail-list {{ list-style: none; }}
.mail-item {{ border-bottom: 1px solid {theme_item_hover}; transition: all 0.15s; }}
.mail-item:nth-child(even) {{ background: {theme_item_hover}; }}
.mail-item:hover {{ background: {theme_btn_bg}20; }}
.mail-item.unread {{ font-weight: 600; }}
.mail-item.unread .subject-text {{ font-weight: 700; }}
/* 郵件列表行 */
.mail-row {{ display: grid; grid-template-columns: 40px 120px 1fr 140px; gap: 10px; padding: 10px 16px; align-items: center; }}
.mail-checkbox {{ display: flex; align-items: center; justify-content: center; }}
.mail-checkbox input[type="checkbox"] {{ width: 18px; height: 18px; cursor: pointer; }}
.mail-markers {{ display: flex; gap: 6px; align-items: center; }}
.marker {{ font-size: 18px; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }}
.marker:hover {{ opacity: 1; }}
.marker.active {{ opacity: 1; }}
.marker-star {{ color: #f9ab00; }}
.marker-important {{ color: #ea4335; }}
.marker-replied {{ cursor: default; color: #5f6368; }}
.mail-subject-col {{ display: flex; gap: 8px; align-items: center; overflow: hidden; cursor: pointer; }}
.subject-text {{ flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.attachment-icon {{ font-size: 16px; flex-shrink: 0; }}
.mail-date-col {{ font-size: 12px; color: {theme_text_secondary}; text-align: right; cursor: pointer; }}
.no-mail {{ padding: 60px 20px; text-align: center; color: {theme_text_secondary}; font-size: 14px; }}
/* Modal 視窗 */
.modal {{ display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); }}
.modal.show {{ display: flex; align-items: center; justify-content: center; }}
.modal-content {{ background: {theme_container_bg}; width: 90%; max-width: 900px; max-height: 90vh; border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); display: flex; flex-direction: column; }}
.modal-header {{ padding: 16px 20px; background: {theme_btn_bg}; color: white; border-radius: 8px 8px 0 0; display: flex; justify-content: space-between; align-items: center; }}
.modal-header h3 {{ font-size: 18px; font-weight: 500; margin: 0; }}
.modal-close {{ background: transparent; border: none; font-size: 24px; cursor: pointer; color: white; padding: 0; width: 32px; height: 32px; border-radius: 50%; }}
.modal-close:hover {{ background: rgba(255,255,255,0.2); }}
.modal-body {{ padding: 0; overflow-y: auto; flex: 1; }}
.mail-meta {{ padding: 12px 16px; background: {theme_item_hover}; margin: 0; text-align: left; display: grid; grid-template-columns: 1fr; gap: 8px; border-bottom: 2px solid {theme_bg}; }}
.mail-meta-row {{ display: grid; grid-template-columns: 80px 1fr; font-size: 13px; align-items: center; }}
.meta-label {{ color: {theme_text_secondary}; font-weight: 600; }}
.meta-value {{ color: {theme_text_color}; word-break: break-word; }}
.mail-body {{ padding: 16px; font-size: 14px; line-height: 1.6; text-align: left; background: {theme_container_bg}; min-height: 200px; }}
/* 附件列表 */
.attachments-section {{ padding: 16px 20px; background: {theme_item_hover}; margin: 0; border-top: 2px solid {theme_container_bg}; }}
.attachments-title {{ font-weight: 600; margin-bottom: 10px; font-size: 13px; color: {theme_text_secondary}; }}
.attachment-item {{ display: flex; align-items: center; padding: 8px 10px; margin-bottom: 6px; background: {theme_container_bg}; border-radius: 4px; font-size: 13px; border-left: 3px solid {theme_btn_bg}; }}
.attachment-item:last-child {{ margin-bottom: 0; }}
.attachment-item:hover {{ background: {theme_bg}; }}
.attachment-icon {{ margin-right: 10px; font-size: 16px; }}
.attachment-name {{ flex: 1; }}
.attachment-size {{ color: {theme_text_secondary}; font-size: 11px; margin-left: 10px; }}
/* 主題選擇器 */
.theme-selector {{ position: relative; display: inline-block; }}
.theme-menu {{ display: none; position: absolute; right: 0; top: 100%; background: {theme_container_bg}; border: 1px solid {theme_item_hover}; border-radius: 8px; padding: 8px 0; margin-top: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 200; min-width: 160px; }}
.theme-menu.show {{ display: block; }}
.theme-option {{ padding: 10px 16px; cursor: pointer; font-size: 14px; color: {theme_text_color}; }}
.theme-option:hover {{ background: {theme_item_hover}; }}
.theme-option.active {{ background: {theme_btn_bg}; color: white; }}
/* 載入中動畫 */
.loading {{ text-align: center; padding: 40px; color: {theme_text_secondary}; }}
/* === 撰寫郵件 Modal === */
.compose-modal-content {{ background: white; max-width: 800px; width: 90%; margin: 2% auto; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.3); display: flex; flex-direction: column; max-height: 90vh; }}
.compose-modal-header {{ background: {theme_btn_bg}; color: white; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; border-radius: 8px 8px 0 0; }}
.compose-modal-header h2 {{ margin: 0; font-size: 18px; font-weight: 500; }}
.btn-close-modal {{ background: transparent; border: none; color: white; font-size: 24px; cursor: pointer; padding: 0; width: 32px; height: 32px; border-radius: 50%; transition: background 0.2s; }}
.btn-close-modal:hover {{ background: rgba(255,255,255,0.2); }}
.compose-modal-body {{ flex: 1; overflow-y: auto; padding: 20px 24px; }}
.compose-modal-footer {{ padding: 16px 24px; border-top: 1px solid #e0e0e0; display: flex; justify-content: flex-end; gap: 12px; }}
/* Compose 表單欄位 */
.compose-form-field {{ display: flex; border-bottom: 1px solid #e0e0e0; padding: 10px 0; align-items: center; }}
.compose-form-field label {{ width: 80px; font-weight: 600; font-size: 13px; color: #5f6368; flex-shrink: 0; }}
.compose-form-field input {{ flex: 1; border: none; outline: none; font-size: 14px; padding: 4px 0; }}
/* Compose 編輯器 */
.compose-editor-mode-tabs {{ display: flex; background: #f8f9fa; border-bottom: 2px solid #e0e0e0; margin-top: 16px; }}
.compose-editor-tab {{ padding: 8px 16px; cursor: pointer; font-size: 13px; border-bottom: 3px solid transparent; margin-bottom: -2px; transition: all 0.2s; }}
.compose-editor-tab:hover {{ background: #e8eaed; }}
.compose-editor-tab.active {{ border-bottom-color: {theme_btn_bg}; color: {theme_btn_bg}; font-weight: 600; }}
.compose-editor-container {{ min-height: 250px; max-height: 350px; overflow: hidden; position: relative; display: flex; flex-direction: column; }}
.compose-editor-area {{ width: 100%; height: 100%; border: none; padding: 16px; font-size: 14px; font-family: 'Segoe UI', Arial, sans-serif; resize: none; outline: none; }}
.compose-editor-area.plain {{ display: block; min-height: 250px; }}
.compose-editor-area.rich {{ display: flex; flex-direction: column; padding: 0; min-height: 250px; }}
/* Compose Quill 編輯器樣式 */
#composeRichEditor .ql-toolbar {{ border: none; border-bottom: 1px solid #e0e0e0; background: #f8f9fa; padding: 8px 16px; }}
#composeRichEditor .ql-container {{ border: none; flex: 1; font-size: 14px; }}
#composeRichEditor .ql-editor {{ padding: 16px; min-height: 200px; }}
/* Compose 附件區域 */
.compose-attachments {{ margin-top: 16px; border-top: 1px solid #e0e0e0; padding-top: 16px; }}
.compose-attachments-header {{ margin-bottom: 12px; }}
.compose-attachments-list {{ max-height: 150px; overflow-y: auto; }}
.compose-attachments-list .attachment-item {{ display: flex; align-items: center; padding: 8px 12px; margin-bottom: 8px; background: #f8f9fa; border-radius: 6px; border-left: 3px solid {theme_btn_bg}; }}
.compose-attachments-list .attachment-icon {{ margin-right: 10px; }}
.compose-attachments-list .attachment-name {{ flex: 1; font-size: 13px; }}
.compose-attachments-list .attachment-size {{ color: #5f6368; font-size: 12px; margin: 0 12px; }}
.btn-remove-attachment {{ background: #ea4335; color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer; font-size: 16px; line-height: 1; padding: 0; }}
.btn-remove-attachment:hover {{ background: #d33828; }}
.btn-secondary {{ background: #5f6368; }}
.btn-secondary:hover {{ background: #4a4f54; }}
.btn-success {{ background: #1a73e8; }}
.btn-success:hover {{ background: #1557b0; }}
</style>
</head><body>
<!-- 頂部導航列 -->
<div class="header">
<h1>📧 WebMail</h1>
<div class="header-actions">
<button class="btn" onclick="openComposeModal()">✍️ 撰寫</button>
<div class="theme-selector">
<button class="btn btn-icon" onclick="toggleThemeMenu()" title="主題">🎨</button>
<div id="themeMenu" class="theme-menu">
<div class="theme-option {'active' if theme == 'default' else ''}" onclick="setTheme('default')">預設藍色</div>
<div class="theme-option {'active' if theme == 'dark' else ''}" onclick="setTheme('dark')">深色模式</div>
<div class="theme-option {'active' if theme == 'green' else ''}" onclick="setTheme('green')">清新綠色</div>
<div class="theme-option {'active' if theme == 'purple' else ''}" onclick="setTheme('purple')">優雅紫色</div>
</div>
</div>
<div class="user-info">{name} ({mail_email}) | <a href="/logout">登出</a></div>
</div>
</div>
<!-- 主要內容區 - 兩欄式 -->
<div class="main-layout">
<!-- 左側資料夾面板 -->
<div class="sidebar" id="sidebar">
<div class="sidebar-header">
<h2>📁 資料夾</h2>
</div>
<ul class="folder-list">
{folders_html}
</ul>
<div class="resize-handle" id="resizeHandle"></div>
</div>
<!-- 右側郵件列表 -->
<div class="content-area">
<div class="content-header">
<h2>{get_folder_display_name(folder)}</h2>
<div class="content-actions">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="selectAll" onclick="toggleSelectAll()">
<span style="font-size: 14px;">全選</span>
</label>
<button class="btn btn-delete" id="deleteBtn" onclick="deleteSelected()" disabled>🗑️ 刪除選取</button>
</div>
</div>
<div class="mail-list-container">
<ul class="mail-list">
{mail_items_html}
</ul>
</div>
</div>
</div>
<!-- Modal 郵件視窗 -->
<div id="mailModal" class="modal" onclick="closeModalOnBackdrop(event)">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalSubject">載入中...</h3>
<button class="modal-close" onclick="closeMailModal()">&times;</button>
</div>
<div class="modal-body">
<div id="modalContent" class="loading">載入郵件中...</div>
</div>
</div>
</div>
<!-- 隱藏表單 -->
<form id="themeForm" method="POST" action="/theme" style="display:none;">
<input type="hidden" name="theme" value="">
</form>
<script>
// 主題切換
function toggleThemeMenu() {{
document.getElementById('themeMenu').classList.toggle('show');
}}
function setTheme(theme) {{
document.getElementById('themeForm').theme.value = theme;
document.getElementById('themeForm').submit();
}}
window.onclick = function(event) {{
if (!event.target.closest('.theme-selector')) {{
document.getElementById('themeMenu').classList.remove('show');
}}
}}
// 調整側邊欄大小
let isResizing = false;
const sidebar = document.getElementById('sidebar');
const resizeHandle = document.getElementById('resizeHandle');
resizeHandle.addEventListener('mousedown', function(e) {{
isResizing = true;
resizeHandle.classList.add('resizing');
e.preventDefault();
}});
document.addEventListener('mousemove', function(e) {{
if (!isResizing) return;
const newWidth = e.clientX;
if (newWidth >= 180 && newWidth <= 400) {{
sidebar.style.width = newWidth + 'px';
}}
}});
document.addEventListener('mouseup', function() {{
if (isResizing) {{
isResizing = false;
resizeHandle.classList.remove('resizing');
// 儲存寬度到 localStorage
localStorage.setItem('sidebar_width', sidebar.style.width);
}}
}});
// 恢復側邊欄寬度
window.addEventListener('load', function() {{
const savedWidth = localStorage.getItem('sidebar_width');
if (savedWidth) {{
sidebar.style.width = savedWidth;
}}
}});
// Modal 開啟/關閉
function openMailModal(mailId, folder) {{
const modal = document.getElementById('mailModal');
const modalSubject = document.getElementById('modalSubject');
const modalContent = document.getElementById('modalContent');
// 顯示 modal
modal.classList.add('show');
modalSubject.textContent = '載入中...';
modalContent.innerHTML = '<div class="loading">載入郵件中...</div>';
// 發送 AJAX 請求取得郵件內容
fetch(`/api/mail/${{mailId}}?folder=${{encodeURIComponent(folder)}}`).then(response => response.json())
.then(data => {{
if (data.error) {{
modalContent.innerHTML = `<p>錯誤: ${{data.error}}</p>`;
return;
}}
modalSubject.textContent = data.subject;
// 產生附件列表 HTML
let attachmentsHtml = '';
if (data.attachments && data.attachments.length > 0) {{
const formatFileSize = (bytes) => {{
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}};
const attachmentItems = data.attachments.map(att => `
<div class="attachment-item">
<span class="attachment-icon">📎</span>
<span class="attachment-name">${{att.filename}}</span>
<span class="attachment-size">${{formatFileSize(att.size)}}</span>
</div>
`).join('');
attachmentsHtml = `
<div class="attachments-section">
<div class="attachments-title">📎 附件 (${{data.attachments.length}})</div>
${{attachmentItems}}
</div>
`;
}}
// 檢測是否為 HTML 內容
let bodyContent = data.body || '(無內容)';
const isHTML = /<[a-z][\s\S]*>/i.test(bodyContent);
let formattedBody;
if (isHTML) {{
// HTML 內容:直接顯示
formattedBody = `<div style="padding: 16px; background: white; border: 1px solid #e0e0e0; border-radius: 4px;">${{bodyContent}}</div>`;
}} else {{
// 純文字:轉義並格式化
formattedBody = `<div style="padding: 16px; background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; font-family: monospace; white-space: pre-wrap;">${{bodyContent.replace(/</g, '&lt;').replace(/>/g, '&gt;')}}</div>`;
}}
modalContent.innerHTML = `
<div class="mail-meta">
<div class="mail-meta-row">
<span class="meta-label">寄件者:</span>
<span class="meta-value">${{data.from || '(未知)'}}</span>
</div>
<div class="mail-meta-row">
<span class="meta-label">收件者:</span>
<span class="meta-value">${{data.to || '(未知)'}}</span>
</div>
<div class="mail-meta-row">
<span class="meta-label">日期:</span>
<span class="meta-value">${{data.date || '(未知)'}}</span>
</div>
</div>
<div class="mail-body">${{formattedBody}}</div>
${{attachmentsHtml}}
`;
}})
.catch(error => {{
modalContent.innerHTML = `<p>載入失敗: ${{error.message}}</p>`;
}});
}}
function closeMailModal() {{
document.getElementById('mailModal').classList.remove('show');
}}
function closeModalOnBackdrop(event) {{
if (event.target.id === 'mailModal') {{
closeMailModal();
}}
}}
// ESC 關閉 modal
document.addEventListener('keydown', function(e) {{
if (e.key === 'Escape') {{
closeMailModal();
}}
}});
// 全選/取消全選
function toggleSelectAll() {{
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.mail-select');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateDeleteButton();
}}
// 更新刪除按鈕狀態
function updateDeleteButton() {{
const checkboxes = document.querySelectorAll('.mail-select:checked');
const deleteBtn = document.getElementById('deleteBtn');
deleteBtn.disabled = checkboxes.length === 0;
}}
// 監聽所有 checkbox 變化
document.addEventListener('change', function(e) {{
if (e.target.classList.contains('mail-select')) {{
updateDeleteButton();
}}
}});
// 切換星號標記
function toggleStar(event, mailId) {{
event.stopPropagation();
// TODO: 實作 IMAP 星號標記功能
console.log('Toggle star for:', mailId);
alert('星號標記功能開發中');
}}
// 切換重要標記
function toggleImportant(event, mailId) {{
event.stopPropagation();
// TODO: 實作 IMAP 重要標記功能
console.log('Toggle important for:', mailId);
alert('重要標記功能開發中');
}}
// 刪除選取的郵件
function deleteSelected() {{
const checkboxes = document.querySelectorAll('.mail-select:checked');
if (checkboxes.length === 0) return;
const mailIds = Array.from(checkboxes).map(cb => cb.value);
const count = mailIds.length;
if (!confirm(`確定要刪除 ${{count}} 封郵件嗎?`)) {{
return;
}}
// 發送刪除請求
fetch('/api/delete-mails', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json'
}},
body: JSON.stringify({{
mail_ids: mailIds,
folder: '{folder}'
}}),
}})
.then(response => response.json())
.then(data => {{
if (data.success) {{
alert(`成功刪除 ${{count}} 封郵件`);
location.reload();
}} else {{
alert('刪除失敗: ' + (data.error || '未知錯誤'));
}}
}})
.catch(error => {{
alert('刪除失敗: ' + error.message);
}});
}}
// === 撰寫郵件 Modal ===
function openComposeModal() {{
document.getElementById('composeModal').classList.add('show');
document.getElementById('composeToField').value = '';
document.getElementById('composeSubjectField').value = '';
document.getElementById('composePlainEditor').value = '';
document.getElementById('composeContentType').value = 'plain';
document.getElementById('composeAttachmentsList').innerHTML = '';
composeAttachments = [];
// 重置編輯器模式
if (composeQuillInstance) {{
composeQuillInstance.root.innerHTML = '';
}}
const tabs = document.querySelectorAll('.compose-editor-tab');
tabs.forEach(tab => tab.classList.remove('active'));
document.querySelector('.compose-editor-tab[onclick*="plain"]').classList.add('active');
document.getElementById('composePlainEditor').style.display = 'block';
document.getElementById('composeRichEditor').style.display = 'none';
composeEditorMode = 'plain';
}}
function closeComposeModal() {{
if (confirm('確定要放棄編輯並關閉嗎?')) {{
document.getElementById('composeModal').classList.remove('show');
}}
}}
function closeComposeModalOnBackdrop(event) {{
if (event.target.id === 'composeModal') {{
closeComposeModal();
}}
}}
// Compose 編輯器狀態
let composeEditorMode = 'plain';
let composeQuillInstance = null;
let composeAttachments = [];
// 初始化 Compose Quill 編輯器
function initComposeQuill() {{
if (composeQuillInstance) return;
composeQuillInstance = new Quill('#composeRichEditor', {{
theme: 'snow',
modules: {{
toolbar: [
[{{ 'header': [1, 2, 3, false] }}],
['bold', 'italic', 'underline', 'strike'],
[{{ 'list': 'ordered' }}, {{ 'list': 'bullet' }}],
[{{ 'align': [] }}],
['link'],
['clean']
]
}},
placeholder: '輸入郵件內容...'
}});
}}
// 切換 Compose 編輯器模式
function switchComposeEditorMode(mode) {{
const tabs = document.querySelectorAll('.compose-editor-tab');
const plainEditor = document.getElementById('composePlainEditor');
const richEditorDiv = document.getElementById('composeRichEditor');
const contentTypeInput = document.getElementById('composeContentType');
tabs.forEach(tab => tab.classList.remove('active'));
event.target.classList.add('active');
if (mode === 'plain') {{
if (composeQuillInstance) {{
const richContent = composeQuillInstance.root.innerHTML;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = richContent;
plainEditor.value = tempDiv.textContent || tempDiv.innerText || '';
}}
plainEditor.style.display = 'block';
richEditorDiv.style.display = 'none';
contentTypeInput.value = 'plain';
composeEditorMode = 'plain';
}} else {{
const plainContent = plainEditor.value;
plainEditor.style.display = 'none';
richEditorDiv.style.display = 'block';
contentTypeInput.value = 'html';
composeEditorMode = 'rich';
if (!composeQuillInstance) {{
initComposeQuill();
setTimeout(() => {{
if (composeQuillInstance) {{
const htmlContent = plainContent.replace(/\\n/g, '<br>');
composeQuillInstance.root.innerHTML = htmlContent;
}}
}}, 100);
}} else {{
const htmlContent = plainContent.replace(/\\n/g, '<br>');
composeQuillInstance.root.innerHTML = htmlContent;
}}
}}
}}
// 附件上傳處理
function handleComposeAttachments(event) {{
const files = event.target.files;
for (let file of files) {{
composeAttachments.push(file);
// 顯示附件
const listItem = document.createElement('div');
listItem.className = 'attachment-item';
listItem.innerHTML = `
<span class="attachment-icon">📎</span>
<span class="attachment-name">${{file.name}}</span>
<span class="attachment-size">${{formatFileSize(file.size)}}</span>
<button type="button" class="btn-remove-attachment" onclick="removeComposeAttachment(${{composeAttachments.length - 1}})">✕</button>
`;
document.getElementById('composeAttachmentsList').appendChild(listItem);
}}
// 清空 input 以允許重複選擇相同檔案
event.target.value = '';
}}
function removeComposeAttachment(index) {{
composeAttachments.splice(index, 1);
// 重新渲染附件列表
const list = document.getElementById('composeAttachmentsList');
list.innerHTML = '';
composeAttachments.forEach((file, idx) => {{
const listItem = document.createElement('div');
listItem.className = 'attachment-item';
listItem.innerHTML = `
<span class="attachment-icon">📎</span>
<span class="attachment-name">${{file.name}}</span>
<span class="attachment-size">${{formatFileSize(file.size)}}</span>
<button type="button" class="btn-remove-attachment" onclick="removeComposeAttachment(${{idx}})">✕</button>
`;
list.appendChild(listItem);
}});
}}
function formatFileSize(bytes) {{
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}}
// 發送郵件
function sendComposeMail() {{
const form = document.getElementById('composeFormModal');
const formData = new FormData(form);
// 添加郵件內容
if (composeEditorMode === 'rich' && composeQuillInstance) {{
formData.set('body', composeQuillInstance.root.innerHTML);
}} else {{
formData.set('body', document.getElementById('composePlainEditor').value);
}}
// 添加附件
composeAttachments.forEach((file, index) => {{
formData.append('attachments', file);
}});
// 發送請求
fetch('/send', {{
method: 'POST',
body: formData,
}})
.then(response => {{
if (response.ok) {{
alert('郵件發送成功!');
closeComposeModal();
location.reload(); // 重新載入郵件列表
}} else {{
return response.text().then(text => {{
throw new Error(text);
}});
}}
}})
.catch(error => {{
alert('發送失敗: ' + error.message);
}});
}}
</script>
<!-- 撰寫郵件 Modal -->
<div id="composeModal" class="modal" onclick="closeComposeModalOnBackdrop(event)">
<div class="compose-modal-content">
<div class="compose-modal-header">
<h2>✍️ 撰寫新郵件</h2>
<button class="btn-close-modal" onclick="closeComposeModal()">✕</button>
</div>
<div class="compose-modal-body">
<form id="composeFormModal" onsubmit="event.preventDefault();">
<!-- 收件者 -->
<div class="compose-form-field">
<label>收件者:</label>
<input type="email" name="to" id="composeToField" required placeholder="example@lab.taipei">
</div>
<!-- 主旨 -->
<div class="compose-form-field">
<label>主旨:</label>
<input type="text" name="subject" id="composeSubjectField" required placeholder="請輸入郵件主旨">
</div>
<!-- 編輯器模式切換 -->
<div class="compose-editor-mode-tabs">
<div class="compose-editor-tab active" onclick="switchComposeEditorMode('plain')">純文字</div>
<div class="compose-editor-tab" onclick="switchComposeEditorMode('rich')">富文本</div>
</div>
<!-- 內容編輯器 -->
<div class="compose-editor-container">
<input type="hidden" name="content_type" id="composeContentType" value="plain">
<textarea id="composePlainEditor" class="compose-editor-area plain" required placeholder="輸入郵件內容..."></textarea>
<div id="composeRichEditor" class="compose-editor-area rich" style="display: none;"></div>
</div>
<!-- 附件區域 -->
<div class="compose-attachments">
<div class="compose-attachments-header">
<label for="composeAttachmentInput" class="btn btn-secondary" style="margin: 0; cursor: pointer;">
📎 添加附件
</label>
<input type="file" id="composeAttachmentInput" multiple style="display: none;" onchange="handleComposeAttachments(event)">
</div>
<div id="composeAttachmentsList" class="compose-attachments-list"></div>
</div>
</form>
</div>
<div class="compose-modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeComposeModal()">取消</button>
<button type="button" class="btn btn-success" onclick="sendComposeMail()">📤 發送</button>
</div>
</div>
</div>
</body></html>""")
@app.get("/api/mail/{mail_id}")
async def get_mail_api(request: Request, mail_id: str, folder: str = "INBOX"):
"""API 端點取得單封郵件內容JSON 格式供 Modal 使用)"""
session_id = request.session.get("session_id")
if not session_id:
return {"error": "未登入"}
user_data_json = redis_client.get(f"webmail:session:{session_id}")
if not user_data_json:
return {"error": "Session 過期"}
user_data = json.loads(user_data_json)
sso_uuid = user_data.get("sub")
credentials = get_account_by_sso_uuid(sso_uuid)
if not credentials:
return {"error": "找不到郵件帳號"}
mail_email = credentials['email']
mail_password = credentials['password']
# 讀取郵件
mail_data = get_mail_by_id(mail_email, mail_password, mail_id, folder)
if not mail_data:
return {"error": "找不到郵件"}
return {
"subject": mail_data['subject'],
"from": mail_data['from'],
"to": mail_data['to'],
"date": mail_data['date'],
"body": mail_data['body'],
"attachments": mail_data.get('attachments', [])
}
@app.post("/api/delete-mails")
async def delete_mails_api(request: Request):
"""API 端點:批次刪除郵件"""
session_id = request.session.get("session_id")
if not session_id:
return {"success": False, "error": "未登入"}
user_data_json = redis_client.get(f"webmail:session:{session_id}")
if not user_data_json:
return {"success": False, "error": "Session 過期"}
user_data = json.loads(user_data_json)
sso_uuid = user_data.get("sub")
credentials = get_account_by_sso_uuid(sso_uuid)
if not credentials:
return {"success": False, "error": "找不到郵件帳號"}
mail_email = credentials['email']
mail_password = credentials['password']
# 取得請求資料
try:
data = await request.json()
mail_ids = data.get('mail_ids', [])
folder = data.get('folder', 'INBOX')
if not mail_ids:
return {"success": False, "error": "沒有選擇郵件"}
# 連接 IMAP 刪除郵件
mail = imaplib.IMAP4(MAIL_SERVER, 143)
mail.login(mail_email, mail_password)
mail.select(folder)
# 標記郵件為刪除
for mail_id in mail_ids:
mail.store(mail_id, '+FLAGS', '\\Deleted')
# 永久刪除
mail.expunge()
mail.close()
mail.logout()
return {"success": True, "deleted_count": len(mail_ids)}
except Exception as e:
logger.error(f"Delete mail error: {e}")
import traceback
traceback.print_exc()
return {"success": False, "error": str(e)}
@app.get("/callback")
async def callback(request: Request):
try:
token = await oauth.keycloak.authorize_access_token(request)
userinfo = token.get("userinfo")
if not userinfo:
raise HTTPException(status_code=401, detail="No userinfo")
sso_uuid = userinfo.get("sub")
preferred_username = userinfo.get("preferred_username")
email_addr = userinfo.get("email")
name = userinfo.get("name", preferred_username)
if not sso_uuid:
raise HTTPException(status_code=400, detail="No user ID found")
logger.info(f"Keycloak callback: {preferred_username} (UUID: {sso_uuid})")
session_id = secrets.token_urlsafe(32)
user_data = {
"sub": sso_uuid,
"preferred_username": preferred_username,
"email": email_addr,
"name": name
}
redis_client.setex(f"webmail:session:{session_id}", 8*60*60, json.dumps(user_data))
request.session["session_id"] = session_id
logger.info(f"Session created for {preferred_username}")
return RedirectResponse(url="/")
except Exception as e:
logger.error(f"Callback error: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@app.get("/compose")
async def compose_form(request: Request):
session_id = request.session.get("session_id")
if not session_id:
return RedirectResponse(url="/")
user_data_json = redis_client.get(f"webmail:session:{session_id}")
if not user_data_json:
return RedirectResponse(url="/")
user_data = json.loads(user_data_json)
name = user_data.get("name")
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>撰寫郵件</title>
<!-- Quill Editor (Free & Open Source) -->
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; height: 100vh; display: flex; flex-direction: column; }}
/* 頂部標題列 */
.header {{ background: #1a73e8; color: white; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; }}
.header h1 {{ font-size: 18px; font-weight: 500; }}
.header-actions {{ display: flex; gap: 10px; }}
/* 工具列 */
.toolbar {{ background: #f8f9fa; padding: 8px 16px; border-bottom: 2px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; }}
.toolbar-left {{ display: flex; gap: 8px; align-items: center; }}
.toolbar-right {{ display: flex; gap: 8px; }}
/* 按鈕樣式 */
.btn {{ background: #1a73e8; color: white; border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; transition: all 0.2s; text-decoration: none; display: inline-block; }}
.btn:hover {{ background: #1557b0; }}
.btn-secondary {{ background: #5f6368; }}
.btn-secondary:hover {{ background: #3c4043; }}
.btn-success {{ background: #0f9d58; }}
.btn-success:hover {{ background: #0d8043; }}
/* 表單容器 */
.compose-container {{ flex: 1; display: flex; flex-direction: column; overflow: hidden; }}
.compose-form {{ flex: 1; display: flex; flex-direction: column; background: white; margin: 0; }}
/* 表單欄位 */
.form-field {{ display: flex; border-bottom: 1px solid #e0e0e0; padding: 8px 16px; align-items: center; }}
.form-field label {{ width: 80px; font-weight: 600; font-size: 13px; color: #5f6368; flex-shrink: 0; }}
.form-field input {{ flex: 1; border: none; outline: none; font-size: 14px; padding: 4px 0; }}
/* 編輯器容器 */
.editor-container {{ flex: 1; display: flex; flex-direction: column; overflow: hidden; }}
.editor-mode-tabs {{ display: flex; background: #f8f9fa; border-bottom: 2px solid #e0e0e0; padding: 0 16px; }}
.editor-tab {{ padding: 8px 16px; cursor: pointer; font-size: 13px; border-bottom: 3px solid transparent; margin-bottom: -2px; transition: all 0.2s; }}
.editor-tab:hover {{ background: #e8eaed; }}
.editor-tab.active {{ border-bottom-color: #1a73e8; color: #1a73e8; font-weight: 600; }}
.editor-content {{ flex: 1; overflow: hidden; position: relative; }}
.editor-area {{ width: 100%; height: 100%; border: none; padding: 16px; font-size: 14px; font-family: 'Segoe UI', Arial, sans-serif; resize: none; outline: none; }}
.editor-area.plain {{ display: block; }}
.editor-area.rich {{ display: flex; flex-direction: column; padding: 0; }}
/* Quill 編輯器樣式調整 */
#richEditor .ql-toolbar {{ border: none; border-bottom: 1px solid #e0e0e0; background: #f8f9fa; padding: 8px 16px; }}
#richEditor .ql-container {{ border: none; flex: 1; font-size: 14px; }}
#richEditor .ql-editor {{ padding: 16px; min-height: 300px; }}
/* 格式化工具列 */
.format-toolbar {{ display: none; background: #f8f9fa; padding: 6px 16px; border-bottom: 1px solid #e0e0e0; gap: 4px; }}
.format-toolbar.active {{ display: flex; }}
.format-btn {{ padding: 4px 8px; border: 1px solid #dadce0; background: white; border-radius: 3px; cursor: pointer; font-size: 12px; }}
.format-btn:hover {{ background: #f8f9fa; }}
.format-btn.active {{ background: #e8f0fe; border-color: #1a73e8; }}
</style>
</head><body>
<!-- 頂部標題列 -->
<div class="header">
<h1>📧 撰寫新郵件</h1>
<div class="header-actions">
<span style="font-size: 13px;">{name}</span>
</div>
</div>
<!-- 工具列 -->
<div class="toolbar">
<div class="toolbar-left">
<a href="/" class="btn btn-secondary">← 返回</a>
</div>
<div class="toolbar-right">
<button type="button" class="btn btn-success" onclick="document.getElementById('composeForm').submit()">📤 發送</button>
</div>
</div>
<!-- 表單主體 -->
<div class="compose-container">
<form id="composeForm" method="POST" action="/send" class="compose-form">
<!-- 收件者 -->
<div class="form-field">
<label>收件者:</label>
<input type="email" name="to" id="toField" required placeholder="example@lab.taipei">
</div>
<!-- 主旨 -->
<div class="form-field">
<label>主旨:</label>
<input type="text" name="subject" id="subjectField" required placeholder="請輸入郵件主旨">
</div>
<!-- 編輯器模式切換 -->
<div class="editor-mode-tabs">
<div class="editor-tab active" onclick="switchEditorMode('plain')">純文字</div>
<div class="editor-tab" onclick="switchEditorMode('rich')">富文本</div>
</div>
<!-- 內容編輯器 -->
<div class="editor-container">
<input type="hidden" name="content_type" id="contentType" value="plain">
<input type="hidden" name="body" id="hiddenBody">
<textarea id="plainEditor" class="editor-area plain" required placeholder="輸入郵件內容..."></textarea>
<div id="richEditor" class="editor-area rich" style="display: none;"></div>
</div>
</form>
</div>
<script>
let editorMode = 'plain';
let quillInstance = null;
// 初始化 Quill 編輯器
function initQuill() {{
if (quillInstance) return;
quillInstance = new Quill('#richEditor', {{
theme: 'snow',
modules: {{
toolbar: [
[{{ 'header': [1, 2, 3, false] }}],
['bold', 'italic', 'underline', 'strike'],
[{{ 'list': 'ordered' }}, {{ 'list': 'bullet' }}],
[{{ 'align': [] }}],
['link', 'image'],
['clean']
]
}},
placeholder: '輸入郵件內容...'
}});
}}
// 切換編輯器模式
function switchEditorMode(mode) {{
const tabs = document.querySelectorAll('.editor-tab');
const plainEditor = document.getElementById('plainEditor');
const richEditorDiv = document.getElementById('richEditor');
const contentTypeInput = document.getElementById('contentType');
// 更新標籤狀態
tabs.forEach(tab => tab.classList.remove('active'));
event.target.classList.add('active');
if (mode === 'plain') {{
// 切換到純文字
if (quillInstance) {{
const richContent = quillInstance.root.innerHTML;
// 移除 HTML 標籤,保留純文字
const tempDiv = document.createElement('div');
tempDiv.innerHTML = richContent;
plainEditor.value = tempDiv.textContent || tempDiv.innerText || '';
}}
plainEditor.style.display = 'block';
richEditorDiv.style.display = 'none';
contentTypeInput.value = 'plain';
editorMode = 'plain';
}} else {{
// 切換到富文本
const plainContent = plainEditor.value;
plainEditor.style.display = 'none';
richEditorDiv.style.display = 'block';
contentTypeInput.value = 'html';
editorMode = 'rich';
if (!quillInstance) {{
initQuill();
setTimeout(() => {{
if (quillInstance) {{
// 將純文字轉換為 HTML保留換行
const htmlContent = plainContent.replace(/\\n/g, '<br>');
quillInstance.root.innerHTML = htmlContent;
}}
}}, 100);
}} else {{
const htmlContent = plainContent.replace(/\\n/g, '<br>');
quillInstance.root.innerHTML = htmlContent;
}}
}}
}}
// 表單提交前處理
document.getElementById('composeForm').addEventListener('submit', function(e) {{
const hiddenBody = document.getElementById('hiddenBody');
if (editorMode === 'rich' && quillInstance) {{
// 富文本模式:取得 HTML 內容
hiddenBody.value = quillInstance.root.innerHTML;
}} else {{
// 純文字模式:取得純文字
hiddenBody.value = plainEditor.value;
}}
}});
// 快捷鍵支援
document.addEventListener('keydown', function(e) {{
// Ctrl/Cmd + Enter 發送
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {{
e.preventDefault();
document.getElementById('composeForm').submit();
}}
// ESC 返回
if (e.key === 'Escape') {{
if (confirm('確定要放棄編輯並返回嗎?')) {{
window.location.href = '/';
}}
}}
}});
</script>
</body></html>""")
@app.post("/send")
async def send_mail(
request: Request,
to: str = Form(...),
subject: str = Form(...),
body: str = Form(...),
content_type: str = Form('plain'),
attachments: list[UploadFile] = File(default=[])
):
session_id = request.session.get("session_id")
if not session_id:
return JSONResponse({"error": "未登入"}, status_code=401)
user_data_json = redis_client.get(f"webmail:session:{session_id}")
if not user_data_json:
return JSONResponse({"error": "Session 已過期"}, status_code=401)
user_data = json.loads(user_data_json)
sso_uuid = user_data.get("sub")
credentials = get_account_by_sso_uuid(sso_uuid)
if not credentials:
return JSONResponse({"error": "找不到郵件帳號"}, status_code=404)
mail_email = credentials['email']
mail_password = credentials['password']
# 處理附件
attachment_list = []
if attachments:
for upload_file in attachments:
if upload_file.filename: # 確保有檔案名稱
content = await upload_file.read()
attachment_list.append({
'filename': upload_file.filename,
'content': content
})
# 發送郵件(支援 HTML 格式和附件)
success = send_email_smtp(mail_email, mail_password, to, subject, body, content_type, attachment_list if attachment_list else None)
if success:
return JSONResponse({"success": True, "message": "郵件發送成功"})
else:
return JSONResponse({"success": False, "error": "郵件發送失敗"}, status_code=500)
@app.post("/theme")
async def set_theme(request: Request, theme: str = Form(...)):
"""設定使用者布景主題"""
session_id = request.session.get("session_id")
if session_id:
# 儲存主題設定到 Redis
redis_client.hset(f"webmail:settings:{session_id}", "theme", theme)
return RedirectResponse(url="/", status_code=303)
@app.get("/logout")
async def logout(request: Request):
session_id = request.session.get("session_id")
if session_id:
redis_client.delete(f"webmail:session:{session_id}")
redis_client.delete(f"webmail:settings:{session_id}")
request.session.clear()
return HTMLResponse("""<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>
<p>已登出</p><a href="/">重新登入</a></body></html>""")
@app.get("/health")
async def health():
return {"status": "ok"}
# ======================================================================
# Unified inbox rendering function
# ======================================================================
def get_tenant_by_code(tenant_code: str):
"""根據租戶代碼查詢租戶資訊"""
try:
with db_engine.connect() as conn:
result = conn.execute(
text("""
SELECT id, code, name, domain, keycloak_realm
FROM tenants
WHERE code = :tenant_code
AND is_active = true
LIMIT 1
"""),
{"tenant_code": tenant_code}
).fetchone()
if result:
return {
"id": result[0],
"code": result[1],
"name": result[2],
"domain": result[3],
"keycloak_realm": result[4]
}
return None
except Exception as e:
logger.error(f"Database error: {e}")
return None
def generate_pkce_pair():
"""生成 PKCE code_verifier 和 code_challenge"""
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=")
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode("utf-8")).digest()
).decode("utf-8").rstrip("=")
return code_verifier, code_challenge
def get_account_by_sso_uuid_and_realm(sso_uuid: str, realm: str):
"""根據 Keycloak UUID 和 Realm 查詢帳號"""
try:
with db_engine.connect() as conn:
result = conn.execute(
text("""
SELECT a.email, a.default_password, a.sso_account, a.account_code
FROM accounts a
JOIN tenants t ON a.tenant_id = t.id
WHERE a.sso_uuid = :sso_uuid
AND t.keycloak_realm = :realm
LIMIT 1
"""),
{"sso_uuid": sso_uuid, "realm": realm}
).fetchone()
if result:
logger.info(f"Found account: {result[2]} ({result[0]})")
return {
"email": result[0],
"password": result[1],
"sso_account": result[2],
"account_code": result[3]
}
logger.warning(f"No account found for sso_uuid: {sso_uuid} in realm: {realm}")
return None
except Exception as e:
logger.error(f"Database error: {e}")
return None
# ===== 租戶登入頁面 =====
def render_inbox(
request: Request,
folder: str,
mail_email: str,
mail_password: str,
username: str,
name: str = None,
session_id: str = None,
tenant_code: str = None,
is_management_tenant: bool = False
):
"""
統一的收件匣渲染函數
參數:
- is_management_tenant: True = 管理租戶 (根路徑), False = 一般租戶
- tenant_code: 租戶代碼 (一般租戶必須提供)
- session_id: Session ID (用於主題設定)
"""
try:
# 取得資料夾列表
folders = get_imap_folders(mail_email, mail_password)
# 取得郵件列表
messages = get_imap_messages(mail_email, mail_password, folder)
# 取得使用者主題設定
theme = redis_client.hget(f"webmail:settings:{session_id}", "theme") or "default"
# 定義主題樣式
themes = {
"default": {
"bg": "#f5f5f5",
"header_bg": "#1a73e8",
"header_text": "white",
"container_bg": "white",
"item_hover": "#f8f9fa",
"text_secondary": "#5f6368",
"btn_bg": "#1a73e8",
"btn_hover": "#1557b0"
},
"dark": {
"bg": "#1f1f1f",
"header_bg": "#2d2d2d",
"header_text": "#e8eaed",
"container_bg": "#2d2d2d",
"item_hover": "#3c4043",
"text_secondary": "#9aa0a6",
"btn_bg": "#8ab4f8",
"btn_hover": "#aecbfa"
},
"green": {
"bg": "#e8f5e9",
"header_bg": "#2e7d32",
"header_text": "white",
"container_bg": "white",
"item_hover": "#c8e6c9",
"text_secondary": "#558b2f",
"btn_bg": "#43a047",
"btn_hover": "#66bb6a"
},
"purple": {
"bg": "#f3e5f5",
"header_bg": "#6a1b9a",
"header_text": "white",
"container_bg": "white",
"item_hover": "#e1bee7",
"text_secondary": "#7b1fa2",
"btn_bg": "#8e24aa",
"btn_hover": "#ab47bc"
}
}
current_theme = themes.get(theme, themes["default"])
# 提取主題顏色變數(避免 f-string 中使用字典語法)
theme_bg = current_theme['bg']
theme_header_bg = current_theme['header_bg']
theme_header_text = current_theme['header_text']
theme_btn_bg = current_theme['btn_bg']
theme_btn_hover = current_theme['btn_hover']
theme_item_hover = current_theme['item_hover']
theme_container_bg = current_theme['container_bg']
theme_text_secondary = current_theme['text_secondary']
# 計算條件表達式的值
theme_text_color = theme_header_text if theme == 'dark' else '#202124'
# 資料夾處理函數
def get_folder_icon(folder_name):
folder_lower = folder_name.lower()
if 'inbox' in folder_lower:
return '📥'
elif 'sent' in folder_lower or 'sent items' in folder_lower or 'sent mail' in folder_lower:
return '📤'
elif 'draft' in folder_lower:
return '📝'
elif 'trash' in folder_lower or 'deleted' in folder_lower:
return '🗑️'
elif 'junk' in folder_lower or 'spam' in folder_lower:
return '🚫'
elif 'archive' in folder_lower:
return '📦'
elif 'star' in folder_lower or 'flagged' in folder_lower:
return ''
elif 'important' in folder_lower:
return ''
else:
return '📁'
def get_folder_display_name(folder_name):
folder_lower = folder_name.lower()
exact_match = {
'INBOX': '收件匣',
'Sent': '寄件備份',
'Sent Items': '寄件備份',
'Sent Mail': '寄件備份',
'Drafts': '草稿',
'Trash': '垃圾桶',
'Deleted Items': '垃圾桶',
'Junk': '垃圾郵件',
'Spam': '垃圾郵件',
'Archive': '封存',
'Starred': '已加星號',
'Flagged': '已標幟',
'Important': '重要郵件'
}
if folder_name in exact_match:
return exact_match[folder_name]
if 'sent' in folder_lower:
return '寄件備份'
elif 'draft' in folder_lower:
return '草稿'
elif 'trash' in folder_lower or 'deleted' in folder_lower:
return '垃圾桶'
elif 'junk' in folder_lower or 'spam' in folder_lower:
return '垃圾郵件'
elif 'archive' in folder_lower:
return '封存'
elif 'star' in folder_lower or 'flagged' in folder_lower:
return '已加星號'
return folder_name.replace('.', ' / ')
def get_folder_priority(folder_name):
folder_lower = folder_name.lower()
if folder_name == 'INBOX' or 'inbox' in folder_lower:
return 1
elif 'sent' in folder_lower:
return 2
elif 'draft' in folder_lower:
return 3
elif 'trash' in folder_lower or 'deleted' in folder_lower:
return 98
elif 'junk' in folder_lower or 'spam' in folder_lower:
return 99
else:
return 50
# 排序資料夾
sorted_folders = sorted(folders, key=get_folder_priority)
# 產生資料夾列表 HTML - 根據租戶類型使用不同的 URL
folders_html = ""
for f in sorted_folders:
icon = get_folder_icon(f)
display_name = get_folder_display_name(f)
indent_level = f.count('.')
active_class = 'active' if f == folder else ''
indent_style = f"padding-left: {16 + indent_level * 20}px;" if indent_level > 0 else ""
# 根據租戶類型生成 URL
if is_management_tenant:
folder_url = f"/?folder={f}"
else:
folder_url = f"/{tenant_code}/inbox?folder={f}"
folders_html += f"""
<li class="folder-item {active_class}" onclick="location.href='{folder_url}'" style="{indent_style}">
<span class="folder-icon">{icon}</span>
<span class="folder-name">{display_name}</span>
</li>
"""
# 產生郵件列表 HTML
mail_items_html = ""
if messages:
for m in messages:
markers = []
if m['is_flagged']:
markers.append('<span class="marker marker-star active" title="已加星號">⭐</span>')
else:
markers.append(f'<span class="marker marker-star" title="加星號" onclick="toggleStar(event, \'{m["id"]}\')">☆</span>')
markers.append(f'<span class="marker marker-important" title="標記為重要" onclick="toggleImportant(event, \'{m["id"]}\')">❗</span>')
if m['is_answered']:
markers.append('<span class="marker marker-replied" title="已回覆">↩️</span>')
markers_html = ''.join(markers)
attachment_icon = '<span class="attachment-icon" title="有附件">📎</span>' if m['has_attachment'] else ''
unread_class = 'unread' if m['is_unread'] else ''
mail_items_html += f"""
<li class="mail-item {unread_class}">
<div class="mail-row">
<div class="mail-checkbox">
<input type="checkbox" class="mail-select" value="{m['id']}" onclick="event.stopPropagation()">
</div>
<div class="mail-markers">{markers_html}</div>
<div class="mail-subject-col" onclick="openMailModal('{m['id']}', '{folder}')">
<span class="subject-text">{html.escape(m['subject'])}</span>
{attachment_icon}
</div>
<div class="mail-date-col" onclick="openMailModal('{m['id']}', '{folder}')">{html.escape(m['date'])}</div>
</div>
</li>
"""
else:
mail_items_html = '<div class="no-mail">目前沒有郵件</div>'
# 根據租戶類型設定頁面標題和 URL
if is_management_tenant:
page_title = "WebMail"
compose_url = "/compose"
logout_url = "/logout"
theme_form_action = "/theme"
api_mail_url = "/api/mail"
api_delete_url = "/api/delete-mails"
sidebar_storage_key = "sidebar_width"
else:
page_title = f"WebMail - {tenant_code}"
compose_url = f"/{tenant_code}/compose"
logout_url = f"/{tenant_code}/logout"
theme_form_action = f"/{tenant_code}/theme"
api_mail_url = f"/{tenant_code}/api/mail"
api_delete_url = f"/{tenant_code}/api/delete-mails"
sidebar_storage_key = f"sidebar_width_{tenant_code}"
# 撰寫按鈕 HTML (暫時使用換頁方式,待後續整合 Modal)
compose_btn_html = f'<a href="{compose_url}" class="btn">✍️ 撰寫</a>'
# 使用者顯示名稱
display_name = name if name else username
# 渲染完整的 HTML 頁面
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>{page_title}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: 'Google Sans', 'Segoe UI', Arial, sans-serif; background: {theme_bg}; color: {theme_text_color}; overflow: hidden; height: 100vh; display: flex; flex-direction: column; }}
/* 頂部導航列 */
.header {{ background: {theme_header_bg}; color: {theme_header_text}; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); z-index: 100; }}
.header h1 {{ margin: 0; font-size: 22px; font-weight: 400; }}
.header-actions {{ display: flex; gap: 12px; align-items: center; }}
.user-info {{ font-size: 13px; }}
.user-info a {{ color: {theme_header_text}; text-decoration: none; }}
.btn {{ background: {theme_btn_bg}; color: white; border: none; padding: 8px 20px; border-radius: 24px; cursor: pointer; text-decoration: none; display: inline-block; font-size: 14px; transition: all 0.2s; }}
.btn:hover {{ background: {theme_btn_hover}; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }}
.btn-icon {{ background: transparent; padding: 8px; border-radius: 50%; }}
.btn-icon:hover {{ background: rgba(255,255,255,0.1); }}
/* 主要容器 - Gmail 風格兩欄 */
.main-layout {{ flex: 1; display: flex; overflow: hidden; }}
/* 左側資料夾面板 */
.sidebar {{ width: 256px; min-width: 180px; max-width: 400px; background: {theme_bg}; border-right: 2px solid {theme_item_hover}; display: flex; flex-direction: column; position: relative; }}
.sidebar-header {{ padding: 12px 16px; background: {theme_item_hover}; border-bottom: 2px solid {theme_bg}; display: flex; justify-content: space-between; align-items: center; }}
.sidebar-header h2 {{ font-size: 14px; font-weight: 600; margin: 0; }}
.folder-list {{ list-style: none; overflow-y: auto; flex: 1; }}
.folder-item {{ padding: 8px 12px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 10px; font-size: 13px; border-left: 3px solid transparent; }}
.folder-item:hover {{ background: {theme_item_hover}; }}
.folder-item.active {{ background: {theme_btn_bg}; color: white; font-weight: 500; border-left: 3px solid white; }}
.folder-icon {{ font-size: 20px; width: 24px; text-align: center; }}
.folder-name {{ flex: 1; }}
/* 調整大小手柄 */
.resize-handle {{ width: 4px; height: 100%; position: absolute; right: 0; top: 0; cursor: col-resize; background: transparent; }}
.resize-handle:hover, .resize-handle.resizing {{ background: {theme_btn_bg}; }}
/* 右側郵件列表 */
.content-area {{ flex: 1; display: flex; flex-direction: column; background: {theme_container_bg}; overflow: hidden; }}
.content-header {{ padding: 12px 16px; background: {theme_item_hover}; border-bottom: 2px solid {theme_bg}; display: flex; justify-content: space-between; align-items: center; }}
.content-header h2 {{ font-size: 16px; font-weight: 500; margin: 0; }}
.content-actions {{ display: flex; gap: 8px; align-items: center; }}
.btn-delete {{ background: #ea4335; }}
.btn-delete:hover {{ background: #d33828; }}
.btn-delete:disabled {{ background: #ccc; cursor: not-allowed; opacity: 0.5; }}
.mail-list-container {{ flex: 1; overflow-y: auto; }}
.mail-list {{ list-style: none; }}
.mail-item {{ border-bottom: 1px solid {theme_item_hover}; transition: all 0.15s; }}
.mail-item:nth-child(even) {{ background: {theme_item_hover}; }}
.mail-item:hover {{ background: {theme_btn_bg}20; }}
.mail-item.unread {{ font-weight: 600; }}
.mail-item.unread .subject-text {{ font-weight: 700; }}
/* 郵件列表行 */
.mail-row {{ display: grid; grid-template-columns: 40px 120px 1fr 140px; gap: 10px; padding: 10px 16px; align-items: center; }}
.mail-checkbox {{ display: flex; align-items: center; justify-content: center; }}
.mail-checkbox input[type="checkbox"] {{ width: 18px; height: 18px; cursor: pointer; }}
.mail-markers {{ display: flex; gap: 6px; align-items: center; }}
.marker {{ font-size: 18px; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }}
.marker:hover {{ opacity: 1; }}
.marker.active {{ opacity: 1; }}
.marker-star {{ color: #f9ab00; }}
.marker-important {{ color: #ea4335; }}
.marker-replied {{ cursor: default; color: #5f6368; }}
.mail-subject-col {{ display: flex; gap: 8px; align-items: center; overflow: hidden; cursor: pointer; }}
.subject-text {{ flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.attachment-icon {{ font-size: 16px; flex-shrink: 0; }}
.mail-date-col {{ font-size: 12px; color: {theme_text_secondary}; text-align: right; cursor: pointer; }}
.no-mail {{ padding: 60px 20px; text-align: center; color: {theme_text_secondary}; font-size: 14px; }}
/* Modal 視窗 */
.modal {{ display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); }}
.modal.show {{ display: flex; align-items: center; justify-content: center; }}
.modal-content {{ background: {theme_container_bg}; width: 90%; max-width: 900px; max-height: 90vh; border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); display: flex; flex-direction: column; }}
.modal-header {{ padding: 16px 20px; background: {theme_btn_bg}; color: white; border-radius: 8px 8px 0 0; display: flex; justify-content: space-between; align-items: center; }}
.modal-header h3 {{ font-size: 18px; font-weight: 500; margin: 0; }}
.modal-close {{ background: transparent; border: none; font-size: 24px; cursor: pointer; color: white; padding: 0; width: 32px; height: 32px; border-radius: 50%; }}
.modal-close:hover {{ background: rgba(255,255,255,0.2); }}
.modal-body {{ padding: 0; overflow-y: auto; flex: 1; }}
.mail-meta {{ padding: 12px 16px; background: {theme_item_hover}; margin: 0; text-align: left; display: grid; grid-template-columns: 1fr; gap: 8px; border-bottom: 2px solid {theme_bg}; }}
.mail-meta-row {{ display: grid; grid-template-columns: 80px 1fr; font-size: 13px; align-items: center; }}
.meta-label {{ color: {theme_text_secondary}; font-weight: 600; }}
.meta-value {{ color: {theme_text_color}; word-break: break-word; }}
.mail-body {{ padding: 16px; font-size: 14px; line-height: 1.6; text-align: left; background: {theme_container_bg}; min-height: 200px; }}
/* 附件列表 */
.attachments-section {{ padding: 16px 20px; background: {theme_item_hover}; margin: 0; border-top: 2px solid {theme_container_bg}; }}
.attachments-title {{ font-weight: 600; margin-bottom: 10px; font-size: 13px; color: {theme_text_secondary}; }}
.attachment-item {{ display: flex; align-items: center; padding: 8px 10px; margin-bottom: 6px; background: {theme_container_bg}; border-radius: 4px; font-size: 13px; border-left: 3px solid {theme_btn_bg}; }}
.attachment-item:last-child {{ margin-bottom: 0; }}
.attachment-item:hover {{ background: {theme_bg}; }}
.attachment-icon {{ margin-right: 10px; font-size: 16px; }}
.attachment-name {{ flex: 1; }}
.attachment-size {{ color: {theme_text_secondary}; font-size: 11px; margin-left: 10px; }}
/* 主題選擇器 */
.theme-selector {{ position: relative; display: inline-block; }}
.theme-menu {{ display: none; position: absolute; right: 0; top: 100%; background: {theme_container_bg}; border: 1px solid {theme_item_hover}; border-radius: 8px; padding: 8px 0; margin-top: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 200; min-width: 160px; }}
.theme-menu.show {{ display: block; }}
.theme-option {{ padding: 10px 16px; cursor: pointer; font-size: 14px; color: {theme_text_color}; }}
.theme-option:hover {{ background: {theme_item_hover}; }}
.theme-option.active {{ background: {theme_btn_bg}; color: white; }}
/* 載入中動畫 */
.loading {{ text-align: center; padding: 40px; color: {theme_text_secondary}; }}
</style>
</head><body>
<!-- 頂部導航列 -->
<div class="header">
<h1>📧 {page_title}</h1>
<div class="header-actions">
{compose_btn_html}
<div class="theme-selector">
<button class="btn btn-icon" onclick="toggleThemeMenu()" title="主題">🎨</button>
<div id="themeMenu" class="theme-menu">
<div class="theme-option {'active' if theme == 'default' else ''}" onclick="setTheme('default')">預設藍色</div>
<div class="theme-option {'active' if theme == 'dark' else ''}" onclick="setTheme('dark')">深色模式</div>
<div class="theme-option {'active' if theme == 'green' else ''}" onclick="setTheme('green')">清新綠色</div>
<div class="theme-option {'active' if theme == 'purple' else ''}" onclick="setTheme('purple')">優雅紫色</div>
</div>
</div>
<div class="user-info">{display_name} ({mail_email}) | <a href="{logout_url}">登出</a></div>
</div>
</div>
<!-- 主要內容區 - 兩欄式 -->
<div class="main-layout">
<!-- 左側資料夾面板 -->
<div class="sidebar" id="sidebar">
<div class="sidebar-header">
<h2>📁 資料夾</h2>
</div>
<ul class="folder-list">
{folders_html}
</ul>
<div class="resize-handle" id="resizeHandle"></div>
</div>
<!-- 右側郵件列表 -->
<div class="content-area">
<div class="content-header">
<h2>{get_folder_display_name(folder)}</h2>
<div class="content-actions">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="selectAll" onclick="toggleSelectAll()">
<span style="font-size: 14px;">全選</span>
</label>
<button class="btn btn-delete" id="deleteBtn" onclick="deleteSelected()" disabled>🗑️ 刪除選取</button>
</div>
</div>
<div class="mail-list-container">
<ul class="mail-list">
{mail_items_html}
</ul>
</div>
</div>
</div>
<!-- Modal 郵件視窗 -->
<div id="mailModal" class="modal" onclick="closeModalOnBackdrop(event)">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalSubject">載入中...</h3>
<button class="modal-close" onclick="closeMailModal()">&times;</button>
</div>
<div class="modal-body">
<div id="modalContent" class="loading">載入郵件中...</div>
</div>
</div>
</div>
<!-- 隱藏表單 -->
<form id="themeForm" method="POST" action="{theme_form_action}" style="display:none;">
<input type="hidden" name="theme" value="">
</form>
<script>
const API_MAIL_URL = '{api_mail_url}';
const API_DELETE_URL = '{api_delete_url}';
const CURRENT_FOLDER = '{folder}';
const SIDEBAR_STORAGE_KEY = '{sidebar_storage_key}';
// 主題切換
function toggleThemeMenu() {{
document.getElementById('themeMenu').classList.toggle('show');
}}
function setTheme(theme) {{
document.getElementById('themeForm').theme.value = theme;
document.getElementById('themeForm').submit();
}}
window.onclick = function(event) {{
if (!event.target.closest('.theme-selector')) {{
document.getElementById('themeMenu').classList.remove('show');
}}
}}
// 調整側邊欄大小
let isResizing = false;
const sidebar = document.getElementById('sidebar');
const resizeHandle = document.getElementById('resizeHandle');
resizeHandle.addEventListener('mousedown', function(e) {{
isResizing = true;
resizeHandle.classList.add('resizing');
e.preventDefault();
}});
document.addEventListener('mousemove', function(e) {{
if (!isResizing) return;
const newWidth = e.clientX;
if (newWidth >= 180 && newWidth <= 400) {{
sidebar.style.width = newWidth + 'px';
}}
}});
document.addEventListener('mouseup', function() {{
if (isResizing) {{
isResizing = false;
resizeHandle.classList.remove('resizing');
localStorage.setItem(SIDEBAR_STORAGE_KEY, sidebar.style.width);
}}
}});
// 恢復側邊欄寬度
window.addEventListener('load', function() {{
const savedWidth = localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (savedWidth) {{
sidebar.style.width = savedWidth;
}}
}});
// Modal 開啟/關閉
function openMailModal(mailId, folder) {{
const modal = document.getElementById('mailModal');
const modalSubject = document.getElementById('modalSubject');
const modalContent = document.getElementById('modalContent');
modal.classList.add('show');
modalSubject.textContent = '載入中...';
modalContent.innerHTML = '<div class="loading">載入郵件中...</div>';
fetch(`${{API_MAIL_URL}}/${{mailId}}?folder=${{encodeURIComponent(folder)}}`).then(response => response.json())
.then(data => {{
if (data.error) {{
modalContent.innerHTML = `<p>錯誤: ${{data.error}}</p>`;
return;
}}
modalSubject.textContent = data.subject;
let attachmentsHtml = '';
if (data.attachments && data.attachments.length > 0) {{
const formatFileSize = (bytes) => {{
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}};
const attachmentItems = data.attachments.map(att => `
<div class="attachment-item">
<span class="attachment-icon">📎</span>
<span class="attachment-name">${{att.filename}}</span>
<span class="attachment-size">${{formatFileSize(att.size)}}</span>
</div>
`).join('');
attachmentsHtml = `
<div class="attachments-section">
<div class="attachments-title">📎 附件 (${{data.attachments.length}})</div>
${{attachmentItems}}
</div>
`;
}}
let bodyContent = data.body || '(無內容)';
const isHTML = /<[a-z][\s\S]*>/i.test(bodyContent);
let formattedBody;
if (isHTML) {{
formattedBody = `<div style="padding: 16px; background: white; border: 1px solid #e0e0e0; border-radius: 4px;">${{bodyContent}}</div>`;
}} else {{
formattedBody = `<div style="padding: 16px; background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; font-family: monospace; white-space: pre-wrap;">${{bodyContent.replace(/</g, '&lt;').replace(/>/g, '&gt;')}}</div>`;
}}
modalContent.innerHTML = `
<div class="mail-meta">
<div class="mail-meta-row">
<span class="meta-label">寄件者:</span>
<span class="meta-value">${{data.from || '(未知)'}}</span>
</div>
<div class="mail-meta-row">
<span class="meta-label">收件者:</span>
<span class="meta-value">${{data.to || '(未知)'}}</span>
</div>
<div class="mail-meta-row">
<span class="meta-label">日期:</span>
<span class="meta-value">${{data.date || '(未知)'}}</span>
</div>
</div>
<div class="mail-body">${{formattedBody}}</div>
${{attachmentsHtml}}
`;
}})
.catch(error => {{
modalContent.innerHTML = `<p>載入失敗: ${{error.message}}</p>`;
}});
}}
function closeMailModal() {{
document.getElementById('mailModal').classList.remove('show');
}}
function closeModalOnBackdrop(event) {{
if (event.target.id === 'mailModal') {{
closeMailModal();
}}
}}
// 郵件選取功能
function toggleSelectAll() {{
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.mail-select');
checkboxes.forEach(cb => {{
cb.checked = selectAll.checked;
}});
updateDeleteButton();
}}
function updateDeleteButton() {{
const checkboxes = document.querySelectorAll('.mail-select:checked');
const deleteBtn = document.getElementById('deleteBtn');
deleteBtn.disabled = checkboxes.length === 0;
}}
document.addEventListener('change', function(e) {{
if (e.target.classList.contains('mail-select')) {{
updateDeleteButton();
}}
}});
function deleteSelected() {{
const checkboxes = document.querySelectorAll('.mail-select:checked');
if (checkboxes.length === 0) return;
if (!confirm(`確定要刪除 ${{checkboxes.length}} 封郵件嗎?`)) return;
const mailIds = Array.from(checkboxes).map(cb => cb.value);
fetch(API_DELETE_URL, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ mail_ids: mailIds, folder: CURRENT_FOLDER }}),
}})
.then(response => response.json())
.then(data => {{
if (data.success) {{
location.reload();
}} else {{
alert('刪除失敗: ' + data.error);
}}
}})
.catch(error => {{
alert('刪除失敗: ' + error.message);
}});
}}
function toggleStar(event, mailId) {{
event.stopPropagation();
console.log('Toggle star for mail:', mailId);
}}
function toggleImportant(event, mailId) {{
event.stopPropagation();
console.log('Toggle important for mail:', mailId);
}}
</script>
</body></html>
""")
except Exception as e:
logger.error(f"Error loading inbox: {e}")
import traceback
traceback.print_exc()
error_page_title = f"載入失敗 - {tenant_code}" if tenant_code else "載入失敗"
back_url = f"/{tenant_code}" if tenant_code else "/"
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{error_page_title}</title>
<style>
body {{
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}}
.error-box {{
background: white;
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
max-width: 500px;
}}
h1 {{
color: #e74c3c;
margin: 0 0 1rem 0;
}}
p {{
color: #666;
line-height: 1.6;
margin: 0.5rem 0;
}}
.error-detail {{
background: #f8f9fa;
padding: 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
font-family: monospace;
font-size: 0.875rem;
text-align: left;
color: #333;
max-height: 200px;
overflow-y: auto;
}}
.back-link {{
display: inline-block;
margin-top: 1.5rem;
color: #667eea;
text-decoration: none;
font-weight: 500;
}}
.back-link:hover {{
text-decoration: underline;
}}
</style>
</head>
<body>
<div class="error-box">
<h1>❌ 載入收件匣失敗</h1>
<p><strong>使用者:</strong> {username}</p>
<div class="error-detail">
{html.escape(str(e))}
</div>
<a href="{back_url}" class="back-link">← 返回登入頁面</a>
</div>
</body>
</html>
""",
status_code=500
)
# ======================================================================
# Unified compose form function
# ======================================================================
def render_compose_form(name: str, back_url: str, send_url: str, tenant_code: str = None):
"""統一的撰寫郵件頁面渲染函數"""
page_title = f"撰寫郵件 - {tenant_code}" if tenant_code else "撰寫郵件"
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>{page_title}</title>
<!-- Quill Editor (Free & Open Source) -->
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; height: 100vh; display: flex; flex-direction: column; }}
/* 頂部標題列 */
.header {{ background: #1a73e8; color: white; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; }}
.header h1 {{ font-size: 18px; font-weight: 500; }}
.header-actions {{ display: flex; gap: 10px; }}
/* 工具列 */
.toolbar {{ background: #f8f9fa; padding: 8px 16px; border-bottom: 2px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; }}
.toolbar-left {{ display: flex; gap: 8px; align-items: center; }}
.toolbar-right {{ display: flex; gap: 8px; }}
/* 按鈕樣式 */
.btn {{ background: #1a73e8; color: white; border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; transition: all 0.2s; text-decoration: none; display: inline-block; }}
.btn:hover {{ background: #1557b0; }}
.btn-secondary {{ background: #5f6368; }}
.btn-secondary:hover {{ background: #3c4043; }}
.btn-success {{ background: #0f9d58; }}
.btn-success:hover {{ background: #0d8043; }}
/* 表單容器 */
.compose-container {{ flex: 1; display: flex; flex-direction: column; overflow: hidden; }}
.compose-form {{ flex: 1; display: flex; flex-direction: column; background: white; margin: 0; }}
/* 表單欄位 */
.form-field {{ display: flex; border-bottom: 1px solid #e0e0e0; padding: 8px 16px; align-items: center; }}
.form-field label {{ width: 80px; font-weight: 600; font-size: 13px; color: #5f6368; flex-shrink: 0; }}
.form-field input {{ flex: 1; border: none; outline: none; font-size: 14px; padding: 4px 0; }}
/* 編輯器容器 */
.editor-container {{ flex: 1; display: flex; flex-direction: column; overflow: hidden; }}
.editor-mode-tabs {{ display: flex; background: #f8f9fa; border-bottom: 2px solid #e0e0e0; padding: 0 16px; }}
.editor-tab {{ padding: 8px 16px; cursor: pointer; font-size: 13px; border-bottom: 3px solid transparent; margin-bottom: -2px; transition: all 0.2s; }}
.editor-tab:hover {{ background: #e8eaed; }}
.editor-tab.active {{ border-bottom-color: #1a73e8; color: #1a73e8; font-weight: 600; }}
.editor-content {{ flex: 1; overflow: hidden; position: relative; }}
.editor-area {{ width: 100%; height: 100%; border: none; padding: 16px; font-size: 14px; font-family: 'Segoe UI', Arial, sans-serif; resize: none; outline: none; }}
.editor-area.plain {{ display: block; }}
.editor-area.rich {{ display: flex; flex-direction: column; padding: 0; }}
/* Quill 編輯器樣式調整 */
#richEditor .ql-toolbar {{ border: none; border-bottom: 1px solid #e0e0e0; background: #f8f9fa; padding: 8px 16px; }}
#richEditor .ql-container {{ border: none; flex: 1; font-size: 14px; }}
#richEditor .ql-editor {{ padding: 16px; min-height: 300px; }}
/* 格式化工具列 */
.format-toolbar {{ display: none; background: #f8f9fa; padding: 6px 16px; border-bottom: 1px solid #e0e0e0; gap: 4px; }}
.format-toolbar.active {{ display: flex; }}
.format-btn {{ padding: 4px 8px; border: 1px solid #dadce0; background: white; border-radius: 3px; cursor: pointer; font-size: 12px; }}
.format-btn:hover {{ background: #f8f9fa; }}
.format-btn.active {{ background: #e8f0fe; border-color: #1a73e8; }}
</style>
</head><body>
<!-- 頂部標題列 -->
<div class="header">
<h1>📧 撰寫新郵件</h1>
<div class="header-actions">
<span style="font-size: 13px;">{name}</span>
</div>
</div>
<!-- 工具列 -->
<div class="toolbar">
<div class="toolbar-left">
<a href="{back_url}" class="btn btn-secondary">← 返回</a>
</div>
<div class="toolbar-right">
<button type="button" class="btn btn-success" onclick="document.getElementById('composeForm').submit()">📤 發送</button>
</div>
</div>
<!-- 表單主體 -->
<div class="compose-container">
<form id="composeForm" method="POST" action="{send_url}" class="compose-form">
<!-- 收件者 -->
<div class="form-field">
<label>收件者:</label>
<input type="email" name="to" id="toField" required placeholder="example@lab.taipei">
</div>
<!-- 主旨 -->
<div class="form-field">
<label>主旨:</label>
<input type="text" name="subject" id="subjectField" required placeholder="輸入郵件主旨...">
</div>
<!-- 編輯器模式切換 -->
<div class="editor-mode-tabs">
<div class="editor-tab active" id="plainTab" onclick="switchEditorMode('plain')">純文字</div>
<div class="editor-tab" id="richTab" onclick="switchEditorMode('rich')">富文本編輯</div>
</div>
<!-- 編輯器內容區 -->
<div class="editor-container">
<div class="editor-content">
<!-- 純文字編輯器 -->
<textarea name="body" id="plainEditor" class="editor-area plain" placeholder="輸入郵件內容..." required></textarea>
<!-- 富文本編輯器 -->
<div id="richEditor" class="editor-area rich" style="display: none;"></div>
<input type="hidden" name="content_type" id="contentType" value="plain">
</div>
</div>
</form>
</div>
<script>
let editorMode = 'plain';
let quillEditor = null;
// 切換編輯器模式
function switchEditorMode(mode) {{
editorMode = mode;
const plainTab = document.getElementById('plainTab');
const richTab = document.getElementById('richTab');
const plainEditor = document.getElementById('plainEditor');
const richEditorDiv = document.getElementById('richEditor');
const contentType = document.getElementById('contentType');
if (mode === 'plain') {{
// 切換到純文字模式
plainTab.classList.add('active');
richTab.classList.remove('active');
plainEditor.style.display = 'block';
richEditorDiv.style.display = 'none';
contentType.value = 'plain';
// 如果有 Quill 內容,轉換為純文字
if (quillEditor) {{
plainEditor.value = quillEditor.getText();
}}
}} else {{
// 切換到富文本模式
richTab.classList.add('active');
plainTab.classList.remove('active');
plainEditor.style.display = 'none';
richEditorDiv.style.display = 'flex';
contentType.value = 'html';
// 初始化 Quill如果尚未初始化
if (!quillEditor) {{
quillEditor = new Quill('#richEditor', {{
theme: 'snow',
modules: {{
toolbar: [
[{{ 'header': [1, 2, 3, false] }}],
['bold', 'italic', 'underline', 'strike'],
[{{ 'list': 'ordered' }}, {{ 'list': 'bullet' }}],
[{{ 'color': [] }}, {{ 'background': [] }}],
['link', 'image'],
['clean']
]
}}
}});
}} else {{
// 如果有純文字內容,載入到 Quill
quillEditor.setText(plainEditor.value);
}}
}}
}}
// 表單提交前處理
document.getElementById('composeForm').addEventListener('submit', function(e) {{
if (editorMode === 'rich' && quillEditor) {{
// 富文本模式:取得 HTML 內容
const htmlContent = quillEditor.root.innerHTML;
document.getElementById('plainEditor').value = htmlContent;
}}
// 純文字模式:直接使用 textarea 的值
}});
</script>
</body></html>
""")
# ======================================================================
# Tenant routes
# ======================================================================
@app.get("/{tenant_code}")
async def tenant_login_page(tenant_code: str, request: Request):
"""
租戶登入頁面 - 自動導向 Keycloak
URL: https://webmail.lab.taipei/porsche1
"""
# 查詢租戶資訊
tenant = get_tenant_by_code(tenant_code)
if not tenant:
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>租戶不存在</title>
<style>
body {{
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}}
.error-box {{
background: white;
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
max-width: 400px;
}}
.error-icon {{
font-size: 4rem;
margin-bottom: 1rem;
}}
h1 {{
color: #e74c3c;
margin: 0 0 1rem 0;
font-size: 1.5rem;
}}
p {{
color: #666;
margin: 0;
line-height: 1.6;
}}
</style>
</head>
<body>
<div class="error-box">
<div class="error-icon">❌</div>
<h1>租戶不存在</h1>
<p>找不到租戶「{tenant_code}」<br>請確認網址是否正確</p>
</div>
</body>
</html>
""",
status_code=404
)
# 生成 PKCE
code_verifier, code_challenge = generate_pkce_pair()
# 儲存 code_verifier 到 Redis
pkce_key = f"pkce:{tenant_code}:{secrets.token_urlsafe(16)}"
redis_client.setex(pkce_key, 600, code_verifier)
logger.info(f"Generated PKCE for {tenant_code}: key={pkce_key}")
# 建立 Keycloak 認證 URL (含 PKCE)
realm = tenant['keycloak_realm']
client_id = "webmail"
redirect_uri = f"https://webmail.lab.taipei/{tenant_code}/callback"
auth_url = (
f"{KEYCLOAK_SERVER_URL}/realms/{realm}/protocol/openid-connect/auth"
f"?client_id={client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope=openid+email+profile"
f"&state={pkce_key}"
f"&code_challenge={code_challenge}"
f"&code_challenge_method=S256"
)
logger.info(f"Redirecting {tenant_code} to Keycloak: {auth_url}")
# 重定向到 Keycloak
return RedirectResponse(url=auth_url)
# ===== 租戶 Callback =====
@app.get("/{tenant_code}/callback")
async def tenant_callback(
tenant_code: str,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
error_description: Optional[str] = None,
request: Request = None
):
"""
Keycloak 回調處理 - Token 交換 (含 PKCE 驗證)
URL: https://webmail.lab.taipei/porsche1/callback?code=xxx&state=pkce:xxx
"""
# 錯誤處理
if error:
logger.error(f"Keycloak error: {error} - {error_description}")
return JSONResponse(content={"detail": f"認證錯誤: {error_description}"}, status_code=400)
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")
# PKCE 驗證:從 state 取得 pkce_key 並從 Redis 取得 code_verifier
pkce_key = state # state 格式: pkce:{tenant_code}:{random}
logger.info(f"Attempting to retrieve PKCE key: {pkce_key}")
code_verifier = redis_client.get(pkce_key)
if not code_verifier:
logger.error(f"PKCE code_verifier not found in Redis: {pkce_key}")
logger.error(f"Possible reasons: 1) PKCE expired (>10min), 2) Used old login link, 3) Redis connection issue")
raise HTTPException(status_code=400, detail="PKCE verification failed: code_verifier not found")
# Redis client 已設定 decode_responses=True所以 code_verifier 已經是字串
logger.info(f"Retrieved code_verifier from Redis: key={pkce_key}, verifier={code_verifier[:10]}...")
try:
# 1. 查詢租戶資訊
tenant = get_tenant_by_code(tenant_code)
if not tenant:
raise HTTPException(status_code=404, detail=f"Tenant not found: {tenant_code}")
realm = tenant['keycloak_realm']
logger.info(f"Processing callback for tenant: {tenant_code}, realm: {realm}")
# 2. 交換 Token (使用 PKCE)
logger.info(f"Exchanging authorization code for access token")
token_url = f"{KEYCLOAK_SERVER_URL}/realms/{realm}/protocol/openid-connect/token"
redirect_uri = f"https://webmail.lab.taipei/{tenant_code}/callback"
token_data = {
"grant_type": "authorization_code",
"client_id": "webmail",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier
}
import requests
from urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
token_response = requests.post(token_url, data=token_data, verify=False, timeout=10)
if token_response.status_code != 200:
logger.error(f"Token exchange failed: {token_response.status_code} - {token_response.text}")
raise HTTPException(status_code=400, detail=f"Token exchange failed: {token_response.text}")
token_result = token_response.json()
access_token = token_result.get("access_token")
# 刪除已使用的 PKCE key
redis_client.delete(pkce_key)
logger.info(f"✓ Access token received, PKCE key deleted: {pkce_key}")
# 刪除已使用的 PKCE key
redis_client.delete(pkce_key)
logger.info(f"✓ Access token received, PKCE key deleted: {pkce_key}")
# 刪除已使用的 PKCE key
redis_client.delete(pkce_key)
logger.info(f"✓ Access token received, PKCE key deleted: {pkce_key}")
refresh_token = token_result.get("refresh_token")
if not access_token:
raise HTTPException(status_code=400, detail="No access token received")
logger.info(f"✓ Access token received")
# 3. 取得使用者資訊
logger.info("Fetching user info from Keycloak")
userinfo_url = f"{KEYCLOAK_SERVER_URL}/realms/{realm}/protocol/openid-connect/userinfo"
headers = {"Authorization": f"Bearer {access_token}"}
userinfo_response = requests.get(userinfo_url, headers=headers, verify=False, timeout=10)
if userinfo_response.status_code != 200:
logger.error(f"Userinfo fetch failed: {userinfo_response.status_code} - {userinfo_response.text}")
raise HTTPException(status_code=400, detail="Failed to fetch user info")
user_info = userinfo_response.json()
sso_uuid = user_info.get("sub")
username = user_info.get("preferred_username")
email = user_info.get("email")
logger.info(f"✓ User authenticated: {username} (email: {email}, uuid: {sso_uuid})")
# 4. 查詢郵件帳號
logger.info(f"Looking up mail account for SSO UUID: {sso_uuid}")
account = get_account_by_sso_uuid_and_realm(sso_uuid, realm)
if not account:
logger.error(f"No mail account found for SSO UUID: {sso_uuid} in realm: {realm}")
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>郵件帳號不存在</title>
<style>
body {{
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}}
.error-box {{
background: white;
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
max-width: 400px;
}}
h1 {{
color: #e74c3c;
margin: 0 0 1rem 0;
}}
p {{
color: #666;
line-height: 1.6;
margin: 0.5rem 0;
}}
.back-link {{
display: inline-block;
margin-top: 1.5rem;
color: #667eea;
text-decoration: none;
font-weight: 500;
}}
.back-link:hover {{
text-decoration: underline;
}}
</style>
</head>
<body>
<div class="error-box">
<h1>❌ 郵件帳號不存在</h1>
<p><strong>使用者:</strong> {username}</p>
<p><strong>租戶:</strong> {tenant_code}</p>
<p>您的帳號尚未開通郵件服務</p>
<p>請聯絡系統管理員</p>
<a href="/{tenant_code}" class="back-link">← 返回登入頁面</a>
</div>
</body>
</html>
""",
status_code=400
)
logger.info(f"✓ Mail account found: {account['email']}")
# 5. 建立 Session
logger.info(f"Creating session for user: {username}")
# 生成 session_id
import secrets
session_id = secrets.token_urlsafe(32)
# 將使用者資料存入 Redis
session_data = {
"session_id": session_id,
"email": account['email'],
"password": account['password'],
"username": username,
"sso_uuid": sso_uuid,
"realm": realm,
"tenant_code": tenant_code,
"access_token": access_token,
"refresh_token": refresh_token,
"account_code": account['account_code']
}
redis_client.setex(
f"webmail:tenant_session:{session_id}",
3600 * 24, # 24 小時
json.dumps(session_data)
)
logger.info(f"✓ Session created: {session_id}")
# 6. 重定向到收件匣,並設定租戶專屬 Cookie
logger.info(f"Redirecting to inbox: /{tenant_code}/inbox")
response = RedirectResponse(url=f"/{tenant_code}/inbox", status_code=302)
# 設定租戶專屬的 Cookie (HttpOnly, Secure, SameSite)
cookie_name = f"webmail_session_{tenant_code}"
response.set_cookie(
key=cookie_name,
value=session_id,
max_age=86400, # 24 小時
httponly=True,
samesite='lax',
path=f"/{tenant_code}" # Cookie 只在該租戶路徑下有效
)
return response
except HTTPException:
raise
except Exception as e:
logger.error(f"Callback error: {e}")
import traceback
traceback.print_exc()
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登入失敗</title>
<style>
body {{
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}}
.error-box {{
background: white;
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
max-width: 500px;
}}
h1 {{
color: #e74c3c;
margin: 0 0 1rem 0;
}}
p {{
color: #666;
line-height: 1.6;
margin: 0.5rem 0;
}}
.error-detail {{
background: #f8f9fa;
padding: 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
font-family: monospace;
font-size: 0.875rem;
text-align: left;
color: #333;
max-height: 200px;
overflow-y: auto;
}}
.back-link {{
display: inline-block;
margin-top: 1.5rem;
color: #667eea;
text-decoration: none;
font-weight: 500;
}}
.back-link:hover {{
text-decoration: underline;
}}
</style>
</head>
<body>
<div class="error-box">
<h1>❌ 登入處理失敗</h1>
<p><strong>租戶:</strong> {tenant_code}</p>
<p>無法完成登入流程</p>
<div class="error-detail">
{str(e)}
</div>
<a href="/{tenant_code}" class="back-link">← 返回登入頁面</a>
</div>
</body>
</html>
""",
status_code=500
)
@app.get("/{tenant_code}/compose")
async def tenant_compose_form(tenant_code: str, request: Request):
"""一般租戶撰寫郵件"""
# 從租戶專屬 Cookie 讀取 session_id
cookie_name = f"webmail_session_{tenant_code}"
tenant_session_id = request.cookies.get(cookie_name)
if not tenant_session_id:
return RedirectResponse(url=f"/{tenant_code}")
session_data_json = redis_client.get(f"webmail:tenant_session:{tenant_session_id}")
if not session_data_json:
return RedirectResponse(url=f"/{tenant_code}")
session_data = json.loads(session_data_json)
if session_data.get('tenant_code') != tenant_code:
return RedirectResponse(url=f"/{tenant_code}")
username = session_data.get('username')
return render_compose_form(
name=username,
back_url=f"/{tenant_code}/inbox",
send_url=f"/{tenant_code}/send",
tenant_code=tenant_code
)
@app.get("/{tenant_code}/inbox")
async def tenant_inbox(tenant_code: str, request: Request, folder: str = "INBOX"):
"""
租戶收件匣頁面 - 使用統一介面
URL: https://webmail.lab.taipei/porsche1/inbox
"""
# 從租戶專屬 Cookie 讀取 session_id
cookie_name = f"webmail_session_{tenant_code}"
tenant_session_id = request.cookies.get(cookie_name)
if not tenant_session_id:
logger.info(f"No tenant session cookie found, redirecting to login")
return RedirectResponse(url=f"/{tenant_code}", status_code=302)
# 從 Redis 取得租戶 Session 資料
session_data_json = redis_client.get(f"webmail:tenant_session:{tenant_session_id}")
if not session_data_json:
logger.info(f"Tenant session expired, redirecting to login")
return RedirectResponse(url=f"/{tenant_code}", status_code=302)
session_data = json.loads(session_data_json)
# 驗證 tenant_code
if session_data.get('tenant_code') != tenant_code:
logger.warning(f"Tenant code mismatch")
return RedirectResponse(url=f"/{tenant_code}", status_code=302)
# 從 session 取得使用者和郵件帳號資訊
mail_email = session_data.get('email')
mail_password = session_data.get('password')
username = session_data.get('username')
logger.info(f"Loading inbox for tenant user: {username} ({mail_email}), folder: {folder}")
# 呼叫統一的 render_inbox 函數
return render_inbox(
request=request,
folder=folder,
mail_email=mail_email,
mail_password=mail_password,
username=username,
name=None,
session_id=tenant_session_id,
tenant_code=tenant_code,
is_management_tenant=False
)
# ===== 3. 發送郵件 API (一般租戶) =====
@app.post("/{tenant_code}/send")
async def tenant_send_mail(
tenant_code: str,
request: Request,
to: str = Form(...),
subject: str = Form(...),
body: str = Form(...),
content_type: str = Form('plain'),
attachments: list[UploadFile] = File(default=[])
):
"""一般租戶發送郵件"""
# 從租戶專屬 Cookie 讀取 session_id
cookie_name = f"webmail_session_{tenant_code}"
tenant_session_id = request.cookies.get(cookie_name)
if not tenant_session_id:
return JSONResponse({"error": "未登入"}, status_code=401)
session_data_json = redis_client.get(f"webmail:tenant_session:{tenant_session_id}")
if not session_data_json:
return JSONResponse({"error": "Session 已過期"}, status_code=401)
session_data = json.loads(session_data_json)
if session_data.get('tenant_code') != tenant_code:
return JSONResponse({"error": "租戶不符"}, status_code=403)
# 從 session 取得郵件帳號資訊
mail_email = session_data.get('email')
mail_password = session_data.get('password')
if not mail_email or not mail_password:
return JSONResponse({"error": "找不到郵件帳號"}, status_code=404)
# 處理附件
attachment_list = []
if attachments:
for upload_file in attachments:
if upload_file.filename:
content = await upload_file.read()
attachment_list.append({
'filename': upload_file.filename,
'content': content
})
# 發送郵件
success = send_email_smtp(
mail_email,
mail_password,
to,
subject,
body,
content_type,
attachment_list if attachment_list else None
)
if success:
return JSONResponse({"success": True, "message": "郵件發送成功"})
else:
return JSONResponse({"success": False, "error": "郵件發送失敗"}, status_code=500)
# ===== 4. 主題設定 API (管理租戶和一般租戶) =====
@app.post("/theme")
async def set_theme(request: Request, theme: str = Form(...)):
"""管理租戶設定布景主題"""
session_id = request.session.get("session_id")
if session_id:
redis_client.hset(f"webmail:settings:{session_id}", "theme", theme)
return RedirectResponse(url="/", status_code=303)
@app.post("/{tenant_code}/theme")
async def tenant_set_theme(tenant_code: str, request: Request, theme: str = Form(...)):
"""一般租戶設定布景主題"""
tenant_session_id = request.session.get('tenant_session_id')
if tenant_session_id:
redis_client.hset(f"webmail:settings:{tenant_session_id}", "theme", theme)
return RedirectResponse(url=f"/{tenant_code}/inbox", status_code=303)
# ===== 5. 登出功能 (一般租戶) =====
@app.get("/{tenant_code}/logout")
async def tenant_logout(tenant_code: str, request: Request):
"""一般租戶登出"""
tenant_session_id = request.session.get('tenant_session_id')
if tenant_session_id:
redis_client.delete(f"webmail:tenant_session:{tenant_session_id}")
redis_client.delete(f"webmail:settings:{tenant_session_id}")
request.session.clear()
return HTMLResponse(f"""<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>已登出</title>
<style>
body {{
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}}
.logout-box {{
background: white;
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
max-width: 400px;
}}
h1 {{
color: #333;
margin: 0 0 1rem 0;
}}
p {{
color: #666;
line-height: 1.6;
margin: 0.5rem 0;
}}
.login-link {{
display: inline-block;
margin-top: 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 500;
}}
.login-link:hover {{
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}}
</style>
</head>
<body>
<div class="logout-box">
<h1>✅ 已成功登出</h1>
<p><strong>租戶:</strong> {tenant_code}</p>
<p>您已安全登出系統</p>
<a href="/{tenant_code}" class="login-link">← 重新登入</a>
</div>
</body>
</html>
""")
# ===== 6. 郵件查看和刪除 API (一般租戶) =====
@app.get("/{tenant_code}/api/mail/{mail_id}")
async def tenant_get_mail(tenant_code: str, mail_id: str, folder: str, request: Request):
"""一般租戶取得單封郵件內容"""
# 從租戶專屬 Cookie 讀取 session_id
cookie_name = f"webmail_session_{tenant_code}"
tenant_session_id = request.cookies.get(cookie_name)
if not tenant_session_id:
return JSONResponse({"error": "未登入"}, status_code=401)
session_data_json = redis_client.get(f"webmail:tenant_session:{tenant_session_id}")
if not session_data_json:
return JSONResponse({"error": "Session 已過期"}, status_code=401)
session_data = json.loads(session_data_json)
if session_data.get('tenant_code') != tenant_code:
return JSONResponse({"error": "租戶不符"}, status_code=403)
mail_email = session_data.get('email')
mail_password = session_data.get('password')
try:
mail_detail = get_mail_by_id(mail_email, mail_password, mail_id, folder)
return JSONResponse(mail_detail)
except Exception as e:
logger.error(f"Error getting mail: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/{tenant_code}/api/delete-mails")
async def tenant_delete_mails(tenant_code: str, request: Request):
"""一般租戶刪除郵件"""
# 從租戶專屬 Cookie 讀取 session_id
cookie_name = f"webmail_session_{tenant_code}"
tenant_session_id = request.cookies.get(cookie_name)
if not tenant_session_id:
return JSONResponse({"error": "未登入"}, status_code=401)
session_data_json = redis_client.get(f"webmail:tenant_session:{tenant_session_id}")
if not session_data_json:
return JSONResponse({"error": "Session 已過期"}, status_code=401)
session_data = json.loads(session_data_json)
if session_data.get('tenant_code') != tenant_code:
return JSONResponse({"error": "租戶不符"}, status_code=403)
mail_email = session_data.get('email')
mail_password = session_data.get('password')
try:
data = await request.json()
mail_ids = data.get('mail_ids', [])
folder = data.get('folder', 'INBOX')
# 刪除郵件
success = delete_emails(mail_email, mail_password, mail_ids, folder)
if success:
return JSONResponse({"success": True, "message": f"已刪除 {len(mail_ids)} 封郵件"})
else:
return JSONResponse({"success": False, "error": "刪除失敗"}, status_code=500)
except Exception as e:
logger.error(f"Error deleting mails: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=10180)