feat: open office attachments in mail read-only in the document editor #1047

This commit is contained in:
Linus Rath
2026-09-23 19:01:47 +02:00
parent dc9d609c43
commit 7e5b996ee6
15 changed files with 489 additions and 87 deletions
+21 -12
View File
@@ -5,8 +5,9 @@ import { wopiContext } from '@/lib/wopi/request';
/**
* WOPI GetFile / PutFile (#425). Content flows purely over JMAP: GetFile
* streams the node's blob, PutFile uploads the editor's bytes as a new blob
* and points the FileNode at it via `FileNode/set { blobId }`.
* streams the node's blob (or, for a mail attachment, the attachment blob
* itself - #1047), PutFile uploads the editor's bytes as a new blob and
* points the FileNode at it via `FileNode/set { blobId }`.
*/
/** GET = GetFile */
@@ -15,26 +16,33 @@ export async function GET(
{ params }: { params: Promise<{ fileId: string }> },
) {
try {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
const { fileId: documentId } = await params;
const auth = await wopiContext(request, documentId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
const { payload } = auth;
let source: { blobId: string; name: string; type: string };
if (payload.kind === 'attachment') {
source = { blobId: payload.fileId, name: payload.name || 'attachment', type: payload.type || '' };
} else {
const node = await getWopiFileNode(auth.ctx, payload.accountId, payload.fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
source = { blobId: node.blobId, name: node.name, type: node.type };
}
const upstream = await downloadFileBlob(
auth.ctx, auth.payload.accountId, node.blobId, node.name, node.type,
auth.ctx, payload.accountId, source.blobId, source.name, source.type,
);
if (!upstream.ok) {
return NextResponse.json({ error: 'Blob download failed' }, { status: 502 });
}
const headers = new Headers();
headers.set('Content-Type', node.type || 'application/octet-stream');
headers.set('Content-Type', source.type || 'application/octet-stream');
const contentLength = upstream.headers.get('Content-Length');
if (contentLength) headers.set('Content-Length', contentLength);
return new NextResponse(upstream.body, { status: 200, headers });
@@ -52,15 +60,16 @@ export async function POST(
{ params }: { params: Promise<{ fileId: string }> },
) {
try {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
const { fileId: documentId } = await params;
const auth = await wopiContext(request, documentId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
if (!auth.payload.canWrite) {
if (!auth.payload.canWrite || auth.payload.kind === 'attachment') {
return NextResponse.json({ error: 'Read-only token' }, { status: 403 });
}
const fileId = auth.payload.fileId;
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
+25 -5
View File
@@ -18,13 +18,33 @@ export async function GET(
{ params }: { params: Promise<{ fileId: string }> },
) {
try {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
const { fileId: documentId } = await params;
const auth = await wopiContext(request, documentId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
const { payload } = auth;
if (payload.kind === 'attachment') {
// Mail attachments open read-only (#1047): the blob is immutable, so
// its id doubles as the version and there is nothing to save back to.
return NextResponse.json({
BaseFileName: payload.name || 'attachment',
Size: payload.size ?? 0,
OwnerId: payload.username,
UserId: payload.username,
UserFriendlyName: payload.username,
UserCanWrite: false,
ReadOnly: true,
UserCanNotWriteRelative: true,
SupportsUpdate: false,
SupportsLocks: false,
Version: payload.fileId,
PostMessageOrigin: payload.origin,
});
}
const node = await getWopiFileNode(auth.ctx, payload.accountId, payload.fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
@@ -62,8 +82,8 @@ export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> },
) {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
const { fileId: documentId } = await params;
const auth = await wopiContext(request, documentId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
+53 -24
View File
@@ -4,16 +4,19 @@ import { configManager } from '@/lib/admin/config-manager';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
import { getWopiActions, buildWopiActionUrl } from '@/lib/wopi/discovery';
import { getWopiFileNode } from '@/lib/wopi/files';
import { mintWopiToken } from '@/lib/wopi/token';
import { getWopiFileNode, probeBlob } from '@/lib/wopi/files';
import { mintWopiToken, wopiDocumentId, type WopiTokenPayload } from '@/lib/wopi/token';
/**
* POST /api/wopi/launch { fileId: string, accountId?: string }
* POST /api/wopi/launch
* { fileId: string, accountId?: string } - a Files node
* { blobId: string, name: string, type?: string, accountId?: string } - a mail attachment
*
* Mints a WOPI access token scoped to one file node and returns the editor
* URL to POST it to (#425). Session-authenticated - this is the only WOPI
* route the browser calls; everything under /api/wopi/files is called by the
* editor server-to-server with the token minted here.
* Mints a WOPI access token scoped to one document and returns the editor
* URL to POST it to (#425). Attachments always open read-only (#1047).
* Session-authenticated - this is the only WOPI route the browser calls;
* everything under /api/wopi/files is called by the editor server-to-server
* with the token minted here.
*/
export async function POST(request: NextRequest) {
try {
@@ -22,10 +25,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = (await request.json().catch(() => null)) as { fileId?: unknown; accountId?: unknown } | null;
const fileId = typeof body?.fileId === 'string' ? body.fileId : '';
if (!fileId) {
return NextResponse.json({ error: 'fileId is required' }, { status: 400 });
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
const str = (key: string): string => (typeof body?.[key] === 'string' ? (body[key] as string) : '');
const fileId = str('fileId');
const blobId = str('blobId');
const attachmentName = str('name').trim();
if (!fileId && !blobId) {
return NextResponse.json({ error: 'fileId or blobId is required' }, { status: 400 });
}
if (blobId && !attachmentName) {
return NextResponse.json({ error: 'name is required for attachments' }, { status: 400 });
}
const actions = await getWopiActions();
@@ -41,25 +50,45 @@ export async function POST(request: NextRequest) {
const trusted = creds.trusted || new URL(serverUrl).origin === request.nextUrl.origin;
const ctx = { serverUrl, authHeader: creds.authHeader, trusted };
let accountId = typeof body?.accountId === 'string' ? body.accountId : '';
let accountId = str('accountId');
if (!accountId) {
const session = await fetchJmapSession(serverUrl, creds.authHeader, { trusted });
const capability = blobId ? 'urn:ietf:params:jmap:mail' : 'urn:ietf:params:jmap:filenode';
accountId =
session?.primaryAccounts?.['urn:ietf:params:jmap:filenode'] ||
session?.primaryAccounts?.[capability] ||
Object.keys(session?.accounts ?? {})[0] ||
'';
}
if (!accountId) {
return NextResponse.json({ error: 'No files account' }, { status: 404 });
return NextResponse.json({ error: blobId ? 'No mail account' : 'No files account' }, { status: 404 });
}
const node = await getWopiFileNode(ctx, accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
let document: Pick<WopiTokenPayload, 'kind' | 'fileId' | 'name' | 'type' | 'size'>;
let name: string;
let canWrite: boolean;
if (blobId) {
const type = str('type');
// Confirms the blob is readable with these credentials before the
// editor is pointed at it, and reads its size for CheckFileInfo.
const probe = await probeBlob(ctx, accountId, blobId, attachmentName, type);
if (!probe) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
}
const clientSize = typeof body?.size === 'number' && body.size >= 0 ? body.size : 0;
name = attachmentName;
canWrite = false;
document = { kind: 'attachment', fileId: blobId, name, type, size: probe.size ?? clientSize };
} else {
const node = await getWopiFileNode(ctx, accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
name = node.name;
canWrite = node.myRights ? !!node.myRights.mayModifyContent : true;
document = { kind: 'file', fileId };
}
const ext = node.name.split('.').pop()?.toLowerCase() || '';
const canWrite = node.myRights ? !!node.myRights.mayModifyContent : true;
const ext = name.split('.').pop()?.toLowerCase() || '';
const urlsrc = (canWrite && actions.edit[ext]) || actions.view[ext] || actions.edit[ext];
if (!urlsrc) {
return NextResponse.json({ error: 'File type not supported by the editor' }, { status: 415 });
@@ -72,17 +101,17 @@ export async function POST(request: NextRequest) {
const hostBase =
configManager.get<string>('wopiHostUrl', '').trim().replace(/\/+$/, '') ||
request.nextUrl.origin;
const wopiSrc = `${hostBase}/api/wopi/files/${encodeURIComponent(fileId)}`;
const { token, expiresAt } = mintWopiToken({
const tokenPayload = {
serverUrl,
authHeader: creds.authHeader,
username: creds.username,
accountId,
fileId,
...document,
canWrite: editable,
origin: request.nextUrl.origin,
});
};
const wopiSrc = `${hostBase}/api/wopi/files/${wopiDocumentId(tokenPayload)}`;
const { token, expiresAt } = mintWopiToken(tokenPayload);
return NextResponse.json({
url: buildWopiActionUrl(urlsrc, wopiSrc),
+17 -6
View File
@@ -107,6 +107,7 @@ import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invit
import { RecipientPopover } from "./recipient-popover";
import { MailtoLink } from "@/components/ui/mailto-link";
import { inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
import type { TnefAttachment } from "@/lib/tnef";
@@ -677,6 +678,16 @@ export function EmailViewer({
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
// Office documents the built-in preview can't render still open in the
// configured WOPI editor (#1047) - only blob-backed ones, the editor
// fetches the content server-side.
const wopiStatus = useWopiStatus(true);
const isAttachmentPreviewable = useCallback(
(attachment: EffectiveAttachment) =>
isFilePreviewable(attachment.name || undefined, attachment.type)
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name)),
[wopiStatus],
);
const mailAttachmentActionRef = useRef(mailAttachmentAction);
mailAttachmentActionRef.current = mailAttachmentAction;
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
@@ -4067,7 +4078,7 @@ export function EmailViewer({
<div className="relative flex flex-col items-end justify-start gap-1 flex-shrink-0 max-w-[50%]">
{effectiveAttachments.slice(0, 2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
@@ -4164,7 +4175,7 @@ export function EmailViewer({
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
{effectiveAttachments.slice(2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
@@ -4879,7 +4890,7 @@ export function EmailViewer({
.slice(0, visibleBelowHeaderCount ?? effectiveAttachments.length)
.map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
@@ -4981,7 +4992,7 @@ export function EmailViewer({
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[260px] max-h-[60vh] overflow-y-auto">
{effectiveAttachments.slice(visibleBelowHeaderCount).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
@@ -5051,7 +5062,7 @@ export function EmailViewer({
<div className="relative flex items-center gap-1.5 flex-wrap">
{effectiveAttachments.slice(0, 2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
@@ -5147,7 +5158,7 @@ export function EmailViewer({
<div className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
{effectiveAttachments.slice(2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
@@ -37,6 +37,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable, toInertBlob } from "@/lib/file-preview";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
interface ThreadConversationViewProps {
thread: ThreadGroup;
@@ -240,6 +241,7 @@ function EmailCard({
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const density = useSettingsStore((state) => state.density);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const wopiStatus = useWopiStatus(true);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
@@ -647,7 +649,8 @@ function EmailCard({
<div className="flex flex-wrap gap-2">
{visibleAttachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type)
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name));
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<button
+3 -7
View File
@@ -27,7 +27,7 @@ import type { FileNodeRights } from "@/lib/jmap/types";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { WopiEditor } from "@/components/files/wopi-editor";
import { useWopiStatus, fileExtension } from "@/hooks/use-wopi-status";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
@@ -135,11 +135,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
// WOPI document editing (#425): name of the file open in the editor overlay.
const [editFile, setEditFile] = useState<string | null>(null);
const wopiStatus = useWopiStatus(filesEnabled);
const isOfficeEditable = useCallback((name: string) => {
if (!wopiStatus?.enabled) return false;
const ext = fileExtension(name);
return wopiStatus.editExtensions.includes(ext) || wopiStatus.viewExtensions.includes(ext);
}, [wopiStatus]);
const isOfficeEditable = useCallback((name: string) => canWopiOpen(wopiStatus, name), [wopiStatus]);
const [showDetails, setShowDetails] = useState(false);
const [detailName, setDetailName] = useState<string | null>(null);
@@ -727,7 +723,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
const editResource = resources.find(r => r.name === editFile);
return editResource ? (
<WopiEditor
resource={editResource}
target={{ kind: "file", id: editResource.id, name: editResource.name }}
accountId={filesAccountId}
onClose={() => {
setEditFile(null);
+28 -9
View File
@@ -3,12 +3,22 @@
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Loader2, X } from "@/components/icons";
import type { FileResource } from "@/stores/file-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
/** What the overlay opens: a Files node, or a mail attachment (read-only, #1047). */
export type WopiDocument =
| { kind: "file"; id: string; name: string }
| { kind: "attachment"; blobId: string; name: string; type?: string; size?: number };
interface WopiEditorProps {
resource: FileResource;
/** Files account id of the browsing account (multi-account contexts). */
target: WopiDocument;
/** JMAP account holding the node/blob (multi-account and shared contexts). */
accountId?: string | null;
/**
* Cookie slot of the login that owns the document. Defaults to the active
* account; the unified inbox passes the slot of the message's own login.
*/
slot?: number | null;
onClose: () => void;
}
@@ -23,23 +33,32 @@ interface LaunchData {
* Full-screen WOPI editor overlay (#425). The editor (Collabora Online,
* OnlyOffice/EuroOffice, ...) is launched the way the WOPI spec prescribes:
* a form POST of the access token to the editor URL, targeted at an iframe.
* Mail attachments open in the same overlay as a read-only viewer (#1047).
*/
export function WopiEditor({ resource, accountId, onClose }: WopiEditorProps) {
export function WopiEditor({ target, accountId, slot, onClose }: WopiEditorProps) {
const t = useTranslations("files");
const [launch, setLaunch] = useState<LaunchData | null>(null);
const [error, setError] = useState<string | null>(null);
const [frameLoaded, setFrameLoaded] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const submittedRef = useRef(false);
// Launch once per document; the object itself is rebuilt on every render.
const launchBody = JSON.stringify(
target.kind === "attachment"
? { blobId: target.blobId, name: target.name, type: target.type, size: target.size, accountId: accountId || undefined }
: { fileId: target.id, accountId: accountId || undefined },
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const slotHeaders: Record<string, string> =
typeof slot === "number" ? { "X-JMAP-Cookie-Slot": String(slot) } : getActiveAccountSlotHeaders();
const res = await fetch("/api/wopi/launch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileId: resource.id, accountId: accountId || undefined }),
headers: { "Content-Type": "application/json", ...slotHeaders },
body: launchBody,
});
if (!res.ok) throw new Error(`launch failed (${res.status})`);
const data = (await res.json()) as LaunchData;
@@ -51,7 +70,7 @@ export function WopiEditor({ resource, accountId, onClose }: WopiEditorProps) {
return () => {
cancelled = true;
};
}, [resource.id, accountId, t]);
}, [launchBody, slot, t]);
// Submit the launch form exactly once, after it is in the DOM.
useEffect(() => {
@@ -87,7 +106,7 @@ export function WopiEditor({ resource, accountId, onClose }: WopiEditorProps) {
return (
<div className="fixed inset-0 z-[100] bg-background flex flex-col">
<div className="flex items-center justify-between gap-3 px-4 h-12 border-b border-border shrink-0">
<span className="text-sm font-medium text-foreground truncate">{resource.name}</span>
<span className="text-sm font-medium text-foreground truncate">{target.name}</span>
<button
onClick={onClose}
aria-label={t("office_close")}
@@ -112,7 +131,7 @@ export function WopiEditor({ resource, accountId, onClose }: WopiEditorProps) {
)}
<iframe
name="wopi-editor-frame"
title={resource.name}
title={target.name}
className="w-full h-full border-0"
onLoad={() => setFrameLoaded(true)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
+39 -1
View File
@@ -85,6 +85,8 @@ import { useProTabStore } from "@/stores/pro-tab-store";
import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes";
import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { WopiEditor } from "@/components/files/wopi-editor";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { isFilePreviewable } from "@/lib/file-preview";
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
@@ -169,6 +171,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null);
// Office attachments open read-only in the configured WOPI editor (#1047).
const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null);
const wopiStatus = useWopiStatus(true);
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -3036,6 +3041,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return { blobClient, accountId, clientAccountId };
}, [isUnifiedView, client]);
// Opens an attachment the built-in preview cannot render but the office
// editor can (docx, odt, xlsx, ...). The launch route runs server-side with
// one login's cookie, so it needs that login's slot and an explicit
// accountId (the client's default one when the message is not delegated).
const openOfficeAttachment = useCallback((
blobClient: NonNullable<typeof client>,
attachment: { blobId: string; name: string; type?: string; size?: number },
accountId: string | undefined,
clientAccountId: string | undefined,
): boolean => {
if (isFilePreviewable(attachment.name, attachment.type) || !canWopiOpen(wopiStatus, attachment.name)) return false;
const slot = clientAccountId
? (useAccountStore.getState().getAccountById(clientAccountId)?.cookieSlot ?? null)
: null;
setOfficeAttachment({ ...attachment, accountId: accountId || blobClient.getAccountId(), slot });
return true;
}, [wopiStatus]);
// Opening an attachment straight from a list row. Deliberately resolves
// the blob source from that row's own email rather than the selected one:
// in the unified inbox each row can belong to a different account, and
@@ -3050,11 +3073,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, clientAccountId });
return;
}
if (mailAttachmentAction === 'preview' && openOfficeAttachment(
blobClient, { blobId: attachment.blobId, name, type: attachment.type, size: attachment.size }, accountId, clientAccountId,
)) return;
await blobClient.downloadBlob(attachment.blobId, name, attachment.type, accountId);
} catch (error) {
console.error("Failed to open attachment from the list:", error);
}
}, [resolveBlobSource]);
}, [resolveBlobSource, openOfficeAttachment]);
// Where a list row's attachment parts are fetched from: the same routing
// the viewer uses to open that row (source stamps first, then the account
@@ -3100,6 +3126,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setPreviewAttachment({ blobId, name, type, accountId, clientAccountId });
return;
}
if (!forceDownload && mailAttachmentAction === 'preview' && openOfficeAttachment(
blobClient, { blobId, name, type }, accountId, clientAccountId,
)) return;
await blobClient.downloadBlob(blobId, name, type, accountId);
} catch (error) {
@@ -4453,6 +4482,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
/>
)}
{officeAttachment && (
<WopiEditor
target={{ kind: "attachment", blobId: officeAttachment.blobId, name: officeAttachment.name, type: officeAttachment.type, size: officeAttachment.size }}
accountId={officeAttachment.accountId}
slot={officeAttachment.slot}
onClose={() => setOfficeAttachment(null)}
/>
)}
{/* Screen reader live region for dynamic status announcements */}
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
+7
View File
@@ -59,3 +59,10 @@ export function fileExtension(name: string): string {
const parts = name.toLowerCase().split(".");
return parts.length > 1 ? parts.pop() || "" : "";
}
/** Whether the configured editor can open (edit or view) a file of this name. */
export function canWopiOpen(status: WopiStatus | null, name?: string | null): boolean {
if (!status?.enabled || !name) return false;
const ext = fileExtension(name);
return !!ext && (status.editExtensions.includes(ext) || status.viewExtensions.includes(ext));
}
@@ -0,0 +1,178 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
// #1047: mail attachments open read-only in the WOPI editor. The launch route
// mints a blob-scoped token; CheckFileInfo/GetFile serve the attachment blob
// and PutFile refuses to write anything back.
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => 'x'.repeat(32),
hasSessionSecret: () => true,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { get: (_key: string, fallback: unknown) => fallback, ensureLoaded: async () => {} },
}));
vi.mock('@/lib/wopi/discovery', async (importActual) => ({
...(await importActual<typeof import('@/lib/wopi/discovery')>()),
getWopiActions: vi.fn(),
}));
vi.mock('@/lib/stalwart/server-fetch', () => ({
fetchJmapServer: vi.fn(),
isTrustedJmapServerUrl: async () => true,
}));
vi.mock('@/lib/stalwart/jmap-api', () => ({
fetchJmapSession: vi.fn(),
postJmap: vi.fn(),
rebaseApiUrl: () => null,
}));
import { POST as launch } from '@/app/api/wopi/launch/route';
import { GET as checkFileInfo } from '@/app/api/wopi/files/[fileId]/route';
import { GET as getFile, POST as putFile } from '@/app/api/wopi/files/[fileId]/contents/route';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { getWopiActions } from '@/lib/wopi/discovery';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import { fetchJmapSession, postJmap } from '@/lib/stalwart/jmap-api';
const ORIGIN = 'https://webmail.example.com';
const OFFICE = 'https://office.example.com/browser/abc/cool.html?';
const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
function request(url: string, init: { body?: unknown; bytes?: ArrayBuffer; headers?: Record<string, string> } = {}) {
const parsed = new URL(url, ORIGIN);
return {
nextUrl: parsed,
headers: new Headers(init.headers),
json: async () => init.body,
arrayBuffer: async () => init.bytes ?? new ArrayBuffer(0),
} as unknown as Parameters<typeof launch>[0];
}
const params = (id: string) => ({ params: Promise.resolve({ fileId: id }) });
async function launchAttachment(body: Record<string, unknown>) {
const res = await launch(request('/api/wopi/launch', { body }));
const data = await res.json();
return { status: res.status, data };
}
/** Pull the document id and token the editor would use out of a launch response. */
function editorCall(data: { url: string; accessToken: string }) {
const wopiSrc = new URL(new URL(data.url).searchParams.get('WOPISrc')!);
const documentId = decodeURIComponent(wopiSrc.pathname.split('/').pop()!);
const token = `access_token=${encodeURIComponent(data.accessToken)}`;
return { documentId, wopiSrc, token };
}
beforeEach(() => {
vi.clearAllMocks();
(getStalwartCredentials as Mock).mockResolvedValue({
serverUrl: 'https://mail.example.com',
authHeader: 'Basic dXNlcjpwYXNz',
username: 'user@example.com',
trusted: true,
slot: 0,
hasSessionCookie: true,
});
(getWopiActions as Mock).mockResolvedValue({ edit: { docx: OFFICE, odt: OFFICE }, view: {} });
(fetchJmapSession as Mock).mockResolvedValue({
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c', 'urn:ietf:params:jmap:filenode': 'f' },
accounts: { c: {} },
downloadUrl: 'https://public.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
});
(fetchJmapServer as Mock).mockImplementation(async () =>
new Response('DOCX-BYTES', { status: 200, headers: { 'Content-Length': '10' } }),
);
});
describe('WOPI attachment launch', () => {
it('mints a read-only token for the attachment blob in the primary mail account', async () => {
const { status, data } = await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', type: DOCX });
expect(status).toBe(200);
expect(data.readOnly).toBe(true);
expect(data.url.startsWith(OFFICE)).toBe(true);
// The blob was probed in the mail account, not the files account.
const probeUrl = (fetchJmapServer as Mock).mock.calls[0][0] as string;
expect(probeUrl).toBe(`https://mail.example.com/jmap/download/c/Gblob1/Quote.docx?accept=${encodeURIComponent(DOCX)}`);
// Attachments never touch FileNode.
expect(postJmap).not.toHaveBeenCalled();
const { documentId, wopiSrc } = editorCall(data);
expect(wopiSrc.origin).toBe(ORIGIN);
// Not the raw blob id: sessions are keyed by WOPISrc.
expect(documentId).not.toBe('Gblob1');
});
it('uses the accountId the client sends (shared / unified-inbox messages)', async () => {
await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', accountId: 'shared1' });
expect((fetchJmapServer as Mock).mock.calls[0][0]).toContain('/jmap/download/shared1/Gblob1/');
});
it('404s when the blob cannot be downloaded', async () => {
(fetchJmapServer as Mock).mockResolvedValue(new Response('nope', { status: 404 }));
const { status } = await launchAttachment({ blobId: 'Gmissing', name: 'Quote.docx' });
expect(status).toBe(404);
});
it('415s for types the editor does not handle, and 400s without a name', async () => {
expect((await launchAttachment({ blobId: 'Gblob1', name: 'archive.zip' })).status).toBe(415);
expect((await launchAttachment({ blobId: 'Gblob1' })).status).toBe(400);
});
});
describe('WOPI calls for an attachment token', () => {
async function launched() {
const { data } = await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', type: DOCX, size: 999 });
(fetchJmapServer as Mock).mockClear();
return editorCall(data);
}
it('CheckFileInfo describes the attachment as read-only, with the probed size', async () => {
const { documentId, token } = await launched();
const res = await checkFileInfo(request(`/api/wopi/files/${documentId}?${token}`), params(documentId));
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
BaseFileName: 'Quote.docx',
Size: 10,
UserCanWrite: false,
ReadOnly: true,
UserCanNotWriteRelative: true,
Version: 'Gblob1',
PostMessageOrigin: ORIGIN,
});
expect(postJmap).not.toHaveBeenCalled();
});
it('GetFile streams the attachment blob', async () => {
const { documentId, token } = await launched();
const res = await getFile(request(`/api/wopi/files/${documentId}/contents?${token}`), params(documentId));
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe(DOCX);
expect(await res.text()).toBe('DOCX-BYTES');
expect((fetchJmapServer as Mock).mock.calls[0][0]).toContain('/jmap/download/c/Gblob1/Quote.docx');
});
it('PutFile is refused', async () => {
const { documentId, token } = await launched();
const res = await putFile(
request(`/api/wopi/files/${documentId}/contents?${token}`, { bytes: new TextEncoder().encode('x').buffer as ArrayBuffer }),
params(documentId),
);
expect(res.status).toBe(403);
expect(fetchJmapServer).not.toHaveBeenCalled();
expect(postJmap).not.toHaveBeenCalled();
});
it('the token does not work under the raw blob id or another document id', async () => {
const { token } = await launched();
for (const id of ['Gblob1', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA']) {
const res = await checkFileInfo(request(`/api/wopi/files/${id}?${token}`), params(id));
expect(res.status).toBe(401);
}
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { mintWopiToken, verifyWopiToken } from '@/lib/wopi/token';
import { mintWopiToken, verifyWopiToken, wopiDocumentId } from '@/lib/wopi/token';
import { encryptPayload } from '@/lib/auth/crypto';
import {
readStalwartAuthContextFromStore,
@@ -41,13 +41,14 @@ function cookieStore(initial: Record<string, string> = {}): CookieStore {
}
describe('editor token presented as a session cookie', () => {
const { token } = mintWopiToken({
const scope = {
...CREDENTIALS,
accountId: 'c',
fileId: 'd',
canWrite: true,
origin: 'https://mail.example.com',
});
};
const { token } = mintWopiToken(scope);
it('is rejected by the session context reader', () => {
const store = cookieStore({ [stalwartAuthContextCookieName(0)]: token });
@@ -63,8 +64,8 @@ describe('editor token presented as a session cookie', () => {
});
it('still works for the file it was scoped to', () => {
expect(verifyWopiToken(token, 'd')).toMatchObject({ fileId: 'd', username: CREDENTIALS.username });
expect(verifyWopiToken(token, 'other')).toBeNull();
expect(verifyWopiToken(token, wopiDocumentId(scope))).toMatchObject({ fileId: 'd', username: CREDENTIALS.username });
expect(verifyWopiToken(token, wopiDocumentId({ ...scope, fileId: 'other' }))).toBeNull();
});
});
+46 -7
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { parseWopiDiscovery, buildWopiActionUrl } from '@/lib/wopi/discovery';
import { mintWopiToken, verifyWopiToken } from '@/lib/wopi/token';
import { mintWopiToken, verifyWopiToken, wopiDocumentId } from '@/lib/wopi/token';
// token.ts encrypts via lib/auth/crypto, whose key comes solely from
// getSessionSecret() - mock that seam like auth-crypto.test.ts does.
@@ -82,25 +82,64 @@ describe('mintWopiToken / verifyWopiToken', () => {
origin: 'https://webmail.example.com',
};
it('round-trips and binds to the fileId', () => {
const docId = wopiDocumentId(payload);
it('round-trips and binds to the document id', () => {
const { token, expiresAt } = mintWopiToken(payload);
expect(expiresAt).toBeGreaterThan(Date.now());
const verified = verifyWopiToken(token, 'f42');
const verified = verifyWopiToken(token, docId);
expect(verified).toMatchObject(payload);
// A token for one file must not authorize another.
expect(verifyWopiToken(token, 'other-file')).toBeNull();
expect(verifyWopiToken(token, wopiDocumentId({ ...payload, fileId: 'other-file' }))).toBeNull();
// Nor is the raw FileNode id accepted in the URL any more.
expect(verifyWopiToken(token, 'f42')).toBeNull();
});
it('rejects garbage, empty and expired tokens', () => {
expect(verifyWopiToken(null, 'f42')).toBeNull();
expect(verifyWopiToken('not-a-token', 'f42')).toBeNull();
expect(verifyWopiToken(null, docId)).toBeNull();
expect(verifyWopiToken('not-a-token', docId)).toBeNull();
const { token } = mintWopiToken(payload);
const realNow = Date.now;
vi.spyOn(Date, 'now').mockReturnValue(realNow() + 7 * 60 * 60 * 1000);
try {
expect(verifyWopiToken(token, 'f42')).toBeNull();
expect(verifyWopiToken(token, docId)).toBeNull();
} finally {
vi.restoreAllMocks();
}
});
it('carries read-only attachment metadata (#1047)', () => {
const attachment = {
...payload,
kind: 'attachment' as const,
fileId: 'Gblob123',
name: 'Quote.docx',
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: 12345,
canWrite: false,
};
const { token } = mintWopiToken(attachment);
expect(verifyWopiToken(token, wopiDocumentId(attachment))).toMatchObject(attachment);
// A blob id that happens to equal a FileNode id is a different document.
expect(wopiDocumentId(attachment)).not.toBe(wopiDocumentId({ ...payload, fileId: 'Gblob123' }));
});
});
describe('wopiDocumentId', () => {
const base = { serverUrl: 'https://mail.example.com', accountId: 'c', fileId: 'b' };
// WOPI clients key open sessions by WOPISrc: two accounts' node "b" must
// not share one, or the second user is served the first user's document.
it('differs per account and per server for the same node id', () => {
const id = wopiDocumentId(base);
expect(wopiDocumentId({ ...base, accountId: 'd' })).not.toBe(id);
expect(wopiDocumentId({ ...base, serverUrl: 'https://other.example.com' })).not.toBe(id);
});
it('is stable, URL-safe and treats a missing kind as a file', () => {
const id = wopiDocumentId(base);
expect(wopiDocumentId({ ...base })).toBe(id);
expect(wopiDocumentId({ ...base, kind: 'file' })).toBe(id);
expect(id).toMatch(/^[A-Za-z0-9_-]{32}$/);
});
});
+19
View File
@@ -135,6 +135,25 @@ export async function downloadFileBlob(
}, ctx.trusted);
}
/**
* Check that a mail attachment blob is readable with these credentials and
* learn its size (#1047). Only the response headers are read; the body is
* cancelled. Returns null when the blob cannot be downloaded.
*/
export async function probeBlob(
ctx: WopiJmapContext,
accountId: string,
blobId: string,
name: string,
type: string,
): Promise<{ size: number | null } | null> {
const response = await downloadFileBlob(ctx, accountId, blobId, name, type);
await response.body?.cancel().catch(() => {});
if (!response.ok) return null;
const length = Number(response.headers.get('Content-Length'));
return { size: Number.isFinite(length) && length >= 0 && response.headers.has('Content-Length') ? length : null };
}
/** Upload new content as a blob (first half of WOPI PutFile). */
export async function uploadFileBlob(
ctx: WopiJmapContext,
+3 -3
View File
@@ -6,13 +6,13 @@ import { verifyWopiToken, type WopiTokenPayload } from '@/lib/wopi/token';
/**
* Authenticate an incoming WOPI request (called by the editor
* server-to-server, no session cookie) from its `access_token` query
* parameter, scoped to the fileId in the URL.
* parameter, scoped to the document id in the URL (see wopiDocumentId).
*/
export async function wopiContext(
request: NextRequest,
fileId: string,
documentId: string,
): Promise<{ payload: WopiTokenPayload; ctx: WopiJmapContext } | null> {
const payload = verifyWopiToken(request.nextUrl.searchParams.get('access_token'), fileId);
const payload = verifyWopiToken(request.nextUrl.searchParams.get('access_token'), documentId);
if (!payload) return null;
// Trust is re-derived from the current config, not persisted in the token
// (mirrors lib/stalwart/server-fetch.ts): removing a server from the config
+40 -7
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
/**
@@ -7,17 +8,30 @@ import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
* token in the launch form POST and echoes it back on every WOPI call
* (CheckFileInfo, GetFile, PutFile). It is an AES-256-GCM blob keyed off
* SESSION_SECRET - opaque to the editor - carrying the stored Stalwart
* credentials plus the one file node it is scoped to. The editor can
* therefore only reach the /api/wopi/files/<fileId> surface for that node,
* never the JMAP server itself.
* credentials plus the one document it is scoped to: a Files node, or a mail
* attachment blob opened read-only (#1047). The editor can therefore only
* reach the /api/wopi/files/<documentId> surface for that document, never
* the JMAP server itself.
*/
export type WopiDocumentKind = 'file' | 'attachment';
export interface WopiTokenPayload {
serverUrl: string;
authHeader: string;
username: string;
accountId: string;
/** FileNode id for 'file', blobId for 'attachment'. */
fileId: string;
/** Absent on tokens minted before attachments existed - those are files. */
kind?: WopiDocumentKind;
/**
* Attachment metadata. A blob has no name/type/size of its own, so the
* launch route records them for CheckFileInfo/GetFile.
*/
name?: string;
type?: string;
size?: number;
canWrite: boolean;
/** Browser origin that embeds the editor iframe (WOPI PostMessageOrigin). */
origin: string;
@@ -41,6 +55,23 @@ function fromBase64Url(token: string): string {
return token.replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
}
/**
* The id in the WOPISrc URL (/api/wopi/files/<id>). WOPI clients key their
* open-document sessions by WOPISrc, so the raw FileNode id or blobId is not
* enough: ids like "b" repeat in every account, and a second user opening
* their own "b" would be joined into the first user's session and served
* that document. Hashing in the server and account keeps the id unique per
* document while two people on a shared account still co-edit one session.
*/
export function wopiDocumentId(
p: Pick<WopiTokenPayload, 'serverUrl' | 'accountId' | 'fileId' | 'kind'>,
): string {
return createHash('sha256')
.update([p.kind ?? 'file', p.serverUrl, p.accountId, p.fileId].join('\n'))
.digest('base64url')
.slice(0, 32);
}
export function mintWopiToken(payload: Omit<WopiTokenPayload, 'exp'>): { token: string; expiresAt: number } {
const expiresAt = Date.now() + WOPI_TOKEN_TTL_MS;
const token = toBase64Url(encryptPayload({ v: 1, t: 'wopi', ...payload, exp: expiresAt }, 'wopi-token'));
@@ -48,10 +79,11 @@ export function mintWopiToken(payload: Omit<WopiTokenPayload, 'exp'>): { token:
}
/**
* Decrypt and validate a WOPI access token. `fileId` must match the id in
* the request URL so a token for one document cannot address another.
* Decrypt and validate a WOPI access token. `documentId` is the id from the
* request URL; it must be the token's own wopiDocumentId so a token for one
* document cannot address another.
*/
export function verifyWopiToken(token: string | null, fileId: string): WopiTokenPayload | null {
export function verifyWopiToken(token: string | null, documentId: string): WopiTokenPayload | null {
if (!token) return null;
const raw = decryptPayload(fromBase64Url(token), 'wopi-token');
if (!raw || raw.v !== 1 || raw.t !== 'wopi') return null;
@@ -63,7 +95,8 @@ export function verifyWopiToken(token: string | null, fileId: string): WopiToken
typeof p.fileId !== 'string' || !p.fileId ||
typeof p.exp !== 'number'
) return null;
if (p.fileId !== fileId) return null;
if (p.kind !== undefined && p.kind !== 'file' && p.kind !== 'attachment') return null;
if (wopiDocumentId(p) !== documentId) return null;
if (Date.now() > p.exp) return null;
return p;
}