Skip to content

Commit 5bc26bf

Browse files
committed
Harden outbound reply matching and send recovery
1 parent 301b411 commit 5bc26bf

File tree

6 files changed

+486
-60
lines changed

6 files changed

+486
-60
lines changed

src/app/(app)/sequences/page.tsx

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ function outboundDiagnosticMetadata(value: unknown) {
4343
if (!value || typeof value !== "object") {
4444
return {
4545
attemptCount: 0,
46+
blockedReason: null as string | null,
47+
copyRevision: 0,
4648
deliveryDiagnostic: null as string | null,
49+
deliveryState: null as string | null,
50+
failureCategory: null as string | null,
51+
retryable: true,
4752
};
4853
}
4954

@@ -52,10 +57,22 @@ function outboundDiagnosticMetadata(value: unknown) {
5257
return {
5358
attemptCount:
5459
typeof metadata.attemptCount === "number" ? metadata.attemptCount : 0,
60+
blockedReason:
61+
typeof metadata.blockedReason === "string" ? metadata.blockedReason : null,
62+
copyRevision:
63+
typeof metadata.copyRevision === "number" ? metadata.copyRevision : 0,
5564
deliveryDiagnostic:
5665
typeof metadata.deliveryDiagnostic === "string"
5766
? metadata.deliveryDiagnostic
5867
: null,
68+
deliveryState:
69+
typeof metadata.deliveryState === "string" ? metadata.deliveryState : null,
70+
failureCategory:
71+
typeof metadata.failureCategory === "string"
72+
? metadata.failureCategory
73+
: null,
74+
retryable:
75+
typeof metadata.retryable === "boolean" ? metadata.retryable : true,
5976
};
6077
}
6178

@@ -69,7 +86,7 @@ function feedbackMessage(params: Record<string, string | string[] | undefined>)
6986
}
7087

7188
if (params.queued === "1") {
72-
return "Outbound item queued. The worker will send it when policy allows.";
89+
return "Outbound item saved and queued. The worker will send it when policy allows.";
7390
}
7491

7592
if (params.cancelled === "1") {
@@ -147,7 +164,7 @@ export default async function SequencesPage({
147164
</div>
148165
<div className="rounded-2xl bg-secondary/65 px-4 py-4">
149166
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
150-
Pending approvals
167+
Needs attention
151168
</p>
152169
<p className="mt-2 text-2xl font-semibold text-foreground">
153170
{approvals.length}
@@ -570,8 +587,8 @@ export default async function SequencesPage({
570587
<div className="mt-5 space-y-4">
571588
{approvals.length === 0 ? (
572589
<div className="rounded-2xl border border-border/85 bg-background/75 px-4 py-4 text-sm text-muted-foreground">
573-
No drafts are waiting right now. Enroll a contact in an active
574-
sequence, then let the worker generate the due draft.
590+
No outbound items need attention right now. Enroll a contact in
591+
an active sequence, then let the worker generate the due draft.
575592
</div>
576593
) : (
577594
approvals.map((draft) => (
@@ -601,7 +618,9 @@ export default async function SequencesPage({
601618
variant={
602619
draft.status === "failed"
603620
? "destructive"
604-
: "outline"
621+
: draft.status === "queued"
622+
? "secondary"
623+
: "outline"
605624
}
606625
>
607626
{draft.status}
@@ -641,11 +660,25 @@ export default async function SequencesPage({
641660
</div>
642661
) : null}
643662

663+
{diagnostics.blockedReason &&
664+
draft.status === "queued" ? (
665+
<div className="rounded-xl border border-amber-300/40 bg-amber-100/50 px-3 py-3 text-sm text-amber-900">
666+
Blocked for now: {diagnostics.blockedReason}
667+
</div>
668+
) : null}
669+
644670
{diagnostics.attemptCount > 0 ||
645-
diagnostics.deliveryDiagnostic ? (
671+
diagnostics.deliveryDiagnostic ||
672+
diagnostics.copyRevision > 0 ||
673+
diagnostics.failureCategory ? (
646674
<div className="rounded-xl border border-border/80 bg-secondary/60 px-3 py-3 text-sm text-muted-foreground">
647-
Attempts {diagnostics.attemptCount}.{" "}
675+
Attempts {diagnostics.attemptCount}. Revision{" "}
676+
{Math.max(diagnostics.copyRevision, 1)}.{" "}
677+
{diagnostics.failureCategory
678+
? `Failure class: ${diagnostics.failureCategory}. `
679+
: null}
648680
{diagnostics.deliveryDiagnostic ??
681+
diagnostics.deliveryState ??
649682
"No provider diagnostic captured yet."}
650683
</div>
651684
) : null}
@@ -675,7 +708,15 @@ export default async function SequencesPage({
675708
>
676709
Cancel
677710
</Button>
678-
<Button type="submit">Approve and queue send</Button>
711+
<Button type="submit">
712+
{draft.status === "queued"
713+
? "Save copy and keep queued"
714+
: draft.status === "failed"
715+
? diagnostics.retryable
716+
? "Save copy and requeue"
717+
: "Save revised copy and requeue"
718+
: "Approve and queue send"}
719+
</Button>
679720
</div>
680721
</div>
681722
</>

src/lib/google.ts

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import {
2020
GOOGLE_SEND_SCOPE,
2121
} from "@/lib/provider-scopes";
2222
import {
23+
extractMessageReferenceIds,
2324
extractEmailAddress,
2425
matchThreadedInboundReplies,
26+
normalizeMessageReferenceId,
2527
} from "@/lib/reply-detection";
2628
import { decryptSecret, encryptSecret } from "@/lib/secrets";
2729
import { recordReplySignal } from "@/lib/sequences";
@@ -88,6 +90,12 @@ type GmailSendDiagnosticMessage = {
8890
id?: string;
8991
internalDate?: string;
9092
labelIds?: string[];
93+
payload?: {
94+
headers?: Array<{
95+
name?: string;
96+
value?: string;
97+
}>;
98+
};
9199
threadId?: string;
92100
};
93101

@@ -186,6 +194,23 @@ function getGoogleReplySummary(message: GmailMetadataMessage) {
186194
return subject ? `Automatic Gmail reply detected: ${subject}` : null;
187195
}
188196

197+
function getGoogleHeaderValue(
198+
message: GmailMetadataMessage | GmailSendDiagnosticMessage | null | undefined,
199+
name: string,
200+
) {
201+
return (
202+
message?.payload?.headers?.find(
203+
(header) => header.name?.toLowerCase() === name.toLowerCase(),
204+
)?.value ?? null
205+
);
206+
}
207+
208+
function getGoogleProviderInternetMessageId(
209+
message: GmailSendDiagnosticMessage | null,
210+
) {
211+
return normalizeMessageReferenceId(getGoogleHeaderValue(message, "Message-ID"));
212+
}
213+
189214
async function ensureIdentity(params: {
190215
contactId: string;
191216
kind: "email" | "provider_contact_id" | "provider_person_id";
@@ -532,28 +557,40 @@ async function listRecentGoogleInboundMessages(input: {
532557
);
533558

534559
const results = await Promise.all(
535-
(listResponse.messages ?? []).map((message) =>
536-
googleFetch<GmailMetadataMessage>(
537-
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${message.id}?${new URLSearchParams({
560+
(listResponse.messages ?? []).map((message) => {
561+
const metadataParams = (() => {
562+
const params = new URLSearchParams({
538563
format: "metadata",
539-
metadataHeaders: "From",
540-
}).toString()}&metadataHeaders=Subject`,
564+
});
565+
params.append("metadataHeaders", "From");
566+
params.append("metadataHeaders", "Subject");
567+
params.append("metadataHeaders", "In-Reply-To");
568+
params.append("metadataHeaders", "References");
569+
return params;
570+
})();
571+
572+
return googleFetch<GmailMetadataMessage>(
573+
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${message.id}?${metadataParams.toString()}`,
541574
{
542575
headers: {
543576
Authorization: `Bearer ${input.accessToken}`,
544577
},
545578
},
546579
"Unable to fetch Gmail reply metadata",
547-
),
548-
),
580+
);
581+
}),
549582
);
550583

551584
return results
552585
.map((message) => ({
586+
inReplyTo: getGoogleHeaderValue(message, "In-Reply-To"),
553587
providerThreadId: message.threadId ?? null,
588+
referenceMessageIds: extractMessageReferenceIds(
589+
getGoogleHeaderValue(message, "References"),
590+
),
554591
receivedAt: new Date(Number(message.internalDate ?? Date.now())),
555592
senderEmail: extractEmailAddress(
556-
message.payload?.headers?.find((header) => header.name === "From")?.value,
593+
getGoogleHeaderValue(message, "From"),
557594
),
558595
summary: getGoogleReplySummary(message),
559596
}))
@@ -622,6 +659,17 @@ export async function syncGoogleRepliesForAccount(accountId: string) {
622659
{
623660
contactId: string;
624661
contactName: string;
662+
providerInternetMessageId?: string | null;
663+
senderEmail: string;
664+
sentAt: Date;
665+
}
666+
>();
667+
const sentMessageReferences = new Map<
668+
string,
669+
{
670+
contactId: string;
671+
contactName: string;
672+
providerInternetMessageId?: string | null;
625673
senderEmail: string;
626674
sentAt: Date;
627675
}
@@ -631,6 +679,12 @@ export async function syncGoogleRepliesForAccount(accountId: string) {
631679
const providerThreadId = message.providerThreadId?.trim();
632680
const contact = contactMap.get(message.contactId);
633681
const senderEmail = normalizeEmail(contact?.primaryEmail);
682+
const providerInternetMessageId = normalizeMessageReferenceId(
683+
typeof (message.metadata as Record<string, unknown> | null)?.providerInternetMessageId ===
684+
"string"
685+
? ((message.metadata as Record<string, unknown>).providerInternetMessageId as string)
686+
: null,
687+
);
634688

635689
if (!providerThreadId || !senderEmail || !message.sentAt) {
636690
continue;
@@ -642,6 +696,17 @@ export async function syncGoogleRepliesForAccount(accountId: string) {
642696
sentThreads.set(providerThreadId, {
643697
contactId: message.contactId,
644698
contactName: contact?.displayName ?? "Contact",
699+
providerInternetMessageId,
700+
senderEmail,
701+
sentAt: message.sentAt,
702+
});
703+
}
704+
705+
if (providerInternetMessageId) {
706+
sentMessageReferences.set(providerInternetMessageId, {
707+
contactId: message.contactId,
708+
contactName: contact?.displayName ?? "Contact",
709+
providerInternetMessageId,
645710
senderEmail,
646711
sentAt: message.sentAt,
647712
});
@@ -651,6 +716,7 @@ export async function syncGoogleRepliesForAccount(accountId: string) {
651716
const replyMatches = matchThreadedInboundReplies(
652717
sentThreads,
653718
recentInboundMessages,
719+
sentMessageReferences,
654720
);
655721

656722
let detectedCount = 0;
@@ -718,7 +784,6 @@ export async function syncGoogleContactsForAccount(accountId: string) {
718784
let nextSyncToken = account.syncCursor ?? undefined;
719785
let syncedCount = 0;
720786

721-
try {
722787
try {
723788
do {
724789
const params = new URLSearchParams({
@@ -901,24 +966,6 @@ export async function syncGoogleContactsForAccount(accountId: string) {
901966

902967
throw error;
903968
}
904-
} catch (error) {
905-
const message = error instanceof Error ? error.message : "Google sync failed.";
906-
907-
if (message.includes("(410)") && account.syncCursor) {
908-
await db
909-
.update(connectedAccounts)
910-
.set({
911-
syncCursor: null,
912-
updatedAt: new Date(),
913-
})
914-
.where(eq(connectedAccounts.id, account.id));
915-
916-
return syncGoogleContactsForAccount(accountId);
917-
}
918-
919-
throw error;
920-
}
921-
922969
await db
923970
.update(connectedAccounts)
924971
.set({
@@ -984,7 +1031,13 @@ export async function sendGoogleMessage(input: {
9841031

9851032
const messageMetadata = response?.id
9861033
? await googleFetch<GmailSendDiagnosticMessage>(
987-
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${response.id}?format=minimal`,
1034+
(() => {
1035+
const params = new URLSearchParams({
1036+
format: "metadata",
1037+
});
1038+
params.append("metadataHeaders", "Message-ID");
1039+
return `https://gmail.googleapis.com/gmail/v1/users/me/messages/${response.id}?${params.toString()}`;
1040+
})(),
9881041
{
9891042
headers: {
9901043
Authorization: `Bearer ${accessToken}`,
@@ -998,6 +1051,7 @@ export async function sendGoogleMessage(input: {
9981051
diagnostic: messageMetadata?.id
9991052
? `gmail message ${messageMetadata.id} in thread ${messageMetadata.threadId ?? "unknown"}`
10001053
: `gmail send completed for outbound ${input.messageId}`,
1054+
providerInternetMessageId: getGoogleProviderInternetMessageId(messageMetadata),
10011055
providerMessageId: response?.id ?? null,
10021056
providerThreadId: messageMetadata?.threadId ?? response?.threadId ?? null,
10031057
};

src/lib/microsoft.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,7 @@ export async function sendMicrosoftMessage(input: {
917917
diagnostic: draft.id
918918
? `microsoft message ${draft.id} in conversation ${draft.conversationId ?? "unknown"}`
919919
: `microsoft send completed for outbound ${input.messageId}`,
920+
providerInternetMessageId: draft.internetMessageId ?? null,
920921
providerMessageId: draft.id ?? null,
921922
providerThreadId: draft.conversationId ?? null,
922923
};

0 commit comments

Comments
 (0)