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
-Folder: {folder}
-Compose New Email
-| From | Subject | Date |
{msg_rows}
'''
- return HTMLResponse(content=html)
-
-def render_compose_form(name, back_url, send_url, tenant_code):
- html = f'''Compose
-
-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 - 匠耘虛擬辦公室
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+""")
+
+@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"""
+撰寫郵件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+""")
+
+@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 為貴公司的租戶代碼
-
-
-
-
-
-
-