Merge branch 'main' into feature/scheduled-send

# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/layout/sidebar.tsx
#	stores/email-store.ts
#	stores/settings-store.ts
This commit is contained in:
Lucas Gaitzsch
2026-05-22 12:31:06 +02:00
155 changed files with 4702 additions and 869 deletions
+89 -1
View File
@@ -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);
});
});
+1 -1
View File
@@ -120,7 +120,7 @@ describe('impersonationReplayCache', () => {
it('prunes expired jtis on next consume', () => {
const now = Math.floor(Date.now() / 1000);
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
// Far in the future pruning should clear the old entry.
// Far in the future - pruning should clear the old entry.
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
// Re-using the old jti is allowed after pruning (security irrelevant since
// the token would fail signature/exp validation upstream).
+25 -8
View File
@@ -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', () => {