Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
4 changes: 2 additions & 2 deletions .github/workflows/strix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
strix_llm="github_models/gpt-4o"
strix_llm="github_models/gpt-5.4"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
llm_api_key="$(printf '%s' "$LLM_API_KEY" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [ -z "$llm_api_key" ]; then
# On protected/privileged events, fail-closed: missing secrets must
Expand Down Expand Up @@ -117,7 +117,7 @@ jobs:
DEFAULT_PROVIDER: gemini
run: |
. "$TRUSTED_WORKSPACE/scripts/ci/strix_model_utils.sh"
strix_llm="github_models/gpt-4o"
strix_llm="github_models/gpt-5.4"
requires_vertex_auth='false'
model_auth_rc=0
model_requires_vertex_auth "$strix_llm" || model_auth_rc=$?
Expand Down
6 changes: 6 additions & 0 deletions .vooster/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"uid": "XIDB",
"name": "Naruon - AI Email Workspace",
"description": null,
"connectedAt": "2026-05-24T13:23:50.376Z"
}
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
explicit `if: ${{ always() }}` upload steps when needed.
- Prefer upgrading or removing vulnerable dependencies over downgrading patched
packages unless compatibility evidence is recorded in the PR.
- Strix Security Scan must use `github_models/gpt-5.4` as the default model to bypass Vertex AI GCP credential prerequisites in PR bounds.
- Strix Security Scan must use `github_models/gpt-4o` as the default model to bypass Vertex AI GCP credential prerequisites in PR bounds.

## PR automation and review defaults

Expand Down
Binary file modified backend/__pycache__/import_fixtures.cpython-310.pyc
Binary file not shown.
Binary file modified backend/__pycache__/main.cpython-310.pyc
Binary file not shown.
Binary file modified backend/api/__pycache__/accounts.cpython-310.pyc
Binary file not shown.
Binary file modified 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__/ontology.cpython-310.pyc
Binary file not shown.
Binary file modified backend/api/__pycache__/runner_ws.cpython-310.pyc
Binary file not shown.
19 changes: 19 additions & 0 deletions backend/api/caldav_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
from typing import Dict, Any

router = APIRouter()

@router.post("/writeback-intent")
async def register_writeback_intent(payload: Dict[str, Any]):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
# In a real scenario, this would use the auth context dependency.
# Currently just a stub for Phase 10 API wiring.
account_map = payload.get("calendar_account_map", {})
if not account_map:
raise HTTPException(status_code=400, detail="Missing account mapping")

return {"status": "success", "message": "Writeback intent registered", "target": account_map}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
@router.post("/sync")
async def sync_caldav_accounts():
# Deprecated legacy endpoint path, should fail or require strict auth
raise HTTPException(status_code=403, detail="Use specific writeback intent and verified sources instead.")
28 changes: 28 additions & 0 deletions backend/api/webdav.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from typing import List

from api.auth import get_auth_context, AuthContext
from services.webdav_service import webdav_service

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

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

class ProjectFolderResponse(BaseModel):
folder_id: int
project_name: str
webdav_path: str

@router.get("/accounts", response_model=List[WebdavAccountResponse])
async def get_webdav_accounts(auth_context: AuthContext = Depends(get_auth_context)):
user_id = auth_context.user_id
return webdav_service.get_connected_accounts(user_id)

@router.get("/folders", response_model=List[ProjectFolderResponse])
async def get_project_folders(auth_context: AuthContext = Depends(get_auth_context)):
user_id = auth_context.user_id
return webdav_service.get_project_folders(user_id)
Binary file modified backend/core/__pycache__/config.cpython-310.pyc
Binary file not shown.
Binary file modified backend/db/__pycache__/models.cpython-310.pyc
Binary file not shown.
27 changes: 27 additions & 0 deletions backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,30 @@ class SenderRelationship(Base):
default=lambda: datetime.datetime.now(datetime.timezone.utc),
onupdate=lambda: datetime.datetime.now(datetime.timezone.utc),
)


class WebdavAccount(Base):
__tablename__ = "webdav_accounts"

id: Mapped[int] = mapped_column("account_id", primary_key=True)
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)
credentials_encrypted: Mapped[str] = mapped_column(EncryptedString, nullable=False)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.datetime.now(datetime.timezone.utc),
)


class ProjectFolder(Base):
__tablename__ = "project_folders"

id: Mapped[int] = mapped_column("folder_id", primary_key=True)
user_id: Mapped[str] = mapped_column(String, index=True, nullable=False)
project_name: Mapped[str] = mapped_column(String, index=True, nullable=False)
webdav_path: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.datetime.now(datetime.timezone.utc),
)
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from api.runner_ws import router as runner_ws_router
from api.dav import router as dav_router
from api.accounts import router as accounts_router
from api.webdav import router as webdav_router
from services.imap_worker import ImapSyncWorker
from prometheus_fastapi_instrumentator import Instrumentator
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
Expand Down Expand Up @@ -92,6 +93,7 @@ async def lifespan(app: FastAPI):
app.include_router(runner_ws_router)
app.include_router(dav_router)
app.include_router(accounts_router, dependencies=PRIVATE_API_DEPENDENCIES)
app.include_router(webdav_router, dependencies=PRIVATE_API_DEPENDENCIES)


app.add_middleware(
Expand Down
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ opentelemetry-sdk==1.25.0
opentelemetry-instrumentation-fastapi==0.46b0
opentelemetry-exporter-otlp==1.25.0
setuptools==78.1.1
websockets==14.1
57 changes: 57 additions & 0 deletions backend/runner/connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import asyncio
import logging
from typing import Dict, Any

try:
import websockets
except ImportError:
# Optional dependency for the runner
websockets = None

logger = logging.getLogger(__name__)

class SelfHostedConnector:
def __init__(self, target_ws_url: str, token: str):
self.target_ws_url = target_ws_url
self.token = token
self.connection = None
self.is_connected = False

async def connect(self):
if websockets is None:
logger.error("websockets library is not installed. Runner cannot start.")
return

headers = {"Authorization": f"Bearer {self.token}"}
try:
self.connection = await websockets.connect(self.target_ws_url, extra_headers=headers)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
self.is_connected = True
logger.info(f"Connected to Naruon Gateway at {self.target_ws_url}")
await self._listen_loop()
except Exception as e:
logger.error(f"Failed to connect to Naruon Gateway: {e}")
self.is_connected = False
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

async def _listen_loop(self):
try:
while self.is_connected:
message = await self.connection.recv()
await self.handle_message(message)
except websockets.exceptions.ConnectionClosed:
logger.warning("Connection closed by remote gateway.")
self.is_connected = False
Comment on lines +54 to +59

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspect broad catches in connector:"
rg -nP --type=py -C2 '\bexcept\s+Exception\b' backend/runner/connector.py

echo
echo "Inspect listen loop region:"
cat -n backend/runner/connector.py | sed -n '47,70p'

Repository: Seongho-Bae/naruon

Length of output: 1890


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Top of backend/runner/connector.py (imports + class header):"
cat -n backend/runner/connector.py | sed -n '1,120p'

echo
echo "Confirm whether asyncio is imported:"
rg -n --type=py '\bimport\s+asyncio\b|\bfrom\s+asyncio\s+import\b' backend/runner/connector.py

echo
echo "Confirm websockets import:"
rg -n --type=py '\bimport\s+websockets\b|\bfrom\s+websockets\s+import\b' backend/runner/connector.py

Repository: Seongho-Bae/naruon

Length of output: 3720


Narrow SelfHostedConnector._listen_loop exception handling to avoid masking logic bugs.

In backend/runner/connector.py lines 54-59, _listen_loop catches all Exception, logs a warning, and stops the loop—this suppresses failures from handle_message and can hide programming errors.

♻️ Proposed fix
     async def _listen_loop(self):
         if not self.connection:
             return
         try:
             while self.is_connected:
                 message = await self.connection.recv()
                 await self.handle_message(message)
-        except Exception as e:
-            if websockets and isinstance(e, websockets.exceptions.ConnectionClosed):
+        except asyncio.CancelledError:
+            raise
+        except (OSError, asyncio.TimeoutError) as e:
+            logger.warning(f"Connection loop ended: {e}")
+            self.is_connected = False
+        except Exception as e:
+            if websockets and isinstance(e, websockets.exceptions.ConnectionClosed):
                 logger.warning("Connection closed by remote gateway.")
+                self.is_connected = False
             else:
-                logger.warning(f"Connection loop ended: {e}")
-            self.is_connected = False
+                raise
🧰 Tools
🪛 Ruff (0.15.13)

[warning] 54-54: Do not catch blind exception: Exception

(BLE001)

🤖 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/runner/connector.py` around lines 54 - 59, The broad except Exception
in SelfHostedConnector._listen_loop is masking programming errors from
handle_message; narrow it to catch only expected shutdown exceptions (e.g.,
websockets.exceptions.ConnectionClosed and asyncio.CancelledError) and handle
them by logging the connection-close path and setting self.is_connected = False,
but for any other Exception log the full traceback (use logger.exception) and
re-raise so bugs aren't suppressed; update _listen_loop to import asyncio if
needed and ensure self.is_connected is cleared in the shutdown path or a finally
block.


async def handle_message(self, message: str):
# Dispatch message to internal SMTP/IMAP proxy handlers
logger.debug(f"Received instruction from gateway: {message}")
pass
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed

async def send_response(self, response: Dict[str, Any]):
if self.is_connected and self.connection:
import json
await self.connection.send(json.dumps(response))

if __name__ == "__main__":
# Example usage for local bootstrap
connector = SelfHostedConnector("ws://localhost:8080/api/runner/ws", "sample-token")
asyncio.run(connector.connect())
33 changes: 33 additions & 0 deletions backend/schema/connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field


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

connector_id: str = Field(..., description="Unique identifier for the self-hosted connector")
public_key: str = Field(..., description="Public key for mTLS or secure payload exchange")
supported_protocols: list[Literal["imap", "smtp", "pop3", "caldav", "webdav"]] = Field(
default_factory=list, description="Protocols supported by this connector instance"
)
capabilities: list[str] = Field(default_factory=list)


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

connector_id: str
status: Literal["pending_approval", "active", "rejected"]
issued_certificate: str | None = None
endpoint_url: str


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

provider_name: str
issuer_url: str
client_id: str
audience: str
required_scopes: list[str]
enforce_rbac_sync: bool = True
Binary file modified backend/scripts/__pycache__/bootstrap_db.cpython-310.pyc
Binary file not shown.
Binary file modified backend/scripts/__pycache__/import_fixtures.cpython-310.pyc
Binary file not shown.
Binary file modified backend/services/__pycache__/access_policy.cpython-310.pyc
Binary file not shown.
Binary file modified backend/services/__pycache__/archive.cpython-310.pyc
Binary file not shown.
Binary file modified backend/services/__pycache__/calendar_sync.cpython-310.pyc
Binary file not shown.
Binary file modified backend/services/__pycache__/knowledge_extractor.cpython-310.pyc
Binary file not shown.
Binary file modified backend/services/__pycache__/text_safety.cpython-310.pyc
Binary file not shown.
Binary file modified backend/services/__pycache__/threading_service.cpython-310.pyc
Binary file not shown.
26 changes: 26 additions & 0 deletions backend/services/caldav_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import logging
from typing import Dict, Any

logger = logging.getLogger(__name__)

class CalDavService:
def __init__(self):
pass

def determine_writeback_target(self, task_context: Dict[str, Any], connected_accounts: list) -> str:
"""
Determines the most appropriate CalDav account to write back to,
based on the context of the task (e.g., if it originated from a company email).
"""
# Basic ontology/context mock logic
source_email = task_context.get("source_email", "")
for account in connected_accounts:
if account.get("domain") in source_email:
return account.get("account_id")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

# Fallback to the primary account
if connected_accounts:
return connected_accounts[0].get("account_id")
return "default_system_caldav"

caldav_service = CalDavService()
37 changes: 37 additions & 0 deletions backend/services/email_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import hashlib
import logging
from typing import Dict, Any

logger = logging.getLogger(__name__)

def generate_email_fingerprint(email_data: Dict[str, Any]) -> str:
"""
Generates a unique fingerprint for an email based on its sender, subject, date, and body content.
Used to de-duplicate emails from ZIP imports or forwarding loops.
"""
sender = email_data.get("sender", "")
subject = email_data.get("subject", "")
date = str(email_data.get("date", ""))
body_snippet = email_data.get("body", "")[:500] # First 500 chars
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

raw_str = f"{sender}|{subject}|{date}|{body_snippet}"
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()

def detect_reply_tracking(email_data: Dict[str, Any]) -> bool:
"""
Detects if the user sent an email that expects a reply.
"""
body = email_data.get("body", "").lower()
return "please reply" in body or "?" in body

def process_self_to_self(email_data: Dict[str, Any], user_email: str) -> bool:
"""
Detects if an email is sent from the user to themselves, turning it into a knowledge node.
"""
sender = email_data.get("sender", "")
recipients = email_data.get("recipients", "")

if user_email in sender and user_email in recipients:
logger.info(f"Self-to-self email detected. Organizing as knowledge node.")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return True
return False
36 changes: 36 additions & 0 deletions backend/services/ontology_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import logging
from typing import Dict, Any

logger = logging.getLogger(__name__)

class OntologyService:
def __init__(self):
self.relationships = {}

def analyze_sender_relationship(self, user_email: str, sender_email: str, email_content: str) -> Dict[str, Any]:
"""
Analyzes the email content to build a relationship graph (DAG) between the user and the sender.
Returns attributes like the relationship type (e.g., Colleague, Client, Newsletter, Unknown)
and confidence score.
"""
# A simple stub logic for Phase 10 implementation
relationship_type = "Unknown"
confidence = 0.5

if "unsubscribe" in email_content.lower():
relationship_type = "Newsletter"
confidence = 0.9
elif "@" in user_email and "@" in sender_email:
user_domain = user_email.split("@")[1]
sender_domain = sender_email.split("@")[1]
if user_domain == sender_domain:
relationship_type = "Colleague"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
confidence = 0.85

logger.info(f"Analyzed relationship: {sender_email} -> {relationship_type} (conf: {confidence})")
return {
"type": relationship_type,
"confidence": confidence
}

ontology_service = OntologyService()
50 changes: 50 additions & 0 deletions backend/services/webdav_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import logging
from typing import Dict, Any, List

logger = logging.getLogger(__name__)

class WebDavService:
def __init__(self):
pass

def get_connected_accounts(self, user_id: str) -> List[Dict[str, Any]]:
"""
Fetch connected WebDAV accounts for a user.
In a real implementation, this queries the database.
"""
# Mock implementation for now
return [
{
"account_id": 1,
"server_url": "https://webdav.naruon.net",
"username": "demo_user"
}
]

def get_project_folders(self, user_id: str) -> List[Dict[str, Any]]:
"""
Fetch the list of project folders structured by AI.
"""
# Mock implementation
return [
{
"folder_id": 1,
"project_name": "Naruon Roadmap 2026",
"webdav_path": "/Projects/Naruon_Roadmap_2026"
},
{
"folder_id": 2,
"project_name": "Marketing Assets",
"webdav_path": "/Projects/Marketing_Assets"
}
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

def sync_attachments_to_folder(self, email_id: str, project_name: str) -> bool:
"""
Organizes an email's attachments into the specified WebDAV project folder.
"""
logger.info(f"Syncing attachments from email {email_id} to project {project_name}")
# Mock implementation: in reality, this would download from storage and upload via webdavclient3
return True

webdav_service = WebDavService()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file modified backend/tests/__pycache__/test_main.cpython-310-pytest-9.0.3.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file modified backend/tests/__pycache__/test_search.cpython-310-pytest-9.0.3.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Loading