Bunch of fixes for new database model.

This commit is contained in:
Michael Woods
2025-12-25 20:02:47 -05:00
parent bc8a649ff4
commit bec626678e
10 changed files with 246 additions and 233 deletions

View File

@@ -8,7 +8,7 @@ import time
from persistent.mapping import PersistentMapping from persistent.mapping import PersistentMapping
from persistent.list import PersistentList from persistent.list import PersistentList
from packetserver.common.util import is_valid_ax25_callsign from packetserver.common.util import is_valid_ax25_callsign
from .database import ConnectionDependency from .database import DbDependency
ph = PasswordHasher() ph = PasswordHasher()
@@ -52,43 +52,44 @@ class HttpUser(Persistent):
# rf enabled checks.. # rf enabled checks..
# #
def is_rf_enabled(self, conn: ConnectionDependency) -> bool: def is_rf_enabled(self, db: DbDependency) -> bool:
""" """
Check if RF gateway is enabled (i.e., callsign NOT in global blacklist). Check if RF gateway is enabled (i.e., callsign NOT in global blacklist).
Requires an open ZODB connection. Requires an open ZODB connection.
""" """
root = conn.root() with db.transaction() as conn:
blacklist = root.get('config', {}).get('blacklist', []) root = conn.root()
return self.username not in blacklist blacklist = root.get('config', {}).get('blacklist', [])
return self.username not in blacklist
def set_rf_enabled(self, conn: ConnectionDependency, allow: bool): def set_rf_enabled(self, db: DbDependency, allow: bool):
""" """
Enable/disable RF gateway by adding/removing from global blacklist. Enable/disable RF gateway by adding/removing from global blacklist.
Requires an open ZODB connection (inside a transaction). Requires an open ZODB connection (inside a transaction).
Only allows enabling if the username is a valid AX.25 callsign. Only allows enabling if the username is a valid AX.25 callsign.
""" """
from packetserver.common.util import is_valid_ax25_callsign # our validator from packetserver.common.util import is_valid_ax25_callsign # our validator
with db.transaction() as conn:
root = conn.root()
config = root.setdefault('config', PersistentMapping())
blacklist = config.setdefault('blacklist', PersistentList())
root = conn.root() upper_name = self.username
config = root.setdefault('config', PersistentMapping())
blacklist = config.setdefault('blacklist', PersistentList())
upper_name = self.username if allow:
if not is_valid_ax25_callsign(upper_name):
raise ValueError(f"{upper_name} is not a valid AX.25 callsign cannot enable RF access")
if upper_name in blacklist:
blacklist.remove(upper_name)
blacklist._p_changed = True
else:
if upper_name not in blacklist:
blacklist.append(upper_name)
blacklist._p_changed = True
if allow: config._p_changed = True
if not is_valid_ax25_callsign(upper_name): root._p_changed = True
raise ValueError(f"{upper_name} is not a valid AX.25 callsign cannot enable RF access") transaction.commit()
if upper_name in blacklist:
blacklist.remove(upper_name)
blacklist._p_changed = True
else:
if upper_name not in blacklist:
blacklist.append(upper_name)
blacklist._p_changed = True
config._p_changed = True
root._p_changed = True
transaction.commit()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Password handling (unchanged) # Password handling (unchanged)

View File

@@ -46,16 +46,16 @@ def get_db() -> ZODB.DB:
raise RuntimeError("Database not initialized call init_db() on startup") raise RuntimeError("Database not initialized call init_db() on startup")
return _db return _db
def get_connection() -> Generator[Connection, None, None]: #def get_connection() -> Generator[Connection, None, None]:
"""Per-request dependency: yields an open Connection, closes on exit.""" # """Per-request dependency: yields an open Connection, closes on exit."""
db = get_db() # db = get_db()
conn = db.open() # conn = db.open()
try: # try:
yield conn # yield conn
finally: # finally:
#print("not closing connection") # #print("not closing connection")
#conn.close() # #conn.close()
pass # pass
# Optional: per-request transaction (if you want automatic commit/abort) # Optional: per-request transaction (if you want automatic commit/abort)
def get_transaction_manager(): def get_transaction_manager():
@@ -63,4 +63,4 @@ def get_transaction_manager():
# Annotated dependencies for routers # Annotated dependencies for routers
DbDependency = Annotated[ZODB.DB, Depends(get_db)] DbDependency = Annotated[ZODB.DB, Depends(get_db)]
ConnectionDependency = Annotated[Connection, Depends(get_connection)] #ConnectionDependency = Annotated[Connection, Depends(get_connection)]

View File

@@ -3,49 +3,49 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .auth import HttpUser from .auth import HttpUser
from .database import ConnectionDependency from .database import DbDependency
security = HTTPBasic() security = HTTPBasic()
async def get_current_http_user(conn: ConnectionDependency, credentials: HTTPBasicCredentials = Depends(security)): async def get_current_http_user(db: DbDependency, credentials: HTTPBasicCredentials = Depends(security)):
""" """
Authenticate via Basic Auth using HttpUser from ZODB. Authenticate via Basic Auth using HttpUser from ZODB.
Injected by the standalone runner (get_db_connection available). Injected by the standalone runner (get_db_connection available).
""" """
with db.transaction() as conn:
root = conn.root()
root = conn.root() http_users = root.get("httpUsers")
if http_users is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Basic"},
)
http_users = root.get("httpUsers") user: HttpUser | None = http_users.get(credentials.username.upper())
if http_users is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Basic"},
)
user: HttpUser | None = http_users.get(credentials.username.upper()) if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Basic"},
)
if not user: if not user.http_enabled:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid username or password", detail="HTTP access disabled for this user",
headers={"WWW-Authenticate": "Basic"}, )
)
if not user.http_enabled: if not user.verify_password(credentials.password):
raise HTTPException( user.record_login_failure()
status_code=status.HTTP_403_FORBIDDEN, raise HTTPException(
detail="HTTP access disabled for this user", status_code=status.HTTP_401_UNAUTHORIZED,
) detail="Invalid username or password",
headers={"WWW-Authenticate": "Basic"},
)
if not user.verify_password(credentials.password): user.record_login_success()
user.record_login_failure() return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Basic"},
)
user.record_login_success()
return user

View File

@@ -7,7 +7,7 @@ import transaction
from persistent.list import PersistentList from persistent.list import PersistentList
from ZODB.Connection import Connection from ZODB.Connection import Connection
from packetserver.http.database import DbDependency, ConnectionDependency, get_db from packetserver.http.database import DbDependency
from ..dependencies import get_current_http_user from ..dependencies import get_current_http_user
from ..auth import HttpUser from ..auth import HttpUser
from ..server import templates from ..server import templates

View File

@@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
from packetserver.http.dependencies import get_current_http_user from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.http.server import templates from packetserver.http.server import templates
from packetserver.http.database import ConnectionDependency from packetserver.http.database import DbDependency
router = APIRouter(tags=["dashboard"]) router = APIRouter(tags=["dashboard"])
@@ -16,41 +16,43 @@ from .bulletins import list_bulletins
@router.get("/dashboard", response_class=HTMLResponse) @router.get("/dashboard", response_class=HTMLResponse)
async def dashboard( async def dashboard(
conn: ConnectionDependency, db: DbDependency,
request: Request, request: Request,
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
): ):
# Internal call pass explicit defaults to avoid Query object injection
messages_resp = await api_get_messages( messages_resp = await api_get_messages(
conn, db,
current_user=current_user, current_user=current_user,
type="all", type="all",
limit=100, limit=100,
since=None # prevents Query wrapper since=None # prevents Query wrapper
) )
messages = messages_resp["messages"] with db.transaction() as conn:
# Internal call pass explicit defaults to avoid Query object injection
bulletins_resp = await list_bulletins(conn, limit=10, since=None) messages = messages_resp["messages"]
recent_bulletins = bulletins_resp["bulletins"]
return templates.TemplateResponse( bulletins_resp = await list_bulletins(conn, limit=10, since=None)
"dashboard.html", recent_bulletins = bulletins_resp["bulletins"]
{
"request": request, return templates.TemplateResponse(
"current_user": current_user.username, "dashboard.html",
"messages": messages, {
"bulletins": recent_bulletins "request": request,
} "current_user": current_user.username,
) "messages": messages,
"bulletins": recent_bulletins
}
)
@router.get("/dashboard/profile", response_class=HTMLResponse) @router.get("/dashboard/profile", response_class=HTMLResponse)
async def profile_page( async def profile_page(
conn: ConnectionDependency, db: DbDependency,
request: Request, request: Request,
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
): ):
from packetserver.http.routers.profile import profile as api_profile from packetserver.http.routers.profile import profile as api_profile
profile_data = await api_profile(conn, current_user=current_user) profile_data = await api_profile(db, current_user=current_user)
return templates.TemplateResponse( return templates.TemplateResponse(
"profile.html", "profile.html",

View File

@@ -4,11 +4,13 @@ from fastapi.responses import HTMLResponse
from packetserver.http.dependencies import get_current_http_user from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.http.server import templates from packetserver.http.server import templates
from packetserver.http.database import DbDependency
router = APIRouter(tags=["message-detail"]) router = APIRouter(tags=["message-detail"])
@router.get("/dashboard/message/{msg_id}", response_class=HTMLResponse) @router.get("/dashboard/message/{msg_id}", response_class=HTMLResponse)
async def message_detail_page( async def message_detail_page(
db: DbDependency,
request: Request, request: Request,
msg_id: str = Path(..., description="Message UUID as string"), msg_id: str = Path(..., description="Message UUID as string"),
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
@@ -18,6 +20,7 @@ async def message_detail_page(
# Call with mark_retrieved=True to auto-mark as read on view (optional—remove if you prefer manual) # Call with mark_retrieved=True to auto-mark as read on view (optional—remove if you prefer manual)
message_data = await api_get_message( message_data = await api_get_message(
db,
msg_id=msg_id, msg_id=msg_id,
mark_retrieved=True, mark_retrieved=True,
current_user=current_user current_user=current_user

View File

@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, validator
from packetserver.http.dependencies import get_current_http_user from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.http.database import ConnectionDependency from packetserver.http.database import DbDependency
html_router = APIRouter(tags=["messages-html"]) html_router = APIRouter(tags=["messages-html"])
@@ -29,7 +29,7 @@ class MarkRetrievedRequest(BaseModel):
@router.get("/messages") @router.get("/messages")
async def get_messages( async def get_messages(
conn: ConnectionDependency, db: DbDependency,
current_user: HttpUser = Depends(get_current_http_user), current_user: HttpUser = Depends(get_current_http_user),
type: str = Query("received", description="received, sent, or all"), type: str = Query("received", description="received, sent, or all"),
limit: Optional[int] = Query(20, le=100, description="Max messages to return (default 20, max 100)"), limit: Optional[int] = Query(20, le=100, description="Max messages to return (default 20, max 100)"),
@@ -40,128 +40,131 @@ async def get_messages(
limit = 20 limit = 20
username = current_user.username username = current_user.username
root = conn.root() with db.transaction() as conn:
root = conn.root()
if 'messages' not in root: if 'messages' not in root:
root['messages'] = PersistentMapping() root['messages'] = PersistentMapping()
if username not in root['messages']: if username not in root['messages']:
root['messages'][username] = persistent.list.PersistentList() root['messages'][username] = persistent.list.PersistentList()
mailbox = root['messages'][username] mailbox = root['messages'][username]
since_dt = None since_dt = None
if since: if since:
try: try:
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid 'since' format") raise HTTPException(status_code=400, detail="Invalid 'since' format")
messages = [] messages = []
for msg in mailbox: for msg in mailbox:
if type == "received" and msg.msg_from == username: if type == "received" and msg.msg_from == username:
continue continue
if type == "sent" and msg.msg_from != username: if type == "sent" and msg.msg_from != username:
continue continue
if since_dt and msg.sent_at < since_dt: if since_dt and msg.sent_at < since_dt:
continue continue
messages.append({ messages.append({
"id": str(msg.msg_id), "id": str(msg.msg_id),
"from": msg.msg_from, "from": msg.msg_from,
"to": list(msg.msg_to) if isinstance(msg.msg_to, tuple) else [msg.msg_to], "to": list(msg.msg_to) if isinstance(msg.msg_to, tuple) else [msg.msg_to],
"sent_at": msg.sent_at.isoformat() + "Z", "sent_at": msg.sent_at.isoformat() + "Z",
"text": msg.text, "text": msg.text,
"has_attachments": len(msg.attachments) > 0, "has_attachments": len(msg.attachments) > 0,
"retrieved": msg.retrieved, "retrieved": msg.retrieved,
}) })
messages.sort(key=lambda m: m["sent_at"], reverse=True) messages.sort(key=lambda m: m["sent_at"], reverse=True)
return {"messages": messages[:limit], "total_returned": len(messages[:limit])} return {"messages": messages[:limit], "total_returned": len(messages[:limit])}
@router.get("/messages/{msg_id}") @router.get("/messages/{msg_id}")
async def get_message( async def get_message(
conn: ConnectionDependency, db: DbDependency,
msg_id: str = Path(..., description="UUID of the message (as string)"), msg_id: str = Path(..., description="UUID of the message (as string)"),
mark_retrieved: bool = Query(False, description="If true, mark message as retrieved/read"), mark_retrieved: bool = Query(False, description="If true, mark message as retrieved/read"),
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
): ):
root = conn.root() with db.transaction() as conn:
root = conn.root()
username = current_user.username username = current_user.username
messages_root = root.get('messages', {}) messages_root = root.get('messages', {})
mailbox = messages_root.get(username) mailbox = messages_root.get(username)
if not mailbox: if not mailbox:
raise HTTPException(status_code=404, detail="Mailbox not found") raise HTTPException(status_code=404, detail="Mailbox not found")
# Find message by ID # Find message by ID
target_msg = None target_msg = None
for msg in mailbox: for msg in mailbox:
if str(msg.msg_id) == msg_id: if str(msg.msg_id) == msg_id:
target_msg = msg target_msg = msg
break break
if not target_msg: if not target_msg:
raise HTTPException(status_code=404, detail="Message not found") raise HTTPException(status_code=404, detail="Message not found")
# Optionally mark as retrieved # Optionally mark as retrieved
if mark_retrieved and not target_msg.retrieved: if mark_retrieved and not target_msg.retrieved:
target_msg.retrieved = True target_msg.retrieved = True
target_msg._p_changed = True target_msg._p_changed = True
mailbox._p_changed = True mailbox._p_changed = True
# Explicit transaction for the write # Explicit transaction for the write
transaction.get().commit() transaction.get().commit()
return { return {
"id": str(target_msg.msg_id), "id": str(target_msg.msg_id),
"from": target_msg.msg_from or "UNKNOWN", "from": target_msg.msg_from or "UNKNOWN",
"to": list(target_msg.msg_to), "to": list(target_msg.msg_to),
"sent_at": target_msg.sent_at.isoformat() + "Z", "sent_at": target_msg.sent_at.isoformat() + "Z",
"text": target_msg.text, "text": target_msg.text,
"retrieved": target_msg.retrieved, "retrieved": target_msg.retrieved,
"has_attachments": len(target_msg.attachments) > 0, "has_attachments": len(target_msg.attachments) > 0,
# Future: "attachments": [...] metadata # Future: "attachments": [...] metadata
} }
@router.patch("/messages/{msg_id}") @router.patch("/messages/{msg_id}")
async def mark_message_retrieved( async def mark_message_retrieved(
conn: ConnectionDependency, db: DbDependency,
msg_id: str = Path(..., description="Message UUID as string"), msg_id: str = Path(..., description="Message UUID as string"),
payload: MarkRetrievedRequest = None, payload: MarkRetrievedRequest = None,
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
): ):
root = conn.root() with db.transaction() as conn:
root = conn.root()
username = current_user.username username = current_user.username
mailbox = root.get('messages', {}).get(username) mailbox = root.get('messages', {}).get(username)
if not mailbox: if not mailbox:
raise HTTPException(status_code=404, detail="Mailbox not found") raise HTTPException(status_code=404, detail="Mailbox not found")
target_msg = None target_msg = None
for msg in mailbox: for msg in mailbox:
if str(msg.msg_id) == msg_id: if str(msg.msg_id) == msg_id:
target_msg = msg target_msg = msg
break break
if not target_msg: if not target_msg:
raise HTTPException(status_code=404, detail="Message not found") raise HTTPException(status_code=404, detail="Message not found")
if target_msg.retrieved: if target_msg.retrieved:
# Already marked idempotent success # Already marked idempotent success
return {"status": "already_retrieved", "id": msg_id} return {"status": "already_retrieved", "id": msg_id}
target_msg.retrieved = True target_msg.retrieved = True
target_msg._p_changed = True target_msg._p_changed = True
mailbox._p_changed = True mailbox._p_changed = True
transaction.get().commit() transaction.get().commit()
return {"status": "marked_retrieved", "id": msg_id} return {"status": "marked_retrieved", "id": msg_id}
@html_router.get("/messages", response_class=HTMLResponse) @html_router.get("/messages", response_class=HTMLResponse)
async def message_list_page( async def message_list_page(
conn: ConnectionDependency, db: DbDependency,
request: Request, request: Request,
type: str = Query("received", alias="msg_type"), # matches your filter links type: str = Query("received", alias="msg_type"), # matches your filter links
limit: Optional[int] = Query(50, le=100), limit: Optional[int] = Query(50, le=100),
@@ -169,7 +172,7 @@ async def message_list_page(
): ):
from packetserver.http.server import templates from packetserver.http.server import templates
# Directly call the existing API endpoint function # Directly call the existing API endpoint function
api_resp = await get_messages(conn, current_user=current_user, type=type, limit=limit, since=None) api_resp = await get_messages(db, current_user=current_user, type=type, limit=limit, since=None)
messages = api_resp["messages"] messages = api_resp["messages"]
return templates.TemplateResponse( return templates.TemplateResponse(

View File

@@ -6,7 +6,7 @@ import mimetypes
from packetserver.http.dependencies import get_current_http_user from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.http.database import DbDependency, ConnectionDependency from packetserver.http.database import DbDependency
from packetserver.server.objects import Object from packetserver.server.objects import Object
from pydantic import BaseModel from pydantic import BaseModel

View File

@@ -3,26 +3,28 @@ from fastapi import APIRouter, Depends
from packetserver.http.dependencies import get_current_http_user from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.http.database import ConnectionDependency from packetserver.http.database import DbDependency
router = APIRouter(prefix="/api/v1", tags=["auth"]) router = APIRouter(prefix="/api/v1", tags=["auth"])
@router.get("/profile") @router.get("/profile")
async def profile(conn: ConnectionDependency,current_user: HttpUser = Depends(get_current_http_user)): async def profile(db: DbDependency, current_user: HttpUser = Depends(get_current_http_user)):
username = current_user.username username = current_user.username
root = conn.root() rf_enabled = current_user.is_rf_enabled(db)
# Get main BBS User and safe dict # Get main BBS User and safe dict
main_users = root.get('users', {}) with db.transaction() as conn:
bbs_user = main_users.get(username) root = conn.root()
safe_profile = bbs_user.to_safe_dict() if bbs_user else {} main_users = root.get('users', {})
rf_enabled = current_user.is_rf_enabled(conn) bbs_user = main_users.get(username)
safe_profile = bbs_user.to_safe_dict() if bbs_user else {}
return {
**safe_profile, return {
"http_enabled": current_user.http_enabled, **safe_profile,
"rf_enabled": rf_enabled, "http_enabled": current_user.http_enabled,
"http_created_at": current_user.created_at, "rf_enabled": rf_enabled,
"http_last_login": current_user.last_login, "http_created_at": current_user.created_at,
} "http_last_login": current_user.last_login,
}

View File

@@ -11,7 +11,7 @@ from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.server.messages import Message from packetserver.server.messages import Message
from packetserver.common.util import is_valid_ax25_callsign from packetserver.common.util import is_valid_ax25_callsign
from packetserver.http.database import ConnectionDependency from packetserver.http.database import DbDependency
router = APIRouter(prefix="/api/v1", tags=["messages"]) router = APIRouter(prefix="/api/v1", tags=["messages"])
@@ -40,55 +40,57 @@ class SendMessageRequest(BaseModel):
@router.post("/messages") @router.post("/messages")
async def send_message( async def send_message(
conn: ConnectionDependency, db: DbDependency,
payload: SendMessageRequest, payload: SendMessageRequest,
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
): ):
root = conn.root() is_rf_enabled = current_user.is_rf_enabled(db)
with db.transaction() as conn:
root = conn.root()
if not current_user.is_rf_enabled(conn): if not is_rf_enabled:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="RF gateway access required to send messages" detail="RF gateway access required to send messages"
)
username = current_user.username
# Prepare recipients
to_list = payload.to
to_tuple = tuple(to_list)
if "ALL" in to_list:
to_tuple = ("ALL",)
is_bulletin = "ALL" in to_list
recipients = to_list if not is_bulletin else list(root.get('users', {}).keys())
# Create message using only supported core params
new_msg = Message(
text=payload.text,
msg_from=username,
msg_to=to_tuple,
attachments=()
) )
username = current_user.username # Deliver to recipients + always sender (sent folder)
messages_root = root.setdefault('messages', PersistentMapping())
delivered_to = set()
# Prepare recipients for recip in set(recipients) | {username}:
to_list = payload.to mailbox = messages_root.setdefault(recip, PersistentList())
to_tuple = tuple(to_list) mailbox.append(new_msg)
if "ALL" in to_list: mailbox._p_changed = True
to_tuple = ("ALL",) delivered_to.add(recip)
is_bulletin = "ALL" in to_list messages_root._p_changed = True
recipients = to_list if not is_bulletin else list(root.get('users', {}).keys()) transaction.commit()
# Create message using only supported core params return {
new_msg = Message( "status": "sent",
text=payload.text, "message_id": str(new_msg.msg_id),
msg_from=username, "from": username,
msg_to=to_tuple, "to": list(to_tuple),
attachments=() "sent_at": new_msg.sent_at.isoformat() + "Z",
) "recipients_delivered": len(delivered_to)
}
# Deliver to recipients + always sender (sent folder)
messages_root = root.setdefault('messages', PersistentMapping())
delivered_to = set()
for recip in set(recipients) | {username}:
mailbox = messages_root.setdefault(recip, PersistentList())
mailbox.append(new_msg)
mailbox._p_changed = True
delivered_to.add(recip)
messages_root._p_changed = True
transaction.commit()
return {
"status": "sent",
"message_id": str(new_msg.msg_id),
"from": username,
"to": list(to_tuple),
"sent_at": new_msg.sent_at.isoformat() + "Z",
"recipients_delivered": len(delivered_to)
}