diff --git a/internal/api/handler/email_authcheck.go b/internal/api/handler/email_authcheck.go index a6aa4182..53e08999 100644 --- a/internal/api/handler/email_authcheck.go +++ b/internal/api/handler/email_authcheck.go @@ -17,13 +17,15 @@ import ( // Read-only: it reports what DNS says right now and leaves the mailbox's stored // auth_state alone. Use RefreshEmailAuthCheck to record the verdict. func (h *Handler) GetEmailAuthCheck(c *gin.Context) { - userID, err := middleware.GetUserUUID(c) - if err != nil { - errx.JSON(c, errx.ErrUnauthorized) + // Mailboxes are workspace assets and the lookup behind this is scoped by + // organization, so the caller's user id would 404 for everyone. + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } - res, xerr := h.EmailService.CheckDomainAuth(c.Request.Context(), userID.String(), c.Param("id")) + res, xerr := h.EmailService.CheckDomainAuth(c.Request.Context(), orgID.String(), c.Param("id")) if xerr != nil { errx.JSON(c, xerr) return @@ -42,13 +44,13 @@ func (h *Handler) GetEmailAuthCheck(c *gin.Context) { // to change it. No Idempotency-Key: the write is derived entirely from public // DNS with no caller input, so repeating it converges on the same row. func (h *Handler) RefreshEmailAuthCheck(c *gin.Context) { - userID, err := middleware.GetUserUUID(c) - if err != nil { - errx.JSON(c, errx.ErrUnauthorized) + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } - res, xerr := h.EmailService.RefreshDomainAuth(c.Request.Context(), userID.String(), c.Param("id")) + res, xerr := h.EmailService.RefreshDomainAuth(c.Request.Context(), orgID.String(), c.Param("id")) if xerr != nil { errx.JSON(c, xerr) return diff --git a/internal/app/email/handler.go b/internal/app/email/handler.go index 962414c3..a53cc045 100644 --- a/internal/app/email/handler.go +++ b/internal/app/email/handler.go @@ -216,8 +216,8 @@ func (s *emailService) resolveTrackingDomain(ctx context.Context, domain string) // CheckDomainAuth runs a live SPF/DKIM/DMARC lookup for a mailbox's sending // domain and reports it without writing anything. -func (s *emailService) CheckDomainAuth(ctx context.Context, userID, emailAccountID string) (*dnsauth.Result, *errx.Error) { - _, res, xerr := s.resolveDomainAuth(ctx, userID, emailAccountID) +func (s *emailService) CheckDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error) { + _, res, xerr := s.resolveDomainAuth(ctx, orgID, emailAccountID) return res, xerr } @@ -229,8 +229,8 @@ func (s *emailService) CheckDomainAuth(ctx context.Context, userID, emailAccount // their DNS would keep being blocked until the background sweep next reached // their domain, which can be a day away, and "I fixed it and nothing happened" // is how a correct gate still becomes a support incident. -func (s *emailService) RefreshDomainAuth(ctx context.Context, userID, emailAccountID string) (*dnsauth.Result, *errx.Error) { - domain, res, xerr := s.resolveDomainAuth(ctx, userID, emailAccountID) +func (s *emailService) RefreshDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error) { + domain, res, xerr := s.resolveDomainAuth(ctx, orgID, emailAccountID) if xerr != nil { return nil, xerr } @@ -247,11 +247,12 @@ func (s *emailService) RefreshDomainAuth(ctx context.Context, userID, emailAccou return res, nil } -// resolveDomainAuth loads the caller's mailbox and runs the DNS lookup for its -// sending domain, returning the domain alongside the result so the persisting -// caller does not re-derive it. -func (s *emailService) resolveDomainAuth(ctx context.Context, userID, emailAccountID string) (string, *dnsauth.Result, *errx.Error) { - account, xerr := s.emailRepository.Get(ctx, userID, emailAccountID) +// resolveDomainAuth loads the organization's mailbox and runs the DNS lookup +// for its sending domain, returning the domain alongside the result so the +// persisting caller does not re-derive it. Get is organization-scoped: handing +// it a user id made every check 404. +func (s *emailService) resolveDomainAuth(ctx context.Context, orgID, emailAccountID string) (string, *dnsauth.Result, *errx.Error) { + account, xerr := s.emailRepository.Get(ctx, orgID, emailAccountID) if xerr != nil { return "", nil, xerr } diff --git a/internal/app/email/service.go b/internal/app/email/service.go index 13f74976..2f7dee1d 100644 --- a/internal/app/email/service.go +++ b/internal/app/email/service.go @@ -48,11 +48,11 @@ type EmailService interface { StartTrackingDomainSweep(ctx context.Context, interval, staleAfter time.Duration) // CheckDomainAuth runs a live SPF/DKIM/DMARC lookup for a mailbox's // sending domain and returns it without touching stored state. - CheckDomainAuth(ctx context.Context, userID, emailAccountID string) (*dnsauth.Result, *errx.Error) + CheckDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error) // RefreshDomainAuth does the same and PERSISTS the verdict. That write can // lift the cold-send and warmup gate, so it sits behind the write // permission while CheckDomainAuth stays readable. - RefreshDomainAuth(ctx context.Context, userID, emailAccountID string) (*dnsauth.Result, *errx.Error) + RefreshDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error) Delete(ctx context.Context, userID, emailAccountID string) *errx.Error // Onboarding flow diff --git a/web/src/components/app/EmailEditor.tsx b/web/src/components/app/EmailEditor.tsx index 143980eb..98a1b00e 100644 --- a/web/src/components/app/EmailEditor.tsx +++ b/web/src/components/app/EmailEditor.tsx @@ -7,7 +7,7 @@ import { RiText, RiCodeView, } from "@remixicon/react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { TextInput } from "@/components/ui/field"; import { @@ -53,6 +53,14 @@ export default function EmailEditor({ }: EmailEditorProps) { const editorRef = useRef(null); const [activeTab, setActiveTab] = useState<"html" | "plain">("html"); + // The visual editor owns its DOM while the user types: rewriting innerHTML + // from the prop on every render resets the caret to the start, so only + // push htmlText in when it differs from what the element already holds + // (mount, switching back from the source view, an outside reset). + useEffect(() => { + const el = editorRef.current; + if (el && el.innerHTML !== htmlText) el.innerHTML = htmlText; + }, [htmlText, activeTab, code]); const [urlPopover, setUrlPopover] = useState<"link" | "image" | null>(null); const [url, setUrl] = useState(""); // The contentEditable selection is lost as soon as the popover's text @@ -263,9 +271,9 @@ export default function EmailEditor({ ref={editorRef} id={id} contentEditable + suppressContentEditableWarning onInput={(e) => commitHtml(e.currentTarget.innerHTML)} className="min-h-[120px] px-3 py-2.5 text-[13px] text-slate-800 outline-none prose prose-sm max-w-none" - dangerouslySetInnerHTML={{ __html: htmlText }} /> )} diff --git a/web/src/lib/api/hooks/app/emails/patchEmailLists.test.ts b/web/src/lib/api/hooks/app/emails/patchEmailLists.test.ts new file mode 100644 index 00000000..425a03ba --- /dev/null +++ b/web/src/lib/api/hooks/app/emails/patchEmailLists.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import patchEmailLists from "./patchEmailLists"; +import type Inbox from "@/lib/api/models/app/emails/Inbox"; + +const row = (id: string, name = id) => ({ id, name, email: `${id}@x.test` }) as unknown as Inbox; + +describe("patchEmailLists", () => { + it("patches the paginated list and the flat directory without crashing on either shape", () => { + const qc = new QueryClient(); + qc.setQueryData(["emails", "list", "", "", 20], { + pages: [{ data: [row("a"), row("b")], pagination: { has_more: false } }], + pageParams: [null], + }); + qc.setQueryData(["emails", "list", "directory"], [row("a"), row("b")]); + + patchEmailLists(qc, (rows) => rows.map((c) => (c.id === "a" ? row("a", "renamed") : c))); + + const list = qc.getQueryData<{ pages: { data: Inbox[] }[] }>(["emails", "list", "", "", 20]); + expect(list?.pages[0].data.map((c) => c.name)).toEqual(["renamed", "b"]); + const dir = qc.getQueryData(["emails", "list", "directory"]); + expect(dir?.map((c) => c.name)).toEqual(["renamed", "b"]); + }); +}); diff --git a/web/src/lib/api/hooks/app/emails/patchEmailLists.ts b/web/src/lib/api/hooks/app/emails/patchEmailLists.ts new file mode 100644 index 00000000..2a7eeee9 --- /dev/null +++ b/web/src/lib/api/hooks/app/emails/patchEmailLists.ts @@ -0,0 +1,32 @@ +import type GetEmails from "@/lib/api/models/app/emails/GetEmails"; +import type Inbox from "@/lib/api/models/app/emails/Inbox"; +import type { InfiniteData, QueryClient } from "@tanstack/react-query"; + +type EmailListCache = InfiniteData | Inbox[]; + +// Two shapes live under ["emails", "list"]: the paginated accounts list +// (InfiniteData pages) and the store's flat mailbox directory (Inbox[]). +// Patching both through one helper is what keeps a mutation's cache write +// from assuming one shape and crashing on the other. +export default function patchEmailLists(queryClient: QueryClient, patch: (rows: Inbox[]) => Inbox[]) { + const allLists = queryClient.getQueriesData({ queryKey: ["emails", "list"] }); + + for (const [key, oldData] of allLists) { + if (!oldData) continue; + + if (Array.isArray(oldData)) { + queryClient.setQueryData(key, patch(oldData)); + continue; + } + + if (!Array.isArray(oldData.pages)) continue; + + queryClient.setQueryData(key, { + ...oldData, + pages: oldData.pages.map((page) => ({ + ...page, + data: patch(page.data ?? []), + })), + }); + } +} diff --git a/web/src/lib/api/hooks/app/emails/useRemoveEmail.ts b/web/src/lib/api/hooks/app/emails/useRemoveEmail.ts index 82f8391c..ee0ef9da 100644 --- a/web/src/lib/api/hooks/app/emails/useRemoveEmail.ts +++ b/web/src/lib/api/hooks/app/emails/useRemoveEmail.ts @@ -1,6 +1,6 @@ import removeEmail from "@/lib/api/client/app/emails/removeEmail"; -import type GetEmails from "@/lib/api/models/app/emails/GetEmails"; -import { useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import patchEmailLists from "./patchEmailLists"; export default function useRemoveEmail(id: string) { const queryClient = useQueryClient(); @@ -8,21 +8,7 @@ export default function useRemoveEmail(id: string) { return useMutation({ mutationFn: () => removeEmail(id), onSuccess: () => { - const allLists = queryClient.getQueriesData>({ - queryKey: ["emails", "list"], - }); - - for (const [key, oldData] of allLists) { - if (!oldData) continue; - - queryClient.setQueryData(key, { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - data: page.data.filter((c) => c.id !== id), - })), - }); - } + patchEmailLists(queryClient, (rows) => rows.filter((c) => c.id !== id)); queryClient.invalidateQueries({ queryKey: ["emails", id] diff --git a/web/src/lib/api/hooks/app/emails/useUpdateEmail.ts b/web/src/lib/api/hooks/app/emails/useUpdateEmail.ts index fe78dfd9..9cd76c99 100644 --- a/web/src/lib/api/hooks/app/emails/useUpdateEmail.ts +++ b/web/src/lib/api/hooks/app/emails/useUpdateEmail.ts @@ -1,7 +1,7 @@ import updateEmail from "@/lib/api/client/app/emails/updateEmail"; -import type GetEmails from "@/lib/api/models/app/emails/GetEmails"; import type Inbox from "@/lib/api/models/app/emails/Inbox"; -import { useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import patchEmailLists from "./patchEmailLists"; export default function useUpdateEmail(id: string) { const queryClient = useQueryClient(); @@ -9,21 +9,7 @@ export default function useUpdateEmail(id: string) { return useMutation({ mutationFn: (inbox: Partial) => updateEmail(id, inbox), onSuccess: (data) => { - const allLists = queryClient.getQueriesData>({ - queryKey: ["emails", "list"], - }); - - for (const [key, oldData] of allLists) { - if (!oldData) continue; - - queryClient.setQueryData(key, { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - data: page.data.map((c) => c.id === id ? data : c), - })), - }); - } + patchEmailLists(queryClient, (rows) => rows.map((c) => (c.id === id ? data : c))); queryClient.setQueryData( ["emails", id], diff --git a/web/src/lib/api/hooks/app/emails/useUpdateEmailTrackingDomain.ts b/web/src/lib/api/hooks/app/emails/useUpdateEmailTrackingDomain.ts index e532b969..b5ee69cb 100644 --- a/web/src/lib/api/hooks/app/emails/useUpdateEmailTrackingDomain.ts +++ b/web/src/lib/api/hooks/app/emails/useUpdateEmailTrackingDomain.ts @@ -1,7 +1,7 @@ import updateEmailTrackingDomain from "@/lib/api/client/app/emails/updateEmailTrackingDomain"; -import type GetEmails from "@/lib/api/models/app/emails/GetEmails"; import type Inbox from "@/lib/api/models/app/emails/Inbox"; -import { useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import patchEmailLists from "./patchEmailLists"; export default function useUpdateEmailTrackingDomain(id: string) { const queryClient = useQueryClient(); @@ -9,26 +9,18 @@ export default function useUpdateEmailTrackingDomain(id: string) { return useMutation({ mutationFn: (tracking_domain: string) => updateEmailTrackingDomain(id, tracking_domain), onSuccess: (data) => { - const allLists = queryClient.getQueriesData>({ - queryKey: ["emails", "list"], - }); - - for (const [key, oldData] of allLists) { - if (!oldData) continue; - - queryClient.setQueryData(key, { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - data: page.data.map((c) => c.id === id ? { - ...c, - tracking_domain: data.tracking_domain, - tracking_domain_verified: data.tracking_domain_verified, - tracking_domain_verified_at: data.tracking_domain_verified_at, - } : c), - })), - }); - } + patchEmailLists(queryClient, (rows) => + rows.map((c) => + c.id === id + ? { + ...c, + tracking_domain: data.tracking_domain, + tracking_domain_verified: data.tracking_domain_verified, + tracking_domain_verified_at: data.tracking_domain_verified_at, + } + : c, + ), + ); // The card reads its target and diagnostic from this query. queryClient.setQueryData(["emails", id, "tracking-domain"], data); diff --git a/web/src/lib/api/hooks/app/emails/useWarmupLifecycle.ts b/web/src/lib/api/hooks/app/emails/useWarmupLifecycle.ts index c6baf0b0..be5fda20 100644 --- a/web/src/lib/api/hooks/app/emails/useWarmupLifecycle.ts +++ b/web/src/lib/api/hooks/app/emails/useWarmupLifecycle.ts @@ -1,7 +1,7 @@ import warmupLifecycle, { type WarmupAction } from "@/lib/api/client/app/emails/warmupLifecycle"; -import type GetEmails from "@/lib/api/models/app/emails/GetEmails"; import type Inbox from "@/lib/api/models/app/emails/Inbox"; -import { useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import patchEmailLists from "./patchEmailLists"; // Drives the flame-icon dropdown + the warmup tab's enable/pause/resume // control. Patches the mailbox into every emails list page and the single @@ -13,21 +13,7 @@ export default function useWarmupLifecycle(id: string) { return useMutation({ mutationFn: (action: WarmupAction) => warmupLifecycle(id, action), onSuccess: (data) => { - const allLists = queryClient.getQueriesData>({ - queryKey: ["emails", "list"], - }); - - for (const [key, oldData] of allLists) { - if (!oldData) continue; - - queryClient.setQueryData(key, { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - data: page.data.map((c) => (c.id === id ? data : c)), - })), - }); - } + patchEmailLists(queryClient, (rows) => rows.map((c) => (c.id === id ? data : c))); queryClient.setQueryData(["emails", id], data); void queryClient.invalidateQueries({ queryKey: ["analytics", "accounts", id] });