diff --git a/backend/inbox/app.py b/backend/inbox/app.py index dce4ede..c817262 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -26,10 +26,30 @@ async def fetch_email(top:int=Query(100),skip:int=Query(0,ge=0),token=Query(...) return JSONResponse(content={"data":items_lst,"status_code":200}) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) -@router.post("/inbox/fetch") -async def fetch_inbox(session: AsyncSession = Depends(get_session)): - pass +@router.get("/inbox/fetch") +async def fetch_inbox( + record_id: str | None = Query(None), + search: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service=Email(session=session) + if record_id: + item=await service.get_inbox_message_by_id(record_id) + return JSONResponse(content={"data":item,"total":1,"status_code":200}) + + items=await service.get_inbox_messages(top,skip,search) + total=await service.count_inbox_messages(search) + return JSONResponse(content={"data":items,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 09b583a..3196e1c 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -2,10 +2,10 @@ import uuid from datetime import datetime from typing import Any, Optional -from sqlalchemy import Column +from sqlalchemy import Column, func, or_ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import Field, Relationship, SQLModel +from sqlmodel import Field, Relationship, SQLModel, select from users.models import Users @@ -45,6 +45,7 @@ class Inbox_Messages(SQLModel, table=True): __tablename__ = "inbox_messages" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + message_id: str | None = Field(default=None, index=True, unique=True) full_email_response: dict[str, Any] | None = Field( default=None, sa_column=Column(JSONB) ) @@ -73,31 +74,93 @@ class Inbox_Messages(SQLModel, table=True): return email_data.get("bodyPreview") or "" @classmethod - async def insert_email(cls, session: AsyncSession, email_data: dict): - email = cls( - message_subject=email_data.get("subject") or "", - message_body=cls._body_text(email_data), - message_sent_time=email_data.get("sentDateTime") or "", - message_read=bool(email_data.get("isRead")), - message_received_time=email_data.get("receivedDateTime") or "", - message_from=email_data.get("from", {}) + def _fields_from_email(cls, email_data: dict) -> dict: + return { + "message_subject": email_data.get("subject") or "", + "message_body": cls._body_text(email_data), + "message_sent_time": email_data.get("sentDateTime") or "", + "message_read": bool(email_data.get("isRead")), + "message_received_time": email_data.get("receivedDateTime") or "", + "message_from": email_data.get("from", {}) .get("emailAddress", {}) .get("address", ""), - message_to=",".join( + "message_to": ",".join( [r["emailAddress"]["address"] for r in email_data.get("toRecipients", [])] ), - message_cc=",".join( + "message_cc": ",".join( [r["emailAddress"]["address"] for r in email_data.get("ccRecipients", [])] ), - message_bcc=",".join( + "message_bcc": ",".join( [r["emailAddress"]["address"] for r in email_data.get("bccRecipients", [])] ), - attachment=bool(email_data.get("hasAttachments")), - message_reply=",".join( + "attachment": bool(email_data.get("hasAttachments")), + "message_reply": ",".join( [r["emailAddress"]["address"] for r in email_data.get("replyTo", [])] ), - full_email_response=email_data, - ) + "message_id": email_data.get("id"), + "full_email_response": email_data, + } + + @classmethod + async def insert_email(cls, session: AsyncSession, email_data: dict): + fields = cls._fields_from_email(email_data) + external_id = fields.get("message_id") + + if external_id: + existing = ( + await session.execute( + select(cls).where(cls.message_id == external_id) + ) + ).scalars().first() + if existing: + for key, value in fields.items(): + setattr(existing, key, value) + session.add(existing) + await session.commit() + await session.refresh(existing) + return existing + + email = cls(**fields) session.add(email) await session.commit() return email + + @classmethod + def _search_filter(cls, search: str): + pattern = f"%{search}%" + return or_( + cls.message_subject.ilike(pattern), + cls.message_from.ilike(pattern), + cls.message_body.ilike(pattern), + ) + + @classmethod + async def get_inbox_messages( + cls, session: AsyncSession, top: int | None, skip: int, search: str | None + ): + statement = select(cls).order_by(cls.message_received_time.desc()) + if search: + statement = statement.where(cls._search_filter(search)) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return result.scalars().all() + + @classmethod + async def get_inbox_message_by_id(cls, session: AsyncSession, record_id: str): + try: + uid = uuid.UUID(str(record_id)) + except ValueError: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def count_inbox_messages(cls, session: AsyncSession, search: str | None): + statement = select(func.count()).select_from(cls) + if search: + statement = statement.where(cls._search_filter(search)) + result = await session.execute(statement) + return result.scalar_one() diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index e69de29..689e796 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -0,0 +1,40 @@ +from pathlib import Path + +from inbox.models import Inbox_Messages + + +def serialize_message(message: Inbox_Messages) -> dict: + """inbox_messages row -> the shape the #inbox Email tab renders.""" + sender_name = message.message_from + full = message.full_email_response + if isinstance(full, dict): + from_block = full.get("from") + if isinstance(from_block, dict): + email_address = from_block.get("emailAddress") + if isinstance(email_address, dict): + name = email_address.get("name") + if name: + sender_name = name + + attachment_name = None + if message.file_path: + attachment_name = Path(message.file_path).name + + return { + "id": str(message.id), + "message_id": str(message.message_id) if message.message_id else None, + "sender_name": sender_name, + "fromEmail": message.message_from, + "subject": message.message_subject, + "body": message.message_body, + "when": message.message_received_time, + "unread": not message.message_read, + "attachment": message.attachment, + "attachment_name": attachment_name, + "message_to": message.message_to, + "message_cc": message.message_cc, + "message_bcc": message.message_bcc, + "message_sent_time": message.message_sent_time, + "message_reply": message.message_reply, + "file_path": message.file_path, + } diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 2913bc1..ab4248c 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -1,14 +1,17 @@ -import httpx,os +import httpx,os,uuid from fastapi import HTTPException from inbox.models import Inbox_Messages from inbox.file_decoder import decode_attachment, AttachmentDecodeError +from inbox.serializers import serialize_message from dotenv import load_dotenv load_dotenv() from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import func,or_ +from sqlmodel import select from pydantic import BaseModel class Email: - def __init__(self,session:AsyncSession,token): + def __init__(self,session:AsyncSession,token=None): self.session=session self.get_url=os.getenv("EMAIL_URL") self.token=token @@ -41,4 +44,13 @@ class Email: else: raise HTTPException(status_code=response.status_code,detail=response.text) except Exception as e: - raise HTTPException(status_code=500,detail=str(e)) \ No newline at end of file + raise HTTPException(status_code=500,detail=str(e)) + + async def get_inbox_messages(self,top,skip,search=None): + # Now here call the classmethod and return the result to app.py not removing the file or nbot rtouching the app.py code not touching any thin ion app.py only these two functions thjats it nothing else in views.py either pass + pass + + async def count_inbox_messages(self,search=None): + # Now here call the classmethod and return the result to app.py not removing the file or nbot rtouching the app.py code not touching any thin ion app.py only these two functions thjats it nothing else in views.py either + + pass diff --git a/backend/migrations/versions/20260803_1520-a55e6b0a4d9a_auto.py b/backend/migrations/versions/20260803_1520-a55e6b0a4d9a_auto.py new file mode 100644 index 0000000..270bab0 --- /dev/null +++ b/backend/migrations/versions/20260803_1520-a55e6b0a4d9a_auto.py @@ -0,0 +1,34 @@ +"""auto + +Revision ID: a55e6b0a4d9a +Revises: ea9c09868aff +Create Date: 2026-08-03 15:20:16.015993+00:00 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # SQLModel renders AutoString() into migrations but adds no import + + +revision: str = 'a55e6b0a4d9a' +down_revision: Union[str, None] = 'ea9c09868aff' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('inbox_messages', sa.Column('message_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True), schema='app') + op.create_index(op.f('ix_inbox_messages_message_id'), 'inbox_messages', ['message_id'], unique=True, schema='app') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_inbox_messages_message_id'), table_name='inbox_messages', schema='app') + op.drop_column('inbox_messages', 'message_id', schema='app') + # ### end Alembic commands ###