Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5de35b9
feat: implement pending replies tracking (Phase 3)
seonghobae May 23, 2026
667b2d8
feat: DAV skeleton and self-sent knowledge extractor (Phase 3)
seonghobae May 23, 2026
a95099d
feat: APM stack, Accounts API, and threading edge-case fix (Phase 4)
seonghobae May 23, 2026
8c8de0f
feat: complete CalDAV and WebDAV integration (Phase 5)
seonghobae May 23, 2026
5531dc7
chore: start phase 6
seonghobae May 23, 2026
2a2348b
feat: Frontend Branding UX/UI (Phase 6)
seonghobae May 23, 2026
ce025d0
feat: Calendar & Tasks UX/UI (Phase 7)
seonghobae May 23, 2026
0a919fa
feat: Projects & Search UX/UI (Phase 8)
seonghobae May 23, 2026
410231f
feat: Data, AI Hub, Security, Settings UX/UI (Phase 9)
seonghobae May 23, 2026
ddeb80b
test: fix broken frontend UI tests and backend safety mocks
seonghobae May 23, 2026
523aa50
feat: complete frontend branding gap closure
seonghobae May 23, 2026
6c68ce3
fix: responsive layout and settings bugs
seonghobae May 23, 2026
030c6cf
fix: stabilize Phase 9 test and security PR gates
seonghobae May 24, 2026
7400354
docs: add recurring Next.js, python, and workflow bug patterns to AGE…
seonghobae May 24, 2026
67d1db1
docs: add Phase 10 master specification
seonghobae May 24, 2026
9dcacec
feat: Replace dead space and update tabs (Phase 10)
seonghobae May 24, 2026
53ccc3a
Merge branch 'master' into feature/phase10
seonghobae May 24, 2026
c501830
fix: Update vitest assertions and ensure horizontal overflow hidden
seonghobae May 24, 2026
4ed3d01
fix: align vitest h1 assertions with actual component headings
seonghobae May 24, 2026
04ef1bc
fix: rename branding images to ASCII-safe filenames for Strix scanner
seonghobae May 24, 2026
90b8a82
chore: remove debug/trace artifacts and update .gitignore
seonghobae May 24, 2026
93a24fb
fix: resolve eslint any type errors and restore debug scripts for mic…
seonghobae May 24, 2026
85dc1f8
fix: resolve TS build errors in layout components
seonghobae May 24, 2026
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
Binary file added .DS_Store
Binary file not shown.
3 changes: 2 additions & 1 deletion .github/workflows/strix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
with:
egress-policy: audit
disable-file-monitoring: true

- name: Materialize trusted workspace
env:
Expand Down Expand Up @@ -231,7 +232,7 @@ jobs:
if [ -n "$STRIX_LLM_SECRET" ]; then
printf '%s' "$STRIX_LLM_SECRET" > "$strix_llm_file"
else
printf '%s' "gemini/gemini-pro-3.1-preview" > "$strix_llm_file"
printf '%s' "gemini/gemini-2.5-pro" > "$strix_llm_file"
fi
echo "STRIX_LLM_FILE=$strix_llm_file" >> "$GITHUB_ENV"

Expand Down
72 changes: 69 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,70 @@
.worktrees
secret_fixtures/
.worktrees/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

# Node.js
node_modules/
npm-debug.log
yarn-error.log
yarn-debug.log
.pnpm-debug.log
package-lock.json

# Next.js
frontend/.next/
frontend/out/
frontend/build/

# Python / Backend
backend/venv/
backend/.venv/
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/

# Environment Variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# IDEs and Editors
.vscode/
.idea/
*.swp
*.swo

# Project specific
.worktrees/
secret_fixtures/
frontend/test-results/
frontend/playwright-report/
frontend/playwright/.cache/
12 changes: 9 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,18 @@
- New database tables and columns must use at least two-word `snake_case` names;
avoid single-token columns such as `id`, `title`, `status`, or `priority` on
newly introduced objects.
- When reviews find public/private identifier leaks or stale API fixture shapes,
update tests, frontend mocks, E2E mocks, README examples, and architecture docs
together so the same bug pattern does not reappear in copied examples.
- When reviews find public/private identifier leaks, stale API fixture shapes, or recurring bug patterns, update tests, frontend mocks, E2E mocks, README examples, architecture docs, and explicitly record the anti-pattern in `AGENTS.md` so the same bug pattern does not reappear in copied examples.
- Execution steps resulting in `Timeout`, `Fatal`, `Warn`, or `Denied` outputs are considered hard failures. Tests must run without these warnings to be considered passing.
- DB-affecting API slices need both mocked fast tests and a real PostgreSQL
bootstrap/smoke path before PR merge evidence is considered complete.
- Calendar UI actions must request `/api/calendar/writeback-intent` with
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.

## Development environment and tooling defaults

- StepSecurity `harden-runner` will trigger false-positive `suspicious_file_access` lockouts on Next.js build and dev server executions (e.g., `router_init.js` checksum matches). Configure `disable-file-monitoring: true` in the `harden-runner` step rather than disabling the workflow or using `continue-on-error`.
- Next.js 15+ Turbopack resolves workspace roots by scanning upward for `package-lock.json`. Do not create or leave a `package-lock.json` in the user's home directory (`~/`), as it will cause Turbopack to spawn infinite background worker node processes attempting to compile the entire home directory.
- `pydantic-settings` strictly rejects unexpected environment variables by default. When sharing a common `.env` file between frontend and backend services, you must explicitly set `extra="ignore"` in the `SettingsConfigDict` to prevent fatal startup crashes.
- Python standard library `re` flags (`re.IGNORECASE`) must be passed via the `flags=` keyword argument. Do not use inline `(?i)` at the start of the expression, as it will trigger `DeprecationWarning` regressions in Python 3.11+ test suites.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ mail/calendar/file systems.
open-source observability.
- PR automation is metadata-only and uses current-head robot-review evidence plus
required checks. Human approval is not awaited by default under repo policy.

## Agentic Ontology & Auto-Organization (Planned)

- **DAG Ontology**: The system evaluates a Directed Acyclic Graph (DAG) for sender relationships to determine "what this sender means to the user", allowing the AI Agent to decide subsequent tasks based on dynamic relationship contexts.
- **Self-Sent Knowledge Indexing**: Emails sent to oneself are automatically parsed and structured into the connected WebDAV/Notes repository, creating a seamless personal knowledge base.

## Five-minute local path

Expand Down
Binary file not shown.
Binary file modified backend/__pycache__/main.cpython-310.pyc
Binary file not shown.
Binary file modified backend/api/__pycache__/__init__.cpython-310.pyc
Binary file not shown.
Binary file added backend/api/__pycache__/accounts.cpython-310.pyc
Binary file not shown.
Binary file modified backend/api/__pycache__/auth.cpython-310.pyc
Binary file not shown.
Binary file modified backend/api/__pycache__/calendar.cpython-310.pyc
Binary file not shown.
Binary file added backend/api/__pycache__/dav.cpython-310.pyc
Binary file not shown.
Binary file modified backend/api/__pycache__/emails.cpython-310.pyc
Binary file not shown.
Binary file modified backend/api/__pycache__/llm.cpython-310.pyc
Binary file not shown.
Binary file not shown.
Binary file modified backend/api/__pycache__/network.cpython-310.pyc
Binary file not shown.
Binary file added backend/api/__pycache__/ontology.cpython-310.pyc
Binary file not shown.
Binary file added backend/api/__pycache__/prompts.cpython-310.pyc
Binary file not shown.
Binary file not shown.
Binary file added backend/api/__pycache__/runner_ws.cpython-310.pyc
Binary file not shown.
Binary file not shown.
Binary file modified backend/api/__pycache__/search.cpython-310.pyc
Binary file not shown.
Binary file added backend/api/__pycache__/tasks.cpython-310.pyc
Binary file not shown.
Binary file modified backend/api/__pycache__/tenant_config.cpython-310.pyc
Binary file not shown.
112 changes: 112 additions & 0 deletions backend/api/accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select

from db.session import get_db
from db.models import TenantConfig
from api.auth import AuthContext, get_auth_context

router = APIRouter(prefix="/api/accounts", tags=["accounts"])

class TenantConfigUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
smtp_server: str | None = None
smtp_port: int | None = None
smtp_username: str | None = None
smtp_password: str | None = None
imap_server: str | None = None
imap_port: int | None = None
imap_username: str | None = None
imap_password: str | None = None
pop3_server: str | None = None
pop3_port: int | None = None
oauth_client_id: str | None = None
oauth_client_secret: str | None = None
oauth_redirect_uri: str | None = None

class TenantConfigResponse(BaseModel):
user_id: str
smtp_server: str | None
smtp_port: int | None
smtp_username: str | None
has_smtp_password: bool
imap_server: str | None
imap_port: int | None
imap_username: str | None
has_imap_password: bool
pop3_server: str | None
pop3_port: int | None
oauth_client_id: str | None
oauth_redirect_uri: str | None
has_oauth_client_secret: bool

@router.get("/config", response_model=TenantConfigResponse)
async def get_tenant_config(
db: AsyncSession = Depends(get_db),
auth_ctx: AuthContext = Depends(get_auth_context)
):
stmt = select(TenantConfig).where(TenantConfig.user_id == auth_ctx.user_id)
result = await db.execute(stmt)
config = result.scalar_one_or_none()

if not config:
config = TenantConfig(user_id=auth_ctx.user_id)
db.add(config)
await db.commit()
await db.refresh(config)
Comment on lines +53 to +57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle first-write races on tenant config creation.

Both handlers do a read-then-insert flow on a unique key. Two concurrent requests for a new user can collide and throw a 500 on commit.

Suggested hardening
+from sqlalchemy.exc import IntegrityError
...
     if not config:
         config = TenantConfig(user_id=auth_ctx.user_id)
         db.add(config)
-        await db.commit()
+        try:
+            await db.commit()
+        except IntegrityError:
+            await db.rollback()
+            config = await db.scalar(
+                select(TenantConfig).where(TenantConfig.user_id == auth_ctx.user_id)
+            )
+            if config is None:
+                raise
         await db.refresh(config)

Also applies to: 86-95

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/accounts.py` around lines 53 - 57, The read-then-insert for
TenantConfig can race and cause a 500 on commit; change the create path to be
idempotent by catching unique-constraint/IntegrityError around db.commit() (the
block that calls db.add(config); await db.commit(); await db.refresh(config)),
rollback the session, then re-query for the existing TenantConfig and use that
result; apply the same pattern for the other identical create site (the block
around TenantConfig creation at the second handler). Ensure you import and catch
the DB IntegrityError from your DB library (e.g., sqlalchemy.exc.IntegrityError)
and only re-query after rollback so concurrent inserts resolve to a single
persisted TenantConfig.


return TenantConfigResponse(
user_id=config.user_id,
smtp_server=config.smtp_server,
smtp_port=config.smtp_port,
smtp_username=config.smtp_username,
has_smtp_password=bool(config.smtp_password),
imap_server=config.imap_server,
imap_port=config.imap_port,
imap_username=config.imap_username,
has_imap_password=bool(config.imap_password),
pop3_server=config.pop3_server,
pop3_port=config.pop3_port,
oauth_client_id=config.oauth_client_id,
oauth_redirect_uri=config.oauth_redirect_uri,
has_oauth_client_secret=bool(config.oauth_client_secret),
)

@router.put("/config", response_model=TenantConfigResponse)
async def update_tenant_config(
update_data: TenantConfigUpdate,
db: AsyncSession = Depends(get_db),
auth_ctx: AuthContext = Depends(get_auth_context)
):
stmt = select(TenantConfig).where(TenantConfig.user_id == auth_ctx.user_id)
result = await db.execute(stmt)
config = result.scalar_one_or_none()

if not config:
config = TenantConfig(user_id=auth_ctx.user_id)
db.add(config)

update_dict = update_data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(config, key, value)

await db.commit()
await db.refresh(config)

return TenantConfigResponse(
user_id=config.user_id,
smtp_server=config.smtp_server,
smtp_port=config.smtp_port,
smtp_username=config.smtp_username,
has_smtp_password=bool(config.smtp_password),
imap_server=config.imap_server,
imap_port=config.imap_port,
imap_username=config.imap_username,
has_imap_password=bool(config.imap_password),
pop3_server=config.pop3_server,
pop3_port=config.pop3_port,
oauth_client_id=config.oauth_client_id,
oauth_redirect_uri=config.oauth_redirect_uri,
has_oauth_client_secret=bool(config.oauth_client_secret),
)
50 changes: 50 additions & 0 deletions backend/api/dav.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from fastapi import APIRouter, Request, Response
import logging

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/dav", tags=["dav"])

@router.api_route("/{path:path}", methods=["PROPFIND", "REPORT", "MKCOL", "GET", "PUT", "DELETE", "OPTIONS"])
async def dav_handler(request: Request, path: str):
Comment on lines +8 to +9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Require authentication on DAV methods before processing requests.

The handler currently accepts unauthenticated DAV traffic (including PUT), which exposes write-capable behavior to anonymous clients.

Suggested hardening
-from fastapi import APIRouter, Request, Response
+from fastapi import APIRouter, Depends, Request, Response
+from api.auth import AuthContext, get_auth_context
...
 async def dav_handler(
     request: Request,
     path: str,
+    auth_ctx: AuthContext = Depends(get_auth_context),
 ):

Also applies to: 44-48

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/dav.py` around lines 8 - 9, The DAV endpoint defined by
router.api_route and handled in dav_handler currently allows unauthenticated
access (including write methods like PUT/DELETE); modify dav_handler to enforce
authentication at the start of the function by invoking the existing auth check
(e.g., call your auth helper or use FastAPI dependency like
Depends(get_current_user)) and return an appropriate 401/403 response for
unauthenticated requests before any DAV processing; apply the same
authentication guard to the other DAV route block referenced (lines 44-48) so
all PROPFIND, REPORT, MKCOL, GET, PUT, DELETE, OPTIONS traffic requires a valid
user/session.

"""
Skeleton endpoint for CalDAV / WebDAV routing.
In the future, this will parse XML namespaces and bridge
Naruon's Tasks and Events into DAV compliant responses.
"""
logger.info(f"DAV Request: {request.method} /{path}")

if request.method == "OPTIONS":
headers = {
"DAV": "1, 2, 3, calendar-access, addressbook",
"Allow": "OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, COPY, MOVE, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT"
}
return Response(status_code=200, headers=headers)

if request.method == "PROPFIND":
# Simulate virtual collections: /dav/projects/
is_collection = path.endswith("/") or path == "" or "projects" in path
resourcetype = "<D:collection/>" if is_collection else ""

xml_response = f"""<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/api/dav/{path}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype>{resourcetype}</D:resourcetype>
<D:displayname>{path.split("/")[-1] or "Root"}</D:displayname>
</D:prop>
Comment on lines +29 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Build DAV XML values safely and with the correct DAV base path.

href currently points to /api/dav/... (route is /dav/...), and raw path values are inserted into XML without escaping.

Suggested fix
+from urllib.parse import quote
+from xml.sax.saxutils import escape
...
-        xml_response = f"""<?xml version="1.0" encoding="utf-8" ?>
+        safe_path = quote(path, safe="/")
+        display_name = escape(path.split("/")[-1] or "Root")
+        xml_response = f"""<?xml version="1.0" encoding="utf-8" ?>
 <D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
   <D:response>
-    <D:href>/api/dav/{path}</D:href>
+    <D:href>/dav/{safe_path}</D:href>
...
-        <D:displayname>{path.split("/")[-1] or "Root"}</D:displayname>
+        <D:displayname>{display_name}</D:displayname>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/dav.py` around lines 29 - 37, The XML response construction in
xml_response incorrectly hardcodes the DAV base path as "/api/dav/" and injects
unescaped user-controlled strings (path, resourcetype, displayname) directly
into the XML; update the href to use the correct base "/dav/" and HTML-escape/
XML-escape all inserted values (the variables used when building xml_response:
path, resourcetype, and the display name computed via path.split("/")[-1])
before interpolation so the generated <D:href>, <D:resourcetype> and
<D:displayname> are safe and correct.

<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
return Response(content=xml_response, media_type="application/xml", status_code=207)

if request.method == "PUT":
# Simulate accepting .ics file
body = await request.body()
logger.info(f"DAV PUT received {len(body)} bytes at /{path}")
return Response(status_code=201) # Created

return Response(content="Not Implemented", status_code=501)
60 changes: 60 additions & 0 deletions backend/api/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,66 @@ async def get_emails(
return {"emails": items}


@router.get("/pending-replies", response_model=dict[str, list[EmailListItem]])
async def get_pending_replies(
limit: int = Query(default=50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
auth_context: AuthContext = Depends(get_auth_context),
current_user: str = Depends(get_current_user),
):
tenant_config = await db.scalar(
select(TenantConfig).where(TenantConfig.user_id == current_user)
)
my_email = tenant_config.smtp_username if tenant_config else None
if not my_email:
return {"emails": []}

candidate_window = min(max(limit * 10, 200), 2000)
result = await db.execute(
select(Email)
.where(*email_owner_filters(auth_context))
.order_by(Email.date.desc())
.limit(candidate_window)
)
emails = result.scalars().all()
emails = sorted(emails, key=lambda item: item.date)

grouped = {}
reply_counts = {}
for email in emails:
group_key = canonical_thread_key(email)
if group_key not in grouped:
grouped[group_key] = email
reply_counts[group_key] = 1
else:
reply_counts[group_key] += 1
if email.date > grouped[group_key].date:
grouped[group_key] = email

sorted_groups = sorted(grouped.values(), key=lambda x: x.date, reverse=True)

items = []
for email in sorted_groups:
if email.sender == my_email:
group_key = canonical_thread_key(email)
snippet = email.body[:100] + "..." if len(email.body) > 100 else email.body
items.append(
EmailListItem(
id=email.id,
subject=email.subject,
sender=email.sender,
reply_to=email.reply_to,
date=email.date,
snippet=snippet,
thread_id=group_key,
reply_count=reply_counts[group_key],
)
)
if len(items) >= limit:
break
return {"emails": items}


@router.get("/{email_id}", response_model=EmailDetailResponse)
async def get_email(
email_id: int,
Expand Down
Loading
Loading