317 lines
16 KiB
Python
317 lines
16 KiB
Python
import json
|
|
import io
|
|
import bleach
|
|
from typing import Optional
|
|
from fastapi import APIRouter, Request, Depends, HTTPException, Form, UploadFile, File
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
import pathlib
|
|
from .. import jmap
|
|
from ..utils import human_size, fmt_when
|
|
|
|
router = APIRouter()
|
|
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
|
|
|
|
def require_user(request: Request):
|
|
user = request.session.get("user")
|
|
if not user:
|
|
raise HTTPException(status_code=401)
|
|
return user
|
|
|
|
def make_csrf(session: dict) -> str:
|
|
import os, base64
|
|
token = base64.urlsafe_b64encode(os.urandom(24)).decode()
|
|
session["csrf"] = token
|
|
return token
|
|
|
|
def check_csrf(session: dict, token: str):
|
|
if not token or token != session.get("csrf"):
|
|
raise HTTPException(status_code=400, detail="CSRF token invalid")
|
|
|
|
@router.get("/mail", response_class=HTMLResponse)
|
|
async def inbox(request: Request, q: Optional[str] = None, mailbox: Optional[str] = None, user=Depends(require_user)):
|
|
async with jmap.client() as ac:
|
|
api = user["api_url"]
|
|
primary = user.get("primary", {})
|
|
account_id = primary.get("urn:ietf:params:jmap:mail")
|
|
boxes, inbox_id = await get_mailboxes(ac, api, tuple(user["auth"]), account_id)
|
|
box_id = mailbox or inbox_id
|
|
filt = {"text": q} if q else ({"inMailbox": box_id} if box_id else {})
|
|
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
|
["Email/query", {"accountId": account_id, "filter": filt, "sort": [{"property":"receivedAt","isAscending": False}], "limit": 50}, "c1"],
|
|
["Email/get", {"accountId": account_id, "#ids": {"resultOf":"c1","name":"Email/query","path":"ids"}, "properties": ["id","subject","from","size","receivedAt"]}, "c2"]
|
|
])
|
|
emails = []
|
|
|
|
for name, data, _ in res.get("methodResponses", []):
|
|
if name == "Email/get":
|
|
for e in data.get("list", []):
|
|
from_str = ", ".join([a.get("name") or a.get("email","") for a in (e.get("from") or [])])
|
|
emails.append({"id": e["id"], "subject": e.get("subject") or "(no subject)", "from": from_str, "when": fmt_when(e.get("receivedAt")), "size": human_size(e.get("size"))})
|
|
return templates.TemplateResponse("mail.html", {"request": request, "messages": emails, "q": q, "user": user, "mailboxes": boxes, "selected": box_id})
|
|
|
|
@router.get("/mail/{email_id}", response_class=HTMLResponse)
|
|
async def read_message(request: Request, email_id: str, user=Depends(require_user)):
|
|
async with jmap.client() as ac:
|
|
api = user["api_url"]
|
|
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
|
["Email/get", {"ids": [email_id], "properties": ["id","subject","from","to","receivedAt","size","keywords","preview","bodyStructure","htmlBody","textBody"]}, "c1"]
|
|
])
|
|
msg = {"id": email_id, "subject":"", "from":"", "to":[], "when":"", "textBody":"", "htmlBody":"", "attachments":[]}
|
|
bstruct = None
|
|
cid_map = {}
|
|
for name, data, _ in res.get("methodResponses", []):
|
|
if name == "Email/get":
|
|
lst = data.get("list", [])
|
|
if lst:
|
|
e = lst[0]
|
|
msg["subject"] = e.get("subject") or msg["subject"]
|
|
msg["from"] = ", ".join([a.get("name") or a.get("email","") for a in (e.get("from") or [])]) or msg["from"]
|
|
msg["to"] = [a.get("email","") for a in (e.get("to") or [])] or msg["to"]
|
|
msg["when"] = fmt_when(e.get("receivedAt")) or msg["when"]
|
|
if "textBody" in e:
|
|
msg["textBody"] = e.get("textBody") or msg["textBody"]
|
|
if "htmlBody" in e:
|
|
raw_html = e.get("htmlBody")
|
|
if raw_html:
|
|
msg["htmlBody"] = bleach.clean(raw_html, tags=bleach.sanitizer.ALLOWED_TAGS.union({"p","span","div","br","hr","pre","code","blockquote","ul","ol","li","table","thead","tbody","tr","th","td","img","a","b","i","strong","em"}), attributes={"a":["href","title"],"img":["src","alt","title","width","height"]}, strip=True)
|
|
bstruct = bstruct or e.get("bodyStructure")
|
|
def walk_cid(bs):
|
|
if not isinstance(bs, dict): return
|
|
cid = bs.get("cid")
|
|
if cid and bs.get("blobId"):
|
|
cid_map[cid.strip("<>")] = {"blobId": bs["blobId"], "name": bs.get("name") or "inline"}
|
|
for p in bs.get("subParts", []) or []:
|
|
walk_cid(p)
|
|
if bstruct:
|
|
walk_cid(bstruct)
|
|
|
|
def walk_bs(bs, out):
|
|
if not isinstance(bs, dict): return
|
|
if bs.get("disposition") == "attachment":
|
|
out.append({"name": bs.get("name") or "attachment", "type": bs.get("type") or "application/octet-stream", "size": bs.get("size"), "blobId": bs.get("blobId")})
|
|
for p in bs.get("subParts", []) or []:
|
|
walk_bs(p, out)
|
|
att = []
|
|
walk_bs(bstruct, att)
|
|
msg["attachments"] = att
|
|
# Inline CID images via internal route
|
|
if msg.get("htmlBody") and cid_map:
|
|
import re as _re
|
|
def _repl(m):
|
|
cid = m.group(1)
|
|
return f'src="/mail/{email_id}/cid/{cid}"'
|
|
msg["htmlBody"] = _re.sub(r'src=\"cid:([^\"]+)\"', _repl, msg["htmlBody"]) # cid_rewrite
|
|
return templates.TemplateResponse("message.html", {"request": request, "msg": msg, "user": user})
|
|
|
|
@router.get("/compose", response_class=HTMLResponse)
|
|
async def compose_form(request: Request, user=Depends(require_user)):
|
|
csrf = make_csrf(request.session)
|
|
return templates.TemplateResponse("compose.html", {"request": request, "csrf": csrf, "user": user})
|
|
|
|
@router.post("/compose")
|
|
async def compose_send(request: Request, to: str = Form(...), subject: str = Form(""), body: str = Form(""), csrf: str = Form(...), action: str = Form("send"), files: list[UploadFile] = File(default=[]), user=Depends(require_user)):
|
|
check_csrf(request.session, csrf)
|
|
async with jmap.client() as ac:
|
|
api = user["api_url"]
|
|
primary = user.get("primary", {})
|
|
account_id = primary.get("urn:ietf:params:jmap:mail")
|
|
# Upload attachments if any
|
|
upload_url = user.get("upload_url")
|
|
blobs = []
|
|
form = await request.form()
|
|
for k, v in form.multi_items():
|
|
if k == 'preblob':
|
|
try:
|
|
b = json.loads(v)
|
|
if b.get('blobId'): blobs.append(b)
|
|
except Exception:
|
|
pass
|
|
if files:
|
|
for f in files:
|
|
data = await f.read()
|
|
if upload_url:
|
|
url = upload_url.replace("{accountId}", account_id or "")
|
|
ru = await ac.post(url, content=data, headers={"Content-Type": f.content_type or "application/octet-stream"}, auth=tuple(user["auth"]))
|
|
ru.raise_for_status()
|
|
up = ru.json()
|
|
blobs.append({"blobId": up.get("blobId"), "type": f.content_type or "application/octet-stream", "name": f.filename, "size": len(data)})
|
|
email_creation_id = "k1"
|
|
submission_creation_id = "k2"
|
|
create_email = {
|
|
"accountId": account_id,
|
|
"create": {
|
|
email_creation_id: {
|
|
"mailboxIds": {},
|
|
"from": [{"email": user["username"]}],
|
|
"to": [{"email": x.strip()} for x in to.split(",") if x.strip()],
|
|
"subject": subject,
|
|
"textBody": body,
|
|
"attachments": [{"blobId": b["blobId"], "type": b["type"], "name": b["name"]} for b in blobs]
|
|
}
|
|
}
|
|
}
|
|
# Move to Drafts if requested, else submit and move to Sent
|
|
special = await get_special_mailboxes(ac, api, tuple(user["auth"]), account_id)
|
|
sent_id = special.get("sent")
|
|
drafts_id = special.get("drafts")
|
|
calls = []
|
|
calls.append(["Email/set", create_email, "s1"])
|
|
if action == "draft":
|
|
if drafts_id:
|
|
calls.append(["Email/set", {"accountId": account_id, "onSuccessUpdateEmail": {"#kEmail": {"mailboxIds": {drafts_id: True}}}}, "sdraft"])
|
|
else:
|
|
calls.append(["EmailSubmission/set", {"accountId": account_id, "create": {submission_creation_id: {"emailId": {"resultOf":"s1","name":"Email/set","path": f"created/{email_creation_id}/id"}}}}, "s2"])
|
|
if sent_id:
|
|
calls.append(["Email/set", {"accountId": account_id, "onSuccessUpdateEmail": {"#kEmail": {"mailboxIds": {sent_id: True}}}}, "ssent"])
|
|
await jmap.call(ac, api, tuple(user["auth"]), calls)
|
|
return RedirectResponse("/mail", status_code=303)
|
|
|
|
async def get_mailboxes(ac, api, auth, account_id):
|
|
res = await jmap.call(ac, api, auth, [
|
|
["Mailbox/query", {"accountId": account_id, "sort":[{"property":"sortOrder","isAscending": True},{"property":"name","isAscending": True}], "limit": 200}, "q1"],
|
|
["Mailbox/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"Mailbox/query","path":"ids"}, "properties":["id","name","role","totalEmails","unreadEmails"]}, "g1"]
|
|
])
|
|
boxes = []
|
|
inbox_id = None
|
|
for name, data, _ in res.get("methodResponses", []):
|
|
if name == "Mailbox/get":
|
|
for b in data.get("list", []):
|
|
boxes.append({"id": b["id"], "name": b.get("name",""), "role": b.get("role"), "total": b.get("totalEmails",0), "unread": b.get("unreadEmails",0)})
|
|
if b.get("role") == "inbox":
|
|
inbox_id = b["id"]
|
|
return boxes, inbox_id or (boxes[0]["id"] if boxes else None)
|
|
|
|
@router.get("/mail/{email_id}/attach/{index}")
|
|
async def download_attachment(request: Request, email_id: str, index: int, user=Depends(require_user)):
|
|
atts = request.query_params.get("atts")
|
|
# Re-fetch message to resolve bodyStructure (simple approach; could cache)
|
|
async with jmap.client() as ac:
|
|
api = user["api_url"]
|
|
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
|
["Email/get", {"ids": [email_id], "properties": ["bodyStructure"]}, "c1"]
|
|
])
|
|
bstruct = None
|
|
cid_map = {}
|
|
for name, data, _ in res.get("methodResponses", []):
|
|
if name == "Email/get":
|
|
lst = data.get("list", [])
|
|
if lst:
|
|
bstruct = lst[0].get("bodyStructure")
|
|
parts = []
|
|
def walk(bs, out):
|
|
if not isinstance(bs, dict): return
|
|
if bs.get("disposition") == "attachment":
|
|
out.append(bs)
|
|
for p in bs.get("subParts", []) or []:
|
|
walk(p, out)
|
|
walk(bstruct, parts)
|
|
if index < 0 or index >= len(parts):
|
|
raise HTTPException(status_code=404, detail="Attachment not found")
|
|
p = parts[index]
|
|
blob = p.get("blobId")
|
|
name = p.get("name") or "attachment"
|
|
ctype = p.get("type") or "application/octet-stream"
|
|
|
|
# Build download URL from session template
|
|
tmpl = user.get("download_url") or ""
|
|
primary = user.get("primary", {})
|
|
account_id = primary.get("urn:ietf:params:jmap:mail")
|
|
url = tmpl
|
|
if "{accountId}" in url:
|
|
url = url.replace("{accountId}", account_id or "")
|
|
if "{blobId}" in url:
|
|
url = url.replace("{blobId}", blob or "")
|
|
if "{name}" in url:
|
|
from urllib.parse import quote
|
|
url = url.replace("{name}", quote(name))
|
|
# Fallback naive pattern if template missing
|
|
if not url or "{" in url:
|
|
from urllib.parse import urljoin, quote
|
|
base = user.get("jmap_base")
|
|
url = urljoin(base, f"/download/{quote(account_id or '')}/{quote(blob or '')}/{quote(name)}")
|
|
|
|
async with jmap.client() as ac:
|
|
r = await ac.get(url, auth=tuple(user["auth"]))
|
|
r.raise_for_status()
|
|
return StreamingResponse(io.BytesIO(r.content), media_type=ctype, headers={"Content-Disposition": f'attachment; filename="{name}"'})
|
|
|
|
|
|
async def get_special_mailboxes(ac, api, auth, account_id):
|
|
res = await jmap.call(ac, api, auth, [
|
|
["Mailbox/query", {"accountId": account_id, "limit": 200}, "q1"],
|
|
["Mailbox/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"Mailbox/query","path":"ids"}, "properties":["id","role","name"]}, "g1"]
|
|
])
|
|
sent_id = drafts_id = inbox_id = None
|
|
boxes = {}
|
|
for name, data, _ in res.get("methodResponses", []):
|
|
if name == "Mailbox/get":
|
|
for b in data.get("list", []):
|
|
boxes[b["id"]] = b
|
|
role = b.get("role")
|
|
if role == "sent": sent_id = b["id"]
|
|
if role == "drafts": drafts_id = b["id"]
|
|
if role == "inbox": inbox_id = b["id"]
|
|
return {"sent": sent_id, "drafts": drafts_id, "inbox": inbox_id, "all": boxes}
|
|
|
|
|
|
@router.get("/mail/{email_id}/cid/{cid}")
|
|
async def fetch_cid(request: Request, email_id: str, cid: str, user=Depends(require_user)):
|
|
# Walk bodyStructure to find matching cid, then download via downloadUrl
|
|
async with jmap.client() as ac:
|
|
api = user["api_url"]
|
|
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
|
["Email/get", {"ids": [email_id], "properties": ["bodyStructure"]}, "c1"]
|
|
])
|
|
bstruct = None
|
|
for name, data, _ in res.get("methodResponses", []):
|
|
if name == "Email/get":
|
|
lst = data.get("list", [])
|
|
if lst:
|
|
bstruct = lst[0].get("bodyStructure")
|
|
target = None
|
|
def walk(bs):
|
|
nonlocal target
|
|
if not isinstance(bs, dict) or target is not None: return
|
|
if bs.get("cid") and bs.get("cid").strip("<>") == cid:
|
|
target = bs
|
|
return
|
|
for p in bs.get("subParts", []) or []:
|
|
walk(p)
|
|
walk(bstruct)
|
|
if not target:
|
|
raise HTTPException(status_code=404, detail="Inline part not found")
|
|
blob = target.get("blobId")
|
|
ctype = target.get("type") or "application/octet-stream"
|
|
name = target.get("name") or "inline"
|
|
tmpl = user.get("download_url") or ""
|
|
primary = user.get("primary", {})
|
|
account_id = primary.get("urn:ietf:params:jmap:mail")
|
|
from urllib.parse import quote, urljoin
|
|
if tmpl and "{accountId}" in tmpl and "{blobId}" in tmpl:
|
|
url = tmpl.replace("{accountId}", account_id or "").replace("{blobId}", blob or "")
|
|
if "{name}" in url:
|
|
url = url.replace("{name}", quote(name))
|
|
else:
|
|
url = urljoin(user.get("jmap_base"), f"/download/{quote(account_id or '')}/{quote(blob or '')}/{quote(name)}")
|
|
async with jmap.client() as ac:
|
|
r = await ac.get(url, auth=tuple(user["auth"]))
|
|
r.raise_for_status()
|
|
return StreamingResponse(io.BytesIO(r.content), media_type=ctype)
|
|
|
|
|
|
@router.post("/upload")
|
|
async def upload_file(request: Request, file: UploadFile = File(...), user=Depends(require_user)):
|
|
async with jmap.client() as ac:
|
|
primary = user.get("primary", {})
|
|
account_id = user.get("active_account") or primary.get("urn:ietf:params:jmap:mail")
|
|
upload_url = user.get("upload_url")
|
|
if not upload_url or not account_id:
|
|
raise HTTPException(status_code=400, detail="Upload not available")
|
|
url = upload_url.replace("{accountId}", account_id)
|
|
data = await file.read()
|
|
r = await ac.post(url, content=data, headers={"Content-Type": file.content_type or "application/octet-stream"}, auth=tuple(user["auth"]))
|
|
r.raise_for_status()
|
|
up = r.json()
|
|
return JSONResponse({"blobId": up.get("blobId"), "type": file.content_type or "application/octet-stream", "name": file.filename, "size": len(data)})
|