Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +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.
- 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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,11 @@ public task ids instead of exposing database integer surrogates. The new

Calendar actions in `EmailDetail` now request `/api/calendar/writeback-intent`
for each extracted execution item and display the selected trusted source
provenance. The browser no longer claims `/api/calendar/sync` success from the
mail-detail action path; direct provider writes stay deferred until connector and
source registry work can enforce ETag/If-Match and owner capability checks.
provenance. Calendar source selection now reads opaque
`calendar_writeback_sources.source_uid` rows instead of exposing sequential
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.

## Operations and release docs

Expand Down
67 changes: 61 additions & 6 deletions backend/api/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.auth import (
AuthContext,
get_auth_context,
is_system_admin_role,
is_tenant_admin_role,
)
from db.models import CalendarWritebackSource
from db.session import get_db
from services.calendar_service import create_calendar_event, validate_calendar_todo_text
from services.exceptions import CalendarServiceError, UnsafeCalendarTodoError

Expand All @@ -26,7 +30,7 @@ class WritebackSource(BaseModel):
provider: str
protocol: Literal["caldav", "carddav", "webdav", "local"]
owner_id: str
organization_id: str
organization_id: str | None
capabilities: list[str]
writeback_enabled: bool
etag: str | None = None
Expand Down Expand Up @@ -54,6 +58,54 @@ class WritebackIntentResponse(BaseModel):
CUSTOMER_OWNED_PROTOCOLS = {"caldav", "carddav", "webdav"}


def _registry_capabilities(source: CalendarWritebackSource) -> list[str]:
capabilities = ["read"]
if source.writeback_enabled:
capabilities.extend(["write", "etag"])
return capabilities


def _writeback_source_from_registry(
registry_source: CalendarWritebackSource,
) -> WritebackSource:
return WritebackSource(
source_id=registry_source.source_uid,
provider=registry_source.provider_name,
protocol=registry_source.source_protocol,
owner_id=registry_source.user_id,
organization_id=registry_source.organization_id,
capabilities=_registry_capabilities(registry_source),
writeback_enabled=bool(registry_source.writeback_enabled),
etag=registry_source.etag_value,
)


def _registry_scope_statement(auth_context: AuthContext):
statement = (
select(CalendarWritebackSource)
.where(CalendarWritebackSource.source_protocol == "caldav")
.order_by(
CalendarWritebackSource.created_at.asc(),
CalendarWritebackSource.source_uid.asc(),
)
)
if is_system_admin_role(auth_context.role):
return statement
if is_tenant_admin_role(auth_context.role) and auth_context.organization_id:
return statement.where(
CalendarWritebackSource.organization_id == auth_context.organization_id
)
organization_filter = (
CalendarWritebackSource.organization_id == auth_context.organization_id
if auth_context.organization_id is not None
else CalendarWritebackSource.organization_id.is_(None)
)
return statement.where(
CalendarWritebackSource.user_id == auth_context.user_id,
organization_filter,
)


def _has_writeback_capability(source: WritebackSource) -> bool:
return (
source.writeback_enabled
Expand All @@ -64,16 +116,19 @@ def _has_writeback_capability(source: WritebackSource) -> bool:

async def get_writeback_sources(
auth_context: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db),
) -> tuple[WritebackSource, ...]:
"""
Return server-authoritative writeback sources for the authenticated user.

The current slice has no persisted connector/source registry yet, so the
production default is intentionally empty. Tests may override this dependency
with fixture-owned sources, and the future connector registry should replace
this placeholder with a database-backed lookup scoped by `auth_context`.
This currently resolves persisted CalDAV source-registry rows only. Provider
mutations are still out of scope: the endpoint returns intent metadata and
ETag/If-Match requirements so later connector execution can fail closed.
"""
return ()
result = await db.execute(_registry_scope_statement(auth_context))
return tuple(
_writeback_source_from_registry(source) for source in result.scalars().all()
)


async def get_calendar_user_token(
Expand Down
27 changes: 27 additions & 0 deletions backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,33 @@ class CaldavAccount(Base):
)


class CalendarWritebackSource(Base):
__tablename__ = "calendar_writeback_sources"

source_uid: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(String, index=True, nullable=False)
organization_id: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
workspace_id: Mapped[str] = mapped_column(String, index=True, nullable=False)
account_ref: Mapped[str | None] = mapped_column(String, nullable=True)
provider_name: Mapped[str] = mapped_column(String, nullable=False)
source_protocol: Mapped[str] = mapped_column(String, nullable=False)
source_host: Mapped[str] = mapped_column(String, nullable=False)
writeback_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
etag_value: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.datetime.now(datetime.timezone.utc),
)
__table_args__ = (
Index(
"ix_calendar_writeback_sources_scope",
"user_id",
"organization_id",
"source_protocol",
),
)


class ReplyTracker(Base):
__tablename__ = "reply_trackers"

Expand Down
20 changes: 20 additions & 0 deletions backend/scripts/bootstrap_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ def schema_backfill_sql():
text(
"ALTER TABLE llm_providers ADD COLUMN IF NOT EXISTS organization_id varchar"
),
text(
"CREATE TABLE IF NOT EXISTS calendar_writeback_sources ("
"source_uid varchar PRIMARY KEY, "
"user_id varchar NOT NULL, "
"organization_id varchar, "
"workspace_id varchar NOT NULL, "
"account_ref varchar, "
"provider_name varchar NOT NULL, "
"source_protocol varchar NOT NULL, "
"source_host varchar NOT NULL, "
"writeback_enabled boolean NOT NULL DEFAULT false, "
"etag_value varchar, "
"created_at timestamptz DEFAULT CURRENT_TIMESTAMP"
")"
),
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 @@ -61,6 +76,11 @@ def schema_backfill_sql():
"CREATE INDEX IF NOT EXISTS ix_llm_providers_organization_id "
"ON llm_providers (organization_id)"
),
text(
"CREATE INDEX IF NOT EXISTS ix_calendar_writeback_sources_scope "
"ON calendar_writeback_sources "
"(user_id, organization_id, source_protocol)"
),
text("ALTER TABLE emails DROP CONSTRAINT IF EXISTS emails_message_id_key"),
text(
"ALTER TABLE sender_relationships "
Expand Down
41 changes: 40 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 SenderRelationship
from db.models import CalendarWritebackSource, SenderRelationship


def test_schema_backfill_adds_threading_columns_for_existing_tables(monkeypatch):
Expand Down Expand Up @@ -98,6 +98,25 @@ def test_schema_backfill_adds_threading_columns_for_existing_tables(monkeypatch)
in statement
for statement in statements
)
assert any(
"create table if not exists calendar_writeback_sources" in statement
for statement in statements
)
assert any("source_uid varchar primary key" in statement for statement in statements)
assert any("workspace_id varchar not null" in statement for statement in statements)
assert any("provider_name varchar not null" in statement for statement in statements)
assert any("source_protocol varchar not null" in statement for statement in statements)
assert any("source_host varchar not null" in statement for statement in statements)
assert any(
"writeback_enabled boolean not null default false" in statement
for statement in statements
)
assert any("etag_value varchar" in statement for statement in statements)
assert any(
"create index if not exists ix_calendar_writeback_sources_scope"
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 @@ -193,3 +212,23 @@ def test_sender_relationship_model_declares_source_unique_index():
assert "sender_email" in expression_text
assert "source_message_id" in expression_text
assert "source_thread_id" in expression_text


def test_calendar_writeback_source_model_uses_two_word_names():
assert CalendarWritebackSource.__tablename__ == "calendar_writeback_sources"
column_names = {column.name for column in CalendarWritebackSource.__table__.columns}

assert column_names == {
"source_uid",
"user_id",
"organization_id",
"workspace_id",
"account_ref",
"provider_name",
"source_protocol",
"source_host",
"writeback_enabled",
"etag_value",
"created_at",
}
assert all("_" in column_name for column_name in column_names)
Loading
Loading