diff --git a/backend/inbox/decoded_attachments/Fatima Tanveer (4) (1).pdf b/backend/inbox/decoded_attachments/Fatima Tanveer (4) (1).pdf
new file mode 100644
index 0000000..ce10627
Binary files /dev/null and b/backend/inbox/decoded_attachments/Fatima Tanveer (4) (1).pdf differ
diff --git a/backend/inbox/decoded_attachments/M ABDULLAH SIDDIQUI - UI UX DESIGNER - RESUME.pdf b/backend/inbox/decoded_attachments/M ABDULLAH SIDDIQUI - UI UX DESIGNER - RESUME.pdf
new file mode 100644
index 0000000..7259124
Binary files /dev/null and b/backend/inbox/decoded_attachments/M ABDULLAH SIDDIQUI - UI UX DESIGNER - RESUME.pdf differ
diff --git a/backend/main.py b/backend/main.py
index f97a521..dae5ef8 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -21,4 +21,5 @@ app.add_middleware(
allow_headers=["*"],
)
-app.include_router(inbox_router)
\ No newline at end of file
+app.include_router(inbox_router)
+app.include_router(users_router)
\ No newline at end of file
diff --git a/backend/migrations/versions/20260803_1554-72853b8d2126_auto.py b/backend/migrations/versions/20260803_1554-72853b8d2126_auto.py
new file mode 100644
index 0000000..fa120d6
--- /dev/null
+++ b/backend/migrations/versions/20260803_1554-72853b8d2126_auto.py
@@ -0,0 +1,32 @@
+"""auto
+
+Revision ID: 72853b8d2126
+Revises: a55e6b0a4d9a
+Create Date: 2026-08-03 15:54:59.335413+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 = '72853b8d2126'
+down_revision: Union[str, None] = 'a55e6b0a4d9a'
+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('file_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), schema='app')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('inbox_messages', 'file_name', schema='app')
+ # ### end Alembic commands ###
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..e3c9c13
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,28 @@
+# HR-ATS-Portal backend dependencies.
+# pip install -r backend/requirements.txt
+# Versions are the ones this backend is currently developed and verified against.
+
+# --- web framework ---------------------------------------------------------
+fastapi==0.136.1
+uvicorn==0.47.0
+
+# --- database --------------------------------------------------------------
+sqlalchemy==2.0.51
+sqlmodel==0.0.38
+alembic==1.18.4
+asyncpg==0.31.0 # async driver used by the app (postgresql+asyncpg)
+psycopg2-binary==2.9.12 # sync driver for db_setup.url(async_driver=False)
+
+# --- settings and validation ----------------------------------------------
+pydantic==2.12.4
+pydantic-settings==2.12.0 # db_setup.Settings
+python-dotenv==1.2.1
+email-validator==2.3.0 # required by pydantic EmailStr in users/app.py
+
+# --- auth ------------------------------------------------------------------
+PyJWT==2.10.1 # access/refresh token encode+decode in users/plugins.py
+python-multipart==0.0.20 # required by OAuth2PasswordRequestForm in users/app.py
+
+# --- other -----------------------------------------------------------------
+httpx==0.28.1 # Graph email calls in inbox/views.py
+bcrypt==5.0.0 # password hashing in users/plugins.py
diff --git a/backend/users/app.py b/backend/users/app.py
index aafb0ce..6419691 100644
--- a/backend/users/app.py
+++ b/backend/users/app.py
@@ -1,14 +1,136 @@
+from typing import Annotated
+
from fastapi import APIRouter,Depends, Query
from fastapi.responses import JSONResponse
from fastapi import HTTPException
+from fastapi.security import OAuth2PasswordRequestForm
from db_setup import get_session
from sqlalchemy.ext.asyncio import AsyncSession
-from users.models import Users
+from pydantic import BaseModel, EmailStr
+from users.views import User
+from users.permissions import CurrentUser
+from users.serializers import serialize_token
+from users.plugins import create_access_token,create_refresh_token
from dotenv import load_dotenv
load_dotenv()
router = APIRouter()
+
+class UserCreate(BaseModel):
+ name: str
+ email: EmailStr
+ password: str
+ role_id: int | None = None
+ is_active: bool = True
+
+
+class UserUpdate(BaseModel):
+ name: str | None = None
+ email: EmailStr | None = None
+ password: str | None = None
+ role_id: int | None = None
+ is_active: bool | None = None
+
+
+class TokenRefresh(BaseModel):
+ refresh_token: str
+
+
+@router.post("/users/login")
+async def login(
+ form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
+ session: AsyncSession = Depends(get_session),
+):
+ try:
+ service=User(session=session)
+ user=await service.authenticate_user(form_data.username,form_data.password)
+ tokens=serialize_token(create_access_token(user),create_refresh_token(user),user)
+ return JSONResponse(content={**tokens,"status_code":200})
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500,detail=str(e))
+
+
+@router.post("/users/refresh")
+async def refresh(payload: TokenRefresh,session: AsyncSession = Depends(get_session)):
+ try:
+ service=User(session=session)
+ user=await service.refresh_access_token(payload.refresh_token)
+ tokens=serialize_token(create_access_token(user),create_refresh_token(user),user)
+ return JSONResponse(content={**tokens,"status_code":200})
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500,detail=str(e))
+
+
+@router.get("/users/me")
+async def me(current_user: CurrentUser):
+ try:
+ return JSONResponse(content={"data":current_user,"status_code":200})
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500,detail=str(e))
+
+
@router.post("/users/create")
-async def create_user(user: Users, session: AsyncSession = Depends(get_session)):
- pass
\ No newline at end of file
+async def create_user(payload: UserCreate,current_user: CurrentUser,session: AsyncSession = Depends(get_session)):
+ try:
+ service=User(session=session)
+ data=await service.create_user(payload.model_dump())
+ return JSONResponse(content={"data":data,"status_code":200})
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500,detail=str(e))
+
+
+@router.get("/users/fetch")
+async def fetch_users(
+ current_user: CurrentUser,
+ 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=User(session=session)
+ if record_id:
+ item=await service.get_user_by_id(record_id)
+ return JSONResponse(content={"data":item,"total":1,"status_code":200})
+
+ items=await service.get_users(top,skip,search)
+ total=await service.count_users(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))
+
+
+@router.put("/users/update")
+async def update_user(payload: UserUpdate,current_user: CurrentUser,record_id: str = Query(...),session: AsyncSession = Depends(get_session)):
+ try:
+ service=User(session=session)
+ data=await service.update_user(record_id,payload.model_dump())
+ return JSONResponse(content={"data":data,"status_code":200})
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500,detail=str(e))
+
+
+@router.delete("/users/delete")
+async def delete_user(current_user: CurrentUser,record_id: str = Query(...),session: AsyncSession = Depends(get_session)):
+ try:
+ service=User(session=session)
+ data=await service.delete_user(record_id)
+ return JSONResponse(content={"data":data,"status_code":200})
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500,detail=str(e))
diff --git a/backend/users/models.py b/backend/users/models.py
index b201aeb..f33fb33 100644
--- a/backend/users/models.py
+++ b/backend/users/models.py
@@ -1,7 +1,10 @@
import uuid
from datetime import datetime
-from sqlmodel import Field, Relationship, SQLModel
+from sqlalchemy import func, or_
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+from sqlmodel import Field, Relationship, SQLModel, select
from role.models import Roles
@@ -19,3 +22,97 @@ class Users(SQLModel, table=True):
updated_at: datetime = Field(default_factory=datetime.now)
is_active: bool = Field(default=True)
is_deleted: bool = Field(default=False)
+
+ @classmethod
+ def _search_filter(cls, search: str):
+ pattern = f"%{search}%"
+ return or_(
+ cls.name.ilike(pattern),
+ cls.email.ilike(pattern),
+ )
+
+ @staticmethod
+ def _as_uuid(record_id: str) -> uuid.UUID | None:
+ try:
+ return uuid.UUID(str(record_id))
+ except ValueError:
+ return None
+
+ @classmethod
+ async def get_users(
+ cls, session: AsyncSession, top: int | None, skip: int, search: str | None
+ ):
+ statement = (
+ select(cls)
+ .options(selectinload(cls.role))
+ .where(cls.is_deleted == False)
+ .order_by(cls.created_at.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_user_by_id(cls, session: AsyncSession, record_id: str):
+ uid = cls._as_uuid(record_id)
+ if uid is None:
+ return None
+ statement = select(cls).options(selectinload(cls.role)).where(cls.id == uid)
+ result = await session.execute(statement)
+ return result.scalars().first()
+
+ @classmethod
+ async def get_user_by_email(cls, session: AsyncSession, email: str):
+ result = await session.execute(select(cls).where(cls.email == email))
+ return result.scalars().first()
+
+ @classmethod
+ async def count_users(cls, session: AsyncSession, search: str | None):
+ statement = (
+ select(func.count())
+ .select_from(cls)
+ .where(cls.is_deleted == False) # noqa: E712
+ )
+ if search:
+ statement = statement.where(cls._search_filter(search))
+ result = await session.execute(statement)
+ return result.scalar_one()
+
+ @classmethod
+ async def insert_user(cls, session: AsyncSession, fields: dict):
+ """`fields["password"]` is expected to be hashed already — see users.plugins."""
+ user = cls(**fields)
+ session.add(user)
+ await session.commit()
+ return await cls.get_user_by_id(session, user.id)
+
+ @classmethod
+ async def update_user(cls, session: AsyncSession, record_id: str, fields: dict):
+ user = await cls.get_user_by_id(session, record_id)
+ if not user:
+ return None
+ for key, value in fields.items():
+ setattr(user, key, value)
+ user.updated_at = datetime.now()
+ session.add(user)
+ await session.commit()
+ await session.refresh(user)
+ return await cls.get_user_by_id(session, user.id)
+
+ @classmethod
+ async def soft_delete_user(cls, session: AsyncSession, record_id: str):
+ user = await cls.get_user_by_id(session, record_id)
+ if not user:
+ return None
+ user.is_deleted = True
+ user.is_active = False
+ user.updated_at = datetime.now()
+ session.add(user)
+ await session.commit()
+ await session.refresh(user)
+ return user
diff --git a/backend/users/permissions.py b/backend/users/permissions.py
new file mode 100644
index 0000000..084c2d8
--- /dev/null
+++ b/backend/users/permissions.py
@@ -0,0 +1,44 @@
+"""OAuth2 bearer scheme and the current-user dependency for `/users/*` routes."""
+
+from __future__ import annotations
+
+from typing import Annotated
+
+import jwt
+from fastapi import Depends, HTTPException
+from fastapi.security import OAuth2PasswordBearer
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from db_setup import get_session
+from users.models import Users
+from users.plugins import decode_token
+from users.serializers import serialize_user
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="users/login")
+
+
+async def get_current_user(
+ token: Annotated[str, Depends(oauth2_scheme)],
+ session: Annotated[AsyncSession, Depends(get_session)],
+) -> dict:
+ credentials_exception = HTTPException(
+ status_code=401,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ try:
+ payload = decode_token(token, expected_type="access")
+ except jwt.PyJWTError:
+ raise credentials_exception
+
+ user = await Users.get_user_by_id(session, payload.get("sub"))
+ if not user or user.is_deleted or not user.is_active:
+ raise HTTPException(
+ status_code=401,
+ detail="User is inactive or does not exist",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ return serialize_user(user)
+
+
+CurrentUser = Annotated[dict, Depends(get_current_user)]
diff --git a/backend/users/plugins.py b/backend/users/plugins.py
new file mode 100644
index 0000000..74546df
--- /dev/null
+++ b/backend/users/plugins.py
@@ -0,0 +1,119 @@
+"""Users helpers — password hashing, JWT tokens, and payload cleaning.
+
+Uses the `bcrypt` package directly rather than passlib: passlib 1.7.4 reads
+`bcrypt.__about__.__version__`, which bcrypt dropped in 4.1, and the failed
+version probe makes it reject every password as longer than 72 bytes.
+"""
+
+from __future__ import annotations
+
+import os
+import uuid
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+import bcrypt
+import jwt
+from dotenv import load_dotenv
+
+load_dotenv()
+
+# bcrypt hashes at most 72 bytes and raises on anything longer.
+BCRYPT_MAX_BYTES = 72
+
+# Columns the server owns; a client must never be able to set them.
+SERVER_OWNED_FIELDS = ("id", "created_at", "updated_at", "is_deleted")
+
+JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
+REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
+ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60
+
+
+def _encode(raw: str) -> bytes:
+ """UTF-8 bytes truncated to what bcrypt accepts, without splitting a character."""
+ return raw.encode("utf-8")[:BCRYPT_MAX_BYTES].decode("utf-8", "ignore").encode("utf-8")
+
+
+def hash_password(raw: str) -> str:
+ return bcrypt.hashpw(_encode(raw), bcrypt.gensalt()).decode("ascii")
+
+
+def verify_password(raw: str, hashed: str) -> bool:
+ """False rather than raising on rows written before hashing existed."""
+ if not raw or not hashed:
+ return False
+ try:
+ return bcrypt.checkpw(_encode(raw), hashed.encode("utf-8"))
+ except (ValueError, TypeError):
+ return False
+
+
+def clean_user_payload(payload: dict, *, partial: bool = False) -> dict:
+ """Strip server-owned keys and hash the password; on partial, drop unset fields."""
+ fields = {
+ key: value
+ for key, value in payload.items()
+ if key not in SERVER_OWNED_FIELDS
+ }
+ if partial:
+ fields = {key: value for key, value in fields.items() if value is not None}
+ if fields.get("password"):
+ fields["password"] = hash_password(fields["password"])
+ else:
+ fields.pop("password", None)
+ return fields
+
+
+def _secret() -> str:
+ if not JWT_SECRET_KEY:
+ raise RuntimeError("JWT_SECRET_KEY is not set")
+ return JWT_SECRET_KEY
+
+
+def _create_token(
+ subject: str,
+ *,
+ token_type: str,
+ expires_delta: timedelta,
+ claims: dict[str, Any] | None = None,
+) -> str:
+ now = datetime.now(timezone.utc)
+ payload: dict[str, Any] = {
+ "sub": subject,
+ "type": token_type,
+ "iat": now,
+ "exp": now + expires_delta,
+ "jti": str(uuid.uuid4()),
+ }
+ if claims:
+ payload.update(claims)
+ return jwt.encode(payload, _secret(), algorithm=JWT_ALGORITHM)
+
+
+def create_access_token(user) -> str:
+ return _create_token(
+ str(user.id),
+ token_type="access",
+ expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+ claims={
+ "email": user.email,
+ "role_id": user.role_id,
+ },
+ )
+
+
+def create_refresh_token(user) -> str:
+ return _create_token(
+ str(user.id),
+ token_type="refresh",
+ expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
+ )
+
+
+def decode_token(token: str, *, expected_type: str) -> dict:
+ payload = jwt.decode(token, _secret(), algorithms=[JWT_ALGORITHM])
+ if payload.get("type") != expected_type:
+ raise jwt.InvalidTokenError("Unexpected token type")
+ return payload
diff --git a/backend/users/serializers.py b/backend/users/serializers.py
new file mode 100644
index 0000000..03a9230
--- /dev/null
+++ b/backend/users/serializers.py
@@ -0,0 +1,34 @@
+from users.models import Users
+from users.plugins import ACCESS_TOKEN_EXPIRE_SECONDS
+
+
+def serialize_user(user: Users) -> dict:
+ """users row -> the shape the #rbac Users tab renders. Never includes password."""
+ role = getattr(user, "role", None)
+ role_name = None
+ if role is not None:
+ role_name = getattr(role.role_name, "value", role.role_name)
+
+ return {
+ "id": str(user.id),
+ "name": user.name,
+ "email": user.email,
+ "role_id": user.role_id,
+ "role_name": role_name,
+ "role_description": role.description if role is not None else None,
+ "is_active": user.is_active,
+ "is_deleted": user.is_deleted,
+ "created_at": user.created_at.isoformat() if user.created_at else None,
+ "updated_at": user.updated_at.isoformat() if user.updated_at else None,
+ }
+
+
+def serialize_token(access_token: str, refresh_token: str, user: Users) -> dict:
+ """Login/refresh payload. OAuth2 fields live at the root so Swagger's Authorize can read them."""
+ return {
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "token_type": "bearer",
+ "expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
+ "data": serialize_user(user),
+ }
diff --git a/backend/users/views.py b/backend/users/views.py
new file mode 100644
index 0000000..cfee2a0
--- /dev/null
+++ b/backend/users/views.py
@@ -0,0 +1,77 @@
+from fastapi import HTTPException
+from users.models import Users
+from users.serializers import serialize_user
+from users.plugins import clean_user_payload,verify_password,decode_token
+from dotenv import load_dotenv
+load_dotenv()
+from sqlalchemy.ext.asyncio import AsyncSession
+import jwt
+
+class User:
+ def __init__(self,session:AsyncSession):
+ self.session=session
+
+ async def create_user(self,payload):
+ existing=await Users.get_user_by_email(self.session,payload.get("email"))
+ if existing:
+ raise HTTPException(status_code=409,detail="Email already registered")
+ # this is for password hasshing
+ fields=clean_user_payload(payload)
+ if not fields.get("password"):
+ raise HTTPException(status_code=400,detail="Password is required")
+ user=await Users.insert_user(self.session,fields)
+ return serialize_user(user)
+
+ async def get_users(self,top,skip,search=None):
+ users=await Users.get_users(self.session,top,skip,search)
+ return [serialize_user(u) for u in users]
+
+ async def get_user_by_id(self,record_id):
+ user=await Users.get_user_by_id(self.session,record_id)
+ if not user:
+ raise HTTPException(status_code=404,detail="User not found")
+ return serialize_user(user)
+
+ async def update_user(self,record_id,payload):
+ user=await Users.get_user_by_id(self.session,record_id)
+ if not user:
+ raise HTTPException(status_code=404,detail="User not found")
+ fields=clean_user_payload(payload,partial=True)
+ email=fields.get("email")
+ if email and email!=user.email:
+ clash=await Users.get_user_by_email(self.session,email)
+ if clash:
+ raise HTTPException(status_code=409,detail="Email already registered")
+ updated=await Users.update_user(self.session,record_id,fields)
+ return serialize_user(updated)
+
+ async def delete_user(self,record_id):
+ user=await Users.soft_delete_user(self.session,record_id)
+ if not user:
+ raise HTTPException(status_code=404,detail="User not found")
+ return serialize_user(user)
+
+ async def count_users(self,search=None):
+ return await Users.count_users(self.session,search)
+
+ async def authenticate_user(self,email,password):
+ user=await Users.get_user_by_email(self.session,email)
+ if not user or not verify_password(password,user.password):
+ raise HTTPException(
+ status_code=401,
+ detail="Incorrect email or password",
+ headers={"WWW-Authenticate":"Bearer"},
+ )
+ if user.is_deleted or not user.is_active:
+ raise HTTPException(status_code=401,detail="User is inactive")
+ return await Users.get_user_by_id(self.session,user.id)
+
+ async def refresh_access_token(self,refresh_token):
+ try:
+ payload=decode_token(refresh_token,expected_type="refresh")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=401,detail="Invalid or expired refresh token")
+ user=await Users.get_user_by_id(self.session,payload.get("sub"))
+ if not user or user.is_deleted or not user.is_active:
+ raise HTTPException(status_code=401,detail="User is inactive or does not exist")
+ return user
diff --git a/docs/aws-production-cost-estimate.md b/docs/aws-production-cost-estimate.md
new file mode 100644
index 0000000..c84aece
--- /dev/null
+++ b/docs/aws-production-cost-estimate.md
@@ -0,0 +1,678 @@
+# AWS Production Cost Estimate — Utopia Brands HR/ATS Portal
+
+**Status:** Draft for budget approval. Prepared 2026-08-04.
+**Platform:** Amazon Web Services only. Every service, alternative and price in this document is AWS.
+**Prepared against:** `docs/architecture/` (the signed-off-pending architecture package) and the
+current state of the repository.
+
+---
+
+## 0. The number, up front
+
+| Scenario | Monthly (on-demand) | Monthly (with 1-yr commitments) | Annual (committed) |
+|---|---:|---:|---:|
+| **A — Lean** (single-AZ, Spot workers, accepts downtime) | $454 | $404 | **$4,850** |
+| **B — Recommended** (Multi-AZ, HA, the sizing the architecture specifies) | $957 | $814 | **$9,770** |
+| **C — Scale** (3× volume: 100k+ applications/yr, ~75 concurrent users) | $2,800 | $2,380 | **$28,560** |
+
+Add **staging** (~$220/mo, or ~$140/mo with an off-hours shutdown schedule) and **AWS Support**
+(Developer $29/mo, Business ~$130/mo at this spend).
+
+**Recommended budget line: $1,206/month all-in ($957 prod + $220 staging + $29 support)
+= ~$14,470 in year 2 on-demand, ~$12,760 with 1-year commitments.**
+
+**Year 1 is lower** because production does not exist for the first ~7 months. See §10.
+
+> **Unit economics.** At Option B and the architecture's midpoint volume of 40,000
+> applications/year, infrastructure costs **$0.29 per application processed**, or
+> **$15.25 per named seat per month** across 66 seats.
+
+---
+
+## 1. What this document prices, and on what basis
+
+Every sizing input below is taken from the architecture package or read directly out of the
+repository. Nothing is invented. Where the source itself says **ASSUMPTION**, that label is carried
+forward — those are the numbers most likely to move the total.
+
+| Input | Value | Source |
+|---|---|---|
+| Named seats | 66 | `02-system-architecture.md:833` (BRD §4) |
+| Peak concurrent users | 20–25 | `02-system-architecture.md:834` — **ASSUMPTION** |
+| Applications per year | 20,000–60,000 | `02-system-architecture.md:835` — **ASSUMPTION** |
+| Documents per day at peak | 200–600 | `02-system-architecture.md:836` — **ASSUMPTION** |
+| Blob volume, year one | well under 1 TB | `02-system-architecture.md:837` — **ASSUMPTION** |
+| Candidate rows, several years | 10⁴–10⁵ | `02-system-architecture.md:838` — **ASSUMPTION** |
+| Queue throughput | hundreds of jobs/hour | `02-system-architecture.md:839` |
+| Web process | 2 vCPU / 4 GB, autoscale 1–4 | `02-system-architecture.md:439` |
+| Worker process | 2 vCPU / 4 GB, concurrency 4, autoscale 1–3 | `02-system-architecture.md:440` |
+| Database | PostgreSQL 16, 2 vCPU / 8 GB, PITR, 14-day backups | `02-system-architecture.md:445` |
+| Cache | Redis — cache, rate limit, sessions. **Never a broker** | `02-system-architecture.md:447` |
+| Queue | PostgreSQL-backed (`procrastinate`). No Redis broker, no Kafka | ADR 0004 |
+| Environments | local (docker compose), staging, production. **No per-developer cloud env** | `02-system-architecture.md:490-496` |
+| Max upload size | 25 MB per file | `06-api-boundaries.md:1126` — **ASSUMPTION** |
+| Audit retention | 7 years, WORM/immutable archive | `02-system-architecture.md:1102` — **ASSUMPTION** |
+
+The volume figures are modest. This is a **66-seat internal system**, not a public SaaS. The cost
+model reflects that: the dominant lines are the database and the always-on network plumbing, not
+compute or storage.
+
+---
+
+## 2. Azure → AWS service mapping
+
+ADR 0012 recommends Azure on the strength of assumption A1 (Utopia Brands runs Microsoft 365, so
+Entra ID and Graph co-locate). The same document states plainly that **"the architecture is
+unchanged and the equivalent AWS or GCP services substitute directly"**
+(`02-system-architecture.md:950-956`). This is that substitution.
+
+| Architecture calls for | Azure (ADR 0012) | **AWS equivalent used here** | Note |
+|---|---|---|---|
+| Container platform, one image / two revisions | Container Apps | **ECS on Fargate** — one task definition family, two services (`web`, `worker`) | Closest 1:1 fit. See §11 for why not App Runner / EKS / EC2 |
+| Managed PostgreSQL 16 | Flexible Server | **RDS for PostgreSQL 16** (Graviton) | All required extensions available: `pg_trgm`, `unaccent`, `btree_gist`, `pgcrypto`, `pgvector` |
+| Object storage for CV blobs | Blob Storage | **S3** | The architecture already names S3 as *"the default"* if the cloud decision moves to AWS (`04-integrations-and-processing.md:1062`) |
+| Immutable audit archive | Immutable blob container | **S3 Object Lock (Compliance mode)** | `_decisions.md` names Object Lock by name for this purpose |
+| Cache / rate limit / sessions | Managed Redis | **ElastiCache for Valkey** (Redis-compatible) | Valkey is ~20% cheaper than the Redis OSS engine for the same node |
+| Secret store, no keys in code | Key Vault | **Secrets Manager** (rotating) + **SSM Parameter Store** (non-secret config, free) | Accessed via ECS task role — no static credentials |
+| Managed identity | Managed Identity | **IAM roles for tasks (IRSA-equivalent)** | Same "no keys anywhere" property |
+| SSO | Entra ID | **Entra ID, unchanged** — federated to AWS via **IAM Identity Center** (OIDC) for console access | The application keeps using Entra as its IdP. AWS does not replace it |
+| Careers mailbox | Microsoft Graph | **Microsoft Graph, unchanged** | Graph is an M365 service, not a cloud-platform service. Only the *egress path* changes (NAT Gateway) |
+| CDN + TLS + WAF | Front Door | **CloudFront + ACM + AWS WAF** | ACM certificates are free |
+| Load balancer | Container Apps ingress | **Application Load Balancer** | Required in front of ECS |
+| Container registry | ACR | **ECR** | |
+| Log workspace | Log Analytics | **CloudWatch Logs** | |
+| Malware scanning | ClamAV in worker image, or Defender for Storage | **GuardDuty Malware Protection for S3** | See §7 — cheaper *and* better than ClamAV at this volume |
+| OCR | (unspecified) | **Amazon Textract** as fallback behind free local parsers | |
+| AI provider | contracted API provider under DPA | **Amazon Bedrock** | Satisfies the `05-security` T-13 requirement directly: zero retention, no training on customer data, in-region processing, and it is inside the same account boundary |
+| Error tracking | Sentry (self-hosted or EU) | Sentry remains a third-party SaaS. **AWS-native alternative:** CloudWatch Application Signals + X-Ray | Priced as CloudWatch below; Sentry SaaS is out of AWS scope |
+
+**Two things AWS improves over the Azure plan, at no extra cost:**
+
+1. **Bedrock resolves open item BL-3.** `05-security-rbac-ai-governance.md:720` flags AI provider
+ data handling as an unresolved legal blocker requiring a DPA with zero-retention and no-training
+ terms. Bedrock provides exactly that contractually, inside the customer's own AWS account, under
+ the existing AWS agreement — no new vendor, no new DPA negotiation, no new data processor.
+2. **Object Lock is native.** `_decisions.md` layer 4 requires a write-once audit archive. S3 Object
+ Lock in Compliance mode is the reference implementation of that requirement.
+
+---
+
+## 3. Target architecture on AWS
+
+```mermaid
+graph TB
+ subgraph EDGE["Edge — public"]
+ R53["Route 53
DNS + health checks"]
+ CF["CloudFront
static bundle + /api behaviour
1 TB/mo egress free"]
+ WAF["AWS WAF
managed rules + Bot Control
on the careers form"]
+ ACM["ACM
TLS certs — free"]
+ end
+
+ subgraph VPC["VPC — 2 Availability Zones"]
+ subgraph PUB["Public subnets"]
+ ALB["Application Load Balancer"]
+ NAT["NAT Gateway x2
outbound to Graph + job boards"]
+ end
+ subgraph PRIV["Private subnets — no inbound from internet"]
+ WEB["ECS Fargate service: web
2 vCPU / 4 GB
desired 2, autoscale 2-4"]
+ WRK["ECS Fargate service: worker
2 vCPU / 4 GB
desired 1, autoscale 1-3"]
+ MIG["ECS RunTask: migrate
one-off, per deploy"]
+ end
+ subgraph DATA["Data — private, encrypted at rest"]
+ RDS[("RDS PostgreSQL 16
db.m7g.large Multi-AZ
PITR 14 days")]
+ EC[("ElastiCache for Valkey
cache.t4g.small x2
cache / rate limit / sessions")]
+ end
+ VPE["S3 Gateway Endpoint
FREE — keeps CV traffic off NAT"]
+ end
+
+ subgraph STORE["Storage & AI — regional services"]
+ S3A[("S3: candidate-documents
SSE-KMS, versioning off,
7-day soft delete")]
+ S3B[("S3: audit-archive
Object Lock COMPLIANCE
Glacier Instant Retrieval")]
+ S3C[("S3: static frontend
OAC-restricted to CloudFront")]
+ BR["Amazon Bedrock
ATS scoring + chatbot"]
+ TX["Amazon Textract
OCR fallback only"]
+ GD["GuardDuty
Malware Protection for S3"]
+ end
+
+ subgraph OPS["Security & ops"]
+ SM["Secrets Manager
Graph creds, DB password"]
+ KMS["KMS
4 customer-managed keys"]
+ CW["CloudWatch
logs, metrics, alarms"]
+ CT["CloudTrail + AWS Config"]
+ ECR["ECR
one image, keep last 10"]
+ end
+
+ EXT["Microsoft Graph
careers mailbox + Entra ID SSO"]
+
+ R53 --> CF
+ WAF --> CF
+ ACM -.-> CF
+ CF --> S3C
+ CF --> ALB
+ ALB --> WEB
+ WEB --> RDS
+ WEB --> EC
+ WEB --> VPE
+ WRK --> RDS
+ WRK --> VPE
+ WRK --> NAT
+ WEB --> NAT
+ NAT --> EXT
+ VPE --> S3A
+ RDS -.->|"LISTEN / NOTIFY"| WRK
+ RDS -->|"nightly closed-partition export"| S3B
+ S3A --> GD
+ WRK --> BR
+ WRK --> TX
+ WEB --> BR
+ WEB --> SM
+ WRK --> SM
+ KMS -.-> S3A
+ KMS -.-> RDS
+ WEB --> CW
+ WRK --> CW
+ MIG --> RDS
+ ECR -.-> WEB
+ ECR -.-> WRK
+
+ classDef free fill:#eafff4,stroke:#004d43,stroke-width:2px
+ classDef costly fill:#fff4e6,stroke:#a35200,stroke-width:2px
+ class VPE,ACM free
+ class RDS,NAT,BR costly
+```
+
+Green = free and load-bearing. Amber = the three lines that dominate the bill.
+
+---
+
+## 4. Pricing basis and honesty statement
+
+| | |
+|---|---|
+| **Region priced** | `us-east-1` (N. Virginia) — AWS's cheapest major region, used as the baseline |
+| **Prices** | AWS **public list prices**, on-demand, as of **August 2026** |
+| **Excludes** | Taxes/VAT, Enterprise Discount Program terms, AWS Marketplace software, third-party SaaS (Sentry, GitHub), staff time, and one-time engineering effort |
+| **Hours/month** | 730 |
+| **Currency** | USD |
+
+> ⚠️ **These figures were compiled from published AWS list pricing, not from a live query against the
+> AWS Pricing API.** Before this document is used to commit spend, re-run every line through the
+> [AWS Pricing Calculator](https://calculator.aws) for the region actually chosen. Expect individual
+> line items to move by a few percent; expect the **total** to land within ±10% of Option B.
+
+### 4.1 Region multiplier — the single largest lever on this number
+
+Region is not yet decided. `02-system-architecture.md:502` records that postings span **six
+jurisdictions** and that residency is unresolved (BRD OQ-4). Region choice moves the total by up
+to 30%.
+
+| Region | Multiplier vs `us-east-1` | Option B monthly | When you would choose it |
+|---|---:|---:|---|
+| `us-east-1` N. Virginia | 1.00× | $957 | Cheapest; no EU/UK residency guarantee |
+| `us-west-2` Oregon | 1.00× | $957 | Same price, better DR pairing with us-east-1 |
+| `eu-west-1` Ireland | ~1.06× | ~$1,015 | GDPR residency, English-language jurisdiction |
+| `eu-central-1` Frankfurt | ~1.12× | ~$1,072 | Strictest GDPR posture |
+| `eu-west-2` London | ~1.10× | ~$1,053 | UK data residency |
+| `ap-south-1` Mumbai | ~0.97× | ~$928 | Cheapest of the non-US options |
+| `me-central-1` UAE | ~1.25× | ~$1,196 | Only if Gulf residency is mandated |
+
+**Recommendation:** if residency is genuinely unconstrained, use `us-east-1`. If any of the six
+jurisdictions is in the EU/UK — which is likely — use **`eu-west-1`** and budget ~$1,015/mo for
+Option B. Do not split across regions; the architecture forbids it
+(`02-system-architecture.md:66` — "no region column, no tenant module").
+
+---
+
+## 5. Option B — Recommended production, line by line
+
+This is the configuration the architecture actually specifies, deployed with the availability
+posture a system holding candidate PII under legal retention obligations should have.
+
+### 5.1 Compute
+
+| Line | Configuration | Calculation | $/mo |
+|---|---|---|---:|
+| ECS Fargate — `web` | 2 tasks × 2 vCPU / 4 GB, 24×7 | 2 × (2 × $0.04048 + 4 × $0.004445) × 730 | **144.16** |
+| ECS Fargate — `worker` | 1 baseline, bursts to 3; avg 1.2 tasks × 2 vCPU / 4 GB | 1.2 × $0.09874 × 730 | **86.50** |
+| ECS Fargate — `migrate` | One-off task per deploy, ~3 min, ~30 deploys/mo | 30 × 0.05 h × $0.09874 | **0.15** |
+| Application Load Balancer | 1 ALB + ~2 LCU average | $0.0225 × 730 + 2 × $0.008 × 730 | **28.11** |
+| | | **Compute subtotal** | **$258.92** |
+
+Two `web` tasks is not padding — it is the minimum for a zero-downtime rolling deploy and for
+surviving the loss of one Availability Zone. The architecture's "1–4 replicas" describes the
+autoscaling range; the *floor* for production HA is 2.
+
+### 5.2 Data
+
+| Line | Configuration | Calculation | $/mo |
+|---|---|---|---:|
+| RDS PostgreSQL 16 | `db.m7g.large` (2 vCPU / 8 GB, Graviton3), **Multi-AZ** | 2 × $0.1733 × 730 | **253.02** |
+| RDS storage | 100 GB gp3, Multi-AZ (billed on both instances) | 100 × $0.23 | **23.00** |
+| RDS backup storage | PITR 14 days; ~150 GB beyond the free allowance | 150 × $0.095 | **14.25** |
+| ElastiCache for Valkey | `cache.t4g.small` × 2 (primary + replica, Multi-AZ) | 2 × $0.0324 × 730 | **47.30** |
+| S3 — candidate documents | 500 GB S3 Standard (end-of-year-1 projection) | 500 × $0.023 | **11.50** |
+| S3 — requests | ~30k PUT + ~120k GET/mo | 30 × $0.005 + 120 × $0.0004 | **0.20** |
+| S3 — audit archive | 60 GB, Glacier Instant Retrieval, Object Lock Compliance | 60 × $0.004 | **0.24** |
+| S3 — static frontend | ~200 MB | negligible | **0.01** |
+| | | **Data subtotal** | **$349.52** |
+
+**Why `db.m7g.large` and not a burstable `db.t4g.large`** (which would save $158/mo Multi-AZ): the
+`worker` process runs sustained CPU-bound parsing and batch rescoring against this database. A
+burstable instance that exhausts its CPU credits during a bulk-import or rescore batch degrades the
+*interactive* path at the same time — exactly the coupling `02-system-architecture.md:6.1` splits the
+processes to avoid. `db.t4g.large` is priced in Option A and is a legitimate choice while volumes
+stay at the low end of the assumed range; it is not the right default.
+
+**Storage grows.** At 40,000 applications/year × ~0.5 MB average CV, blob storage grows ~20 GB/month.
+By year 3 that is ~1.2 TB (~$28/mo). This line is not a budget risk — S3 is the cheapest thing here.
+
+### 5.3 Edge and network
+
+| Line | Configuration | Calculation | $/mo |
+|---|---|---|---:|
+| CloudFront | ~200 GB egress/mo — **inside the perpetual 1 TB/mo free tier** | 0 | **0.00** |
+| AWS WAF — web ACL | 1 ACL + 4 managed rule groups | $5.00 + 4 × $1.00 | **9.00** |
+| AWS WAF — requests | ~2M requests/mo | 2 × $0.60 | **1.20** |
+| AWS WAF — Bot Control | Targeted at the public careers form only | $10.00 + 2 × $1.00 | **12.00** |
+| Route 53 | 1 hosted zone + ~2M queries | $0.50 + 2 × $0.40 | **1.30** |
+| **NAT Gateway** | 2 AZ (HA) — required for Graph, job boards, ECR | 2 × $0.045 × 730 | **65.70** |
+| NAT data processing | ~120 GB (S3 traffic excluded via Gateway Endpoint) | 120 × $0.045 | **5.40** |
+| **S3 Gateway VPC Endpoint** | **FREE** — and it is what keeps the NAT bill small | 0 | **0.00** |
+| Data transfer out (non-CloudFront) | ~20 GB | 20 × $0.09 | **1.80** |
+| ACM certificates | Public certs for CloudFront/ALB | free | **0.00** |
+| | | **Edge & network subtotal** | **$96.40** |
+
+**The NAT Gateway is the most-underestimated line in any AWS estimate**, and at $71/mo it is the
+third-largest item here — more than the entire cache tier. Two design decisions keep it from being
+much worse:
+
+- **The free S3 Gateway Endpoint is mandatory, not optional.** Every CV upload, download and
+ virus-scan read flows to S3. Routed through NAT instead, that traffic alone would add ~$25/mo at
+ year-1 volume and scale linearly with document count. Configure it on day one.
+- **CloudFront's 1 TB/month free egress tier covers this workload entirely.** A 66-seat internal
+ tool plus a careers site will not approach 1 TB. Serving the frontend from S3 through CloudFront
+ is therefore genuinely free, and *cheaper* than serving it from the ALB.
+
+*Lean alternative:* a single NAT Gateway saves $32.85/mo but means a worker in the failed AZ loses
+all outbound connectivity — Graph polling stops until ECS reschedules the task into the healthy AZ.
+Given that `04-integrations-and-processing.md` promises **zero documents lost** with reconciliation,
+and that intake is idempotent and replayable, one NAT is defensible. It is priced in Option A.
+
+### 5.4 Security and operations
+
+| Line | Configuration | Calculation | $/mo |
+|---|---|---|---:|
+| Secrets Manager | ~10 secrets (DB, Graph client secret, webhook `clientState`, job-board keys) + API calls | 10 × $0.40 + calls | **4.50** |
+| SSM Parameter Store | Non-secret config (Standard tier) | free | **0.00** |
+| KMS | 4 customer-managed keys (S3 docs, S3 audit, RDS, Secrets) + requests | 4 × $1.00 + $1.00 | **5.00** |
+| ECR | ~20 GB across the last 10 image tags | 20 × $0.10 | **2.00** |
+| CloudWatch Logs | ~15 GB/mo ingest + ~60 GB retained | 15 × $0.50 + 60 × $0.03 | **9.30** |
+| CloudWatch metrics + alarms | 50 custom metrics + 30 alarms | 50 × $0.30 + 30 × $0.10 | **18.00** |
+| CloudTrail | Management events free; S3 data events on the document buckets | | **2.00** |
+| AWS Config | Compliance recording, ~10 rules | | **10.00** |
+| GuardDuty | Account-level threat detection + **Malware Protection for S3** | see §7 | **25.00** |
+| AWS Backup | RDS snapshot copies to a second region for DR | | **8.00** |
+| | | **Security & ops subtotal** | **$83.80** |
+
+### 5.5 AI and document services (usage-based)
+
+These are the only lines that scale with *business* volume rather than with time. They are also
+the only lines with genuinely unbounded downside if left unmonitored — see §8.
+
+| Line | Basis | Calculation | $/mo |
+|---|---|---|---:|
+| Bedrock — ATS scoring | 3,300 applications/mo (40k/yr midpoint), Claude Sonnet 4.5, prompt caching on the job-description prefix | 3,300 × $0.0214 | **70.62** |
+| Bedrock — chatbot | ~4,400 queries/mo (20 active users × 10/day × 22 days), Sonnet 4.5, cached system prompt + tool schemas | 4,400 × $0.0147 | **64.68** |
+| Bedrock — rescore batches | Model/config version changes trigger full re-evaluation (`05-security:519`); amortised | | **25.00** |
+| Amazon Textract | OCR **fallback only** — ~5,000 pages/mo after free local parsers | 5 × $1.50 | **7.50** |
+| Amazon SES | ~5,000 transactional emails/mo (optional; Graph handles most outbound) | 5 × $0.10 | **0.50** |
+| | | **AI subtotal** | **$168.30** |
+
+Token model used for scoring, per call: ~1,200 cached prefix tokens (system prompt + job version)
+at $0.30/M, ~3,000 fresh input tokens (redacted CV body) at $3.00/M, ~800 output tokens
+(score + rationale + skill matches) at $15.00/M.
+
+### 5.6 Option B total
+
+| Group | $/mo |
+|---|---:|
+| Compute | 258.92 |
+| Data | 349.52 |
+| Edge & network | 96.40 |
+| Security & ops | 83.80 |
+| AI & document services | 168.30 |
+| **Production total, on-demand** | **$956.94** |
+| **Production total, with 1-yr commitments** (§9) | **$814** |
+
+---
+
+## 6. Option A (lean) and Option C (scale)
+
+### 6.1 Option A — Lean: $454/mo
+
+Everything single-AZ. Suitable for a pilot or a first quarter in production while volumes are
+proven. **Not suitable as a permanent posture** for a system under 7-year audit retention.
+
+| Change from Option B | Saving |
+|---|---:|
+| `web` 1 task instead of 2 (deploys cause a brief outage) | −$72.08 |
+| `worker` on **Fargate Spot** (~70% off; safe — every task is idempotent and retried) | −$60.55 |
+| RDS `db.t4g.large` **Single-AZ** instead of `db.m7g.large` Multi-AZ | −$174.16 |
+| ElastiCache single `cache.t4g.micro`, no replica (degrades gracefully per `02:820`) | −$35.62 |
+| 1 NAT Gateway instead of 2 | −$32.85 |
+| No WAF Bot Control | −$12.00 |
+| Reduced CloudWatch metrics/alarms, no AWS Config, no cross-region backup | −$21.00 |
+| Claude **Haiku 4.5** for ATS scoring instead of Sonnet 4.5 ($1/M in, $5/M out) | −$47.00 |
+| Smaller storage footprint in month 1–6 | −$8.00 |
+| **Total saving** | **−$463** |
+| **Option A total** | **$454/mo** |
+
+**What you give up, stated plainly:** RTO on an AZ failure goes from seconds to roughly 20–40
+minutes (RDS single-AZ restore). Every deploy is a short outage. Scoring quality drops somewhat with
+Haiku — acceptable, because `05-security` mandates that AI output is **advisory only** and no
+automatic path reaches a terminal-negative outcome, so a weaker model cannot reject anyone.
+
+### 6.2 Option C — Scale: $2,800/mo
+
+Priced at 3× the assumed volume — 100,000+ applications/year, ~75 concurrent users — which is where
+`02-system-architecture.md:10.2`'s scaling ladder steps 2, 3, 4 and 6 have all been climbed.
+
+| Line | Configuration | $/mo |
+|---|---|---:|
+| Fargate `web` | 4 tasks × 4 vCPU / 8 GB | 576.64 |
+| Fargate `worker` | avg 2.5 tasks × 4 vCPU / 8 GB | 360.40 |
+| ALB | higher LCU | 45.00 |
+| RDS | `db.m7g.xlarge` Multi-AZ + one `db.m7g.large` **read replica** for analytics and search | 632.00 |
+| RDS storage + backups | 300 GB | 109.00 |
+| ElastiCache | `cache.m7g.large` × 2 | 230.00 |
+| S3 | 2 TB + requests | 47.00 |
+| WAF / Route 53 / CloudFront | egress still under the free tier | 25.00 |
+| NAT × 3 AZ + data | | 110.00 |
+| CloudWatch / GuardDuty / Config / Secrets / KMS / ECR | | 160.00 |
+| Bedrock | 3× scoring + chatbot volume | 480.00 |
+| Textract + SES | | 24.00 |
+| **Option C total** | | **$2,799** |
+
+Note that a 3× volume increase produces a ~2.9× cost increase — this architecture scales close to
+linearly, with no step-function cliff. The read replica at step 6 of the ladder is the realistic
+ceiling; `02-system-architecture.md:875` projects that PostgreSQL FTS + trigram will **never** be
+outgrown by this system (the extraction trigger sits 3–4 orders of magnitude away).
+
+---
+
+## 7. Malware scanning — a place where AWS is both cheaper and better
+
+`04-integrations-and-processing.md:1109` selects ClamAV in the worker image for Phase 1 and states
+its own limitation honestly: *"ClamAV's detection rate on targeted or novel malware is materially
+below a commercial multi-engine service. It is a hygiene control, not a guarantee."* It then names
+the upgrade path — a cloud-native scanner behind the same `MalwareScanner` port (§7.4).
+
+On AWS that upgrade is available immediately, at trivial cost:
+
+| Option | Monthly cost at 9,000 documents / 4.5 GB | Detection quality | Operational burden |
+|---|---:|---|---|
+| ClamAV in the worker image | $0 direct — but adds ~400 MB to the image (toward the 2 GB T1 trigger), needs a signature-update job, and consumes worker CPU | Signature-based only | Signature freshness is your problem |
+| **GuardDuty Malware Protection for S3** | 4.5 GB × $0.60 + 9 × $0.187 ≈ **$4.38** | AWS-managed multi-engine, continuously updated | Zero — event-driven on `s3:ObjectCreated` |
+
+**Recommendation: use GuardDuty Malware Protection for S3.** It costs about $4/month at this volume,
+removes a dependency from the image, removes the signature-update scheduled job from the 24-job
+catalogue, keeps the worker CPU free for parsing, and gives strictly better detection. It fits the
+existing `MalwareScanner` port without changing anything above it — the adapter writes the verdict
+into `virus_scan_status` exactly as `ClamAvScanner` would, and the quarantine-prefix rule at
+`04-integrations-and-processing.md:635` is unchanged.
+
+The $25 GuardDuty line in §5.4 covers this *plus* account-level threat detection (VPC flow log, DNS
+and CloudTrail analysis), which is worth having on its own.
+
+---
+
+## 8. Bedrock cost sensitivity — the only line that can surprise you
+
+Everything else in this estimate is bounded by an instance size. Bedrock is bounded only by how many
+times the application calls it. This table is the one to keep.
+
+| Scenario | Scoring model | Chatbot model | Caching | Apps/mo | Chat queries/mo | **$/mo** |
+|---|---|---|---|---:|---:|---:|
+| Floor | Haiku 4.5 | Haiku 4.5 | on | 1,700 | 2,000 | **$26** |
+| Lean (Option A) | Haiku 4.5 | Sonnet 4.5 | on | 3,300 | 4,400 | **$88** |
+| **Baseline (Option B)** | Sonnet 4.5 | Sonnet 4.5 | on | 3,300 | 4,400 | **$160** |
+| No caching | Sonnet 4.5 | Sonnet 4.5 | **off** | 3,300 | 4,400 | **$209** |
+| High volume | Sonnet 4.5 | Sonnet 4.5 | on | 5,000 | 8,000 | **$258** |
+| Worst realistic | Sonnet 4.5 | Sonnet 4.5 | off | 5,000 | 12,000 | **$412** |
+| Runaway (no guardrails) | Sonnet 4.5 | Sonnet 4.5 | off | rescore loop | unbounded | **unbounded** |
+
+### Four controls that must exist before Bedrock is enabled in production
+
+1. **The kill switch already in the design.** `05-security:376` specifies a `config` setting that
+ disables all provider calls and degrades the product. Wire it to a CloudWatch billing alarm.
+2. **Idempotency on rescore batches.** `06-api-boundaries.md:263` already requires an idempotency
+ key on any `POST` that enqueues an async job, with the key doubling as the `procrastinate`
+ queueing lock. This is what prevents an impatient double-click from costing $200.
+3. **AWS Budgets with an action.** Set a $300/mo Bedrock budget with an SNS alert at 80% and an
+ IAM action at 100%. Costs nothing.
+4. **Enable prompt caching from day one.** It is a request parameter, not a project. On the chatbot
+ path — where the system prompt and tool schemas are a large fixed prefix — it cuts cost ~40%.
+
+### Explicitly do NOT buy Bedrock Provisioned Throughput
+
+Provisioned Throughput is priced per model-unit-hour and starts in the range of **$40–60/hour**
+(~$30,000+/month for a single unit on a 1-month commitment). At this workload's volume that is
+roughly **190× more expensive** than on-demand token pricing. It exists for sustained
+high-throughput inference. Use **on-demand** token pricing. If anyone proposes Provisioned
+Throughput for this system, the answer is no.
+
+---
+
+## 9. Commitment discounts — what to buy, and when
+
+Do not buy any commitment until production has run for 30 days and the usage baseline is real. Then:
+
+| Commitment | Applies to | Discount | Monthly saving | Risk |
+|---|---|---:|---:|---|
+| **Compute Savings Plan**, 1-yr, no upfront | Fargate `web` + `worker` (and any future Lambda/EC2) | ~20% | **−$46** | Low — it is compute-generic, not service-locked |
+| **RDS Reserved Instance**, 1-yr, no upfront | `db.m7g.large` Multi-AZ | ~33% | **−$83** | Medium — locks the instance class for 12 months |
+| **ElastiCache Reserved Node**, 1-yr, no upfront | `cache.t4g.small` × 2 | ~30% | **−$14** | Low |
+| **S3 Intelligent-Tiering** | Candidate documents older than 90 days | ~40% on aged objects | −$3 now, grows with volume | None — automatic |
+| | | **Total** | **−$146/mo** | |
+
+**Do not** take 3-year terms in year one. The volume assumptions carry an explicit **ASSUMPTION**
+label (`02-system-architecture.md:1218`, risk A3: *"Bulk job-board feeds could be 1–2 orders
+higher"*). A 3-year RDS RI at ~52% off saves another $50/mo and would be the wrong trade against a
+sizing assumption the architecture itself flags as unvalidated.
+
+---
+
+## 10. Year-1 cash flow — production does not exist for seven months
+
+`07-implementation-plan.md` §15.3 states plainly that Phase 1 alone is **24–30 weeks** and that
+within the first month what can be demonstrated is Phase 0 output plus the beginnings of the
+Phase 1 spine — not a working ATS. Budgeting a full production environment from month one would
+overstate year-1 spend by roughly $6,000.
+
+| Period | What exists | $/mo | Subtotal |
+|---|---|---:|---:|
+| Months 1–2 | Phase 0. Local `docker compose` only (`02:960`). AWS = an account, ECR, and IAM Identity Center | $50 | $100 |
+| Months 3–7 | Staging live, auto-deploying on merge to `main` (`02:493`). Real test-mailbox traffic | $249 | $1,245 |
+| Months 8–12 | **Production live** + staging + Developer support | $1,206 | $6,030 |
+| | | **Year 1 total** | **$7,375** |
+| | | **Year 2 total** (12 × $1,063 committed) | **$12,756** |
+| | | **Year 3** (volume growth, ~1.2 TB storage, +15%) | **~$14,700** |
+
+### Staging environment detail — $220/mo
+
+| Line | Configuration | $/mo |
+|---|---|---:|
+| Fargate `web` | 1 task × 1 vCPU / 2 GB | 36.03 |
+| Fargate `worker` | 1 task × 1 vCPU / 2 GB | 36.03 |
+| ALB | 1 + minimal LCU | 22.27 |
+| RDS | `db.t4g.medium` Single-AZ + 50 GB | 53.20 |
+| ElastiCache | `cache.t4g.micro` × 1 | 11.68 |
+| NAT Gateway | 1 AZ + data | 34.85 |
+| S3 + CloudWatch + Secrets | | 11.15 |
+| Bedrock | Mocked by default (`02:492`); real-credential smoke tests only | 15.00 |
+| **Staging total** | | **$220.21** |
+
+**Optimisation:** stop the Fargate services and the RDS instance outside business hours with an
+EventBridge rule and a small Lambda (12h × 5 days = 36% of the week). Saves ~$80/mo. The ALB and
+NAT Gateway run 24×7 regardless — $57 of the $220 is irreducible.
+
+**No per-developer cloud environment is priced**, matching `02-system-architecture.md:496`:
+*"Two developers do not need six environments; they need one that behaves like production."*
+Each additional full environment would add ~$220/mo.
+
+---
+
+## 11. Alternatives considered and rejected
+
+| Option | Monthly (prod-equivalent) | Verdict |
+|---|---:|---|
+| **ECS on Fargate** | $231 compute | **Chosen.** Maps 1:1 onto ADR 0012's "one image, two revisions". No servers to patch, per-second billing, native autoscaling on both HTTP metrics and queue depth |
+| **AWS App Runner** | ~$228 for web alone | **Rejected.** $0.064/vCPU-hr + $0.007/GB-hr is ~55% more than Fargate for the same shape, and it has no clean model for a long-running queue-consumer process. It optimises for a request-driven service, which is exactly half of this workload |
+| **Amazon EKS** | +$73/mo control plane, before nodes | **Rejected.** The architecture's binding constraint is *two developers, no ops staff* (`02:496`, `_decisions.md:287`). Kubernetes adds a control plane, an upgrade cadence, an add-on ecosystem and a second scheduler to reason about, for zero capability this workload uses |
+| **EC2 + Docker Compose** | ~$120 for 2 × `t4g.medium` | **Rejected.** The cheapest option on paper and the most expensive in practice: OS patching, AMI rebuilds, log shipping and capacity management all become the two developers' problem. Saves ~$110/mo and costs several days per quarter |
+| **AWS Lambda for the worker** | ~$20 | **Rejected.** The 15-minute ceiling is survivable, but the worker holds a `procrastinate` LISTEN/NOTIFY connection and runs multi-second CPU-bound parsing with a memory cap and restricted OS user (`04:1114`) — a persistent process, not an event handler |
+| **Aurora Serverless v2** | $44 floor, ~$175 realistic + I/O charges | **Rejected as default.** The worker keeps a persistent connection, so it never scales to the floor. Compute is comparable but I/O-per-request billing makes the monthly number unpredictable — the opposite of what a budget document needs. Reconsider at Option C scale with I/O-Optimized |
+| **RDS Multi-AZ *cluster*** (2 readable standbys) | ~$380 | **Rejected for Phase 1.** ~$127/mo more than Multi-AZ instance deployment for a read-scaling capability this workload does not need until step 6 of the scaling ladder |
+| **Amazon OpenSearch for search** | +$150 minimum | **Rejected.** ADR-level decision: Phase 1 search is PostgreSQL FTS + `pg_trgm`, and `02:875` projects the extraction trigger sits 3–4 orders of magnitude away. Adding OpenSearch now buys a second datastore, a second backup story and a sync problem, for nothing |
+| **Amazon MQ / MSK for the queue** | +$130 / +$300 | **Rejected.** ADR 0004 makes the queue PostgreSQL-backed specifically to preserve transactional enqueue. Kafka is named in `_decisions.md` as explicitly out of scope for Phase 1 |
+| **Bedrock Provisioned Throughput** | ~$30,000 | **Rejected.** ~190× on-demand at this volume. See §8 |
+| **VPC Interface Endpoints** (ECR, Secrets, Logs, Bedrock) | +$58/mo | **Rejected at this scale.** 4 services × 2 AZ × $0.01/hr costs more than the NAT data processing it would displace ($5.40). The **S3 Gateway Endpoint is free and is kept.** Revisit interface endpoints at Option C, or if a compliance requirement forbids internet egress |
+
+---
+
+## 12. Cost optimisation levers, ranked by saving per unit of effort
+
+| # | Lever | Saving | Effort | Do it? |
+|---|---|---:|---|---|
+| 1 | **S3 Gateway Endpoint** (free) so CV traffic bypasses NAT | ~$25/mo, grows with volume | 5 minutes of Terraform | **Day one, non-negotiable** |
+| 2 | **Serve the frontend from S3 + CloudFront**, not the ALB — 1 TB/mo egress is free | ~$20/mo + lower ALB LCU | Already the plan | **Day one** |
+| 3 | **Fargate Spot for the `worker` service** — tasks are idempotent and retried by design | ~$61/mo | One line in the capacity provider strategy | **Yes** |
+| 4 | **Prompt caching on all Bedrock calls** | ~$49/mo | A request parameter | **Yes** |
+| 5 | **1-yr Compute Savings Plan + RDS RI** after 30 days of real baseline | ~$143/mo | One purchase | **Yes, at month 2 of production** |
+| 6 | **Off-hours shutdown for staging** (EventBridge + Lambda) | ~$80/mo | Half a day | **Yes** |
+| 7 | **Haiku 4.5 for bulk ATS scoring**, Sonnet reserved for the chatbot | ~$47/mo | A model-id config change; AI is advisory only, so quality risk is contained | Evaluate |
+| 8 | **S3 Intelligent-Tiering** on candidate documents | $3/mo now, ~$20/mo by year 3 | A bucket lifecycle rule | **Yes** |
+| 9 | **Graviton everywhere** (`m7g`, `t4g`, `cache.t4g`) | ~15% vs x86, already in the estimate | Build ARM64 images | **Already assumed — do not regress to x86** |
+| 10 | **Single NAT Gateway** | $33/mo | Config | Only in Option A |
+| 11 | **CloudWatch log retention 30 days**, archive to S3 beyond | ~$5/mo | A retention setting | Yes |
+| 12 | **Delete the `decoded_attachments/` local-disk path** (see §13) | Prevents an EFS line item of ~$30–150/mo | Real engineering work | **Required regardless** |
+
+Levers 1–6 and 8 together save **$381/mo** — 40% of the Option B bill — and none of them changes the
+architecture.
+
+---
+
+## 13. Repository gaps that must close before this estimate holds
+
+The cost model above assumes the application is deployable as the architecture describes. Five
+things in the repository today contradict that. Four are correctness problems that also have a cost
+consequence.
+
+| # | Finding | Cost consequence if not fixed |
+|---|---|---|
+| 1 | **Attachments are written to local disk.** [file_decoder.py:18](backend/inbox/file_decoder.py#L18) sets `_DEFAULT_OUT_DIR` to a directory beside the source file, and [views.py:40-41](backend/inbox/views.py#L40-L41) stores that absolute path in `Inbox_Messages.file_path`. On Fargate the task filesystem is **ephemeral and per-task** — files vanish on restart and are invisible to the other `web` replica | Must move to S3 (already budgeted at $11.50/mo). "Fixing" it with EFS instead adds **$30–150/mo** ($0.30/GB-mo Standard, plus throughput) and reintroduces a shared mutable filesystem the architecture does not want |
+| 2 | **No Dockerfile exists.** `docker-compose.yml` provisions only `minio` and `postgres` — the application itself is not containerised | Blocks ECS entirely. Prerequisite engineering, not an AWS cost |
+| 3 | **Migrations run on application startup.** [db_setup.py:226-244](backend/db_setup.py#L226-L244) — `lifespan` calls `init_db()`, which runs Alembic to head when `db_auto_migrate` is set. With 2+ `web` tasks this is a concurrent-migration race on every deploy | Move to the one-off ECS `migrate` RunTask already priced at $0.15/mo. Set `db_auto_migrate=false` in the task definition |
+| 4 | **CORS is `allow_origins=["*"]` with `allow_credentials=True`.** [main.py:16-22](backend/main.py#L16-L22) — browsers reject this combination outright, and no CloudFront or WAF configuration compensates for it | None directly, but it will look like a CDN misconfiguration and burn debugging time at go-live |
+| 5 | **Database credentials live in `backend/.env`.** `.gitignore` correctly excludes it, but the deployment model must be Secrets Manager + ECS task role, never an env file baked into an image | Already budgeted at $4.50/mo |
+
+Item 1 is the one that matters most for this document: it is the difference between an $11.50/mo
+storage line and a $150/mo one, and it has to be resolved before the first production deploy either
+way.
+
+---
+
+## 14. What would change this number
+
+| # | Risk | Direction | Magnitude |
+|---|---|---|---|
+| 1 | **Region is not `us-east-1`** (likely — six jurisdictions, GDPR unresolved) | ↑ | +6% to +25% (§4.1) |
+| 2 | **Legal requires self-hosted models** instead of Bedrock. `05-security:720` (BL-3) names this: *"Phase 1 gains GPU infrastructure and an MLOps burden two developers cannot absorb"* | ↑↑↑ | A single `g5.xlarge` is ~$730/mo on-demand; realistic HA inference is **$1,500–3,000/mo**, more than doubling the total |
+| 3 | **Bulk job-board feeds arrive.** Risk A3 (`02:1218`) warns volumes could be *"1–2 orders higher"* | ↑↑ | Option C, or beyond |
+| 4 | **Data residency forces multi-region.** Directly conflicts with the one-database constraint (`_decisions.md:283`) and requires a business exception | ↑↑ | Roughly ×1.8 — a second full stack |
+| 5 | Audit retention exceeds 7 years, or the immutable archive grows faster than projected | ↑ | Small — Glacier Deep Archive is $0.00099/GB-mo |
+| 6 | Chatbot adoption exceeds 20 active users | ↑ | +$15/mo per additional 1,000 queries |
+| 7 | `pgvector` embeddings land in Phase 2 (migration 028, ~200,000 rows per `03-database-design.md:2607`) | ↑ | +$20/mo Bedrock embeddings, +~5 GB storage. Negligible — this is exactly why the architecture put vectors in the same database |
+| 8 | Volumes stay at the **low** end (20k applications/yr, not 60k) | ↓ | −$80/mo |
+| 9 | Enterprise Discount Program / Private Pricing, if Utopia Brands has existing AWS spend | ↓ | −5% to −15% |
+
+---
+
+## 15. Recommendations
+
+1. **Budget $1,206/month** for a fully HA production plus staging plus Developer support, in
+ `us-east-1`. If EU/UK residency is required — decide this before provisioning — budget
+ **$1,270/month** in `eu-west-1`.
+2. **Deploy Option A (lean, $454/mo) for the first production quarter**, then move to Option B once
+ real volume is observed. The migration between them is instance-class changes and a replica
+ count — hours of work, no re-architecture.
+3. **Adopt Amazon Bedrock.** It closes open item BL-3 (`05-security:720`) under the existing AWS
+ agreement, with no new data processor and no new DPA to negotiate. This is the strongest
+ platform-specific argument for AWS in this whole document.
+4. **Replace ClamAV with GuardDuty Malware Protection for S3** at ~$4/mo (§7). Better detection,
+ smaller image, one fewer scheduled job.
+5. **Configure the free S3 Gateway Endpoint on day one.** It is the single highest-value free
+ configuration change available and its value grows with document volume.
+6. **Fix the local-disk attachment path** ([file_decoder.py:18](backend/inbox/file_decoder.py#L18))
+ before the first deploy. It is the only repository finding with a real cost consequence.
+7. **Set an AWS Budget of $1,400/month with alerts at 80% and 100%**, plus a separate $300 Bedrock
+ budget wired to the kill switch already specified at `05-security:376`.
+8. **Buy no commitments until production has 30 days of real baseline**, then take 1-year
+ no-upfront terms only. The volume assumptions are labelled ASSUMPTION for a reason.
+9. **Re-validate every line in the AWS Pricing Calculator** for the chosen region before this
+ document is used to commit spend (§4).
+
+---
+
+## Appendix A — Unit prices used
+
+| Service | Unit | Price (`us-east-1`, Aug 2026) |
+|---|---|---|
+| Fargate | vCPU-hour / GB-hour | $0.04048 / $0.004445 |
+| Fargate Spot | — | ~70% off on-demand |
+| ALB | hour / LCU-hour | $0.0225 / $0.008 |
+| RDS `db.m7g.large` PostgreSQL | instance-hour (Single-AZ) | ~$0.1733 |
+| RDS `db.t4g.large` / `db.t4g.medium` | instance-hour | ~$0.1296 / ~$0.0650 |
+| RDS gp3 storage | GB-month (Single-AZ / Multi-AZ) | $0.115 / $0.23 |
+| RDS backup beyond free tier | GB-month | $0.095 |
+| ElastiCache `cache.t4g.small` / `.micro` | node-hour | ~$0.0324 / ~$0.016 |
+| S3 Standard | GB-month | $0.023 |
+| S3 PUT / GET | per 1,000 | $0.005 / $0.0004 |
+| S3 Glacier Instant Retrieval | GB-month | $0.004 |
+| S3 Gateway VPC Endpoint | — | **free** |
+| CloudFront egress | GB (first 10 TB, after 1 TB/mo free) | $0.085 |
+| AWS WAF | web ACL / rule / million requests | $5.00 / $1.00 / $0.60 |
+| AWS WAF Bot Control | month / million requests | $10.00 / $1.00 |
+| NAT Gateway | hour / GB processed | $0.045 / $0.045 |
+| Route 53 | hosted zone / million queries | $0.50 / $0.40 |
+| Secrets Manager | secret-month / 10k API calls | $0.40 / $0.05 |
+| KMS | key-month / 10k requests | $1.00 / $0.03 |
+| ECR | GB-month | $0.10 |
+| CloudWatch Logs | GB ingest / GB-month stored | $0.50 / $0.03 |
+| CloudWatch | custom metric-month / alarm-month | $0.30 / $0.10 |
+| GuardDuty Malware Protection for S3 | GB scanned / 1,000 objects | $0.60 / $0.187 |
+| Textract `DetectDocumentText` | 1,000 pages | $1.50 |
+| SES | 1,000 outbound emails | $0.10 |
+| Bedrock — Claude Sonnet 4.5 | 1M input / 1M output tokens | $3.00 / $15.00 |
+| Bedrock — Claude Sonnet 4.5 cache | 1M cache write / 1M cache read | $3.75 / $0.30 |
+| Bedrock — Claude Haiku 4.5 | 1M input / 1M output tokens | $1.00 / $5.00 |
+| ACM public certificates | — | **free** |
+| SSM Parameter Store (Standard) | — | **free** |
+| CloudTrail management events (first trail) | — | **free** |
+
+## Appendix B — Cost allocation tags
+
+Apply these on every resource from day one; retrofitting tags is the reason most AWS bills are
+unattributable.
+
+| Tag | Values |
+|---|---|
+| `Project` | `hr-ats-portal` |
+| `Environment` | `production` \| `staging` |
+| `Component` | `web` \| `worker` \| `database` \| `cache` \| `storage` \| `edge` \| `ai` \| `observability` |
+| `CostCentre` | (Utopia Brands HR) |
+| `Owner` | `talha` \| `ahmed` |
+| `DataClass` | `candidate-pii` \| `audit` \| `public` |
+
+Activate them as **cost allocation tags** in the Billing console — they are not usable in Cost
+Explorer until you do, and activation is not retroactive.
diff --git a/index.html b/index.html
index c2ccd8d..e24d668 100644
--- a/index.html
+++ b/index.html
@@ -264,6 +264,7 @@
+
diff --git a/js/api.js b/js/api.js
new file mode 100644
index 0000000..304253a
--- /dev/null
+++ b/js/api.js
@@ -0,0 +1,25 @@
+/* ============================================================
+ api.js — minimal HTTP client for the FastAPI backend
+ ============================================================ */
+window.Api = {
+ base: 'http://localhost:8000',
+
+ async get(path, params) {
+ const url = new URL(path.replace(/^\//, ''), this.base.endsWith('/') ? this.base : this.base + '/');
+ if (params) {
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined && value !== null && value !== '') {
+ url.searchParams.set(key, value);
+ }
+ });
+ }
+ const res = await fetch(url.toString());
+ let body = null;
+ try { body = await res.json(); } catch (_) { body = null; }
+ if (!res.ok) {
+ const detail = body && body.detail != null ? body.detail : res.statusText;
+ throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));
+ }
+ return body;
+ }
+};
diff --git a/js/app.js b/js/app.js
index 0ddf485..5978053 100644
--- a/js/app.js
+++ b/js/app.js
@@ -88,7 +88,10 @@ App.updateBadges = function () {
setBadge('navJobsBadge', openJobs);
const unread = DB.notifications.filter(n => n.unread).length;
setBadge('navNotifBadge', unread);
- const inboxUnread = DB.inbox.filter(i => i.unread).length + DB.emails.filter(e => e.unread).length;
+ const emailUnread = (window.Inbox && Array.isArray(Inbox._emails))
+ ? Inbox._emails.filter(e => e.unread).length
+ : 0;
+ const inboxUnread = DB.inbox.filter(i => i.unread).length + emailUnread;
setBadge('navInboxBadge', inboxUnread);
const openTasks = DB.tasks.filter(t => !t.done).length;
setBadge('navTaskBadge', openTasks);
diff --git a/js/inbox.js b/js/inbox.js
index 4247188..28e0008 100644
--- a/js/inbox.js
+++ b/js/inbox.js
@@ -27,7 +27,7 @@ Views.inbox = function () {
'Processed': DB.inbox.filter(i => i.processing === 'Processed').length,
'Rejected': DB.inbox.filter(i => i.processing === 'Rejected').length,
'Duplicates': DB.inbox.filter(i => i.duplicate).length,
- 'Email': DB.emails.filter(e => e.unread).length
+ 'Email': (Inbox._emails || []).filter(e => e.unread).length
};
}
@@ -254,33 +254,70 @@ Inbox.reject = function (id) {
};
// ---------------- Email (Outlook) tab ----------------
+Inbox._emails = [];
+Inbox._lastSync = null;
+
+Inbox._mapApiEmail = function (row) {
+ const from = row.sender_name || row.fromEmail || 'Unknown';
+ return {
+ id: String(row.id),
+ from,
+ fromEmail: row.fromEmail || '',
+ subject: row.subject || '',
+ body: row.body || '',
+ when: row.when ? new Date(row.when) : new Date(),
+ unread: !!row.unread,
+ attachment: row.attachment_name || 'Resume.pdf',
+ attachmentSize: '—',
+ atsScore: 70,
+ imported: false,
+ jobId: null,
+ jobTitle: ''
+ };
+};
+
+Inbox._syncLabel = function () {
+ if (!Inbox._lastSync) return 'Not synced yet';
+ const mins = Math.max(0, Math.round((Date.now() - Inbox._lastSync.getTime()) / 60000));
+ if (mins < 1) return 'Just now';
+ return DB.relTime(mins);
+};
+
+Inbox._loadEmails = async function () {
+ const res = await Api.get('/inbox/fetch');
+ const rows = Array.isArray(res.data) ? res.data : [];
+ Inbox._emails = rows.map(Inbox._mapApiEmail);
+ Inbox._lastSync = new Date();
+ return Inbox._emails;
+};
+
+Inbox._refreshEmailCounts = function () {
+ const tab = document.querySelector('#inboxTabs .tab[data-tab="Email"]');
+ if (tab) {
+ const countEl = tab.querySelector('.k-count');
+ if (countEl) countEl.textContent = Inbox._emails.filter(e => e.unread).length;
+ }
+ if (window.App && App.updateBadges) App.updateBadges();
+};
+
Inbox._emailView = function () {
- const st = Inbox._state;
- const listHtml = DB.emails.map(e => `
-
Fetching mailbox from the server.
Preview email body and resume attachments here.
No emails in the mailbox.
${err.message || 'Request failed'}