feat: fix the three mailbox Settings bugs from #247: patch both cache shapes under ["emails", "list"] (paginated pages and the flat directory array) through one patchEmailLists helper so saving, removing, warmup lifecycle and tracking-domain writes no longer crash on undefined.map, stop rewriting the signature editor's contentEditable innerHTML on every keystroke so the caret stays where the user is typing, and scope the auth-check GET/POST handlers by organization instead of user id so the SPF/DKIM/DMARC check stops returning 404

This commit is contained in:
Matthew Meszaros
2026-08-28 22:05:41 -07:00
parent 6fb09b803f
commit 6da2c3dfaa
10 changed files with 111 additions and 94 deletions
+10 -8
View File
@@ -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
+10 -9
View File
@@ -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
}
+2 -2
View File
@@ -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
+10 -2
View File
@@ -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<HTMLDivElement>(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 }}
/>
)}
</div>
@@ -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<Inbox[]>(["emails", "list", "directory"]);
expect(dir?.map((c) => c.name)).toEqual(["renamed", "b"]);
});
});
@@ -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<GetEmails> | 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<EmailListCache>({ 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 ?? []),
})),
});
}
}
@@ -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<InfiniteData<GetEmails>>({
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]
@@ -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<Inbox>) => updateEmail(id, inbox),
onSuccess: (data) => {
const allLists = queryClient.getQueriesData<InfiniteData<GetEmails>>({
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<Inbox>(
["emails", id],
@@ -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<InfiniteData<GetEmails>>({
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);
@@ -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<InfiniteData<GetEmails>>({
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<Inbox>(["emails", id], data);
void queryClient.invalidateQueries({ queryKey: ["analytics", "accounts", id] });