Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,11 @@
server-authoritative source selection and provenance. Do not wire browser
actions back to legacy `/api/calendar/sync` unless a trusted backend credential
dependency and source-owner contract are explicitly in scope.
- Calendar writeback source selection must resolve through opaque
`calendar_writeback_sources.source_uid` registry rows, not sequential CalDAV
or WebDAV account ids. Browser-visible source ids must not reveal account
primary keys, and provider mutations remain future work until connector
execution can enforce capability, consent, and ETag/If-Match checks.
- Calendar and WebDAV writeback source selection must resolve through opaque
`source_uid` values, not sequential CalDAV or WebDAV account ids.
Browser-visible source ids must not reveal account primary keys, and provider
mutations remain future work until connector execution can enforce capability,
consent, and ETag/If-Match checks.
- Self-sent knowledge extraction must first prove true self-to-self addressing,
stay idempotent per source email, preserve email/thread provenance, and store
only plain-text task titles. Do not create unlinked knowledge tasks from raw
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,9 @@ provenance. Calendar source selection now reads opaque
CalDAV account ids. The browser no longer claims `/api/calendar/sync` success
from the mail-detail action path; direct provider writes stay deferred until
connector execution can enforce ETag/If-Match and owner capability checks.
WebDAV writeback and self-sent knowledge materialization use
`webdav_accounts.source_uid` as the browser-visible source id and reject legacy
`target_account_id` payloads.

## Operations and release docs

Expand Down
27 changes: 18 additions & 9 deletions backend/api/webdav.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List

Expand All @@ -17,7 +17,7 @@
}

class WebdavAccountResponse(BaseModel):
account_id: int
source_id: str
server_url: str
username: str

Expand All @@ -27,20 +27,24 @@ class ProjectFolderResponse(BaseModel):
webdav_path: str

class WritebackIntentRequest(BaseModel):
target_account_id: int | None = None
model_config = ConfigDict(extra="forbid")

target_source_id: str | None = None

class WritebackIntentResponse(BaseModel):
intent: str
source_id: int | None
source_id: str | None
server_url: str | None
requires_if_match: bool
provenance: str
status: str | None = None
message: str | None = None

class KnowledgeMaterializationIntentRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

source_task_id: str
target_account_id: int | None = None
target_source_id: str | None = None

class KnowledgeMaterializationIntentResponse(BaseModel):
intent: str
Expand All @@ -49,7 +53,7 @@ class KnowledgeMaterializationIntentResponse(BaseModel):
source_type: str
source_email_id: str | None
source_thread_id: str | None
source_id: int | None
source_id: str | None
server_url: str | None
target_path: str
requires_if_match: bool
Expand All @@ -63,7 +67,11 @@ async def get_webdav_accounts(
db: AsyncSession = Depends(get_db),
):
user_id = auth_context.user_id
return await webdav_service.get_connected_accounts_from_db(db, user_id)
return await webdav_service.get_connected_accounts_from_db(
db,
user_id,
auth_context.organization_id,
)

@router.get("/folders", response_model=List[ProjectFolderResponse])
async def get_project_folders(
Expand All @@ -83,7 +91,8 @@ async def get_webdav_writeback_intent(
result = await webdav_service.determine_webdav_writeback_intent_from_db(
db,
user_id,
target_account_id=req.target_account_id,
auth_context.organization_id,
target_source_id=req.target_source_id,
)
if result.get("status") == "error":
raise HTTPException(status_code=422, detail=result.get("message"))
Expand All @@ -103,7 +112,7 @@ async def get_knowledge_materialization_intent(
auth_context.user_id,
auth_context.organization_id,
req.source_task_id,
target_account_id=req.target_account_id,
target_source_id=req.target_source_id,
)
if result.get("status") == "error":
status_code = WEB_DAV_ERROR_STATUS_CODES.get(
Expand Down
7 changes: 7 additions & 0 deletions backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,13 @@ class WebdavAccount(Base):
__tablename__ = "webdav_accounts"

id: Mapped[int] = mapped_column("account_id", primary_key=True)
source_uid: Mapped[str] = mapped_column(
String,
unique=True,
index=True,
default=lambda: f"webdav_src_{uuid.uuid4().hex}",
nullable=False,
)
user_id: Mapped[str] = mapped_column(String, index=True, nullable=False)
server_url: Mapped[str] = mapped_column(String, nullable=False)
username: Mapped[str] = mapped_column(String, nullable=False)
Expand Down
13 changes: 13 additions & 0 deletions backend/scripts/bootstrap_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def schema_backfill_sql():
"created_at timestamptz DEFAULT CURRENT_TIMESTAMP"
")"
),
text("ALTER TABLE webdav_accounts ADD COLUMN IF NOT EXISTS source_uid varchar"),
text("ALTER TABLE tenant_configs ADD COLUMN IF NOT EXISTS pop3_username varchar"),
text("ALTER TABLE tenant_configs ADD COLUMN IF NOT EXISTS pop3_password varchar"),
text("ALTER TABLE emails ADD COLUMN IF NOT EXISTS in_reply_to varchar"),
Expand Down Expand Up @@ -81,6 +82,18 @@ def schema_backfill_sql():
"ON calendar_writeback_sources "
"(user_id, organization_id, source_protocol)"
),
text(
"UPDATE webdav_accounts "
"SET source_uid = 'webdav_src_' || md5("
"account_id::text || ':' || user_id || ':' || server_url"
") "
"WHERE source_uid IS NULL OR source_uid = ''"
),
text("ALTER TABLE webdav_accounts ALTER COLUMN source_uid SET NOT NULL"),
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_webdav_accounts_source_uid "
"ON webdav_accounts (source_uid)"
),
text("ALTER TABLE emails DROP CONSTRAINT IF EXISTS emails_message_id_key"),
text(
"ALTER TABLE sender_relationships "
Expand Down
59 changes: 38 additions & 21 deletions backend/services/webdav_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ def __init__(self):
self._mock_accounts = {
"demo_user": [
{
"account_id": 1,
"source_id": "webdav_src_demo_primary",
"server_url": "https://webdav.naruon.net",
"username": "demo_user"
"username": "demo_user",
"writeback_enabled": True,
}
]
}
Expand Down Expand Up @@ -67,16 +68,23 @@ async def get_connected_accounts_from_db(
self,
session: AsyncSession,
user_id: str,
organization_id: str | None = None,
) -> List[Dict[str, Any]]:
stmt = select(WebdavAccount).where(WebdavAccount.user_id == user_id)
del organization_id
stmt = select(
WebdavAccount.source_uid,
WebdavAccount.server_url,
WebdavAccount.username,
).where(WebdavAccount.user_id == user_id)
result = await session.execute(stmt)
return [
{
"account_id": account.id,
"server_url": account.server_url,
"username": account.username,
"source_id": source_uid,
"server_url": server_url,
"username": username,
"writeback_enabled": True,
}
for account in result.scalars().all()
for source_uid, server_url, username in result.all()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
]

async def get_project_folders_from_db(
Expand All @@ -103,26 +111,31 @@ def sync_attachments_to_folder(self, email_id: str, project_name: str) -> bool:
# Mock implementation: in reality, this would download from storage and upload via webdavclient3
return True

def determine_webdav_writeback_intent(self, user_id: str, target_account_id: int | None = None) -> Dict[str, Any]:
def determine_webdav_writeback_intent(
self, user_id: str, target_source_id: str | None = None
) -> Dict[str, Any]:
"""
Server-authoritative WebDAV writeback source selection.
"""
accounts = self.get_connected_accounts(user_id)
return self.determine_webdav_writeback_intent_from_accounts(
accounts,
target_account_id=target_account_id,
target_source_id=target_source_id,
)

async def determine_webdav_writeback_intent_from_db(
self,
session: AsyncSession,
user_id: str,
target_account_id: int | None = None,
organization_id: str | None = None,
target_source_id: str | None = None,
) -> Dict[str, Any]:
accounts = await self.get_connected_accounts_from_db(session, user_id)
accounts = await self.get_connected_accounts_from_db(
session, user_id, organization_id
)
return self.determine_webdav_writeback_intent_from_accounts(
accounts,
target_account_id=target_account_id,
target_source_id=target_source_id,
)

async def determine_knowledge_materialization_intent_from_db(
Expand All @@ -131,7 +144,7 @@ async def determine_knowledge_materialization_intent_from_db(
user_id: str,
organization_id: str | None,
source_task_id: str,
target_account_id: int | None = None,
target_source_id: str | None = None,
) -> Dict[str, Any]:
task_result = await session.execute(
select(TicketTask, Email.message_id)
Expand Down Expand Up @@ -172,7 +185,8 @@ async def determine_knowledge_materialization_intent_from_db(
result = await self.determine_webdav_writeback_intent_from_db(
session,
user_id,
target_account_id=target_account_id,
organization_id,
target_source_id=target_source_id,
)
if result.get("status") == "error":
return result
Expand All @@ -196,20 +210,23 @@ async def determine_knowledge_materialization_intent_from_db(
def determine_webdav_writeback_intent_from_accounts(
self,
accounts: List[Dict[str, Any]],
target_account_id: int | None = None,
target_source_id: str | None = None,
) -> Dict[str, Any]:
if not accounts:
writable_accounts = [
account for account in accounts if account.get("writeback_enabled", True)
]
if not writable_accounts:
return {
"status": "error",
"error_code": "no_webdav_account",
"message": "No connected WebDAV accounts found.",
}

selected_account = accounts[0]
if target_account_id is not None:
selected_account = writable_accounts[0]
if target_source_id is not None:
selected_account = None
for acc in accounts:
if acc["account_id"] == target_account_id:
for acc in writable_accounts:
if acc["source_id"] == target_source_id:
selected_account = acc
break
if selected_account is None:
Expand All @@ -221,7 +238,7 @@ def determine_webdav_writeback_intent_from_accounts(

return {
"intent": "writeback",
"source_id": selected_account["account_id"],
"source_id": selected_account["source_id"],
"server_url": selected_account["server_url"],
"requires_if_match": True,
"provenance": "server-authoritative"
Expand Down
31 changes: 30 additions & 1 deletion backend/tests/test_bootstrap_db.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from scripts.bootstrap_db import schema_backfill_sql
from db.models import CalendarWritebackSource, SenderRelationship
from db.models import CalendarWritebackSource, SenderRelationship, WebdavAccount


def test_schema_backfill_adds_threading_columns_for_existing_tables(monkeypatch):
Expand Down Expand Up @@ -117,6 +117,27 @@ def test_schema_backfill_adds_threading_columns_for_existing_tables(monkeypatch)
in statement
for statement in statements
)
assert any(
"alter table webdav_accounts add column if not exists source_uid"
in statement
for statement in statements
)
assert any(
"update webdav_accounts set source_uid" in statement
and "webdav_src_" in statement
and "md5" in statement
for statement in statements
)
assert any(
"alter table webdav_accounts alter column source_uid set not null"
in statement
for statement in statements
)
assert any(
"create unique index if not exists uq_webdav_accounts_source_uid"
in statement
for statement in statements
)
assert any(
"create index if not exists ix_llm_providers_organization_id" in statement
for statement in statements
Expand Down Expand Up @@ -232,3 +253,11 @@ def test_calendar_writeback_source_model_uses_two_word_names():
"created_at",
}
assert all("_" in column_name for column_name in column_names)


def test_webdav_account_model_exposes_opaque_source_uid():
column_names = {column.name for column in WebdavAccount.__table__.columns}

assert "source_uid" in column_names
assert WebdavAccount.__table__.c.source_uid.nullable is False
assert WebdavAccount.__table__.c.source_uid.unique is True
Loading
Loading