Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -270,4 +270,51 @@ describe('Message — attachment list vs inline images', () => {
);
expect(screen.queryByText('logo.png')).not.toBeInTheDocument();
});

it('says the file is no longer available instead of its size', () => {
// The size reads as a promise the message cannot keep.
render(
<Message
message={makeMessage({
content: '<p>My CV is attached.</p>',
attachments: [attachment({ unavailable: true, url: undefined })],
})}
/>,
);
expect(screen.getByText('attachment.unavailable')).toBeInTheDocument();
expect(screen.queryByText(/23,359|22\.8/)).not.toBeInTheDocument();
});

it('offers no download for an attachment whose bytes are gone', () => {
render(
<Message
message={makeMessage({
content: '<p>My CV is attached.</p>',
attachments: [attachment({ unavailable: true, url: undefined })],
})}
/>,
);
expect(
screen.queryByRole('button', { name: /attachment\.download CV\.pdf/ }),
).not.toBeInTheDocument();
});

it('still shows the size and a download for an attachment that is there', () => {
// The other half of the pair: a flag that hid every attachment would
// pass the two cases above on its own.
render(
<Message
message={makeMessage({
content: '<p>My CV is attached.</p>',
attachments: [attachment()],
})}
/>,
);
expect(
screen.queryByText('attachment.unavailable'),
).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: /attachment\.download CV\.pdf/ }),
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ interface AttachmentCardProps {
size: number;
storageId?: string;
url?: string;
unavailable?: boolean;
};
}

Expand Down Expand Up @@ -149,7 +150,11 @@ function AttachmentCard({ attachment }: AttachmentCardProps) {
<AttachmentFileChip
fileName={attachment.filename}
contentType={attachment.contentType}
detail={formatFileSize(attachment.size)}
detail={
attachment.unavailable
? t('attachment.unavailable')
: formatFileSize(attachment.size)
}
trailing={trailing}
/>
);
Expand Down
26 changes: 25 additions & 1 deletion services/platform/backend/domains/conversations/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
notifyConversationAssignedTeam,
} from '../collab/service.ts';
import { emitEvent } from '../events/emit.ts';
import { getFileUrl } from '../files/service.ts';
import { getFileUrl, statOrgBlob } from '../files/service.ts';
import { assertNotHeld } from '../legal_holds/service.ts';

/**
Expand Down Expand Up @@ -986,6 +986,30 @@ export async function presignMessageAttachments(
{ organizationId },
raw.storageId,
);
// Presigning does NOT prove the object is there — it signs a
// path. Without this the message keeps offering a download that
// fails with a browser error and no explanation, which is what
// #3017 saw after a recovery re-imported the database without
// file storage.
//
// A probe that THROWS is not evidence of absence (unreachable
// store, unresolved org), so it fails open and the attachment is
// offered as before. Only a definite `null` marks it.
let gone = false;
try {
gone =
(await statOrgBlob(sql, organizationId, raw.storageId)) ===
null;
} catch (error) {
console.warn(
`[conversations] attachment presence probe failed for ${raw.storageId}, offering it anyway:`,
error,
);
}
if (gone) {
const { url: _unreachable, ...rest } = raw;
return { ...rest, unavailable: true };
}
return { ...raw, url };
} catch (error) {
console.warn(
Expand Down
48 changes: 39 additions & 9 deletions services/platform/backend/integration-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14873,6 +14873,10 @@ async function checkConversations(
// ── A bytesless attachment (Gmail/Outlook chips the connector never fetched)
// must offer NO download affordance — the detail projection presigns a URL
// only from a stored storageId, so a metadata-only chip carries none.
const attSlugRows = await sql<{ slug: string }[]>`
SELECT "slug" FROM "organization" WHERE "id" = ${orgId} LIMIT 1
`;
const attOrgSlug = attSlugRows[0]?.slug ?? '';
await sql`
INSERT INTO app.conversation_messages (
org_id, conversation_id, channel, direction, external_message_id,
Expand All @@ -14891,10 +14895,23 @@ async function checkConversations(
contentType: 'image/jpeg',
size: 0,
},
// A ref whose blob is NOT in the store. Presigning signs a path and
// succeeds anyway, so without a presence probe the message keeps
// offering a download that 404s (#3017).
{
id: 'att-gone',
filename: 'gone.pdf',
contentType: 'application/pdf',
size: 23_359,
storageId: `s3:${attOrgSlug}/att-gone-never-uploaded.pdf`,
},
],
})}, ${Date.now()}
)
`;
// The ref has to be org-scoped or `requireOrgScopedKey` refuses it before
// any presence probe runs — which is how the first draft of this check
// passed for the wrong reason.
const attDetail = z
.object({
item: z
Expand All @@ -14913,18 +14930,31 @@ async function checkConversations(
})
.loose()
.safeParse(await (await api(`/${conversationId}`)).json());
const bytelessChip = attDetail.success
? attDetail.data.item.messages
.flatMap((message) => message.attachments ?? [])
.find((attachment) => attachment.id === 'att-nobytes')
: undefined;
record(
'conversations: connectorName filters the Inbox, and a bytesless attachment offers no download',
const attChips = attDetail.success
? attDetail.data.item.messages.flatMap(
(message) => message.attachments ?? [],
)
: [];
const bytelessChip = attChips.find(
(attachment) => attachment.id === 'att-nobytes',
);
// Only assertable with a store to probe: `statOrgBlob` throws without one,
// and that failure deliberately fails OPEN — a store it cannot reach is not
// evidence the blob is gone.
const goneChip = attChips.find((attachment) => attachment.id === 'att-gone');
const goneMarked =
!process.env.ITEST_S3_ENDPOINT ||
(goneChip !== undefined &&
goneChip.unavailable === true &&
!('url' in goneChip));
record(
'conversations: connectorName filters the Inbox; a bytesless and a vanished attachment both offer no download',
matchedFilter.includes(conversationId) &&
!wrongFilter.includes(conversationId) &&
bytelessChip !== undefined &&
!('url' in bytelessChip),
`imapFilter=${matchedFilter.includes(conversationId)} gmailExcluded=${!wrongFilter.includes(conversationId)} bytelessChipNoUrl=${bytelessChip !== undefined && !('url' in bytelessChip)}`,
!('url' in bytelessChip) &&
goneMarked,
`imapFilter=${matchedFilter.includes(conversationId)} gmailExcluded=${!wrongFilter.includes(conversationId)} bytelessChipNoUrl=${bytelessChip !== undefined && !('url' in bytelessChip)} goneMarked=${goneMarked}${process.env.ITEST_S3_ENDPOINT ? ` (unavailableFlag=${goneChip?.unavailable === true} url=${goneChip !== undefined && 'url' in goneChip})` : ' (SKIPPED, no ITEST_S3_ENDPOINT)'}`,
);

const closed = z
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export interface ProjectedMessage {
storageId?: string;
url?: string;
contentId?: string;
/** The bytes are gone, so this cannot be offered for download. Stamped at
* read time from live storage; `url` is absent alongside it. */
unavailable?: boolean;
}[];
}

Expand Down Expand Up @@ -117,6 +120,7 @@ function projectConversationMessage(
...(typeof a.contentId === 'string'
? { contentId: a.contentId }
: {}),
...(a.unavailable === true ? { unavailable: true } : {}),
}))
: undefined;
const deliveryState = message.deliveryState || 'sent';
Expand Down
1 change: 1 addition & 0 deletions services/platform/messages/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1835,6 +1835,7 @@ conversations:
send: Nachricht senden
attachment:
download: Herunterladen
unavailable: Nicht mehr verfügbar
attachments: '{count, plural, one {# Anhang} other {# Anhänge}}'
panel:
uploadFailed: Anhänge konnten nicht hochgeladen werden. Versuch es erneut.
Expand Down
1 change: 1 addition & 0 deletions services/platform/messages/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1778,6 +1778,7 @@ conversations:
send: Send message
attachment:
download: Download
unavailable: No longer available
attachments: '{count, plural, one {# attachment} other {# attachments}}'
panel:
uploadFailed: Couldn't upload attachments. Try again.
Expand Down
1 change: 1 addition & 0 deletions services/platform/messages/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1851,6 +1851,7 @@ conversations:
send: Envoyer le message
attachment:
download: Télécharger
unavailable: Plus disponible
attachments: '{count, plural, one {# pièce jointe} other {# pièces jointes}}'
panel:
uploadFailed: Échec du téléversement des pièces jointes. Merci de réessayer.
Expand Down
Loading