Skip to content
Draft
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 .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,8 @@
**Vulnerability:** The `_safe_filename` function in `backend/services/attachment_parser.py` used `pathlib.Path().name` to strip directory components from attachment filenames, but failed to normalize backslashes beforehand. This allowed attackers to use Windows-style path separators (e.g., `..\..\upload`) to bypass path validation on POSIX systems.
**Learning:** Checking for traversal sequences using `pathlib.Path().name` may leave the result vulnerable if the input path can contain Windows-style path separators but the program interprets it dynamically or decodes payloads using backslashes, because POSIX `pathlib` treats backslashes as valid filename characters, not separators.
**Prevention:** Always convert backslashes to forward slashes before parsing filenames using `pathlib.Path().name`.

## 2024-05-18 - Prevent Exception String Interpolation in Logs
**Vulnerability:** String-interpolating exception objects into logs (e.g., `logger.error(f'Error: {e}')`) can leak sensitive information like API keys.
**Learning:** In Python, printing or string-formatting an exception object can inadvertently expose its underlying variables or message contents, which may contain sensitive credentials that caused the error.
**Prevention:** Use `logger.error("Generic error message", exc_info=True)` to securely log the stack trace and generic exceptions (`raise MyError("Generic error") from e`) instead of interpolating the exception object directly into the string.
4 changes: 2 additions & 2 deletions backend/api/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,8 +772,8 @@ async def send_email_endpoint(
return send_result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error sending email: {e}", exc_info=True)
except Exception:
logger.error("Error sending email", exc_info=True)
raise HTTPException(
status_code=500, detail="An internal error occurred while sending the email"
)
4 changes: 2 additions & 2 deletions backend/api/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,10 @@ async def execute_prompt_with_llm(
)
content = response.choices[0].message.content
return {"result": content if content else ""}
except Exception as e:
except Exception:
import logging

logging.getLogger(__name__).error(f"Prompt execution failed: {e}")
logging.getLogger(__name__).error("Prompt execution failed", exc_info=True)
raise HTTPException(
status_code=502,
detail="Failed to execute prompt with AI provider. Check provider status.",
Expand Down
19 changes: 11 additions & 8 deletions backend/import_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ async def generate_fixture_embedding(text: str) -> list[float]:
async def import_eml_file(session, eml_file: Path) -> bool:
try:
parsed = parse_eml(eml_file)
except Exception as e:
logger.error(f"Failed to parse {eml_file}: {e}")
except Exception:
logger.error(f"Failed to parse {eml_file}", exc_info=True)
return False

existing = await session.execute(
Expand All @@ -55,8 +55,8 @@ async def import_eml_file(session, eml_file: Path) -> bool:
body_text = parsed["body"] if parsed["body"].strip() else "Empty body"
try:
body_emb = await generate_fixture_embedding(body_text)
except Exception as e:
logger.error(f"Failed to generate embedding for {eml_file}: {e}")
except Exception:
logger.error(f"Failed to generate embedding for {eml_file}", exc_info=True)
return False

thread_id = await assign_thread_id(
Expand Down Expand Up @@ -93,15 +93,18 @@ async def import_eml_file(session, eml_file: Path) -> bool:
embedding=att_emb,
)
)
except Exception as e:
logger.error(f"Failed to generate embedding for attachment {att['filename']}: {e}")
except Exception:
logger.error(
f"Failed to generate embedding for attachment {att['filename']}",
exc_info=True,
)

session.add(email_obj)
try:
await session.commit()
except Exception as e:
except Exception:
await session.rollback()
logger.error(f"Failed to commit {eml_file}: {e}")
logger.error(f"Failed to commit {eml_file}", exc_info=True)
return False
logger.info(
f"Imported {eml_file.name} with {len(parsed.get('attachments', []))} attachments."
Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies = [
"PyJWT==2.13.0",
"icalendar==7.2.0",
"defusedxml==0.7.1",
"anyio>=4.14.2",
]

[dependency-groups]
Expand Down
42 changes: 22 additions & 20 deletions backend/scripts/import_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,15 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession):
logger.info(f"Extracting {zip_path}...")
extracted_files = await extract_backup_async(zip_path, temp_dir)


batch_values = []
for file_path in extracted_files:
if not str(file_path).endswith(".eml"):
continue

try:
email_data = parse_eml(file_path)
except Exception as e:
logger.error(f"Failed to parse {file_path}: {e}")
except Exception:
logger.error(f"Failed to parse {file_path}", exc_info=True)
continue

chunks = chunk_text(email_data["body"])
Expand All @@ -70,9 +69,10 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession):
embeddings[0],
STORAGE_EMBEDDING_DIMENSION,
)
except Exception as e:
except Exception:
logger.error(
f"Failed to generate embedding for {email_data['message_id']}: {e}"
f"Failed to generate embedding for {email_data['message_id']}",
exc_info=True,
)

# Upsert into database
Expand All @@ -83,21 +83,23 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession):
organization_id=IMPORT_ORGANIZATION_ID,
)

batch_values.append(dict(
user_id=IMPORT_USER_ID,
organization_id=IMPORT_ORGANIZATION_ID,
message_id=email_data["message_id"],
sender=email_data["sender"],
reply_to=email_data.get("reply_to"),
recipients=email_data["recipients"],
subject=email_data["subject"],
in_reply_to=email_data.get("in_reply_to"),
references=email_data.get("references"),
thread_id=thread_id,
date=email_data["date"],
body=email_data["body"],
embedding=embedding,
))
batch_values.append(
dict(
user_id=IMPORT_USER_ID,
organization_id=IMPORT_ORGANIZATION_ID,
message_id=email_data["message_id"],
sender=email_data["sender"],
reply_to=email_data.get("reply_to"),
recipients=email_data["recipients"],
subject=email_data["subject"],
in_reply_to=email_data.get("in_reply_to"),
references=email_data.get("references"),
thread_id=thread_id,
date=email_data["date"],
body=email_data["body"],
embedding=embedding,
)
)

if batch_values:
stmt = insert(Email)
Expand Down
18 changes: 11 additions & 7 deletions backend/services/imap_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ async def process_fetched_email(
await extract_knowledge_from_self_sent(session, new_email, owner_addresses)
return new_email


logger = logging.getLogger(__name__)
MAX_IMAP_FETCH_MESSAGES = 10

Expand All @@ -112,7 +113,11 @@ def flags_indicate_seen(fetch_data) -> bool:
for item in fetch_data or []:
parts = item if isinstance(item, (tuple, list)) else (item,)
for part in parts:
raw = part if isinstance(part, bytes) else str(part).encode("utf-8", "replace")
raw = (
part
if isinstance(part, bytes)
else str(part).encode("utf-8", "replace")
)
upper = raw.upper()
if b"FLAGS" in upper and b"\\SEEN" in upper:
return True
Expand Down Expand Up @@ -162,8 +167,8 @@ async def _run_loop(self):
await self._sync()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in ImapSyncWorker loop: {e}", exc_info=True)
except Exception:
logger.error("Error in ImapSyncWorker loop", exc_info=True)

# Sleep for 1 minute before the next sync
if self._is_running:
Expand Down Expand Up @@ -217,7 +222,7 @@ async def _sync_tenant(self, config: TenantConfig | ImapSyncConfig):
config.user_id,
)
return 0

logger.info(
"Connecting to IMAP server %s:%s for user %s",
imap_server,
Expand Down Expand Up @@ -252,6 +257,7 @@ async def _fetch_messages(
if imap_server is None or imap_port is None:
imap_server, imap_port = self._validated_destination(config)
import ssl

ssl_context = ssl.create_default_context()
imap_client = aioimaplib.IMAP4_SSL(
imap_server, imap_port, ssl_context=ssl_context
Expand Down Expand Up @@ -388,6 +394,4 @@ def _looks_like_rfc822_message(self, value: bytes) -> bool:
header_block = value.split(b"\r\n\r\n", maxsplit=1)[0]
if header_block == value:
header_block = value.split(b"\n\n", maxsplit=1)[0]
return b":" in header_block and (
b"\r\n\r\n" in value or b"\n\n" in value
)
return b":" in header_block and (b"\r\n\r\n" in value or b"\n\n" in value)
48 changes: 24 additions & 24 deletions backend/services/llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,27 +62,27 @@ async def extract_action_items_and_summary(
response = await provider_circuit_breaker.call(
validated_base_url or "openai-default",
lambda: retry_transient(
lambda: client.beta.chat.completions.parse(
model=selected_model,
messages=[
{
"role": "system",
"content": (
"You are a helpful assistant. Summarize the email, "
"extract action items, and include a confidence score "
"from 0 to 100 when enough evidence is available."
),
},
{"role": "user", "content": email_body},
],
response_format=ExtractionResult,
),
operation_name="summary extraction",
lambda: client.beta.chat.completions.parse(
model=selected_model,
messages=[
{
"role": "system",
"content": (
"You are a helpful assistant. Summarize the email, "
"extract action items, and include a confidence score "
"from 0 to 100 when enough evidence is available."
),
},
{"role": "user", "content": email_body},
],
response_format=ExtractionResult,
),
operation_name="summary extraction",
),
)
except Exception as e:
logger.error(f"Error calling LLM API for extraction: {e}")
raise LLMServiceError(f"LLM API error during extraction: {e}") from e
logger.error("Error calling LLM API for extraction", exc_info=True)
raise LLMServiceError("LLM API error during extraction") from e
finally:
await client.close()

Expand Down Expand Up @@ -147,8 +147,8 @@ async def translate_email_body(
),
)
except Exception as e:
logger.error(f"Error calling LLM API for translation: {e}")
raise LLMServiceError(f"LLM API error during translation: {e}") from e
logger.error("Error calling LLM API for translation", exc_info=True)
raise LLMServiceError("LLM API error during translation") from e
finally:
await client.close()

Expand Down Expand Up @@ -189,8 +189,8 @@ async def draft_reply(
messages,
)
except Exception as e:
logger.error(f"Error calling LLM API for drafting: {e}")
raise LLMServiceError(f"LLM API error during drafting: {e}") from e
logger.error("Error calling LLM API for drafting", exc_info=True)
raise LLMServiceError("LLM API error during drafting") from e
finally:
await http_client.aclose()

Expand All @@ -211,8 +211,8 @@ async def draft_reply(
),
)
except Exception as e:
logger.error(f"Error calling LLM API for drafting: {e}")
raise LLMServiceError(f"LLM API error during drafting: {e}") from e
logger.error("Error calling LLM API for drafting", exc_info=True)
raise LLMServiceError("LLM API error during drafting") from e
finally:
await client.close()

Expand Down
16 changes: 8 additions & 8 deletions backend/services/pop3_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ async def _run_loop(self):
await self._sync()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in Pop3SyncWorker loop: {e}", exc_info=True)
except Exception:
logger.error("Error in Pop3SyncWorker loop", exc_info=True)

if self._is_running:
try:
Expand All @@ -57,16 +57,18 @@ async def _run_loop(self):

async def _sync(self):
async with AsyncSessionLocal() as session:
result = await session.execute(select(TenantConfig).where(TenantConfig.pop3_server.isnot(None)))
result = await session.execute(
select(TenantConfig).where(TenantConfig.pop3_server.isnot(None))
)
configs = result.scalars().all()

semaphore = asyncio.Semaphore(10)
tasks = []
for config in configs:
if not config.pop3_server or not config.pop3_port:
continue
tasks.append(self._sync_tenant(config, semaphore))

if tasks:
await asyncio.gather(*tasks, return_exceptions=True)

Expand Down Expand Up @@ -195,7 +197,5 @@ def _message_number_from_listing(self, listing: bytes | str) -> int | None:

def _bytes_line(self, line: bytes | str) -> bytes:
return (
line
if isinstance(line, bytes)
else line.encode("utf-8", errors="replace")
line if isinstance(line, bytes) else line.encode("utf-8", errors="replace")
)
10 changes: 6 additions & 4 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.27.0",
"next": "16.2.12",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-resizable-panels": "^4.12.2",
"sharp": "0.35.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
Expand Down
Loading