Initial upload
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import os, secrets
|
||||
|
||||
APP_SECRET = os.getenv("APP_SECRET") or secrets.token_urlsafe(32)
|
||||
COOKIE_NAME = os.getenv("COOKIE_NAME", "stalwart_webmail")
|
||||
JMAP_BASE = os.getenv("JMAP_BASE", "https://mail.example.com/jmap")
|
||||
CALDAV_BASE = os.getenv("CALDAV_BASE", "https://mail.example.com/caldav/")
|
||||
WEBDAV_BASE = os.getenv("WEBDAV_BASE", "https://mail.example.com/webdav/")
|
||||
TRUST_PROXY = os.getenv("TRUST_PROXY", "1") == "1"
|
||||
UPSTREAM_TIMEOUT = float(os.getenv("UPSTREAM_TIMEOUT", "15"))
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
import httpx
|
||||
from urllib.parse import urljoin
|
||||
from . import config
|
||||
|
||||
DAV_PROPFIND = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
async def propfind(ac: httpx.AsyncClient, base: str, path: Optional[str], auth: Tuple[str,str]) -> List[Dict[str, Any]]:
|
||||
href = urljoin(base, path or "/")
|
||||
r = await ac.request("PROPFIND", href, content=DAV_PROPFIND, headers={"Depth": "1"}, auth=auth)
|
||||
if r.status_code not in (207, 200):
|
||||
raise RuntimeError(f"WebDAV error {r.status_code}")
|
||||
import xml.etree.ElementTree as ET
|
||||
tree = ET.fromstring(r.text)
|
||||
ns = {"d":"DAV:"}
|
||||
items: List[Dict[str, Any]] = []
|
||||
for resp in tree.findall("d:response", ns):
|
||||
href_el = resp.find("d:href", ns)
|
||||
prop = resp.find("d:propstat/d:prop", ns)
|
||||
if href_el is None or prop is None:
|
||||
continue
|
||||
name = prop.find("d:displayname", ns)
|
||||
cl = prop.find("d:getcontentlength", ns)
|
||||
rtype = prop.find("d:resourcetype", ns)
|
||||
is_collection = rtype is not None and rtype.find("d:collection", ns) is not None
|
||||
items.append({
|
||||
"href": href_el.text,
|
||||
"name": (name.text if name is not None and name.text else href_el.text.rstrip("/").split("/")[-1] or "/"),
|
||||
"type": "directory" if is_collection else "file",
|
||||
"size": int(cl.text) if (cl is not None and cl.text and cl.text.isdigit()) else None
|
||||
})
|
||||
if items:
|
||||
items = items[1:]
|
||||
return items
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
from typing import Any, Dict, List, Tuple
|
||||
import httpx
|
||||
from . import config
|
||||
|
||||
def client() -> httpx.AsyncClient:
|
||||
limits = httpx.Limits(max_connections=20, max_keepalive_connections=10)
|
||||
return httpx.AsyncClient(timeout=config.UPSTREAM_TIMEOUT, limits=limits, trust_env=True)
|
||||
|
||||
async def get_session(ac: httpx.AsyncClient, base: str, username: str, password: str) -> Dict[str, Any]:
|
||||
r = await ac.get(base, auth=(username, password))
|
||||
if r.status_code == 401:
|
||||
raise PermissionError("Invalid credentials")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def call(ac: httpx.AsyncClient, api_url: str, auth: Tuple[str,str] | None, method_calls: List[list]) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"using": [
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:contacts",
|
||||
"urn:ietf:params:jmap:calendars"
|
||||
],
|
||||
"methodCalls": method_calls
|
||||
}
|
||||
kwargs: Dict[str, Any] = {"json": payload}
|
||||
if auth:
|
||||
kwargs["auth"] = auth
|
||||
r = await ac.post(api_url, **kwargs)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import bleach
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
from . import config
|
||||
from .routes import auth, mail, contacts, calendar, webdav, sieve
|
||||
|
||||
app = FastAPI(title="Stalwart Webmail (Python)")
|
||||
|
||||
if config.TRUST_PROXY:
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
|
||||
app.add_middleware(SessionMiddleware, secret_key=config.APP_SECRET, session_cookie=config.COOKIE_NAME, same_site="lax", https_only=True)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root(request: Request):
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/mail" if request.session.get("user") else "/login")
|
||||
|
||||
# Routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(mail.router)
|
||||
app.include_router(contacts.router)
|
||||
app.include_router(calendar.router)
|
||||
app.include_router(webdav.router)
|
||||
app.include_router(sieve.router)
|
||||
|
||||
|
||||
@app.get("/healthz", include_in_schema=False)
|
||||
async def healthz():
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, Request, Form, HTTPException
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import PlainTextResponse
|
||||
from .. import config, jmap
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from jinja2 import FileSystemLoader, Environment, select_autoescape
|
||||
import pathlib, base64, os
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
def make_csrf(session: dict) -> str:
|
||||
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("/login", response_class=HTMLResponse)
|
||||
async def login_form(request: Request):
|
||||
csrf = make_csrf(request.session)
|
||||
return templates.TemplateResponse("login.html", {"request": request, "csrf": csrf, "jmap_base": config.JMAP_BASE})
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(request: Request, username: str = Form(...), password: str = Form(...), jmap_base: str = Form(...), csrf: str = Form(...)):
|
||||
check_csrf(request.session, csrf)
|
||||
async with jmap.client() as ac:
|
||||
try:
|
||||
session = await jmap.get_session(ac, jmap_base, username, password)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
api_url = session.get("apiUrl") or jmap_base
|
||||
download_url = session.get("downloadUrl") or ""
|
||||
primary = session.get("primaryAccounts") or {}
|
||||
request.session["user"] = {"username": username, "jmap_base": jmap_base, "api_url": api_url, "auth": (username, password), "download_url": download_url, "primary": primary, "session": session}
|
||||
return RedirectResponse("/mail", status_code=303)
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
@@ -0,0 +1,39 @@
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import pathlib, datetime
|
||||
from .. import jmap
|
||||
from ..utils import 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
|
||||
|
||||
@router.get("/calendar", response_class=HTMLResponse)
|
||||
async def calendar(request: Request, user=Depends(require_user)):
|
||||
async with jmap.client() as ac:
|
||||
api = user["api_url"]
|
||||
account_id = None
|
||||
now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
|
||||
until = now + datetime.timedelta(days=30)
|
||||
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
||||
["CalendarEvent/query", {"accountId": account_id, "limit": 200, "sort":[{"property":"start","isAscending": True}]}, "q1"],
|
||||
["CalendarEvent/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"CalendarEvent/query","path":"ids"}, "properties":["id","title","start","end","location"]}, "g1"]
|
||||
])
|
||||
events = []
|
||||
for name, data, _ in res.get("methodResponses", []):
|
||||
if name == "CalendarEvent/get":
|
||||
for e in data.get("list", []):
|
||||
try:
|
||||
s = datetime.datetime.fromisoformat((e.get("start") or "").replace("Z","+00:00"))
|
||||
if s < now - datetime.timedelta(days=1) or s > until:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
events.append({"title": e.get("title") or "(no title)", "start": fmt_when(e.get("start")), "end": fmt_when(e.get("end")), "loc": e.get("location")})
|
||||
return templates.TemplateResponse("calendar.html", {"request": request, "events": events, "user": user})
|
||||
@@ -0,0 +1,35 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import pathlib
|
||||
from .. import jmap
|
||||
|
||||
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
|
||||
|
||||
@router.get("/contacts", response_class=HTMLResponse)
|
||||
async def contacts(request: Request, q: Optional[str] = None, user=Depends(require_user)):
|
||||
async with jmap.client() as ac:
|
||||
api = user["api_url"]
|
||||
account_id = None
|
||||
filter_cond = {"text": q} if q else {}
|
||||
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
||||
["Contact/query", {"accountId": account_id, "filter": filter_cond, "limit": 100}, "c1"],
|
||||
["Contact/get", {"accountId": account_id, "#ids": {"resultOf":"c1","name":"Contact/query","path":"ids"}, "properties":["id","firstName","lastName","emails","company"]}, "c2"]
|
||||
])
|
||||
contacts = []
|
||||
for name, data, _ in res.get("methodResponses", []):
|
||||
if name == "Contact/get":
|
||||
for c in data.get("list", []):
|
||||
emails = [e.get("email","") for e in (c.get("emails") or [])]
|
||||
contacts.append({"name": f"{c.get('firstName','')} {c.get('lastName','')}".strip() or (emails[0] if emails else ""),
|
||||
"email": ", ".join(emails),
|
||||
"org": c.get("company")})
|
||||
return templates.TemplateResponse("contacts.html", {"request": request, "contacts": contacts, "q": q, "user": user})
|
||||
@@ -0,0 +1,316 @@
|
||||
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)})
|
||||
@@ -0,0 +1,21 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import pathlib
|
||||
from .. import dav, jmap, config
|
||||
|
||||
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
|
||||
|
||||
@router.get("/webdav", response_class=HTMLResponse)
|
||||
async def webdav_browse(request: Request, path: Optional[str]=None, user=Depends(require_user)):
|
||||
async with jmap.client() as ac:
|
||||
items = await dav.propfind(ac, config.WEBDAV_BASE, path, tuple(user["auth"]))
|
||||
return templates.TemplateResponse("webdav.html", {"request": request, "items": items, "base": config.WEBDAV_BASE, "user": user})
|
||||
@@ -0,0 +1,30 @@
|
||||
:root { color-scheme: light dark; --header-bg: #f6f7f9; --header-fg: #111; --card-bg: #fff; }
|
||||
@media (prefers-color-scheme: dark) { :root { --header-bg: #0f172a; --header-fg: #e5e7eb; --card-bg: #0b1222; } }
|
||||
body { margin:0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
header, footer { padding: 10px 14px; border-bottom: 1px solid #4443; background: var(--header-bg); color: var(--header-fg); }
|
||||
main { padding: 14px; max-width: 1100px; margin: 0 auto; }
|
||||
nav a { margin-right: 12px; }
|
||||
.btn { display:inline-block; padding:6px 10px; border:1px solid #6665; border-radius:8px; text-decoration:none; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { padding: 8px; border-bottom: 1px solid #6662; text-align: left; vertical-align: top; }
|
||||
.muted { color: #888; }
|
||||
input, textarea, select { padding:6px 8px; width:100%; box-sizing: border-box; }
|
||||
form .row { display:grid; grid-template-columns: 160px 1fr; gap: 8px; align-items: center; margin-bottom:10px; }
|
||||
.msg { cursor:pointer; }
|
||||
.pill { display:inline-block; font-size:12px; padding:2px 6px; border:1px solid #6663; border-radius:999px; margin-right:6px;}
|
||||
.nowrap { white-space: nowrap; }
|
||||
.right { text-align:right; }
|
||||
.toolbar { display:flex; gap:8px; align-items:center; margin:8px 0; }
|
||||
.panel { border:1px solid #6663;padding:10px;border-radius:8px;margin:10px 0;white-space:pre-wrap }
|
||||
|
||||
#dropzone{padding:16px;border:2px dashed #6665;border-radius:8px;text-align:center;margin:10px 0}
|
||||
|
||||
.brand { display:flex; align-items:center; gap:10px; }
|
||||
.brand .logo { height:28px; vertical-align:middle; }
|
||||
.brand-link { text-decoration:none; color:inherit; }
|
||||
header nav { margin-top:6px; }
|
||||
.badge { display:inline-block; padding:0 6px; border-radius:10px; font-size:12px; background:#6662; margin-left:6px; }
|
||||
|
||||
.card{background:var(--card-bg); border:1px solid #6663; border-radius:12px; padding:18px; box-shadow:0 2px 6px #0001;}
|
||||
.center{display:grid; place-items:center; min-height:60vh;}
|
||||
.logo-lg{height:64px;}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title or "ihasmail" }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' https://unpkg.com;">
|
||||
<link rel="icon" href="/static/img/logo.png">
|
||||
<link rel="preconnect" href="https://unpkg.com">
|
||||
<script defer src="https://unpkg.com/[email protected]"></script>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<a href="/" class="brand-link"><img src="/static/img/logo.png" alt="ihasmail" class="logo"> <strong>ihasmail</strong></a>
|
||||
</div>
|
||||
<nav>
|
||||
{% if user %}
|
||||
<span class="muted">Signed in as {{ user.get("username") }}</span>
|
||||
<a class="btn" href="/mail">Inbox</a>
|
||||
<a class="btn" href="/compose">Compose</a>
|
||||
<a class="btn" href="/calendar">Calendar</a>
|
||||
<a class="btn" href="/contacts">Contacts</a>
|
||||
<a class="btn" href="/webdav">WebDAV</a>
|
||||
<a class="btn" href="/logout">Logout</a>
|
||||
{% else %}
|
||||
<a class="btn" href="/login">Login</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="muted">ihasmail • JMAP • Sieve • DAV • FastAPI • reverse-proxy ready</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Calendar (JMAP & CalDAV)</h1>
|
||||
<p class="muted">Listing upcoming events via JMAP. CalDAV endpoints available for DAV clients.</p>
|
||||
<table>
|
||||
<tr><th>When</th><th>Summary</th><th>Where</th></tr>
|
||||
{% for e in events %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ e.start }} – {{ e.end }}</td>
|
||||
<td>{{ e.title }}</td>
|
||||
<td>{{ e.loc or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Compose</h1>
|
||||
<form method="post" action="/compose">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="row"><label>To</label><input name="to" required></div>
|
||||
<div class="row"><label>Subject</label><input name="subject"></div>
|
||||
<div class="row"><label>Body</label><textarea name="body" rows="14"></textarea></div>
|
||||
<button class="btn">Send</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Contacts (Directory via JMAP)</h1>
|
||||
<div class="toolbar">
|
||||
<form>
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search name/email…">
|
||||
</form>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Email</th><th>Org</th></tr>
|
||||
{% for c in contacts %}
|
||||
<tr>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.email }}</td>
|
||||
<td>{{ c.org or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="center"><div class="card" style="min-width:320px; max-width:420px;">
|
||||
<div style="text-align:center;margin-bottom:8px"><img class="logo-lg" src="/static/img/logo.png" alt="ihasmail"></div>
|
||||
<h2 style="text-align:center;margin-top:0">Sign in</h2>
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="row">
|
||||
<label>Username</label>
|
||||
<input name="username" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>JMAP Base</label>
|
||||
<input name="jmap_base" value="{{ jmap_base }}">
|
||||
</div>
|
||||
<button class="btn" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div></div>
|
||||
<p class="muted">Credentials are sent to your JMAP server to obtain a session/auth token; they are not stored on the server.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Inbox</h1>
|
||||
<div class="toolbar">
|
||||
<form method="get" action="/mail">
|
||||
<select name="mailbox" onchange="this.form.submit()">
|
||||
{% for b in mailboxes %}
|
||||
<option value="{{ b.id }}" {% if b.id == selected %}selected{% endif %}>{{ b.name }}{% if b.unread %} ({{ b.unread }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input name="q" placeholder="Search (from, subject, text…)" value="{{ q or '' }}">
|
||||
</form>
|
||||
<a class="btn" href="/compose">Compose</a>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th class="nowrap">When</th><th>From</th><th>Subject</th><th class="right">Size</th></tr>
|
||||
{% for m in messages %}
|
||||
<tr class="msg" onclick="location.href='/mail/{{ m.id }}'">
|
||||
<td class="nowrap">{{ m.when }}</td>
|
||||
<td>{{ m.from }}</td>
|
||||
<td>{{ m.subject }}</td>
|
||||
<td class="right">{{ m.size }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ msg.subject or "(no subject)" }}</h1>
|
||||
<p><span class="pill">From</span> {{ msg.from }} <span class="pill">To</span> {{ msg.to|join(", ") }}</p>
|
||||
<p class="muted">{{ msg.when }}</p>
|
||||
{% if msg.htmlBody %}
|
||||
<div class="panel">{{ (msg.htmlBody | safe) }}</div>
|
||||
{% elif msg.textBody %}
|
||||
<div class="panel">{{ msg.textBody }}</div>
|
||||
{% else %}
|
||||
<div class="panel muted">(no body)</div>
|
||||
{% endif %}
|
||||
<div class="toolbar">
|
||||
<a class="btn" href="/compose?reply={{ msg.id }}">Reply</a>
|
||||
<a class="btn" href="/compose?forward={{ msg.id }}">Forward</a>
|
||||
</div>
|
||||
{% if msg.attachments %}
|
||||
<h3>Attachments</h3>
|
||||
<ul>
|
||||
{% for a in msg.attachments %}
|
||||
<li>{{ a.name }} ({{ a.type }}, {{ a.size }} bytes)</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>WebDAV</h1>
|
||||
<p class="muted">Browsing {{ base }}</p>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Type</th><th class="right">Size</th></tr>
|
||||
{% for i in items %}
|
||||
<tr>
|
||||
<td>{{ i.name }}</td>
|
||||
<td>{{ i.type }}</td>
|
||||
<td class="right">{% if i.size is not none %}{{ i.size }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
import datetime
|
||||
|
||||
def human_size(n: int | None) -> str:
|
||||
if n is None: return ""
|
||||
units = ["B","KB","MB","GB","TB","PB"]
|
||||
i = 0
|
||||
x = float(n)
|
||||
while x >= 1024 and i < len(units)-1:
|
||||
x /= 1024.0
|
||||
i += 1
|
||||
return f"{x:.0f} {units[i]}"
|
||||
|
||||
def fmt_when(iso: str | None) -> str:
|
||||
if not iso: return ""
|
||||
try:
|
||||
dt = datetime.datetime.fromisoformat(iso.replace("Z","+00:00")).astimezone()
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
return iso or ""
|
||||
Reference in New Issue
Block a user