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
2 changes: 1 addition & 1 deletion .github/workflows/strix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ jobs:
run: |
umask 077
strix_llm_file="$RUNNER_TEMP/strix_llm.txt"
printf '%s' "github_models/gpt-5.4" > "$strix_llm_file"
printf '%s' "github_models/gpt-4o" > "$strix_llm_file"
echo "STRIX_LLM_FILE=$strix_llm_file" >> "$GITHUB_ENV"

- name: Prepare LLM API base input file
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.
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
74 changes: 74 additions & 0 deletions backend/runner/connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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, additional_headers=headers)
self.is_connected = True
logger.info(f"Connected to Naruon Gateway at {self.target_ws_url}")
await self._listen_loop()
except asyncio.CancelledError:
raise
except (ConnectionRefusedError, OSError, asyncio.TimeoutError) as e:
self.is_connected = False
logger.exception(f"Failed to connect to Naruon Gateway: {e}")
except Exception as e:
# We catch Exception here but limit logger.exception to websocket errors
# to avoid referencing websockets.exceptions directly in the tuple
# in case websockets is None (though we return early if it is)
if websockets and isinstance(e, websockets.exceptions.WebSocketException):
self.is_connected = False
logger.exception(f"Failed to connect to Naruon Gateway: {e}")
else:
self.is_connected = False
logger.exception(f"Failed to connect to Naruon Gateway with unexpected error: {e}")

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):
logger.warning("Connection closed by remote gateway.")
else:
logger.warning(f"Connection loop ended: {e}")
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 | bytes):
# 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.
29 changes: 29 additions & 0 deletions backend/services/caldav_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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", "")
if isinstance(source_email, str) and "@" in source_email:
source_domain = source_email.strip().lower().rsplit("@", 1)[-1]
for account in connected_accounts:
account_domain = str(account.get("domain", "")).lower().strip()
if account_domain and source_domain == account_domain:
return account.get("account_id")

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

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

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 = str(email_data.get("sender") or "")
subject = str(email_data.get("subject") or "")
date = str(email_data.get("date") or "")
body = str(email_data.get("body") or "")
body_snippet = body[:500] # First 500 chars

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 = str(email_data.get("body") or "").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_raw = str(email_data.get("sender") or "")
recipients_raw = email_data.get("recipients") or []
recipient_inputs = recipients_raw if isinstance(recipients_raw, list) else [recipients_raw]
recipient_inputs = [str(v) for v in recipient_inputs]

_, sender_addr = email.utils.parseaddr(sender_raw)
normalized_user = user_email.strip().lower()
normalized_sender = sender_addr.strip().lower()
parsed_recipients = {
addr.strip().lower()
for _, addr in email.utils.getaddresses(recipient_inputs)
if addr
}

if normalized_user and normalized_user == normalized_sender and normalized_user in parsed_recipients:
logger.info("Self-to-self email detected. Organizing as knowledge node.")
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].lower()
sender_domain = sender_email.split("@")[1].lower()
if user_domain == sender_domain:
relationship_type = "Colleague"
confidence = 0.85

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

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

logger = logging.getLogger(__name__)

class WebDavService:
def __init__(self):
self._mock_accounts = {
"demo_user": [
{
"account_id": 1,
"server_url": "https://webdav.naruon.net",
"username": "demo_user"
}
]
}
self._mock_folders = {
"demo_user": [
{
"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"
}
]
}

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.
"""
return self._mock_accounts.get(user_id, [])

def get_project_folders(self, user_id: str) -> List[Dict[str, Any]]:
"""
Fetch the list of project folders structured by AI.
"""
return self._mock_folders.get(user_id, [])

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 not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Loading