# Conflicts:
#	app/api/dev-jmap/[...path]/route.ts
This commit is contained in:
Linus Rath
2026-07-30 19:17:43 +02:00
71 changed files with 3682 additions and 1551 deletions
+2
View File
@@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-
import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
@@ -39,6 +40,7 @@ export default async function LocaleLayout({
<TourProvider>
<ProtocolLaunchHandlerProvider>
<ProInterfaceRedirect />
<ImpersonationReconciler />
{children}
<PluginDialogHost />
<PluginConsentDialog />
+107 -17
View File
@@ -34,6 +34,7 @@ import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
import {
ErrorBoundary,
SidebarErrorFallback,
@@ -78,6 +79,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from
import { emailToReadView } from "@/lib/plugin-projection";
import { buildQuoteHeader } from "@/lib/quote-header";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
import { getEffectiveLocale } from '@/i18n/detect-locale';
import type { QuoteHeader } from "@/lib/plugin-types";
@@ -785,7 +787,15 @@ export default function Home() {
// This makes the Pro composer behave like Thunderbird's pop-out window.
useEffect(() => {
if (!isEmbedded || !showComposer) return;
const replyTo = selectedEmail ? {
// pendingDraft.replyTo, when set, was built by the opener (e.g.
// handleForwardAsAttachment) with intent that must survive the hop into
// the Pro tab - mirrors the same precedence the non-embedded render path
// uses just below (`replyTo={pendingDraft !== null ? pendingDraft.replyTo
// : ...}`). Building fresh from selectedEmail unconditionally here would
// silently drop that intent (e.g. the synthetic message/rfc822
// attachment "Forward as attachment" stages), falling back to a normal
// quoted forward instead.
const replyTo = pendingDraft?.replyTo ?? (selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to,
@@ -801,7 +811,7 @@ export default function Home() {
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
} : undefined;
} : undefined);
const effectiveMode = pendingDraft?.mode ?? composerMode;
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
@@ -1540,6 +1550,77 @@ export default function Home() {
if (isMobile) setActiveView('viewer');
};
// Forward the original message as a message/rfc822 attachment instead of
// inline-quoted text - e.g. for reporting spam to an upstream gateway
// that expects the raw original as an attachment, or preserving exact
// formatting/headers the recipient needs to see untouched. Reuses the
// same attachment-carry-forward mechanism native Forward already uses
// for a forwarded message's own attachments (see the `attachments`
// useState initializer in email-composer.tsx) - we just add one more
// synthetic entry representing the whole original message, referenced
// by its existing blobId (no re-fetch/re-upload needed - JMAP blobs are
// account-scoped, not per-email). Skips prepareComposerQuoteHeader
// entirely, so the body starts blank instead of quoting the original.
// Takes an explicit `email` (defaulting to selectedEmail), same pattern
// handleDelete uses just below, rather than always reading selectedEmail
// from this closure - callers that just called selectEmail(email) and
// invoke this synchronously in the same tick would otherwise see the
// PRE-update value (the Zustand store updates immediately, but this
// render's selectedEmail closure doesn't until the next render),
// forwarding the previously selected message or no-op'ing on an
// unselected row. See the list context-menu wiring below.
const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => {
if (!email) return;
// Same filename options "Export as .eml" uses (see emailFilenameOptions
// in email-viewer.tsx), so the two actions produce consistent filenames
// for the same message rather than the synthetic attachment silently
// ignoring the user's configured naming template.
const {
emailDownloadTemplate,
filenameSpaceReplacement,
filenameLowercase,
filenameStripDiacritics,
filenameCollapseSeparators,
} = useSettingsStore.getState();
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
template: emailDownloadTemplate,
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
});
if (!payload) return;
const ok = await emailHooks.onBeforeForward.intercept({
originalEmailId: email.id,
originalEmail: emailToReadView(email),
mode: 'forward' as const,
});
if (!ok) return;
startFreshComposerSession();
setPendingDraft({
to: "",
cc: "",
bcc: "",
subject: payload.subject,
body: "",
showCc: false,
showBcc: false,
selectedIdentityId: null,
subAddressTag: "",
mode: "forward",
draftId: null,
replyTo: {
subject: email.subject,
attachments: [payload.attachment],
},
});
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
};
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
if (!client || !emailToDelete) return;
@@ -1754,7 +1835,7 @@ export default function Home() {
keywords['$pinned'] = true;
}
// Same unified-view routing as color tags: write to the email's own
// Same unified-view routing as tags: write to the email's own
// account via the login it is reachable through. (#281)
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
@@ -1778,31 +1859,35 @@ export default function Home() {
}
};
const handleSetColorTag = async (emailId: string, color: string | null) => {
const handleSetTag = async (emailId: string, tagId: string | null) => {
if (!client) return;
try {
// Remove any existing label/color tags
// Remove any existing tag keywords
const email = emails.find(e => e.id === emailId);
if (!email) return;
const keywords = { ...email.keywords };
if (color === null) {
// Remove all label/color tags
if (tagId === null) {
// Remove all tag keywords
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
keywords[key] = false;
}
});
} else {
const jmapKey = `$label:${color}`;
if (keywords[jmapKey]) {
// Toggle off if already active
keywords[jmapKey] = false;
// Both prefixes name the same tag when read, so taking one off has to
// clear whichever spellings are actually set.
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
.filter(key => keywords[key]);
if (activeKeys.length > 0) {
activeKeys.forEach(key => {
keywords[key] = false;
});
} else {
// Add the tag without disturbing others
keywords[jmapKey] = true;
keywords[KEYWORD_PREFIX + tagId] = true;
}
}
@@ -1828,7 +1913,7 @@ export default function Home() {
// Refresh tag counts
fetchTagCounts(client);
} catch (error) {
console.error("Failed to set color tag:", error);
console.error("Failed to set tag:", error);
}
};
@@ -3226,6 +3311,10 @@ export default function Home() {
selectEmail(email);
handleForward();
}}
onForwardAsAttachment={(email) => {
selectEmail(email);
handleForwardAsAttachment(email);
}}
onMarkAsRead={async (email, read) => {
if (client) {
await markAsRead(client, email.id, read);
@@ -3245,8 +3334,8 @@ export default function Home() {
onArchive={async (email) => {
await handleArchive(email);
}}
onSetColorTag={(emailId, color) => {
handleSetColorTag(emailId, color);
onSetTag={(emailId, color) => {
handleSetTag(emailId, color);
}}
onMoveToMailbox={async (emailId, mailboxId) => {
if (client) {
@@ -3456,6 +3545,7 @@ export default function Home() {
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onForwardAsAttachment={handleForwardAsAttachment}
onDelete={() => {
// Deleting the open message returns to the list (Gmail-style),
// not the next email — unless the user turned the setting off.
@@ -3469,7 +3559,7 @@ export default function Home() {
}}
onArchive={() => handleArchive()}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onSetTag={handleSetTag}
onMarkAsSpam={() => handleMarkAsSpam()}
onUndoSpam={() => handleUndoSpam()}
onMarkAsRead={async (emailId, read) => {
+3 -2
View File
@@ -39,7 +39,8 @@ function impersonationCookieOptions() {
* Master-user impersonation via signed JWT. The token carries the target
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
* master credentials from env, then mints the same session cookies the
* password-login path produces. The browser is redirected to "/" and the
* password-login path produces. The browser is redirected to "/?impersonated=1" (see
* ImpersonationReconciler, GH #646) and the
* SPA hydrates as if the user had just logged in with master@target%master.
*
* Returns 404 when the feature is not configured so an unconfigured
@@ -136,6 +137,6 @@ export async function GET(request: NextRequest) {
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
return new NextResponse(null, {
status: 303,
headers: { Location: '/' },
headers: { Location: '/?impersonated=1' },
});
}
+6 -6
View File
@@ -120,7 +120,7 @@ const emails: MockEmail[] = [
},
},
{
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work/clients/acme': true }, size: 5100, receivedAt: daysAgo(1),
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
@@ -152,7 +152,7 @@ const emails: MockEmail[] = [
},
},
{
id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:red': true }, size: 6200, receivedAt: daysAgo(0),
id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:work/clients': true, '$label:receipts': true }, size: 6200, receivedAt: daysAgo(0),
from: [{ name: 'GitHub', email: 'notifications@github.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: '[bulwark-webmail] Theme choice is ignored after an OS theme change (#42)',
@@ -180,7 +180,7 @@ const emails: MockEmail[] = [
},
// Newsletter with a full HTML body
{
id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0),
id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal/finance': true }, size: 18200, receivedAt: daysAgo(0),
from: [{ name: 'Sidenote', email: 'post@sidenote.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Sidenote 47: the Component Model shipped and nobody has to care yet',
@@ -346,7 +346,7 @@ const emails: MockEmail[] = [
],
},
{
id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:green': true }, size: 4100, receivedAt: hoursAgo(3),
id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal': true }, size: 4100, receivedAt: hoursAgo(3),
from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'JMAP-342 is up: vCard import',
@@ -373,7 +373,7 @@ const emails: MockEmail[] = [
},
},
{
id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 8900, receivedAt: daysAgo(1),
id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work': true }, size: 8900, receivedAt: daysAgo(1),
from: [{ name: 'Nordhost GmbH', email: 'rechnung@nordhost.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Invoice NH-2026-0284 for February',
@@ -522,7 +522,7 @@ const emails: MockEmail[] = [
},
},
{
id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 4100, receivedAt: daysAgo(6),
id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$color:work/archived': true }, size: 4100, receivedAt: daysAgo(6),
from: [{ name: 'Mollie Developers', email: 'developers@mollie.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'API version 2023-10 stops working on 15 April',
+35
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:where(.dark, .dark *));
@@ -558,6 +559,40 @@ body {
overscroll-behavior: none;
}
/* Shake animation (for rejected input) */
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
20%,
60% {
transform: translateX(-4px);
}
40%,
80% {
transform: translateX(4px);
}
}
.animate-shake {
animation: shake 0.4s ease-in-out;
}
/* Fade in animation (for popovers) */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
/* Slide in from right animation (for mobile views) */
@keyframes slide-in-from-right {
from {