setting, session and profile fix

This commit is contained in:
debu-2 2026-10-07 06:16:26 +05:30
parent 068142ed61
commit a434af224c
34 changed files with 497 additions and 119 deletions

View file

@ -13,6 +13,7 @@ from app.database import get_mongo_database, get_postgres_session
from app.models import Account
from app.routers.auth import get_current_account
from app.schemas import (
EmailAttachmentCreate,
EmailCreate,
EmailRead,
MailAddress,
@ -27,6 +28,7 @@ from app.schemas import (
)
from app.services.domain_provisioning import get_domain_by_name
from app.services.mail_sender import deliver_email
from app.services.mime_body_extractor import ExtractedMimePart
from app.services.message_ids import canonical_message_id, message_id_fields
from app.services.mime_parser import parse_mime
from app.services.raw_mime_storage import get_raw_mime_storage
@ -44,8 +46,63 @@ from app.services.mailbox_scope import apply_external_scope
from app.services.thread_resolver import ThreadResolution
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024
MAX_ATTACHMENTS_TOTAL_BYTES = 25 * 1024 * 1024
logger = logging.getLogger(__name__)
def sanitize_attachment_filename(name: str) -> str:
cleaned = re.sub(r"[\\/]+", "", (name or "").strip()) or "attachment"
return cleaned[:255]
def decode_outbound_attachments(
items: list[EmailAttachmentCreate],
) -> tuple[list[ExtractedMimePart], list[ExtractedMimePart], list[dict]]:
inline_parts: list[ExtractedMimePart] = []
file_attachments: list[ExtractedMimePart] = []
metadata: list[dict] = []
total = 0
for item in items:
try:
payload_bytes = base64.b64decode(item.content_base64, validate=True)
except (ValueError, TypeError) as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Attachment {item.filename} is not valid base64.",
) from error
if len(payload_bytes) > MAX_ATTACHMENT_BYTES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{item.filename} is larger than 15 MB.",
)
total += len(payload_bytes)
if total > MAX_ATTACHMENTS_TOTAL_BYTES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Attachments together must be 25 MB or smaller.",
)
part = ExtractedMimePart(
filename=sanitize_attachment_filename(item.filename),
content_type=item.content_type or "application/octet-stream",
payload=payload_bytes,
content_id=item.content_id,
disposition=item.disposition,
)
meta = {
"filename": part.filename,
"content_type": part.content_type,
"size": len(part.payload),
"content_id": part.content_id,
"disposition": part.disposition,
}
metadata.append(meta)
if part.disposition == "inline":
inline_parts.append(part)
else:
file_attachments.append(part)
return inline_parts, file_attachments, metadata
router = APIRouter(prefix="/emails", tags=["emails"])
@ -196,6 +253,10 @@ async def create_email(
detail="Complete DNS setup for your company domain before sending mail.",
)
inline_parts, file_attachments, attachment_meta = decode_outbound_attachments(
payload.attachments
)
created_at = utc_now()
sender_name = " ".join(
part.strip()
@ -241,7 +302,7 @@ async def create_email(
"has_quoted_text": reply_display["has_quoted_text"],
"body_text": payload.body_text,
"body_html": payload.body_html,
"attachments": [],
"attachments": attachment_meta,
"created_at": created_at,
"sent_at": created_at,
"status": "queued",
@ -274,6 +335,8 @@ async def create_email(
domain=domain,
in_reply_to=threading_fields.get("in_reply_to"),
references=threading_fields.get("references"),
inline_parts=inline_parts,
file_attachments=file_attachments,
)
document["status"] = "accepted"
normalized_id = canonical_message_id(internet_message_id)
@ -328,7 +391,7 @@ async def create_email(
"recipients": document["to"],
"subject": document["subject"],
"snippet": document["snippet"],
"attachments": [],
"attachments": attachment_meta,
"status": document["status"],
"is_read": True,
"is_starred": False,

View file

@ -418,6 +418,14 @@ class OrgMemberRoleUpdate(BaseModel):
org_role: Literal["user", "admin"]
class EmailAttachmentCreate(BaseModel):
filename: str = Field(min_length=1, max_length=255)
content_type: str = Field(default="application/octet-stream", max_length=200)
content_base64: str = Field(min_length=1)
disposition: Literal["attachment", "inline"] = "attachment"
content_id: str | None = Field(default=None, max_length=200)
class EmailCreate(BaseModel):
recipients: list[EmailStr] = Field(default_factory=list)
cc: list[EmailStr] = Field(default_factory=list)
@ -428,6 +436,7 @@ class EmailCreate(BaseModel):
reply_to_message_id: str | None = None
reply_all: bool = False
thread_id: str | None = None
attachments: list[EmailAttachmentCreate] = Field(default_factory=list, max_length=10)
@model_validator(mode="after")
def recipients_required_without_reply(self):

View file

@ -1,8 +1,16 @@
import base64
from datetime import datetime, timezone
from bson import ObjectId
from app.routers.emails import decode_cursor, encode_cursor, summarize
from app.routers.emails import (
decode_cursor,
decode_outbound_attachments,
encode_cursor,
sanitize_attachment_filename,
summarize,
)
from app.schemas import EmailAttachmentCreate
def test_cursor_round_trip_preserves_sort_key():
@ -56,3 +64,30 @@ def test_summary_uses_account_mailbox_read_state():
assert result.is_read is False
assert result.mailbox == "inbox"
assert result.snippet == "A short preview"
def test_sanitize_attachment_filename_strips_paths():
assert sanitize_attachment_filename("../../secret.pdf") == "secret.pdf"
def test_decode_outbound_attachments_splits_inline_and_files():
pdf = EmailAttachmentCreate(
filename="invoice.pdf",
content_type="application/pdf",
content_base64=base64.b64encode(b"%PDF-1.4").decode(),
disposition="attachment",
)
image = EmailAttachmentCreate(
filename="logo.png",
content_type="image/png",
content_base64=base64.b64encode(b"PNG").decode(),
disposition="inline",
content_id="logo@agmail",
)
inline_parts, files, metadata = decode_outbound_attachments([pdf, image])
assert [part.filename for part in files] == ["invoice.pdf"]
assert [part.filename for part in inline_parts] == ["logo.png"]
assert metadata[0]["size"] == 8
assert metadata[1]["disposition"] == "inline"

View file

@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from app.services.mail_sender import build_message, deliver_email, send_via_smtp, sign_message
from app.services.mime_body_extractor import ExtractedMimePart
def test_build_message_formats_named_sender_header():
@ -46,6 +47,32 @@ def test_build_message_adds_safe_fallback_sender_name():
assert message["From"] == "aGentic Email <notifications@gentic.email>"
def test_build_message_includes_file_attachments():
message = build_message(
sender="suyash@gentic.email",
sender_name="Suyash Dixit",
recipients=["recipient@example.com"],
cc=[],
subject="Invoice",
body_text="See attached",
body_html="<p>See attached</p>",
file_attachments=[
ExtractedMimePart(
filename="invoice.pdf",
content_type="application/pdf",
payload=b"%PDF-1.4",
)
],
)
filenames = [
part.get_filename()
for part in message.walk()
if part.get_filename()
]
assert filenames == ["invoice.pdf"]
def test_dkim_sign_uses_selector_before_domain():
message = build_message(
sender="user@ritoesis.com",