Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc6185002 | ||
|
|
4269d0589c | ||
|
|
2b1b06abd6 | ||
|
|
ac4a89120d | ||
|
|
704a259432 | ||
|
|
1c02970ae1 | ||
|
|
66b2036e37 | ||
|
|
bd2ffab3bc | ||
|
|
7142627cec |
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# 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)
|
## 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.
|
> **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**: Multi-account contacts and a cross-account file picker
|
||||||
- **Pro**: Composer From dropdown grouped by account
|
- **Pro**: Composer From dropdown grouped by account
|
||||||
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
|
- **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**: Allow the setup wizard over plain HTTP with a dismissable warning gate
|
||||||
- **Setup**: Warn when the JMAP URL points at a local-only host
|
- **Setup**: Warn when the JMAP URL points at a local-only host
|
||||||
- **Account**: List and reorder logged-in accounts from settings (#282)
|
- **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**: Load `globals.css` and Geist font in the plugin sandbox iframe
|
||||||
- **Plugins**: Sync plugin slot iframe height with reported content height
|
- **Plugins**: Sync plugin slot iframe height with reported content height
|
||||||
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
|
- **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
|
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
|
||||||
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
|
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
|
||||||
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
|
- **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)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
[](https://grafana.external.bulwarkmail.org/)
|
[](https://grafana.external.bulwarkmail.org/)
|
||||||
|
|
||||||
|
|||||||
@@ -1973,7 +1973,7 @@ export default function Home() {
|
|||||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||||
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
const shouldHideViewerPane = !isMobile && !hasViewerContent && (isEmbedded || isFocusedMailLayout);
|
||||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||||
|
|
||||||
// Handle email selection with mobile view switching
|
// Handle email selection with mobile view switching
|
||||||
@@ -2596,7 +2596,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email list resize handle (desktop only) */}
|
{/* Email list resize handle (desktop only) */}
|
||||||
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
|
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && (
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ const TEXT_FIELDS = [
|
|||||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
{ 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() {
|
export function BrandingTab() {
|
||||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
@@ -262,6 +276,142 @@ export function BrandingTab() {
|
|||||||
</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">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="border border-border rounded-lg">
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
<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 */
|
/** Slots that correspond to branding config keys */
|
||||||
const VALID_SLOTS = new Set([
|
const VALID_SLOTS = new Set([
|
||||||
'faviconUrl',
|
'faviconUrl',
|
||||||
|
'pwaIconUrl',
|
||||||
'appLogoLightUrl',
|
'appLogoLightUrl',
|
||||||
'appLogoDarkUrl',
|
'appLogoDarkUrl',
|
||||||
'loginLogoLightUrl',
|
'loginLogoLightUrl',
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import sharp from 'sharp';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
|
|
||||||
const VALID_SIZES = new Set([192, 512]);
|
const VALID_SIZES = new Set([192, 512]);
|
||||||
|
|
||||||
// Cache resized images in memory to avoid reprocessing on every request
|
// Cache resized images keyed by (size, source URL) so admin re-uploads or URL
|
||||||
const cache = new Map<number, Blob>();
|
// changes invalidate the prior render instead of serving stale bytes forever.
|
||||||
|
const cache = new Map<string, Blob>();
|
||||||
|
|
||||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||||
// Absolute URL (http/https)
|
// Absolute URL (http/https)
|
||||||
@@ -17,6 +19,14 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
|||||||
return Buffer.from(await res.arrayBuffer());
|
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
|
// Path relative to public/ directory
|
||||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||||
return readFile(publicPath);
|
return readFile(publicPath);
|
||||||
@@ -47,9 +57,11 @@ export async function GET(
|
|||||||
'Cache-Control': 'public, max-age=86400',
|
'Cache-Control': 'public, max-age=86400',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cacheKey = `${size}|${iconUrl}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (cache.has(size)) {
|
if (cache.has(cacheKey)) {
|
||||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||||
@@ -61,7 +73,7 @@ export async function GET(
|
|||||||
const ab = new ArrayBuffer(resized.byteLength);
|
const ab = new ArrayBuffer(resized.byteLength);
|
||||||
new Uint8Array(ab).set(resized);
|
new Uint8Array(ab).set(resized);
|
||||||
const blob = new Blob([ab], { type: 'image/png' });
|
const blob = new Blob([ab], { type: 'image/png' });
|
||||||
cache.set(size, blob);
|
cache.set(cacheKey, blob);
|
||||||
|
|
||||||
return new NextResponse(blob, { headers: pngHeaders });
|
return new NextResponse(blob, { headers: pngHeaders });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ import type { EmailTemplate } from "@/lib/template-types";
|
|||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
|
import {
|
||||||
|
rewriteCidImagesForEditor,
|
||||||
|
replaceInlineImagePlaceholders,
|
||||||
|
} from "@/lib/email-composer-utils";
|
||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
|
|
||||||
@@ -300,7 +304,8 @@ export function EmailComposer({
|
|||||||
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
const wrap = replyTo.quoteWrapInBlockquote !== false;
|
const wrap = replyTo.quoteWrapInBlockquote !== false;
|
||||||
const originalHtml = replyTo.htmlBody
|
const originalHtml = replyTo.htmlBody
|
||||||
?? (replyTo.body
|
? rewriteCidImagesForEditor(replyTo.htmlBody)
|
||||||
|
: (replyTo.body
|
||||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||||
: '');
|
: '');
|
||||||
const bodyHtml = wrap
|
const bodyHtml = wrap
|
||||||
@@ -314,7 +319,10 @@ export function EmailComposer({
|
|||||||
const quoteHeader = mode === 'forward'
|
const quoteHeader = mode === 'forward'
|
||||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||||
: `On ${date}, ${fromStr} wrote:<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) {
|
if (replyTo.body) {
|
||||||
@@ -534,6 +542,76 @@ export function EmailComposer({
|
|||||||
selectedIdentityId,
|
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
|
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||||
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||||
: signatureIdentity?.textSignature
|
: signatureIdentity?.textSignature
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import { EmailIdentityBadge } from "./email-identity-badge";
|
|||||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||||
import { useTour } from "@/components/tour/tour-provider";
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||||
import { RecipientPopover } from "./recipient-popover";
|
import { RecipientPopover } from "./recipient-popover";
|
||||||
@@ -922,6 +923,7 @@ export function EmailViewer({
|
|||||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||||
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||||
@@ -3253,19 +3255,21 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
return (
|
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={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">
|
{!isEmbedded && (
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
<div className="text-center p-8">
|
||||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,17 @@ export const ResizableImage = Node.create({
|
|||||||
width: { default: null },
|
width: { default: null },
|
||||||
cid: {
|
cid: {
|
||||||
default: null,
|
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 } : {}),
|
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,7 +78,10 @@ export function FilterRuleModal({
|
|||||||
const pathMap = new Map<string, string>();
|
const pathMap = new Map<string, string>();
|
||||||
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
|
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
|
||||||
for (const node of nodes) {
|
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);
|
pathMap.set(node.id, fullPath);
|
||||||
if (node.children.length > 0) buildPaths(node.children, 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;
|
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 {
|
interface UseMailboxDropOptions {
|
||||||
mailbox: Mailbox;
|
mailbox: Mailbox;
|
||||||
onDropComplete?: () => void;
|
onDropComplete?: () => void;
|
||||||
@@ -123,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
// Group dragged emails by source account. In single-account flows this
|
// Group dragged emails by source account. In single-account flows this
|
||||||
// collapses to one bucket; in unified view or the Pro multi-account
|
// collapses to one bucket; in unified view or the Pro multi-account
|
||||||
// sidebar a single drag can mix sources.
|
// 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 idToEmail = new Map(draggedEmails.map((em) => [em.id, em]));
|
||||||
const bySource = new Map<string, string[]>();
|
const bySource = new Map<string, string[]>();
|
||||||
for (const id of emailIds) {
|
for (const id of emailIds) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("plainTextToComposerBody", () => {
|
||||||
it("returns an empty string for empty input", () => {
|
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 pathMap = new Map<string, string>();
|
||||||
const walk = (nodes: MailboxNode[], parentPath = '') => {
|
const walk = (nodes: MailboxNode[], parentPath = '') => {
|
||||||
for (const node of nodes) {
|
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);
|
pathMap.set(node.id, fullPath);
|
||||||
if (node.children.length > 0) walk(node.children, 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 mailboxes = [makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' })];
|
||||||
const tree = buildMailboxTree(mailboxes);
|
const tree = buildMailboxTree(mailboxes);
|
||||||
const paths = buildMailboxPathMap(tree);
|
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', () => {
|
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 tree = buildMailboxTree(mailboxes);
|
||||||
const paths = buildMailboxPathMap(tree);
|
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', () => {
|
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 tree = buildMailboxTree(mailboxes);
|
||||||
const paths = buildMailboxPathMap(tree);
|
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', () => {
|
it('should handle multiple root-level folders', () => {
|
||||||
@@ -79,7 +80,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
|||||||
];
|
];
|
||||||
const tree = buildMailboxTree(mailboxes);
|
const tree = buildMailboxTree(mailboxes);
|
||||||
const paths = buildMailboxPathMap(tree);
|
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('archive')).toBe('Archive');
|
||||||
expect(paths.get('sub1')).toBe('Archive/Work');
|
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.has(node.id)).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(paths.get('inbox')).toBe('Inbox');
|
expect(paths.get('inbox')).toBe('INBOX');
|
||||||
expect(paths.get('sub1')).toBe('Inbox/Projects');
|
expect(paths.get('sub1')).toBe('INBOX/Projects');
|
||||||
expect(paths.get('sub2')).toBe('Inbox/Projects/Active');
|
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', () => {
|
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>`)
|
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
|
||||||
.join("");
|
.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",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
Reference in New Issue
Block a user