Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc6185002 | ||
|
|
4269d0589c | ||
|
|
2b1b06abd6 | ||
|
|
ac4a89120d | ||
|
|
704a259432 | ||
|
|
1c02970ae1 | ||
|
|
66b2036e37 | ||
|
|
bd2ffab3bc | ||
|
|
7142627cec |
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.1 (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
- **Admin**: Expose PWA branding fields in the admin Branding tab
|
||||
- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Preserve inline images when replying (#163)
|
||||
- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313)
|
||||
- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop
|
||||
|
||||
## 1.7.0 (2026-05-21)
|
||||
|
||||
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
|
||||
@@ -21,6 +34,7 @@
|
||||
- **Pro**: Multi-account contacts and a cross-account file picker
|
||||
- **Pro**: Composer From dropdown grouped by account
|
||||
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
|
||||
- **Plugins**: Marketplace update flow for installed plugins and themes
|
||||
- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate
|
||||
- **Setup**: Warn when the JMAP URL points at a local-only host
|
||||
- **Account**: List and reorder logged-in accounts from settings (#282)
|
||||
@@ -62,6 +76,7 @@
|
||||
- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe
|
||||
- **Plugins**: Sync plugin slot iframe height with reported content height
|
||||
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
|
||||
- **Plugins**: Trust the directory version on marketplace install and update
|
||||
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
|
||||
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
|
||||
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
|
||||
@@ -1973,7 +1973,7 @@ export default function Home() {
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||
const shouldHideViewerPane = !isMobile && !hasViewerContent && (isEmbedded || isFocusedMailLayout);
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
|
||||
// Handle email selection with mobile view switching
|
||||
@@ -2596,7 +2596,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Email list resize handle (desktop only) */}
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||
|
||||
@@ -25,6 +25,20 @@ const TEXT_FIELDS = [
|
||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||
];
|
||||
|
||||
const PWA_IMAGE_FIELDS = [
|
||||
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||
];
|
||||
|
||||
const PWA_TEXT_FIELDS = [
|
||||
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
|
||||
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
|
||||
];
|
||||
|
||||
const PWA_COLOR_FIELDS = [
|
||||
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
|
||||
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
|
||||
];
|
||||
|
||||
export function BrandingTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
@@ -262,6 +276,142 @@ export function BrandingTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Progressive Web App</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{PWA_IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title="Upload file"
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{PWA_TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{PWA_COLOR_FIELDS.map(field => {
|
||||
const value = currentValue(field.key) || field.defaultValue;
|
||||
return (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
|
||||
title="Pick a color"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue}
|
||||
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
|
||||
@@ -24,6 +24,7 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
/** Slots that correspond to branding config keys */
|
||||
const VALID_SLOTS = new Set([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
|
||||
@@ -3,11 +3,13 @@ import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
|
||||
const VALID_SIZES = new Set([192, 512]);
|
||||
|
||||
// Cache resized images in memory to avoid reprocessing on every request
|
||||
const cache = new Map<number, Blob>();
|
||||
// Cache resized images keyed by (size, source URL) so admin re-uploads or URL
|
||||
// changes invalidate the prior render instead of serving stale bytes forever.
|
||||
const cache = new Map<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
// Absolute URL (http/https)
|
||||
@@ -17,6 +19,14 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||
// but stored on disk under getConfigDir()/branding/.
|
||||
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
@@ -47,9 +57,11 @@ export async function GET(
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
};
|
||||
|
||||
const cacheKey = `${size}|${iconUrl}`;
|
||||
|
||||
try {
|
||||
if (cache.has(size)) {
|
||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||
if (cache.has(cacheKey)) {
|
||||
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||
@@ -61,7 +73,7 @@ export async function GET(
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(size, blob);
|
||||
cache.set(cacheKey, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
|
||||
@@ -35,6 +35,10 @@ import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import {
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
@@ -300,7 +304,8 @@ export function EmailComposer({
|
||||
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const wrap = replyTo.quoteWrapInBlockquote !== false;
|
||||
const originalHtml = replyTo.htmlBody
|
||||
?? (replyTo.body
|
||||
? rewriteCidImagesForEditor(replyTo.htmlBody)
|
||||
: (replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
: '');
|
||||
const bodyHtml = wrap
|
||||
@@ -314,7 +319,10 @@ export function EmailComposer({
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
||||
// cid: image refs are rewritten so they render in the editor (browsers
|
||||
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
|
||||
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
|
||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`;
|
||||
}
|
||||
|
||||
if (replyTo.body) {
|
||||
@@ -534,6 +542,76 @@ export function EmailComposer({
|
||||
selectedIdentityId,
|
||||
]);
|
||||
|
||||
// Hydrate inline images referenced by the quoted body (issue #163).
|
||||
// `getInitialBody` rewrites `<img src="cid:xxx">` to placeholder src +
|
||||
// data-cid; here we (1) register each inline attachment in inlineImagesRef
|
||||
// so the send path re-attaches the blob with the right cid, and (2) fetch
|
||||
// each blob as a data URL and swap it into the body so the editor actually
|
||||
// shows the image instead of a blank placeholder.
|
||||
useEffect(() => {
|
||||
if (plainTextMode) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||
if (!composerClient || !replyTo?.attachments?.length) return;
|
||||
|
||||
const inlineAtts = replyTo.attachments.filter((att) =>
|
||||
att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')
|
||||
);
|
||||
if (inlineAtts.length === 0) return;
|
||||
|
||||
// Seed the ref synchronously so a fast Send still attaches the right blobs
|
||||
// even if the FileReader work below hasn't resolved yet.
|
||||
for (const att of inlineAtts) {
|
||||
if (!att.cid) continue;
|
||||
if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue;
|
||||
inlineImagesRef.current.push({
|
||||
cid: att.cid,
|
||||
blobId: att.blobId,
|
||||
type: att.type,
|
||||
name: att.name || 'inline',
|
||||
size: att.size,
|
||||
dataUrl: '',
|
||||
});
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const updates = new Map<string, string>();
|
||||
for (const att of inlineAtts) {
|
||||
if (!att.cid) continue;
|
||||
try {
|
||||
const buffer = await composerClient.fetchBlobArrayBuffer(
|
||||
att.blobId,
|
||||
att.name || 'inline',
|
||||
att.type,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const blob = new Blob([buffer], { type: att.type });
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
if (cancelled) return;
|
||||
const entry = inlineImagesRef.current.find((e) => e.cid === att.cid);
|
||||
if (entry) entry.dataUrl = dataUrl;
|
||||
updates.set(att.cid, dataUrl);
|
||||
} catch (err) {
|
||||
debug.error('Failed to load inline image for compose', err);
|
||||
}
|
||||
}
|
||||
if (cancelled || updates.size === 0) return;
|
||||
setBody((prev) => replaceInlineImagePlaceholders(prev, updates));
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// We deliberately hydrate once per composer open - subsequent replyTo
|
||||
// object identity churn from parent renders shouldn't refetch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [composerClient, plainTextMode, mode]);
|
||||
|
||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
|
||||
@@ -79,6 +79,7 @@ import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
@@ -922,6 +923,7 @@ export function EmailViewer({
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const { startTour } = useTour();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||
@@ -3253,19 +3255,21 @@ export function EmailViewer({
|
||||
}
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
{!isEmbedded && (
|
||||
<div className="text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||
{onCompose && (
|
||||
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t('compose')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||
{onCompose && (
|
||||
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t('compose')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,17 @@ export const ResizableImage = Node.create({
|
||||
width: { default: null },
|
||||
cid: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-cid"),
|
||||
parseHTML: (el) => {
|
||||
const dataCid = el.getAttribute("data-cid");
|
||||
if (dataCid) return dataCid;
|
||||
// Fall back to deriving the cid from `src="cid:xxx"` so inline
|
||||
// image refs survive editor round-trips even when data-cid was
|
||||
// never set (defensive — the composer normally pre-rewrites
|
||||
// quoted-body cid: refs into data-cid).
|
||||
const src = el.getAttribute("src") || "";
|
||||
if (/^cid:/i.test(src)) return src.slice(4) || null;
|
||||
return null;
|
||||
},
|
||||
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -78,7 +78,10 @@ export function FilterRuleModal({
|
||||
const pathMap = new Map<string, string>();
|
||||
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
|
||||
for (const node of nodes) {
|
||||
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
// Sieve fileinto expects the IMAP-canonical "INBOX" for the inbox,
|
||||
// not the localized JMAP display name (e.g. "Entrada" in pt-BR).
|
||||
const segment = node.role === "inbox" ? "INBOX" : node.name;
|
||||
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
pathMap.set(node.id, fullPath);
|
||||
if (node.children.length > 0) buildPaths(node.children, fullPath);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,27 @@ function resolveSourceAccountId(email: Email | undefined): string | null {
|
||||
return useAuthStore.getState().activeAccountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local accountId ("user@host") that owns the destination
|
||||
* mailbox. `mailbox.accountId` is the JMAP server's opaque account id, but
|
||||
* `clients`, `activeAccountId`, and `email.accountId` all live in the local
|
||||
* namespace. We map back by matching the JMAP id against each connected
|
||||
* client's `getAccountId()`. Falls back to the viewing/active account so
|
||||
* single-account flows (no connected clients map entry yet, in-memory edits,
|
||||
* etc.) still resolve correctly.
|
||||
*/
|
||||
function resolveDestAccountId(mailbox: Mailbox): string | null {
|
||||
const jmapId = mailbox.accountId;
|
||||
if (jmapId) {
|
||||
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||
for (const [localId, client] of clients) {
|
||||
if (client.getAccountId() === jmapId) return localId;
|
||||
}
|
||||
}
|
||||
return useEmailStore.getState().viewingAccountId
|
||||
?? useAuthStore.getState().activeAccountId;
|
||||
}
|
||||
|
||||
interface UseMailboxDropOptions {
|
||||
mailbox: Mailbox;
|
||||
onDropComplete?: () => void;
|
||||
@@ -123,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
// Group dragged emails by source account. In single-account flows this
|
||||
// collapses to one bucket; in unified view or the Pro multi-account
|
||||
// sidebar a single drag can mix sources.
|
||||
const destAccountId = mailbox.accountId;
|
||||
const destAccountId = resolveDestAccountId(mailbox);
|
||||
const idToEmail = new Map(draggedEmails.map((em) => [em.id, em]));
|
||||
const bySource = new Map<string, string[]>();
|
||||
for (const id of emailIds) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { plainTextToComposerBody } from "../email-composer-utils";
|
||||
import {
|
||||
plainTextToComposerBody,
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
INLINE_IMAGE_PLACEHOLDER,
|
||||
} from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
it("returns an empty string for empty input", () => {
|
||||
@@ -24,3 +29,86 @@ describe("plainTextToComposerBody", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteCidImagesForEditor", () => {
|
||||
it("returns input unchanged when no cid: refs are present", () => {
|
||||
const html = '<p>hi</p><img src="https://example.com/x.png">';
|
||||
expect(rewriteCidImagesForEditor(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(rewriteCidImagesForEditor("")).toBe("");
|
||||
});
|
||||
|
||||
it("rewrites a cid: src to placeholder + data-cid", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="cid:abc@x" alt="logo">'
|
||||
);
|
||||
expect(out).toContain('data-cid="abc@x"');
|
||||
expect(out).toContain(`src="${INLINE_IMAGE_PLACEHOLDER}"`);
|
||||
expect(out).toContain('alt="logo"');
|
||||
expect(out).not.toContain('src="cid:');
|
||||
});
|
||||
|
||||
it("preserves an existing data-cid attribute", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="cid:abc" data-cid="kept">'
|
||||
);
|
||||
expect(out).toContain('data-cid="kept"');
|
||||
expect(out).not.toContain('data-cid="abc"');
|
||||
});
|
||||
|
||||
it("leaves non-cid images alone", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="https://example.com/x.png"><img src="cid:y">'
|
||||
);
|
||||
expect(out).toContain('src="https://example.com/x.png"');
|
||||
expect(out).toContain('data-cid="y"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceInlineImagePlaceholders", () => {
|
||||
it("returns input unchanged when the map is empty", () => {
|
||||
const html = '<img src="..." data-cid="x">';
|
||||
expect(replaceInlineImagePlaceholders(html, new Map())).toBe(html);
|
||||
});
|
||||
|
||||
it("swaps the placeholder src to the data URL for matching cids", () => {
|
||||
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="abc">`;
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
expect(out).toContain('data-cid="abc"');
|
||||
});
|
||||
|
||||
it("also rewrites raw cid: src refs that lack a placeholder", () => {
|
||||
const html = '<img src="cid:abc" data-cid="abc">';
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
});
|
||||
|
||||
it("does not overwrite images the user has re-pointed away from the cid", () => {
|
||||
const html =
|
||||
'<img src="https://example.com/other.png" data-cid="abc">';
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="https://example.com/other.png"');
|
||||
expect(out).not.toContain("data:image/png;base64,AAAA");
|
||||
});
|
||||
|
||||
it("leaves unknown cids untouched", () => {
|
||||
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="missing">`;
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,8 @@ function buildMailboxPathMap(tree: MailboxNode[]): Map<string, string> {
|
||||
const pathMap = new Map<string, string>();
|
||||
const walk = (nodes: MailboxNode[], parentPath = '') => {
|
||||
for (const node of nodes) {
|
||||
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
const segment = node.role === 'inbox' ? 'INBOX' : node.name;
|
||||
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
pathMap.set(node.id, fullPath);
|
||||
if (node.children.length > 0) walk(node.children, fullPath);
|
||||
}
|
||||
@@ -47,7 +48,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
const mailboxes = [makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' })];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
});
|
||||
|
||||
it('should produce correct path for a single-level subfolder', () => {
|
||||
@@ -57,7 +58,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('sub1')).toBe('Inbox/Projects');
|
||||
expect(paths.get('sub1')).toBe('INBOX/Projects');
|
||||
});
|
||||
|
||||
it('should produce correct path for deeply nested subfolders', () => {
|
||||
@@ -68,7 +69,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('sub2')).toBe('Inbox/Test/Test2');
|
||||
expect(paths.get('sub2')).toBe('INBOX/Test/Test2');
|
||||
});
|
||||
|
||||
it('should handle multiple root-level folders', () => {
|
||||
@@ -79,7 +80,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('archive')).toBe('Archive');
|
||||
expect(paths.get('sub1')).toBe('Archive/Work');
|
||||
});
|
||||
@@ -99,9 +100,25 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
expect(paths.has(node.id)).toBe(true);
|
||||
}
|
||||
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('sub1')).toBe('Inbox/Projects');
|
||||
expect(paths.get('sub2')).toBe('Inbox/Projects/Active');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('sub1')).toBe('INBOX/Projects');
|
||||
expect(paths.get('sub2')).toBe('INBOX/Projects/Active');
|
||||
});
|
||||
|
||||
it('uses canonical INBOX even when JMAP returns a localized inbox name', () => {
|
||||
// Stalwart returns localized display names for the inbox based on the
|
||||
// user's locale (e.g. "Entrada" for pt-BR). Sieve fileinto must still
|
||||
// target the IMAP-canonical "INBOX" so the message is filed correctly.
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'Entrada', role: 'inbox' }),
|
||||
makeMailbox({ id: 'host', name: 'Host', parentId: 'inbox' }),
|
||||
makeMailbox({ id: 'eveo', name: 'EVEO', parentId: 'host' }),
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('host')).toBe('INBOX/Host');
|
||||
expect(paths.get('eveo')).toBe('INBOX/Host/EVEO');
|
||||
});
|
||||
|
||||
it('should preserve depth info in flattened tree', () => {
|
||||
|
||||
@@ -21,3 +21,59 @@ export function plainTextToComposerBody(text: string): string {
|
||||
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Transparent 1x1 GIF used as a stand-in src while the real inline image is
|
||||
// being fetched from JMAP. Browsers cannot render `cid:` URLs directly, so
|
||||
// without this swap the editor would show a broken-image icon (issue #163).
|
||||
export const INLINE_IMAGE_PLACEHOLDER =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
/**
|
||||
* Rewrites `<img src="cid:xxx">` references into `<img src="<placeholder>" data-cid="xxx">`
|
||||
* so TipTap can render the editor (the original cid: URL would 404) while still
|
||||
* carrying the cid through edits. The placeholder is swapped to the actual
|
||||
* image data once the corresponding inline blob has been fetched.
|
||||
*/
|
||||
export function rewriteCidImagesForEditor(html: string): string {
|
||||
if (!html || html.indexOf("cid:") === -1) return html;
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
||||
let touched = false;
|
||||
doc.querySelectorAll("img").forEach((img) => {
|
||||
const src = img.getAttribute("src") || "";
|
||||
if (!/^cid:/i.test(src)) return;
|
||||
const cid = src.slice(4);
|
||||
if (!cid) return;
|
||||
if (!img.getAttribute("data-cid")) {
|
||||
img.setAttribute("data-cid", cid);
|
||||
}
|
||||
img.setAttribute("src", INLINE_IMAGE_PLACEHOLDER);
|
||||
touched = true;
|
||||
});
|
||||
return touched ? doc.body.innerHTML : html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the placeholder src on `<img data-cid="...">` elements with the
|
||||
* resolved data URL once the inline blob has been fetched. Leaves images
|
||||
* whose src has been edited away from the placeholder/cid alone.
|
||||
*/
|
||||
export function replaceInlineImagePlaceholders(
|
||||
html: string,
|
||||
cidToDataUrl: Map<string, string>
|
||||
): string {
|
||||
if (!html || cidToDataUrl.size === 0) return html;
|
||||
if (html.indexOf("data-cid") === -1) return html;
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
||||
let changed = false;
|
||||
doc.querySelectorAll("img[data-cid]").forEach((img) => {
|
||||
const cid = img.getAttribute("data-cid");
|
||||
if (!cid) return;
|
||||
const dataUrl = cidToDataUrl.get(cid);
|
||||
if (!dataUrl) return;
|
||||
const currentSrc = img.getAttribute("src") || "";
|
||||
if (currentSrc !== INLINE_IMAGE_PLACEHOLDER && !/^cid:/i.test(currentSrc)) return;
|
||||
img.setAttribute("src", dataUrl);
|
||||
changed = true;
|
||||
});
|
||||
return changed ? doc.body.innerHTML : html;
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
Reference in New Issue
Block a user