## 主要修正
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>
3446 lines
132 KiB
Python
3446 lines
132 KiB
Python
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()">×</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, '<').replace(/>/g, '>')}}</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()">×</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, '<').replace(/>/g, '>')}}</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)
|