Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts: # app/api/dev-jmap/[...path]/route.ts
This commit is contained in:
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user