Initial upload

This commit is contained in:
LINUXexpert.org
2025-10-18 23:11:15 -07:00
committed by GitHub
parent 5d778e00f6
commit fe17e1d507
36 changed files with 1111 additions and 670 deletions
View File
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
View File
+45
View File
@@ -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)
+39
View File
@@ -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})
+35
View File
@@ -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})
+316
View File
@@ -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)})
+21
View File
@@ -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})
+30
View File
@@ -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

+37
View File
@@ -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>
+15
View File
@@ -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 %}
+11
View File
@@ -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 %}
+19
View File
@@ -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 %}
+24
View File
@@ -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 %}
+26
View File
@@ -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 %}
+25
View File
@@ -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 %}
+15
View File
@@ -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 %}
+19
View File
@@ -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 ""