setting, session and profile fix

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <DashboardPage />;
}

View file

@ -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");
}

View file

@ -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) {

View file

@ -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);

View file

@ -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);

View file

@ -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: "",

View file

@ -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("");

View file

@ -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([]);

View file

@ -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);

View file

@ -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("");

View file

@ -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([]);

View file

@ -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);

View file

@ -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 });

View file

@ -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);

View file

@ -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()}`,
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,
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 === "<br>" || rawHtml === "<div><br></div>" ? "" : 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,20 +864,25 @@ export default function ComposeModal({
{attachments.length > 0 && (
<div className="compose-attachments" aria-label="Attachments">
{attachments.map((file) => (
<div key={file.id} className="compose-attachment-card">
<span className="compose-attachment-icon">{file.inline ? "🖼" : "📄"}</span>
<div>
<div key={file.id} className="compose-attachment-card" title={file.name}>
<span className="compose-attachment-icon" aria-hidden="true">
{file.inline || file.type?.startsWith("image/")
? <Image size={16} strokeWidth={1.7} />
: <FileText size={16} strokeWidth={1.7} />}
</span>
<div className="compose-attachment-meta">
<strong>{file.name}</strong>
<small>{file.type || "File"} · {formatFileSize(file.size)}</small>
<small>{fileMetaLine(file.type, file.size, file.name)}</small>
</div>
<div className="compose-attachment-actions">
<button type="button" onClick={() => flashNotice("Attachment preview — coming soon")}>Preview</button>
<button type="button" onClick={() => flashNotice("Summarize attachment — coming soon")}>Summarize</button>
<button type="button" aria-label={`Remove ${file.name}`} onClick={() => removeAttachment(file.id)}>
<button
type="button"
className="compose-attachment-remove"
aria-label={`Remove ${file.name}`}
onClick={() => removeAttachment(file.id)}
>
<X size={14} strokeWidth={iconStroke} />
</button>
</div>
</div>
))}
</div>
)}

View file

@ -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);
}}
/>
)}

View file

@ -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("");

View file

@ -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("");

View file

@ -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);

View file

@ -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,

View file

@ -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 });

View file

@ -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 });

View file

@ -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 && (
<div className="mail-detail-attachments" aria-label="Attachments">
{attachments.map((file) => (
<div key={`${file.filename}-${file.size}`} className="mail-detail-attachment">
<div key={`${file.filename}-${file.size}`} className="mail-detail-attachment" title={file.filename}>
<span className="mail-detail-attachment-icon" aria-hidden="true">
<FileText size={18} strokeWidth={1.7} />
</span>
<div>
<div className="mail-detail-attachment-meta">
<strong>{file.filename}</strong>
<small>{file.content_type || "File"} · {formatFileSize(file.size)}</small>
<small>{fileMetaLine(file.content_type, file.size, file.filename)}</small>
</div>
</div>
))}

View file

@ -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);

View file

@ -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 });

View file

@ -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("");

View file

@ -139,7 +139,6 @@ export default function RecipientField({
<div className={`compose-recipient-field${inline ? " inline" : ""}`} ref={wrapRef}>
{label ? <span className="compose-recipient-label">{label}</span> : null}
<div className="compose-recipient-chips" onClick={() => inputRef.current?.focus()}>
{trailing}
{recipients.map((recipient, index) => (
<span
key={`${recipientKey(recipient)}-${index}`}
@ -159,6 +158,7 @@ export default function RecipientField({
ref={inputRef}
className="compose-recipient-input"
value={input}
size={Math.max(input.length + 1, recipients.length ? 8 : 12)}
onChange={(event) => {
setInput(event.target.value);
setOpen(true);
@ -176,6 +176,7 @@ export default function RecipientField({
autoComplete="off"
spellCheck={false}
/>
{trailing}
</div>
{open && filtered.length > 0 && (
<ul className="compose-recipient-suggestions" role="listbox" style={inline ? { left: 0 } : undefined}>

View file

@ -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); }

View file

@ -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 `<div dir="ltr">${cleaned}</div>`;
const align = normalizeAlignValue(textAlign);
const style = align ? ` style="text-align:${align}"` : "";
return `<div dir="ltr"${style}>${cleaned}</div>`;
}
export function visibleMessageHtml(value) {

View file

@ -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(" · ");
}