{get_folder_display_name(folder)}
-
{mail_items_html}
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('
', '\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('
', '\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"""
SSO: {preferred_username}
登出 """) 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"""已登出
重新登入""") @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"""找不到租戶「{tenant_code}」
請確認網址是否正確