fix(rebase): reconcile viewer blob routing and dedupe settings after main rebase

Rebasing feat/unified-mailbox onto main hit deep, divergent conflicts in the
mail-view/settings area (main added its own All-Mail + RTL refactor + a
username-keyed #507 identity impl). Post-rebase reconciliation:

- re-apply the cross-account blob routing to the message viewer (inline images,
  drag-out, TNEF, embedded messages, thumbnails, bundle download) on main's
  restructured file — every fetch goes through blobClient/blobAccountId derived
  from the message's source account;
- drop the duplicate `preferredIdentityIds` declaration that both main
  (username-keyed) and the branch (accountId-keyed) introduced — the branch's
  account-scoped map is kept, matching the resolved modal/store logic.

tsc + eslint clean; unified-mailbox unit tests pass (settings-store all-mail /
preferred-identity, unified-mailbox-cross, jmap-client-resilience, migrate-policy).
This commit is contained in:
Stefan Hildebrandt
2026-07-11 21:16:02 +02:00
parent e1e83c4a83
commit d3addf54b4
2 changed files with 35 additions and 30 deletions
+35 -21
View File
@@ -560,6 +560,8 @@ export function ContactSidebarPanel({
interface DraggableAttachmentChipProps {
attachment: EffectiveAttachment;
client: IJMAPClient | null;
/** Owner accountId for the blob when it lives in a delegated/shared account. */
accountId?: string;
enabled: boolean;
downloadName?: string;
children: (dragProps: {
@@ -570,14 +572,14 @@ interface DraggableAttachmentChipProps {
}) => React.ReactNode;
}
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
function DraggableAttachmentChip({ attachment, client, accountId, enabled, downloadName, children }: DraggableAttachmentChipProps) {
const source = useMemo<AttachmentDragSource>(() => ({
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type);
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type, accountId);
} catch {
return null;
}
@@ -595,7 +597,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, downloadName, ch
}
return null;
},
}), [attachment, client, downloadName]);
}), [attachment, client, accountId, downloadName]);
const drag = useAttachmentDrag(source, enabled);
return <>{children(drag)}</>;
}
@@ -714,6 +716,18 @@ export function EmailViewer({
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
// Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle
// downloads) are account-scoped. In the unified / All-Mail view the open
// message may belong to another login (route to its client) or a delegated
// shared account (same client, owner accountId in the URL). Resolve both from
// the message's source so cross-account blob fetches don't 404 against the
// active account.
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
const blobClient = useMemo(() => {
const scid = isUnifiedView ? email?.sourceClientAccountId : undefined;
return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client;
}, [isUnifiedView, email?.sourceClientAccountId, client]);
const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined;
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
// client, handing a mailto: URL to the OS mail handler goes nowhere for
@@ -1250,7 +1264,7 @@ export function EmailViewer({
async function processTnef() {
try {
debug.time('TNEF fetch blob', 'email');
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
const blobBytes = await blobClient!.fetchBlobArrayBuffer(tnefAtt!.blobId!, undefined, undefined, blobAccountId);
debug.timeEnd('TNEF fetch blob', 'email');
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
@@ -1303,7 +1317,7 @@ export function EmailViewer({
processTnef();
return () => { cancelled = true; };
}, [email, client]);
}, [email, client, blobClient, blobAccountId]);
// Embedded message/rfc822 unwrapping
// When Outlook forwards an email as an attachment, the outer email body is
@@ -1340,7 +1354,7 @@ export function EmailViewer({
async function unwrapEmbedded() {
try {
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
const blobBytes = await blobClient!.fetchBlobArrayBuffer(rfc822Att!.blobId!, undefined, undefined, blobAccountId);
if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) {
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
@@ -1380,7 +1394,7 @@ export function EmailViewer({
unwrapEmbedded();
return () => { cancelled = true; };
}, [email, client]);
}, [email, client, blobClient, blobAccountId]);
// Fetch inline CID images with authentication to prevent browser auth dialogs
useEffect(() => {
@@ -1426,7 +1440,7 @@ export function EmailViewer({
await Promise.all(cidAttachments.map(async (att) => {
const cidValue = att.cid!.replace(/^<|>$/g, '');
try {
const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type);
const objectUrl = await blobClient!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type, blobAccountId);
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
@@ -1448,7 +1462,7 @@ export function EmailViewer({
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [client, email?.id, pluginRenderedAttachments, email?.attachments]);
}, [client, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]);
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
if (pluginRenderedAttachments.length > 0) {
@@ -1926,8 +1940,8 @@ export function EmailViewer({
for (const attachment of effectiveAttachments) {
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
try {
if (attachment.blobId && client) {
const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type);
if (attachment.blobId && blobClient) {
const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId);
zip.file(entryName, blob);
added++;
} else if (attachment.tnefData) {
@@ -1959,7 +1973,7 @@ export function EmailViewer({
} finally {
setIsDownloadingAll(false);
}
}, [isDownloadingAll, effectiveAttachments, client, email]);
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]);
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
const downloadAllButton = effectiveAttachments.length > 1 ? (
@@ -2001,8 +2015,8 @@ export function EmailViewer({
await Promise.all(imageAttachments.map(async (att) => {
let url: string | undefined;
try {
if (att.blobId && client) {
url = await client.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type);
if (att.blobId && blobClient) {
url = await blobClient.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type, blobAccountId);
} else if (att.decryptedAttachment) {
const bytes = getAttachmentContentBytes(att.decryptedAttachment);
if (!bytes || bytes.byteLength === 0) return;
@@ -2033,7 +2047,7 @@ export function EmailViewer({
cancelled = true;
createdUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [effectiveAttachments, client, attachmentImagePreviewsEnabled]);
}, [effectiveAttachments, client, blobClient, blobAccountId, attachmentImagePreviewsEnabled]);
// Iframe for rendering HTML emails true-to-life
const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -3693,7 +3707,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -3776,7 +3790,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -4469,7 +4483,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -4557,7 +4571,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -4615,7 +4629,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -4697,7 +4711,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
-9
View File
@@ -178,12 +178,6 @@ interface SettingsState {
requestReadReceiptDefault: boolean; // Pre-check "request read receipt" in the composer
readReceiptResponse: ReadReceiptResponse; // How to respond to incoming read-receipt requests
// Identities
// Per-account default ("preferred primary") sender identity, keyed by
// username (the same key settings sync uses). A JMAP identity id is only
// meaningful within its own account, so this must be account-scoped. Synced
// so the choice survives a new browser / cleared site data (#507).
preferredIdentityIds: Record<string, string | null>;
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
@@ -406,8 +400,6 @@ const DEFAULT_SETTINGS = {
requestReadReceiptDefault: false,
readReceiptResponse: 'ask' as ReadReceiptResponse,
// Identities
preferredIdentityIds: {} as Record<string, string | null>,
// Privacy & Security
sessionTimeout: 0, // Never
@@ -619,7 +611,6 @@ export const useSettingsStore = create<SettingsState>()(
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
requestReadReceiptDefault: state.requestReadReceiptDefault,
readReceiptResponse: state.readReceiptResponse,
preferredIdentityIds: state.preferredIdentityIds,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,