From a434af224c492d643c5c2d47cd65189206aea730 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Wed, 7 Oct 2026 06:16:26 +0530 Subject: [PATCH] setting, session and profile fix --- backend/app/routers/emails.py | 67 ++++++- backend/app/schemas.py | 9 + backend/tests/test_email_helpers.py | 37 +++- backend/tests/test_mail_sender.py | 27 +++ frontend/src/App.jsx | 6 +- frontend/src/auth.js | 62 ++++++- frontend/src/components/AccountProfile.jsx | 7 +- frontend/src/components/AccountSettings.jsx | 3 +- frontend/src/components/AddEmployeeForm.jsx | 3 +- .../src/components/ChangePasswordModal.jsx | 3 +- .../src/components/CompanyAdministrators.jsx | 3 +- frontend/src/components/CompanyAliases.jsx | 5 +- frontend/src/components/CompanyDnsSetup.jsx | 3 +- frontend/src/components/CompanyDomains.jsx | 7 +- frontend/src/components/CompanyGroups.jsx | 5 +- frontend/src/components/CompanyOverview.jsx | 3 +- frontend/src/components/CompanyPeople.jsx | 3 +- frontend/src/components/CompanySecurity.jsx | 3 +- frontend/src/components/ComposeModal.jsx | 169 ++++++++++++++---- frontend/src/components/DashboardPage.jsx | 9 +- .../src/components/HandoffConversation.jsx | 3 +- frontend/src/components/HandoffInbox.jsx | 3 +- frontend/src/components/HandoffSettings.jsx | 3 +- frontend/src/components/ImportUsersPanel.jsx | 3 +- frontend/src/components/InviteAcceptPage.jsx | 4 +- frontend/src/components/LoginPage.jsx | 5 +- frontend/src/components/MailDetail.jsx | 14 +- frontend/src/components/MailboxView.jsx | 3 +- frontend/src/components/SignupPage.jsx | 4 +- frontend/src/components/StickyNotesPanel.jsx | 3 +- .../src/components/compose/RecipientField.jsx | 3 +- frontend/src/styles.css | 54 +++--- frontend/src/utils/emailReply.js | 37 +++- frontend/src/utils/fileMeta.js | 43 +++++ 34 files changed, 497 insertions(+), 119 deletions(-) create mode 100644 frontend/src/utils/fileMeta.js diff --git a/backend/app/routers/emails.py b/backend/app/routers/emails.py index 677960b..03a223e 100644 --- a/backend/app/routers/emails.py +++ b/backend/app/routers/emails.py @@ -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, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index aa8238d..d3f2d0e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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): diff --git a/backend/tests/test_email_helpers.py b/backend/tests/test_email_helpers.py index 5d9b080..8f84d7c 100644 --- a/backend/tests/test_email_helpers.py +++ b/backend/tests/test_email_helpers.py @@ -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" diff --git a/backend/tests/test_mail_sender.py b/backend/tests/test_mail_sender.py index fe60a47..c538689 100644 --- a/backend/tests/test_mail_sender.py +++ b/backend/tests/test_mail_sender.py @@ -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 " +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="

See attached

", + 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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9dca954..bdfabf2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,7 +7,7 @@ import MobileLanding from "./components/MobileLanding"; import SignupPage from "./components/SignupPage"; import SmtpCredentialsPage from "./components/SmtpCredentialsPage"; import WebLanding from "./components/WebLanding"; -import { APP_HOME_PATH, currentPath, enterApp, isLoggedIn, SESSION_GATED_PATHS } from "./auth"; +import { APP_HOME_PATH, currentPath, enterApp, isLoggedIn, onSessionChange, SESSION_GATED_PATHS } from "./auth"; function useLocationPath() { const [path, setPath] = useState(currentPath); @@ -32,6 +32,10 @@ export default function App() { } }, [authed, path]); + useEffect(() => onSessionChange(() => { + window.location.reload(); + }), []); + if (showApp) { return ; } diff --git a/frontend/src/auth.js b/frontend/src/auth.js index da5dcf4..6082de0 100644 --- a/frontend/src/auth.js +++ b/frontend/src/auth.js @@ -6,17 +6,72 @@ export const SESSION_GATED_PATHS = new Set(["/", "/login", "/signup", "/forgot-p let sessionRedirecting = false; +function readShared(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function writeShared(key, value) { + localStorage.setItem(key, value); + sessionStorage.setItem(key, value); +} + +function removeShared(key) { + localStorage.removeItem(key); + sessionStorage.removeItem(key); +} + +function syncSharedSession() { + const sharedToken = readShared(AUTH_TOKEN_KEY); + if (sharedToken) { + sessionStorage.setItem(AUTH_TOKEN_KEY, sharedToken); + const sharedAccount = readShared(AUTH_ACCOUNT_KEY); + if (sharedAccount) sessionStorage.setItem(AUTH_ACCOUNT_KEY, sharedAccount); + return; + } + + const legacyToken = sessionStorage.getItem(AUTH_TOKEN_KEY); + if (!legacyToken) return; + localStorage.setItem(AUTH_TOKEN_KEY, legacyToken); + const legacyAccount = sessionStorage.getItem(AUTH_ACCOUNT_KEY); + if (legacyAccount) localStorage.setItem(AUTH_ACCOUNT_KEY, legacyAccount); +} + +syncSharedSession(); + export function getAccessToken() { - return sessionStorage.getItem(AUTH_TOKEN_KEY); + return readShared(AUTH_TOKEN_KEY) || sessionStorage.getItem(AUTH_TOKEN_KEY); } export function isLoggedIn() { return Boolean(getAccessToken()); } +export function saveSession(token, account) { + writeShared(AUTH_TOKEN_KEY, token); + if (account) writeShared(AUTH_ACCOUNT_KEY, JSON.stringify(account)); +} + +export function saveStoredAccount(account) { + writeShared(AUTH_ACCOUNT_KEY, JSON.stringify(account)); +} + export function clearSession() { - sessionStorage.removeItem(AUTH_TOKEN_KEY); - sessionStorage.removeItem(AUTH_ACCOUNT_KEY); + removeShared(AUTH_TOKEN_KEY); + removeShared(AUTH_ACCOUNT_KEY); +} + +export function onSessionChange(listener) { + function handle(event) { + if (event.storageArea !== localStorage) return; + if (event.key !== AUTH_TOKEN_KEY && event.key !== AUTH_ACCOUNT_KEY && event.key !== null) return; + listener(event); + } + window.addEventListener("storage", handle); + return () => window.removeEventListener("storage", handle); } export function currentPath() { @@ -46,4 +101,3 @@ export function redirectSessionExpired() { clearSession(); window.location.replace("/?session=expired"); } - diff --git a/frontend/src/components/AccountProfile.jsx b/frontend/src/components/AccountProfile.jsx index 2f02818..9bb41c5 100644 --- a/frontend/src/components/AccountProfile.jsx +++ b/frontend/src/components/AccountProfile.jsx @@ -1,5 +1,6 @@ import { useRef, useState } from "react"; import { updateAccountProfile } from "../api"; +import { getAccessToken, saveStoredAccount } from "../auth"; const MAX_AVATAR_BYTES = 512 * 1024; const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]); @@ -14,7 +15,7 @@ function displayPhone(phone) { } export default function AccountProfile({ account, onUpdated }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const inputRef = useRef(null); const [status, setStatus] = useState({ loading: false, error: "", notice: "" }); const fullName = `${account?.first_name || ""} ${account?.last_name || ""}`.trim(); @@ -44,7 +45,7 @@ export default function AccountProfile({ account, onUpdated }) { reader.readAsDataURL(file); }); const updated = await updateAccountProfile({ profile_picture_url: dataUrl }, token); - sessionStorage.setItem("agmail_account", JSON.stringify(updated)); + saveStoredAccount(updated); onUpdated?.(updated); setStatus({ loading: false, error: "", notice: "Saved" }); } catch (error) { @@ -56,7 +57,7 @@ export default function AccountProfile({ account, onUpdated }) { setStatus({ loading: true, error: "", notice: "" }); try { const updated = await updateAccountProfile({ profile_picture_url: null }, token); - sessionStorage.setItem("agmail_account", JSON.stringify(updated)); + saveStoredAccount(updated); onUpdated?.(updated); setStatus({ loading: false, error: "", notice: "Saved" }); } catch (error) { diff --git a/frontend/src/components/AccountSettings.jsx b/frontend/src/components/AccountSettings.jsx index 67824f8..454a899 100644 --- a/frontend/src/components/AccountSettings.jsx +++ b/frontend/src/components/AccountSettings.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { useCallback, useEffect, useState } from "react"; import { getAccountSecurity, updateAccountSecurity } from "../api"; import { SESSION_TIMEOUT_OPTIONS, formatSessionTimeout } from "../utils/sessionTimeouts"; @@ -23,7 +24,7 @@ function ToggleRow({ title, description, checked, disabled, onChange }) { } export default function AccountSettings() { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); diff --git a/frontend/src/components/AddEmployeeForm.jsx b/frontend/src/components/AddEmployeeForm.jsx index 5cae01e..2cc0f55 100644 --- a/frontend/src/components/AddEmployeeForm.jsx +++ b/frontend/src/components/AddEmployeeForm.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { Info, Lock, Mail, Plus, User, UsersRound } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { createOrgMember, createOrgSection, listOrgSections } from "../api"; @@ -65,7 +66,7 @@ export default function AddEmployeeForm({ onCreated, onCancel, }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [sections, setSections] = useState([]); const [sectionsLoading, setSectionsLoading] = useState(true); const [showNewSection, setShowNewSection] = useState(false); diff --git a/frontend/src/components/ChangePasswordModal.jsx b/frontend/src/components/ChangePasswordModal.jsx index 47669af..3d4df98 100644 --- a/frontend/src/components/ChangePasswordModal.jsx +++ b/frontend/src/components/ChangePasswordModal.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { useState } from "react"; import { changePassword } from "../api"; import PasswordField from "./PasswordField"; @@ -21,7 +22,7 @@ function validateForm(form) { } export default function ChangePasswordModal({ account, onComplete }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [form, setForm] = useState({ current_password: "", new_password: "", diff --git a/frontend/src/components/CompanyAdministrators.jsx b/frontend/src/components/CompanyAdministrators.jsx index 92d04af..6e316ab 100644 --- a/frontend/src/components/CompanyAdministrators.jsx +++ b/frontend/src/components/CompanyAdministrators.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { Crown, Search } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { listOrgMembers, updateOrgMemberRole } from "../api"; @@ -13,7 +14,7 @@ function roleLabel(member) { } export default function CompanyAdministrators({ account }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); diff --git a/frontend/src/components/CompanyAliases.jsx b/frontend/src/components/CompanyAliases.jsx index 0e95280..3959fa6 100644 --- a/frontend/src/components/CompanyAliases.jsx +++ b/frontend/src/components/CompanyAliases.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { AlertCircle, CheckCircle2, @@ -57,7 +58,7 @@ function SetupField({ label, error, children, hint, status }) { } function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [form, setForm] = useState({ alias_local: "", forwards_to: "", @@ -321,7 +322,7 @@ function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) { } export default function CompanyAliases({ account }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const companyDomain = account.company_domain; const [aliases, setAliases] = useState([]); const [destinations, setDestinations] = useState([]); diff --git a/frontend/src/components/CompanyDnsSetup.jsx b/frontend/src/components/CompanyDnsSetup.jsx index be47332..d3cb632 100644 --- a/frontend/src/components/CompanyDnsSetup.jsx +++ b/frontend/src/components/CompanyDnsSetup.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { ArrowRight, CheckCircle2, Copy, XCircle } from "lucide-react"; import { useState } from "react"; import { verifyCompanyDns } from "../api"; @@ -65,7 +66,7 @@ function DnsRecordTable({ records }) { } export default function CompanyDnsSetup({ domain, account, onComplete, onLogout }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [status, setStatus] = useState({ loading: false, error: "" }); const [checks, setChecks] = useState(null); diff --git a/frontend/src/components/CompanyDomains.jsx b/frontend/src/components/CompanyDomains.jsx index 49902c5..1ccaa22 100644 --- a/frontend/src/components/CompanyDomains.jsx +++ b/frontend/src/components/CompanyDomains.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { ArrowLeft, ArrowRight, @@ -156,7 +157,7 @@ function AddDomainWizard({ onCancel, onComplete, }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [step, setStep] = useState(1); const [domainName, setDomainName] = useState(""); const [domain, setDomain] = useState(null); @@ -340,7 +341,7 @@ function DomainDetail({ onBack, onRefresh, }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [tab, setTab] = useState("DNS Configuration"); const [checks, setChecks] = useState(null); const [lastChecked, setLastChecked] = useState(null); @@ -497,7 +498,7 @@ function DomainDetail({ } export default function CompanyDomains({ account, primaryDomainName }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [domains, setDomains] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); diff --git a/frontend/src/components/CompanyGroups.jsx b/frontend/src/components/CompanyGroups.jsx index 0ae45ae..51593aa 100644 --- a/frontend/src/components/CompanyGroups.jsx +++ b/frontend/src/components/CompanyGroups.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { AlertCircle, CheckCircle2, @@ -93,7 +94,7 @@ function SetupField({ label, error, children, hint, status }) { } function CreateGroupPanel({ account, companyDomain, members, onClose, onCreated }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [form, setForm] = useState({ name: "", email_local: "", @@ -447,7 +448,7 @@ function CreateGroupPanel({ account, companyDomain, members, onClose, onCreated } export default function CompanyGroups({ account }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const companyDomain = account.company_domain; const [groups, setGroups] = useState([]); const [members, setMembers] = useState([]); diff --git a/frontend/src/components/CompanyOverview.jsx b/frontend/src/components/CompanyOverview.jsx index 1a30a16..f38559e 100644 --- a/frontend/src/components/CompanyOverview.jsx +++ b/frontend/src/components/CompanyOverview.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { ArrowRight, CheckCircle2, @@ -55,7 +56,7 @@ function StatCard({ icon: Icon, tone, label, value, detail }) { } export default function CompanyOverview({ account, companyDomain, onNavigate }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const domainName = companyDomain?.domain_name || account.company_domain || ""; const details = getOrgDetails(account.id); const [loading, setLoading] = useState(true); diff --git a/frontend/src/components/CompanyPeople.jsx b/frontend/src/components/CompanyPeople.jsx index 8676a82..ba36d12 100644 --- a/frontend/src/components/CompanyPeople.jsx +++ b/frontend/src/components/CompanyPeople.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { CheckCircle2, Clock3, @@ -92,7 +93,7 @@ function AddUserPanel({ companyDomain, onClose, onCreated }) { } export default function CompanyPeople({ account }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const companyDomain = account.company_domain; const [members, setMembers] = useState([]); const [counts, setCounts] = useState({ all: 0, active: 0, invited: 0, suspended: 0 }); diff --git a/frontend/src/components/CompanySecurity.jsx b/frontend/src/components/CompanySecurity.jsx index f1bc897..e3928f2 100644 --- a/frontend/src/components/CompanySecurity.jsx +++ b/frontend/src/components/CompanySecurity.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { useCallback, useEffect, useState } from "react"; import { getOrgSecurity, updateOrgSecurity } from "../api"; import { SESSION_TIMEOUT_OPTIONS } from "../utils/sessionTimeouts"; @@ -23,7 +24,7 @@ function ToggleRow({ title, description, checked, disabled, onChange }) { } export default function CompanySecurity() { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); diff --git a/frontend/src/components/ComposeModal.jsx b/frontend/src/components/ComposeModal.jsx index 82365b1..140626b 100644 --- a/frontend/src/components/ComposeModal.jsx +++ b/frontend/src/components/ComposeModal.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { AlignCenter, AlignLeft, @@ -25,6 +26,7 @@ import { X, Square, ExternalLink, + FileText, RemoveFormatting, IndentIncrease, IndentDecrease, @@ -36,6 +38,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { listMailAliases, listOrgMembers, sendEmail } from "../api"; import { clearDraft, draftStorageKey } from "../utils/composeDraft"; import { escapeHtml, htmlForOutboundEmail, plainTextFromEmailHtml, sanitizeEmailHtml } from "../utils/emailReply"; +import { fileMetaLine } from "../utils/fileMeta"; const EMOJI_SAMPLES = ["๐Ÿ˜€", "๐Ÿ˜Š", "๐Ÿ‘", "๐Ÿ™", "โœ…", "๐ŸŽ‰", "๐Ÿ“Ž", "๐Ÿ“…", "๐Ÿ’ก", "๐Ÿ”ฅ", "โค๏ธ", "๐Ÿ˜…"]; @@ -43,10 +46,21 @@ function plainTextFromHtml(value) { return plainTextFromEmailHtml(value); } -function formatFileSize(bytes) { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024; +const MAX_ATTACHMENTS = 10; +const MAX_ATTACHMENTS_TOTAL_BYTES = 25 * 1024 * 1024; + +function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result || ""); + const comma = result.indexOf(","); + resolve(comma >= 0 ? result.slice(comma + 1) : result); + }; + reader.onerror = () => reject(new Error("Could not read attachment.")); + reader.readAsDataURL(file); + }); } function detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain }) { @@ -147,6 +161,49 @@ function alignmentBlock(editor, range) { return block; } +function wrapEditorContents(editor) { + const wrapper = editor.ownerDocument.createElement("div"); + while (editor.firstChild) wrapper.appendChild(editor.firstChild); + editor.appendChild(wrapper); + const range = editor.ownerDocument.createRange(); + range.selectNodeContents(wrapper); + range.collapse(false); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + return wrapper; +} + +function ensureAlignableBlock(editor, range) { + const block = alignmentBlock(editor, range); + if (block && block !== editor) return block; + if (editor.childElementCount === 1 && ["DIV", "P"].includes(editor.firstElementChild.tagName) + && !editor.firstElementChild.classList.contains("gmail_quote")) { + return editor.firstElementChild; + } + return wrapEditorContents(editor); +} + +function normalizeEditorAlignment(editor) { + if (!editor) return ""; + const rootAlign = explicitAlignment(editor); + if (rootAlign) { + const block = ensureAlignableBlock(editor, selectionInside(editor)?.range); + if (block && block !== editor && !explicitAlignment(block)) { + block.style.textAlign = rootAlign; + } + editor.style.textAlign = ""; + editor.removeAttribute("align"); + if (!editor.getAttribute("style")) editor.removeAttribute("style"); + } + editor.querySelectorAll("[align]").forEach((element) => { + const value = explicitAlignment(element); + if (value && !element.style.textAlign) element.style.textAlign = value; + element.removeAttribute("align"); + }); + return explicitAlignment(editor.firstElementChild) || ""; +} + function explicitAlignment(block) { if (!block) return ""; const value = (block.style?.textAlign || block.getAttribute?.("align") || "").toLowerCase(); @@ -329,7 +386,7 @@ export default function ComposeModal({ }, [draftKey]); useEffect(() => { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); if (!token) return; listOrgMembers(token) .then((members) => { @@ -446,9 +503,14 @@ export default function ComposeModal({ if (align) { const editor = editorRef.current; const current = selectionInside(editor); - const block = alignmentBlock(editor, current?.range); + const block = ensureAlignableBlock(editor, current?.range); if (explicitAlignment(block) === align) clearAlignment(block); - else document.execCommand(command, false, value); + else { + document.execCommand(command, false, value); + const next = ensureAlignableBlock(editor, selectionInside(editor)?.range); + if (next && next !== editor) next.style.textAlign = align; + normalizeEditorAlignment(editor); + } } else { document.execCommand(command, false, value); } @@ -517,17 +579,35 @@ export default function ComposeModal({ function addFiles(fileList, asInline = false) { const files = Array.from(fileList || []); if (!files.length) return; - setAttachments((current) => [ - ...current, - ...files.map((file) => ({ - id: `${file.name}-${file.size}-${Date.now()}`, - name: file.name, - size: file.size, - type: file.type, - inline: asInline, - preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : null, - })), - ]); + setAttachments((current) => { + if (current.length + files.length > MAX_ATTACHMENTS) { + flashNotice(`You can attach up to ${MAX_ATTACHMENTS} files.`); + return current; + } + const accepted = []; + let total = current.reduce((sum, item) => sum + Number(item.size || 0), 0); + for (const file of files) { + if (file.size > MAX_ATTACHMENT_BYTES) { + flashNotice(`${file.name} is larger than 15 MB.`); + continue; + } + if (total + file.size > MAX_ATTACHMENTS_TOTAL_BYTES) { + flashNotice("Attachments together must be 25 MB or smaller."); + break; + } + total += file.size; + accepted.push({ + id: `${file.name}-${file.size}-${Date.now()}-${accepted.length}`, + name: file.name, + size: file.size, + type: file.type || "application/octet-stream", + inline: asInline, + file, + preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : null, + }); + } + return accepted.length ? [...current, ...accepted] : current; + }); } function removeAttachment(id) { @@ -572,12 +652,29 @@ export default function ComposeModal({ setStatus({ sending: false, error: "Add at least one recipient." }); return; } - const rawHtml = editorRef.current?.innerHTML || bodyHtml; - const outboundHtml = htmlForOutboundEmail(rawHtml); + const editor = editorRef.current; + normalizeEditorAlignment(editor); + const rawHtml = editor?.innerHTML || bodyHtml; + const outboundHtml = htmlForOutboundEmail(rawHtml, { + textAlign: explicitAlignment(editor?.firstElementChild) || explicitAlignment(editor), + }); const outboundText = plainTextFromHtml(outboundHtml || rawHtml); setBodyHtml(rawHtml === "
" || rawHtml === "

" ? "" : rawHtml); setStatus({ sending: true, error: "" }); try { + const encodedAttachments = await Promise.all( + attachments.map(async (item) => { + if (!item.file) { + throw new Error(`${item.name} could not be attached. Add the file again.`); + } + return { + filename: item.name, + content_type: item.type || "application/octet-stream", + content_base64: await fileToBase64(item.file), + disposition: item.inline ? "inline" : "attachment", + }; + }), + ); const payload = { recipients: recipientsToPayload(to), cc: recipientsToPayload(cc), @@ -585,19 +682,20 @@ export default function ComposeModal({ subject, body_text: outboundText, body_html: outboundHtml || null, + attachments: encodedAttachments, }; if (replyToMessageId) { payload.reply_to_message_id = replyToMessageId; payload.reply_all = replyAll; } - await sendEmail(payload, sessionStorage.getItem("agmail_access_token")); + await sendEmail(payload, getAccessToken()); clearDraft(windowId || account.id); flashNotice("Message sent. Undo available for 5 seconds."); onSent?.(); } catch (error) { setStatus({ sending: false, error: error.message }); } - }, [to, cc, bcc, subject, bodyHtml, replyToMessageId, replyAll, windowId, account.id, onSent]); + }, [to, cc, bcc, subject, bodyHtml, attachments, replyToMessageId, replyAll, windowId, account.id, onSent]); useEffect(() => { function onKeyDown(event) { @@ -766,19 +864,24 @@ export default function ComposeModal({ {attachments.length > 0 && (
{attachments.map((file) => ( -
- {file.inline ? "๐Ÿ–ผ" : "๐Ÿ“„"} -
+
+ +
{file.name} - {file.type || "File"} ยท {formatFileSize(file.size)} -
-
- - - + {fileMetaLine(file.type, file.size, file.name)}
+
))}
diff --git a/frontend/src/components/DashboardPage.jsx b/frontend/src/components/DashboardPage.jsx index 372932b..5ff08aa 100644 --- a/frontend/src/components/DashboardPage.jsx +++ b/frontend/src/components/DashboardPage.jsx @@ -29,10 +29,9 @@ import { import { useCallback, useEffect, useState } from "react"; import { getCurrentAccount, getMyCompanyDomain } from "../api"; import { - AUTH_ACCOUNT_KEY, - AUTH_TOKEN_KEY, clearSession, getAccessToken, + saveStoredAccount, } from "../auth"; import Brand from "./Brand"; import MobileChrome from "./MobileChrome"; @@ -148,7 +147,7 @@ export default function DashboardPage() { getCurrentAccount(token) .then(async (currentAccount) => { - sessionStorage.setItem(AUTH_ACCOUNT_KEY, JSON.stringify(currentAccount)); + saveStoredAccount(currentAccount); setAccount(currentAccount); setMustChangePassword(Boolean(currentAccount.must_change_password)); if (currentAccount.account_type === "company" && currentAccount.is_domain_owner) { @@ -282,7 +281,7 @@ export default function DashboardPage() { function updateAccount(next) { setAccount(next); - sessionStorage.setItem("agmail_account", JSON.stringify(next)); + saveStoredAccount(next); } function openExternalThread(threadId) { @@ -624,7 +623,7 @@ export default function DashboardPage() { onComplete={(updated) => { setAccount(updated); setMustChangePassword(false); - sessionStorage.setItem("agmail_account", JSON.stringify(updated)); + saveStoredAccount(updated); }} /> )} diff --git a/frontend/src/components/HandoffConversation.jsx b/frontend/src/components/HandoffConversation.jsx index 6e4f4e8..7c4d591 100644 --- a/frontend/src/components/HandoffConversation.jsx +++ b/frontend/src/components/HandoffConversation.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { ArrowLeft } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { getHandoffConversation, retryHandoffDelivery } from "../api"; @@ -27,7 +28,7 @@ function HandoffMessageBody({ message }) { } export default function HandoffConversation({ handoffId, onBack, onOpenExternal }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [conversation, setConversation] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); diff --git a/frontend/src/components/HandoffInbox.jsx b/frontend/src/components/HandoffInbox.jsx index e9d4809..de2d182 100644 --- a/frontend/src/components/HandoffInbox.jsx +++ b/frontend/src/components/HandoffInbox.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { ArrowLeftRight, ChevronRight } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { listHandoffConversations } from "../api"; @@ -19,7 +20,7 @@ function previewSnippet(value) { } export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); diff --git a/frontend/src/components/HandoffSettings.jsx b/frontend/src/components/HandoffSettings.jsx index 762c630..a547c7e 100644 --- a/frontend/src/components/HandoffSettings.jsx +++ b/frontend/src/components/HandoffSettings.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { MoreHorizontal, UserRound } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { @@ -17,7 +18,7 @@ function ruleScope(rule) { } export default function HandoffSettings({ createToken = 0 }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [rules, setRules] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); diff --git a/frontend/src/components/ImportUsersPanel.jsx b/frontend/src/components/ImportUsersPanel.jsx index 4888826..fc72fb1 100644 --- a/frontend/src/components/ImportUsersPanel.jsx +++ b/frontend/src/components/ImportUsersPanel.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { Download, FileUp, Upload, X } from "lucide-react"; import { useMemo, useState } from "react"; import { importOrgMembers } from "../api"; @@ -20,7 +21,7 @@ function SetupField({ label, children, hint }) { } export default function ImportUsersPanel({ companyDomain, onClose, onImported }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [form, setForm] = useState({ org_role: "user", send_invitation: true, diff --git a/frontend/src/components/InviteAcceptPage.jsx b/frontend/src/components/InviteAcceptPage.jsx index f7b73c3..ebd4cd3 100644 --- a/frontend/src/components/InviteAcceptPage.jsx +++ b/frontend/src/components/InviteAcceptPage.jsx @@ -1,6 +1,7 @@ import { ArrowRight } from "lucide-react"; import { useEffect, useState } from "react"; import { acceptInvite, previewInvite } from "../api"; +import { saveSession } from "../auth"; import AuthLayout from "./AuthLayout"; import PasswordField from "./PasswordField"; @@ -40,8 +41,7 @@ export default function InviteAcceptPage() { } try { const result = await acceptInvite(token, { password }); - sessionStorage.setItem("agmail_access_token", result.access_token); - sessionStorage.setItem("agmail_account", JSON.stringify(result.account)); + saveSession(result.access_token, result.account); window.location.replace("/app"); } catch (error) { setStatus({ loading: false, error: error.message }); diff --git a/frontend/src/components/LoginPage.jsx b/frontend/src/components/LoginPage.jsx index 1e18add..53854aa 100644 --- a/frontend/src/components/LoginPage.jsx +++ b/frontend/src/components/LoginPage.jsx @@ -1,7 +1,7 @@ import { ArrowRight, Mail } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { login, resendLoginOtp } from "../api"; -import { AUTH_ACCOUNT_KEY, AUTH_TOKEN_KEY, isLoggedIn } from "../auth"; +import { isLoggedIn, saveSession } from "../auth"; import AuthLayout from "./AuthLayout"; import PasswordField from "./PasswordField"; @@ -50,8 +50,7 @@ export default function LoginPage() { setStatus({ loading: false, error: "" }); return; } - sessionStorage.setItem(AUTH_TOKEN_KEY, result.access_token); - sessionStorage.setItem(AUTH_ACCOUNT_KEY, JSON.stringify(result.account)); + saveSession(result.access_token, result.account); window.location.replace("/app"); } catch (error) { setStatus({ loading: false, error: error.message }); diff --git a/frontend/src/components/MailDetail.jsx b/frontend/src/components/MailDetail.jsx index 09b4590..9021176 100644 --- a/frontend/src/components/MailDetail.jsx +++ b/frontend/src/components/MailDetail.jsx @@ -23,6 +23,7 @@ import { addressText, senderDisplayName } from "../utils/senderAvatar"; import { attachmentCount } from "../utils/inboxSignals"; import { visibleMessageHtml } from "../utils/emailReply"; import { formatMailDetailTime } from "../utils/datetime"; +import { fileMetaLine } from "../utils/fileMeta"; const TOOLBAR_ICON = { size: 16, strokeWidth: 1.7 }; const ACTION_ICON = { size: 14, strokeWidth: 1.7 }; @@ -123,13 +124,6 @@ function recipientLabel(email, viewerEmail) { return `to ${addresses.length} recipients`; } -function formatFileSize(bytes) { - if (!bytes) return ""; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - function mailboxLabel(mailbox) { if (mailbox === "inbox") return "Inbox"; if (mailbox === "sent") return "Sent"; @@ -256,13 +250,13 @@ function MessageBlock({ {attachments.length > 0 && (
{attachments.map((file) => ( -
+
-
+
{file.filename} - {file.content_type || "File"} ยท {formatFileSize(file.size)} + {fileMetaLine(file.content_type, file.size, file.filename)}
))} diff --git a/frontend/src/components/MailboxView.jsx b/frontend/src/components/MailboxView.jsx index d5f1ec1..53d96c9 100644 --- a/frontend/src/components/MailboxView.jsx +++ b/frontend/src/components/MailboxView.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { Bell, ChevronDown, @@ -123,7 +124,7 @@ export default function MailboxView({ onCompose, onStats, }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const { sheet, setSheet } = useShellMode(); const [emails, setEmails] = useState([]); const [nextCursor, setNextCursor] = useState(null); diff --git a/frontend/src/components/SignupPage.jsx b/frontend/src/components/SignupPage.jsx index 4c81d4d..b9cd3e2 100644 --- a/frontend/src/components/SignupPage.jsx +++ b/frontend/src/components/SignupPage.jsx @@ -3,6 +3,7 @@ import { } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { register, sendSignupOtp } from "../api"; +import { saveSession } from "../auth"; import AuthLayout from "./AuthLayout"; import PasswordField from "./PasswordField"; @@ -95,8 +96,7 @@ export default function SignupPage() { otp: isIndia ? otp.trim() : null, password: form.get("password"), }); - sessionStorage.setItem("agmail_access_token", result.access_token); - sessionStorage.setItem("agmail_account", JSON.stringify(result.account)); + saveSession(result.access_token, result.account); window.location.replace("/app"); } catch (error) { setStatus({ loading: false, error: error.message }); diff --git a/frontend/src/components/StickyNotesPanel.jsx b/frontend/src/components/StickyNotesPanel.jsx index cb1fff9..f2a9594 100644 --- a/frontend/src/components/StickyNotesPanel.jsx +++ b/frontend/src/components/StickyNotesPanel.jsx @@ -1,3 +1,4 @@ +import { getAccessToken } from "../auth"; import { ChevronDown, MoreHorizontal, @@ -80,7 +81,7 @@ function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) { } export default function StickyNotesPanel({ compact = false, contextEmail = null }) { - const token = sessionStorage.getItem("agmail_access_token"); + const token = getAccessToken(); const [notes, setNotes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); diff --git a/frontend/src/components/compose/RecipientField.jsx b/frontend/src/components/compose/RecipientField.jsx index 2a039e3..8dd41d6 100644 --- a/frontend/src/components/compose/RecipientField.jsx +++ b/frontend/src/components/compose/RecipientField.jsx @@ -139,7 +139,6 @@ export default function RecipientField({
{label ? {label} : null}
inputRef.current?.focus()}> - {trailing} {recipients.map((recipient, index) => ( { setInput(event.target.value); setOpen(true); @@ -176,6 +176,7 @@ export default function RecipientField({ autoComplete="off" spellCheck={false} /> + {trailing}
{open && filtered.length > 0 && (
    diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 24ef4b9..8b4a35c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1031,6 +1031,9 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; } .mail-detail-body-html { white-space:normal; overflow-wrap:anywhere; } .mail-detail-body-html p,.mail-detail-body-html h1,.mail-detail-body-html h2,.mail-detail-body-html h3 { margin:0 0 .55em; } .mail-detail-body-html div { margin:0; } +.mail-detail-body-html [style*="text-align:center"],.mail-detail-body-html [style*="text-align: center"] { text-align:center; } +.mail-detail-body-html [style*="text-align:right"],.mail-detail-body-html [style*="text-align: right"] { text-align:right; } +.mail-detail-body-html [style*="text-align:left"],.mail-detail-body-html [style*="text-align: left"] { text-align:left; } .mail-detail-body-html a { color:#4d74c4; text-decoration:underline; } .mail-detail-body-html strong,.mail-detail-body-html b { font-weight:700; } .mail-detail-body-html em,.mail-detail-body-html i { font-style:italic; } @@ -1040,10 +1043,11 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; } .mail-detail-body-html li { margin:0 0 .2em; } .mail-detail-body-html blockquote { margin:.4em 0 .4em .8ex; padding-left:1ex; border-left:1px solid #ccc; color:#555; } .mail-detail-attachments { margin-top:16px; max-width:760px; display:flex; flex-wrap:wrap; gap:10px; } -.mail-detail-attachment { min-width:220px; max-width:320px; padding:10px 12px; display:grid; grid-template-columns:auto 1fr auto; gap:10px; align-items:center; border:1px solid #ECE9EE; border-radius:8px; background:#fcfbfa; } -.mail-detail-attachment-icon { display:grid; place-items:center; color:#6f6b7c; } +.mail-detail-attachment { width:min(100%,260px); min-width:0; padding:10px 12px; display:grid; grid-template-columns:28px minmax(0,1fr); gap:10px; align-items:center; border:1px solid #ECE9EE; border-radius:8px; background:#fcfbfa; overflow:hidden; } +.mail-detail-attachment-icon { width:28px; height:28px; display:grid; place-items:center; color:#6f6b7c; } +.mail-detail-attachment-meta { min-width:0; } .mail-detail-attachment strong { display:block; font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.mail-detail-attachment small { display:block; margin-top:2px; color:#77758a; font-size:10.5px; } +.mail-detail-attachment small { display:block; margin-top:2px; color:#77758a; font-size:10.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .mail-detail-attachment-actions { display:flex; flex-direction:column; gap:2px; } .mail-detail-attachment-actions button { padding:2px 4px; border:0; background:transparent; color:#7a7390; font-size:10px; font-weight:600; } .mail-detail-message-footer { margin-top:18px; padding:0 0 18px; width:100%; box-sizing:border-box; } @@ -1116,16 +1120,16 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; } .compose-recipient-row { height:auto; min-height:44px; padding-top:8px; padding-bottom:8px; align-items:start; grid-template-columns:var(--compose-label) minmax(0,1fr); } .compose-recipient-row .compose-row-label { line-height:28px; } .compose-recipient-row .compose-row-main { display:block; min-height:28px; } -.compose-recipient-toggles { float:right; height:28px; margin-left:10px; display:flex; align-items:center; gap:12px; } +.compose-recipient-toggles { height:28px; margin-left:auto; display:flex; align-items:center; gap:12px; flex:0 0 auto; } .compose-recipient-toggles button { padding:0; border:0; background:transparent; color:#847e87; font-size:10px; font-weight:700; line-height:1; } .compose-recipient-toggles button:hover { color:#bc5948; } .compose-recipient-field { position:relative; width:100%; } .compose-recipient-field.inline { display:block; } -.compose-recipient-chips { min-height:28px; display:block; } -.compose-recipient-input { width:40px; min-width:40px; max-width:100%; height:28px; margin:0; border:0; border-radius:0; outline:none; background:transparent; color:var(--ink); font:500 12px/28px "DM Sans"; box-shadow:none; vertical-align:middle; } -.compose-recipient-chips:not(:has(.compose-chip)) .compose-recipient-input { width:160px; } +.compose-recipient-chips { min-height:28px; display:flex; flex-wrap:wrap; align-items:center; gap:4px 6px; } +.compose-recipient-input { flex:1 1 8ch; min-width:8ch; width:auto; max-width:100%; height:28px; margin:0; border:0; border-radius:0; outline:none; background:transparent; color:var(--ink); font:500 12px/28px "DM Sans"; box-shadow:none; } +.compose-recipient-chips:not(:has(.compose-chip)) .compose-recipient-input { flex-basis:160px; min-width:160px; } .compose-recipient-input:focus { outline:none; box-shadow:none; } -.compose-chip { max-width:100%; height:24px; margin:2px 6px 2px 0; padding:0 4px 0 8px; display:inline-flex; align-items:center; vertical-align:middle; gap:4px; border-radius:6px; background:#f3efec; color:var(--ink); font-size:11px; font-weight:600; } +.compose-chip { max-width:100%; height:24px; margin:0; padding:0 4px 0 8px; display:inline-flex; align-items:center; gap:4px; border-radius:6px; background:#f3efec; color:var(--ink); font-size:11px; font-weight:600; flex:0 1 auto; } .compose-chip.external { background:#fff4ea; color:#9a5b28; } .compose-chip.invalid { background:#fdeeed; color:#b54f3d; } .compose-chip.group { background:#eef4ff; color:#4d74c4; } @@ -1144,14 +1148,14 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; } .compose-subject-input:focus { outline:none; box-shadow:none; } .compose-subject-suggest { flex-shrink:0; padding:0; border:0; background:transparent; color:#7357F6; font-size:10px; font-weight:700; display:inline-flex; align-items:center; gap:4px; } .compose-subject-suggest:hover { color:#5a3fe0; } -.compose-attachments { padding:8px var(--compose-pad) 4px; display:flex; flex-wrap:wrap; gap:8px; border-bottom:1px solid #ECE9EE; flex-shrink:0; } -.compose-attachment-card { min-width:min(100%,200px); max-width:260px; padding:7px 9px; display:grid; grid-template-columns:auto 1fr auto; gap:8px; align-items:center; border:1px solid #ECE9EE; border-radius:6px; background:#fcfbfa; } -.compose-attachment-icon { font-size:16px; line-height:1; } -.compose-attachment-card strong { display:block; font-size:11px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.compose-attachment-card small { display:block; margin-top:1px; color:#8a848d; font-size:9px; } -.compose-attachment-actions { display:flex; align-items:center; gap:2px; } -.compose-attachment-actions button { padding:3px 4px; border:0; border-radius:4px; background:transparent; color:#8a848d; font-size:8px; font-weight:700; } -.compose-attachment-actions button:hover { background:#f3efec; color:var(--ink); } +.compose-attachments { padding:8px var(--compose-pad) 6px; display:flex; flex-wrap:wrap; gap:8px; border-bottom:1px solid #ECE9EE; flex-shrink:0; } +.compose-attachment-card { width:min(100%,248px); min-width:0; padding:8px 8px 8px 10px; display:grid; grid-template-columns:24px minmax(0,1fr) 28px; gap:8px; align-items:center; border:1px solid #ECE9EE; border-radius:8px; background:#fcfbfa; overflow:hidden; } +.compose-attachment-icon { width:24px; height:24px; display:grid; place-items:center; color:#6f6b7c; } +.compose-attachment-meta { min-width:0; } +.compose-attachment-card strong { display:block; font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.compose-attachment-card small { display:block; margin-top:2px; color:#8a848d; font-size:10px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.compose-attachment-remove { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#8a848d; } +.compose-attachment-remove:hover { background:#f3efec; color:var(--ink); } .compose-editor { min-height:160px; flex:1; padding:16px; overflow:auto; outline:none; color:var(--ink); font:400 13px/1.6 "DM Sans"; background:#fff; } .compose-editor i,.compose-editor em,.compose-editor span[style*="italic"] { font-style:italic; } .compose-editor:focus { outline:none; box-shadow:none; } @@ -1160,7 +1164,7 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; } .compose-editor.quotes-collapsed .gmail_quote { display:none; } .compose-quoted-toggle { align-self:flex-start; margin:0 0 10px 16px; padding:1px 8px 3px; border:1px solid #dedbe2; border-radius:5px; background:#f3f1f5; color:#77717d; font:700 12px/1 "DM Sans"; letter-spacing:1px; cursor:pointer; } .compose-quoted-toggle:hover { background:#eae7ed; color:#45404a; } -.compose-bottom-stack { flex-shrink:0; border-top:1px solid #EEECEF; background:#fff; } +.compose-bottom-stack { position:relative; z-index:8; flex-shrink:0; border-top:1px solid #EEECEF; background:#fff; } .compose-agent-bar { height:42px; padding:0 var(--compose-pad); display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; border-top:1px solid #EEEAF8; border-bottom:1px solid #EEEAF8; background:#FBF9FF; color:#77728C; font-size:11px; } .compose-agent-bar .agent-spark { color:#7357F6; } .compose-agent-bar-disabled { pointer-events:none; } @@ -1179,22 +1183,22 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; } .compose-error { margin:0; padding:6px var(--compose-pad); color:#b54f3d; font-size:10px; background:#fdeeed; } .compose-notice { margin:0; padding:6px var(--compose-pad); color:#4d74c4; font-size:10px; background:#eef4ff; } .compose-action-bar { height:52px; padding:0 12px; display:flex; align-items:center; gap:8px; background:#fff; } -.compose-action-left { display:flex; align-items:center; gap:4px; min-width:0; flex:1; overflow:hidden; } +.compose-action-left { display:flex; align-items:center; gap:4px; min-width:0; flex:1; overflow:visible; } .compose-action-right { display:flex; align-items:center; gap:8px; flex-shrink:0; margin-left:auto; } -.compose-send-split { position:relative; height:36px; display:inline-flex; align-items:stretch; border-radius:8px; overflow:hidden; flex-shrink:0; } -.compose-send-primary { min-width:94px; height:36px; padding:0 12px; display:inline-flex; align-items:center; justify-content:center; gap:6px; border:0; background:var(--coral); color:var(--ink); font-size:12px; font-weight:700; } +.compose-send-split { position:relative; z-index:9; height:36px; display:inline-flex; align-items:stretch; border-radius:8px; overflow:visible; flex-shrink:0; } +.compose-send-primary { min-width:94px; height:36px; padding:0 12px; display:inline-flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:8px 0 0 8px; background:var(--coral); color:var(--ink); font-size:12px; font-weight:700; } .compose-send-primary:hover:not(:disabled) { filter:brightness(1.03); } .compose-send-primary:disabled { opacity:.6; cursor:wait; } -.compose-send-caret { width:30px; height:36px; display:grid; place-items:center; border:0; border-left:1px solid rgba(8,8,33,.08); background:#ff7f67; color:var(--ink); } +.compose-send-caret { width:30px; height:36px; display:grid; place-items:center; border:0; border-radius:0 8px 8px 0; border-left:1px solid rgba(8,8,33,.08); background:#ff7f67; color:var(--ink); } .compose-send-caret:hover:not(:disabled) { background:#ff7560; } -.compose-send-menu-panel { position:absolute; left:0; bottom:calc(100% + 6px); z-index:8; min-width:170px; padding:6px; display:grid; gap:2px; border:1px solid #ECE9EE; border-radius:8px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); } +.compose-send-menu-panel { position:absolute; left:0; bottom:calc(100% + 6px); z-index:20; min-width:170px; padding:6px; display:grid; gap:2px; border:1px solid #ECE9EE; border-radius:8px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); } .compose-send-menu-panel button { width:100%; min-height:34px; padding:0 10px; display:flex; align-items:center; gap:8px; border:0; border-radius:6px; background:transparent; font-size:11px; font-weight:600; text-align:left; } .compose-send-menu-panel button:hover { background:#f7f4f1; } .compose-menu { position:relative; } .compose-menu-trigger { min-height:34px; padding:0 12px; display:inline-flex; align-items:center; gap:6px; border:0; border-radius:8px; background:var(--coral); color:var(--ink); font-size:12px; font-weight:700; } .compose-icon-menu .compose-menu-trigger,.compose-icon-btn { width:32px; height:32px; padding:0; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#6f6a73; box-shadow:none; } .compose-icon-menu .compose-menu-trigger:hover,.compose-icon-btn:hover { background:#f3efec; color:var(--ink); } -.compose-menu-panel { position:absolute; bottom:calc(100% + 6px); left:0; z-index:8; min-width:190px; padding:6px; display:grid; gap:2px; border:1px solid #ECE9EE; border-radius:8px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); } +.compose-menu-panel { position:absolute; bottom:calc(100% + 6px); left:0; z-index:20; min-width:190px; padding:6px; display:grid; gap:2px; border:1px solid #ECE9EE; border-radius:8px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); } .compose-menu-panel.align-right { left:auto; right:0; } .compose-menu-panel button { width:100%; min-height:34px; padding:0 10px; display:flex; align-items:center; gap:8px; border:0; border-radius:6px; background:transparent; color:var(--ink); font-size:11px; font-weight:600; text-align:left; } .compose-menu-panel button small { margin-left:auto; color:#8a848d; font-size:9px; font-weight:500; } @@ -1204,8 +1208,8 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; } .compose-draft-status { color:#8a848d; font-size:10px; font-weight:600; white-space:nowrap; } .compose-discard { width:32px; height:32px; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#8a848d; margin-left:auto; } .compose-discard:hover { background:#fdeeed; color:#b54f3d; } -.compose-emoji-wrap { position:relative; } -.compose-emoji-panel { position:absolute; bottom:calc(100% + 6px); left:0; z-index:8; width:180px; padding:8px; display:grid; grid-template-columns:repeat(6,1fr); gap:4px; border:1px solid #ECE9EE; border-radius:8px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); } +.compose-emoji-wrap { position:relative; z-index:10; } +.compose-emoji-panel { position:absolute; bottom:calc(100% + 6px); left:0; z-index:20; width:180px; padding:8px; display:grid; grid-template-columns:repeat(6,1fr); gap:4px; border:1px solid #ECE9EE; border-radius:8px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); } .compose-emoji-panel button { width:26px; height:26px; border:0; border-radius:4px; background:transparent; font-size:15px; } .compose-emoji-panel button:hover { background:#f3efec; } .compose-link-popover { position:absolute; left:50%; bottom:148px; z-index:12; width:min(360px,calc(100% - 32px)); transform:translateX(-50%); padding:10px; display:grid; gap:8px; border:1px solid #ece9ee; border-radius:12px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,.16); } diff --git a/frontend/src/utils/emailReply.js b/frontend/src/utils/emailReply.js index 9dd3571..42ebff3 100644 --- a/frontend/src/utils/emailReply.js +++ b/frontend/src/utils/emailReply.js @@ -77,9 +77,18 @@ function wrapContents(element, tagName) { element.appendChild(wrapper); } +function normalizeAlignValue(value) { + const align = String(value || "").toLowerCase(); + if (align === "center") return "center"; + if (align === "right" || align === "end") return "right"; + if (align === "left" || align === "start" || align === "justify") return align === "justify" ? "justify" : "left"; + return ""; +} + function applySemanticStyles(element) { const style = parseStyle(element.getAttribute("style")); const decoration = String(style["text-decoration"] || "").toLowerCase(); + const aligned = normalizeAlignValue(element.getAttribute("align") || style["text-align"]); if (isBold(style["font-weight"])) { wrapContents(element, "strong"); @@ -94,7 +103,9 @@ function applySemanticStyles(element) { if (decoration.includes("underline") || decoration.includes("line-through")) { delete style["text-decoration"]; } - if (!["DIV", "P", "H1", "H2", "H3", "LI", "BLOCKQUOTE"].includes(element.tagName)) { + if (["DIV", "P", "H1", "H2", "H3", "LI", "BLOCKQUOTE", "TD", "TH"].includes(element.tagName) && aligned) { + style["text-align"] = aligned; + } else { delete style["text-align"]; } @@ -113,6 +124,14 @@ export function sanitizeEmailHtml(value) { child.remove(); return; } + if (child.tagName === "CENTER") { + const replacement = child.ownerDocument.createElement("div"); + replacement.setAttribute("style", "text-align: center"); + while (child.firstChild) replacement.appendChild(child.firstChild); + child.replaceWith(replacement); + clean(replacement); + return; + } if (!SAFE_TAGS.has(child.tagName)) { clean(child); child.replaceWith(...child.childNodes); @@ -122,6 +141,7 @@ export function sanitizeEmailHtml(value) { const href = child.tagName === "A" ? allowedHref(child.getAttribute("href")) : ""; const className = child.getAttribute("class") || ""; const styleText = child.getAttribute("style") || ""; + const alignAttr = child.getAttribute("align") || ""; const keepQuoteClass = /\b(gmail_quote|gmail_attr)\b/.test(className); [...child.attributes].forEach((attribute) => child.removeAttribute(attribute.name)); if (href) child.setAttribute("href", href); @@ -131,12 +151,15 @@ export function sanitizeEmailHtml(value) { )).join(" ")); } if (child.tagName === "BLOCKQUOTE") { + const quoteAlign = normalizeAlignValue(alignAttr); child.setAttribute( "style", - "margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex", + `margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex${quoteAlign ? `;text-align:${quoteAlign}` : ""}`, ); - } else if (styleText) { - child.setAttribute("style", styleText); + } else if (styleText || alignAttr) { + const nextStyle = styleText || ""; + child.setAttribute("style", nextStyle); + if (alignAttr) child.setAttribute("align", alignAttr); applySemanticStyles(child); } clean(child); @@ -238,10 +261,12 @@ export function plainTextFromEmailHtml(value) { .trim(); } -export function htmlForOutboundEmail(value) { +export function htmlForOutboundEmail(value, { textAlign } = {}) { const cleaned = sanitizeEmailHtml(value); if (!cleaned || !plainTextFromEmailHtml(cleaned)) return ""; - return `
    ${cleaned}
    `; + const align = normalizeAlignValue(textAlign); + const style = align ? ` style="text-align:${align}"` : ""; + return `
    ${cleaned}
    `; } export function visibleMessageHtml(value) { diff --git a/frontend/src/utils/fileMeta.js b/frontend/src/utils/fileMeta.js new file mode 100644 index 0000000..cce6972 --- /dev/null +++ b/frontend/src/utils/fileMeta.js @@ -0,0 +1,43 @@ +const TYPE_LABELS = { + "application/pdf": "PDF", + "application/msword": "Word", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "Word", + "application/vnd.ms-excel": "Excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "Excel", + "application/vnd.ms-powerpoint": "PowerPoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": "PowerPoint", + "application/zip": "ZIP", + "application/json": "JSON", + "text/plain": "Text", + "text/csv": "CSV", + "image/jpeg": "JPEG", + "image/jpg": "JPEG", + "image/png": "PNG", + "image/gif": "GIF", + "image/webp": "WebP", + "image/svg+xml": "SVG", +}; + +export function formatFileSize(bytes) { + const size = Number(bytes); + if (!Number.isFinite(size) || size <= 0) return ""; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`; + if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`; + return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +export function fileTypeLabel(type, filename = "") { + const mime = String(type || "").toLowerCase(); + if (TYPE_LABELS[mime]) return TYPE_LABELS[mime]; + if (mime.startsWith("image/")) return mime.slice(6).toUpperCase(); + if (mime.startsWith("video/")) return "Video"; + if (mime.startsWith("audio/")) return "Audio"; + const ext = String(filename).split(".").pop(); + if (ext && ext !== filename && ext.length <= 5) return ext.toUpperCase(); + return "File"; +} + +export function fileMetaLine(type, size, filename = "") { + return [fileTypeLabel(type, filename), formatFileSize(size)].filter(Boolean).join(" ยท "); +}