mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 00:01:24 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] });
|
||||
|
||||
Reference in New Issue
Block a user