diff --git a/START_DEVELOPMENT.bat b/START_DEVELOPMENT.bat index 4413847..6981b32 100644 --- a/START_DEVELOPMENT.bat +++ b/START_DEVELOPMENT.bat @@ -13,12 +13,19 @@ set REDIS_PASSWORD=DC1qaz2wsx set REDIS_DB=2 set DATABASE_URL=postgresql://admin:DC1qaz2wsx@10.1.0.20:5433/virtual_mis set KEYCLOAK_SERVER_URL=https://auth.lab.taipei +set MAIL_SERVER=10.1.0.254 +set SMTP_SERVER=10.1.0.254 echo Starting Uvicorn on port 8100... echo. +echo Environment Variables: +echo MAIL_SERVER=%MAIL_SERVER% +echo SMTP_SERVER=%SMTP_SERVER% +echo REDIS_HOST=%REDIS_HOST% +echo. echo Access URL: http://10.1.0.245:8100 echo. echo Press Ctrl+C to stop the server echo. -uvicorn app:app --host 0.0.0.0 --port 8100 --reload +python -m uvicorn app:app --host 0.0.0.0 --port 8100 --reload diff --git a/app.py b/app.py index c3a5e2e..20ab9ac 100644 --- a/app.py +++ b/app.py @@ -17,120 +17,11 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from datetime import datetime import html - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -# ============================================================================ -# 輔助函數:從 Virtual MIS Backend 取得租戶資訊 -# ============================================================================ - -import requests - -VIRTUAL_MIS_API = "https://vmis.lab.taipei/api/v1" - -def get_tenant_by_code(tenant_code: str): - """從 Virtual MIS Backend 取得租戶資訊""" - try: - response = requests.get( - f"{VIRTUAL_MIS_API}/tenants/by-code/{tenant_code}", - timeout=5 - ) - - if response.status_code == 200: - tenant_data = response.json() - return { - "id": tenant_data.get("id"), - "code": tenant_data.get("code"), - "name": tenant_data.get("name"), - "realm": tenant_data.get("code"), - "is_manager": tenant_data.get("is_manager", False) - } - else: - logger.error(f"取得租戶資訊失敗: {response.status_code}") - return None - except Exception as e: - logger.error(f"取得租戶資訊錯誤: {e}") - return None - - - -# ============================================================================ -# PKCE 支援函數 -# ============================================================================ import hashlib import base64 -def generate_pkce_pair(): - 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): - try: - with db_engine.connect() as conn: - result = conn.execute(text(""" - SELECT a.id, 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: - return {"id": result[0], "email": result[1], "password": result[2], "sso_account": result[3], "account_code": result[4]} - return None - except Exception as e: - logger.error(f"DB error: {e}") - return None - -def render_inbox(request, folder, mail_email, mail_password, username, name, session_id, tenant_code, is_management_tenant=False): - folders = get_imap_folders(mail_email, mail_password) - messages = get_imap_messages(mail_email, mail_password, folder) - msg_rows = "".join([f'{m.get("from","")}{m.get("subject","")}{m.get("date","")}' for m in messages]) - folder_links = "".join([f'
  • {f}
  • ' for f in folders]) - html = f'''WebMail

    WebMail - {username}

    {mail_email} | Tenant: {tenant_code}

    -Logout
    -

    Folder: {folder}

    -

    Compose New Email

    -{msg_rows}
    FromSubjectDate
    ''' - return HTMLResponse(content=html) - -def render_compose_form(name, back_url, send_url, tenant_code): - html = f'''Compose

    Compose Email

    From: {name}

    -
    - - - - -
    -

    Back to Inbox

    ''' - return HTMLResponse(content=html) - -def get_mail_detail(mail_email, mail_password, mail_id, folder): - return get_mail_by_id(mail_email, mail_password, mail_id, folder) - -def delete_emails(mail_email, mail_password, mail_ids, folder): - try: - mail = imaplib.IMAP4("mailserver", 143) - mail.login(mail_email, mail_password) - mail.select(folder) - for mid in mail_ids: - mail.store(mid, "+FLAGS", "\\Deleted") - mail.expunge() - mail.close() - mail.logout() - return True - except Exception as e: - logger.error(f"Delete error: {e}") - return False - - +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) app = FastAPI() @@ -162,6 +53,10 @@ 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", @@ -218,7 +113,7 @@ def decode_header_value(header_value): def get_imap_folders(email_addr, password): """取得 IMAP 資料夾列表""" try: - mail = imaplib.IMAP4('mailserver', 143) + mail = imaplib.IMAP4(MAIL_SERVER, 143) mail.login(email_addr, password) # 列出所有資料夾(包括子資料夾) @@ -270,7 +165,7 @@ def get_imap_messages(email_addr, password, folder='INBOX', limit=50): """從 IMAP 取得郵件列表(含附件、標記等資訊)""" try: # 連接 IMAP - mail = imaplib.IMAP4('mailserver', 143) + mail = imaplib.IMAP4(MAIL_SERVER, 143) mail.login(email_addr, password) # 選擇資料夾 @@ -356,7 +251,7 @@ def get_imap_messages(email_addr, password, folder='INBOX', limit=50): def get_mail_by_id(email_addr, password, mail_id, folder='INBOX'): """讀取單封郵件內容""" try: - mail = imaplib.IMAP4('mailserver', 143) + mail = imaplib.IMAP4(MAIL_SERVER, 143) mail.login(email_addr, password) mail.select(folder) @@ -473,9 +368,9 @@ def send_email_smtp(from_addr, password, to_addr, subject, body, content_type='p part.add_header('Content-Disposition', f'attachment; filename="{attachment["filename"]}"') msg.attach(part) - # 連接 SMTP (內部網路,不使用 TLS) - smtp = smtplib.SMTP('mailserver', 587) - smtp.login(from_addr, password) + # 連接 SMTP (內部網路,不使用 TLS 和認證) + smtp = smtplib.SMTP(SMTP_SERVER, 25, timeout=10) + # 內部郵件伺服器不需要認證 smtp.send_message(msg) smtp.quit() @@ -488,115 +383,2359 @@ def send_email_smtp(from_addr, password, to_addr, subject, body, content_type='p traceback.print_exc() return False - -# ===== 根路徑重定向 ===== - @app.get("/") -async def root(): - """WebMail 服務說明頁面""" - return HTMLResponse(""" - - - - - WebMail Service - 匠耘虛擬辦公室 - + + + +
    +

    📧 WebMail

    +
    + +
    + +
    +
    預設藍色
    +
    深色模式
    +
    清新綠色
    +
    優雅紫色
    +
    +
    + +
    +
    + + +
    + + + + +
    +
    +

    {get_folder_display_name(folder)}

    +
    + + +
    +
    +
    +
      +{mail_items_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 } - .logo img { - max-width: 200px; - height: auto; + + 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""" +撰寫郵件 + + + + + + + +
    +

    📧 撰寫新郵件

    +
    +{name} +
    +
    + + +
    +
    +← 返回 +
    +
    + +
    +
    + + +
    +
    + +
    + + +
    + + +
    + + +
    + + +
    +
    純文字
    +
    富文本
    +
    + + +
    + + + + +
    +
    +
    + + +""") + +@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(""" +

    已登出

    重新登入""") + +@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" + } } - .service-icon { - font-size: 3rem; - margin-bottom: 1rem; - } - h1 { - color: #1a73e8; - margin: 0 0 0.5rem 0; - font-size: 1.8rem; - } - .subtitle { - color: #888; - font-size: 0.9rem; - margin-bottom: 1rem; - } - p { - color: #666; - margin: 0.5rem 0; - line-height: 1.6; - } - .usage { - background: #f8f9fa; - padding: 1rem; - border-radius: 0.5rem; - margin-top: 1.5rem; - text-align: left; - } - .usage code { - color: #1a73e8; - font-weight: 600; - } - .footer { - margin-top: 1.5rem; - padding-top: 1rem; - border-top: 1px solid #e0e0e0; - color: #999; - font-size: 0.85rem; - } - - - -
    - -
    📧
    -

    電子郵件服務

    -
    Virtual Office - Email Service
    -

    企業租戶專屬的郵件服務

    -
    -

    服務位址:

    -

    https://webmail.lab.taipei/{realm}

    -

    - * realm 為貴公司的租戶代碼 -

    -
    -
    -

    虛擬辦公室完整服務:

    -

    - 🏢 管理入口
    - 📧 電子郵件
    - 📅 日曆服務
    - 💾 硬碟服務
    - 📄 Office 服務 -

    -
    - -
    - -""") + + 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""" +
  • +{icon} +{display_name} +
  • +""" + + # 產生郵件列表 HTML + mail_items_html = "" + if messages: + for m in messages: + markers = [] + if m['is_flagged']: + markers.append('') + else: + markers.append(f'') + + markers.append(f'') + + if m['is_answered']: + markers.append('↩️') + + markers_html = ''.join(markers) + attachment_icon = '📎' if m['has_attachment'] else '' + unread_class = 'unread' if m['is_unread'] else '' + + mail_items_html += f""" +
  • +
    +
    + +
    +
    {markers_html}
    +
    +{html.escape(m['subject'])} +{attachment_icon} +
    +
    {html.escape(m['date'])}
    +
    +
  • +""" + else: + mail_items_html = '
    目前沒有郵件
    ' + + # 根據租戶類型設定頁面標題和 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'✍️ 撰寫' + + # 使用者顯示名稱 + display_name = name if name else username + + # 渲染完整的 HTML 頁面 + return HTMLResponse(f""" +{page_title} + + + + +
    +

    📧 {page_title}

    +
    +{compose_btn_html} +
    + +
    +
    預設藍色
    +
    深色模式
    +
    清新綠色
    +
    優雅紫色
    +
    +
    + +
    +
    + + +
    + + + + +
    +
    +

    {get_folder_display_name(folder)}

    +
    + + +
    +
    +
    +
      +{mail_items_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""" + + + + + + {error_page_title} + + + +
    +

    ❌ 載入收件匣失敗

    +

    使用者: {username}

    +
    + {html.escape(str(e))} +
    + ← 返回登入頁面 +
    + + + """, + 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""" +{page_title} + + + + + + + +
    +

    📧 撰寫新郵件

    +
    +{name} +
    +
    + + +
    +
    +← 返回 +
    +
    + +
    +
    + + +
    +
    + +
    + + +
    + + +
    + + +
    + + +
    +
    純文字
    +
    富文本編輯
    +
    + + +
    +
    + + + + + + +
    +
    +
    +
    + + + +""") + + +# ====================================================================== +# Tenant routes +# ====================================================================== @app.get("/{tenant_code}") @@ -666,15 +2805,15 @@ async def tenant_login_page(tenant_code: str, request: Request): # 生成 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.get('realm', tenant.get('keycloak_realm', tenant['code'])) + realm = tenant['keycloak_realm'] client_id = "webmail" redirect_uri = f"https://webmail.lab.taipei/{tenant_code}/callback" @@ -717,16 +2856,20 @@ async def tenant_callback( return JSONResponse(content={"detail": f"認證錯誤: {error_description}"}, status_code=400) if not code: - return JSONResponse(content={"detail": "Missing authorization code"}, status_code=400) + 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}") - # 從 Redis 取回 code_verifier - pkce_key = state code_verifier = redis_client.get(pkce_key) if not code_verifier: - logger.error(f"PKCE key not found: {pkce_key}") - return JSONResponse(content={"detail": "PKCE 驗證失敗或已過期,請重新登入"}, status_code=400) + 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") - logger.info(f"Retrieved code_verifier from Redis: key={pkce_key}") + # Redis client 已設定 decode_responses=True,所以 code_verifier 已經是字串 + logger.info(f"Retrieved code_verifier from Redis: key={pkce_key}, verifier={code_verifier[:10]}...") try: # 1. 查詢租戶資訊 @@ -734,16 +2877,15 @@ async def tenant_callback( if not tenant: raise HTTPException(status_code=404, detail=f"Tenant not found: {tenant_code}") - realm = tenant.get('realm', tenant.get('keycloak_realm', tenant['code'])) + realm = tenant['keycloak_realm'] logger.info(f"Processing callback for tenant: {tenant_code}, realm: {realm}") - # 2. 交換 Token + # 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" - # Public Client + PKCE token_data = { "grant_type": "authorization_code", "client_id": "webmail", @@ -764,14 +2906,24 @@ async def tenant_callback( 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") - # 刪除已使用的 PKCE key - redis_client.delete(pkce_key) - logger.info(f"✓ Access token received, PKCE key deleted: {pkce_key}") + logger.info(f"✓ Access token received") # 3. 取得使用者資訊 logger.info("Fetching user info from Keycloak") @@ -887,15 +3039,24 @@ async def tenant_callback( json.dumps(session_data) ) - # 將 session_id 存入 Cookie - request.session['tenant_session_id'] = session_id - request.session['tenant_code'] = tenant_code - logger.info(f"✓ Session created: {session_id}") - # 6. 重定向到收件匣 + # 6. 重定向到收件匣,並設定租戶專屬 Cookie logger.info(f"Redirecting to inbox: /{tenant_code}/inbox") - return RedirectResponse(url=f"/{tenant_code}/inbox", status_code=302) + 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 @@ -983,7 +3144,9 @@ async def tenant_callback( @app.get("/{tenant_code}/compose") async def tenant_compose_form(tenant_code: str, request: Request): """一般租戶撰寫郵件""" - tenant_session_id = request.session.get('tenant_session_id') + # 從租戶專屬 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}") @@ -1013,11 +3176,12 @@ async def tenant_inbox(tenant_code: str, request: Request, folder: str = "INBOX" URL: https://webmail.lab.taipei/porsche1/inbox """ - # 檢查租戶 Session - tenant_session_id = request.session.get('tenant_session_id') + # 從租戶專屬 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 found, redirecting to login") + logger.info(f"No tenant session cookie found, redirecting to login") return RedirectResponse(url=f"/{tenant_code}", status_code=302) # 從 Redis 取得租戶 Session 資料 @@ -1025,7 +3189,6 @@ async def tenant_inbox(tenant_code: str, request: Request, folder: str = "INBOX" if not session_data_json: logger.info(f"Tenant session expired, redirecting to login") - request.session.clear() return RedirectResponse(url=f"/{tenant_code}", status_code=302) session_data = json.loads(session_data_json) @@ -1069,7 +3232,9 @@ async def tenant_send_mail( attachments: list[UploadFile] = File(default=[]) ): """一般租戶發送郵件""" - tenant_session_id = request.session.get('tenant_session_id') + # 從租戶專屬 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) @@ -1209,10 +3374,12 @@ async def tenant_logout(tenant_code: str, request: Request): # ===== 6. 郵件查看和刪除 API (一般租戶) ===== -@app.get("/{tenant_code}/api/mail/{{mail_id}}") +@app.get("/{tenant_code}/api/mail/{mail_id}") async def tenant_get_mail(tenant_code: str, mail_id: str, folder: str, request: Request): """一般租戶取得單封郵件內容""" - tenant_session_id = request.session.get('tenant_session_id') + # 從租戶專屬 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) @@ -1228,7 +3395,7 @@ async def tenant_get_mail(tenant_code: str, mail_id: str, folder: str, request: mail_password = session_data.get('password') try: - mail_detail = get_mail_detail(mail_email, mail_password, mail_id, folder) + 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}") @@ -1238,7 +3405,9 @@ async def tenant_get_mail(tenant_code: str, mail_id: str, folder: str, request: @app.post("/{tenant_code}/api/delete-mails") async def tenant_delete_mails(tenant_code: str, request: Request): """一般租戶刪除郵件""" - tenant_session_id = request.session.get('tenant_session_id') + # 從租戶專屬 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) diff --git a/requirements.txt b/requirements.txt index defdd45..e1d81aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ -fastapi==0.115.0 -uvicorn[standard]==0.32.0 -authlib==1.3.2 -itsdangerous==2.2.0 -redis==5.2.0 -httpx==0.27.2 -python-multipart==0.0.12 -sqlalchemy==2.0.23 -psycopg2-binary==2.9.9 -imap-tools==1.7.1 -requests==2.31.0 +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +authlib>=1.3.2 +itsdangerous>=2.2.0 +redis>=5.2.0 +httpx>=0.27.2 +python-multipart>=0.0.12 +sqlalchemy>=2.0.23 +psycopg2-binary>=2.9.10 +imap-tools>=1.7.1 +requests>=2.31.0