Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts: # app/api/dev-jmap/[...path]/route.ts
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildForwardAsAttachmentPayload } from '@/lib/forward-as-attachment';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// Pin TZ so the local-time date rendering in the filename test is deterministic,
|
||||
// restoring it after so this doesn't leak into other test files in the same worker.
|
||||
let originalTZ: string | undefined;
|
||||
beforeAll(() => {
|
||||
originalTZ = process.env.TZ;
|
||||
process.env.TZ = 'UTC';
|
||||
});
|
||||
afterAll(() => {
|
||||
// process.env coerces to strings, so `= undefined` would leave the literal
|
||||
// string "undefined" behind when TZ was originally unset - delete instead.
|
||||
if (originalTZ === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = originalTZ;
|
||||
});
|
||||
|
||||
function makeEmail(overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: 'e1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
size: 12345,
|
||||
receivedAt: '2026-07-26T22:25:22Z',
|
||||
subject: 'Your waste service day is changing',
|
||||
hasAttachment: false,
|
||||
blobId: 'blob123',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildForwardAsAttachmentPayload', () => {
|
||||
it('returns null when the email has no blobId', () => {
|
||||
const email = makeEmail({ blobId: undefined });
|
||||
expect(buildForwardAsAttachmentPayload(email, 'Fwd:')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefixes the subject using the given forward prefix', () => {
|
||||
const email = makeEmail({ subject: 'Missed spam example' });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.subject).toBe('Fwd: Missed spam example');
|
||||
});
|
||||
|
||||
it('builds a message/rfc822 attachment referencing the email\'s own blobId, not a new upload', () => {
|
||||
const email = makeEmail({ blobId: 'the-real-blob-id', size: 26489 });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.attachment).toEqual({
|
||||
blobId: 'the-real-blob-id',
|
||||
name: expect.stringMatching(/\.eml$/),
|
||||
type: 'message/rfc822',
|
||||
size: 26489,
|
||||
});
|
||||
});
|
||||
|
||||
it('is idempotent - repeated forwarding does not stack prefixes', () => {
|
||||
const email = makeEmail({ subject: 'Fwd: already forwarded once' });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.subject).toBe('Fwd: already forwarded once');
|
||||
});
|
||||
|
||||
it('leaves the subject blank (not just the bare prefix) for a subject-less message, matching normal Forward', () => {
|
||||
const email = makeEmail({ subject: undefined });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.subject).toBe('');
|
||||
});
|
||||
|
||||
it('applies user space/case transforms but ignores a custom filename template, unlike "Export as .eml"', () => {
|
||||
const email = makeEmail({ subject: 'Missed spam example' });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:', {
|
||||
template: 'custom-{subject}',
|
||||
lowercase: true,
|
||||
spaceReplacement: 'dash',
|
||||
});
|
||||
expect(payload?.attachment.name).toBe('2026-07-26-22.25.22-missed-spam-example.eml');
|
||||
});
|
||||
|
||||
it('uses a dash between date and subject by default', () => {
|
||||
const email = makeEmail({ subject: 'Missed spam example' });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.attachment.name).toBe('2026-07-26 22.25.22-Missed spam example.eml');
|
||||
});
|
||||
|
||||
it('never includes from/to in the filename, even with the default template, to avoid leaking names to the recipient', () => {
|
||||
const email = makeEmail({
|
||||
subject: 'Missed spam example',
|
||||
from: [{ name: 'Alice Sender', email: 'alice@example.com' }],
|
||||
to: [{ name: "'Bobby'", email: 'bob@example.com' }],
|
||||
});
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.attachment.name).not.toContain('Alice');
|
||||
expect(payload?.attachment.name).not.toContain('Bobby');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
|
||||
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
|
||||
|
||||
// Work
|
||||
// Clients
|
||||
// Acme
|
||||
// Personal
|
||||
const KEYWORDS: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/personal", "Personal"),
|
||||
];
|
||||
|
||||
describe("formatKeyword with nesting on", () => {
|
||||
it("joins the display name of every level", () => {
|
||||
expect(formatKeyword("work/clients/acme", KEYWORDS, true)).toBe("Work/Clients/Acme");
|
||||
});
|
||||
|
||||
it("returns the plain display name for a tag with one level", () => {
|
||||
expect(formatKeyword("work", KEYWORDS, true)).toBe("Work");
|
||||
});
|
||||
|
||||
it("falls back to the raw level for one this client does not know", () => {
|
||||
expect(formatKeyword("work/archive/2026", KEYWORDS, true)).toBe("Work/archive/2026");
|
||||
expect(formatKeyword("unknown", [], true)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatKeyword with nesting off", () => {
|
||||
it("names a tag by its own label, leaving a slash in the id uninterpreted", () => {
|
||||
// The setting says a slash means nothing, so an id that happens to contain
|
||||
// one - from before it was turned off, or from another client - is a single
|
||||
// opaque token rather than a hierarchy.
|
||||
expect(formatKeyword("work/clients/acme", KEYWORDS, false)).toBe("Acme");
|
||||
expect(formatKeyword("work", KEYWORDS, false)).toBe("Work");
|
||||
});
|
||||
|
||||
it("falls back to the whole id when the tag has no definition", () => {
|
||||
expect(formatKeyword("work/archive/2026", KEYWORDS, false)).toBe("work/archive/2026");
|
||||
});
|
||||
|
||||
it("offers no shortening, leaving the markup to clip", () => {
|
||||
expect(keywordRenderings(formatKeywordLabels("work/clients/acme", KEYWORDS, false)))
|
||||
.toEqual(["Acme"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keywordRenderings", () => {
|
||||
it("shortens by one intermediate level at a time, outermost first", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme", "EU", "Sales"])).toEqual([
|
||||
"Work/Clients/Acme/EU/Sales",
|
||||
"Work/../Acme/EU/Sales",
|
||||
"Work/.../EU/Sales",
|
||||
"Work/.../Sales",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses to a single ... as soon as the run covers more than one level", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme", "Sales"])).toEqual([
|
||||
"Work/Clients/Acme/Sales",
|
||||
"Work/../Acme/Sales",
|
||||
"Work/.../Sales",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses .. for a lone intermediate level, never ...", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme"])).toEqual([
|
||||
"Work/Clients/Acme",
|
||||
"Work/../Acme",
|
||||
]);
|
||||
});
|
||||
|
||||
it("has nothing to shorten without an intermediate level", () => {
|
||||
expect(keywordRenderings(["Work", "Acme"])).toEqual(["Work/Acme"]);
|
||||
expect(keywordRenderings(["Work"])).toEqual(["Work"]);
|
||||
});
|
||||
|
||||
it("drops a rendering that would not come out shorter", () => {
|
||||
// "../" costs as much as the level it replaces, so shortening buys nothing.
|
||||
expect(keywordRenderings(["a", "it", "b"])).toEqual(["a/it/b"]);
|
||||
expect(keywordRenderings(["a", "x", "b"])).toEqual(["a/x/b"]);
|
||||
});
|
||||
});
|
||||
|
||||
// How the components use the two together: resolve a tag to its display names,
|
||||
// then hand the ladder to `useShortenedText` to pick a rung.
|
||||
describe("keywordRenderings over formatKeywordLabels", () => {
|
||||
it("shortens a display name by the same ladder as an id", () => {
|
||||
const deep: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/clients/acme/eu", "Europe"),
|
||||
];
|
||||
|
||||
expect(keywordRenderings(formatKeywordLabels("work/clients/acme/eu", deep, true))).toEqual([
|
||||
"Work/Clients/Acme/Europe",
|
||||
"Work/../Acme/Europe",
|
||||
"Work/.../Europe",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a slash inside one display name as part of that name, not a level", () => {
|
||||
const slashed: KeywordDefinition[] = [kw("work", "Work"), kw("work/acme-r-d", "Acme/R&D")];
|
||||
|
||||
// Two levels, so there is no intermediate level to shorten.
|
||||
expect(keywordRenderings(formatKeywordLabels("work/acme-r-d", slashed, true))).toEqual([
|
||||
"Work/Acme/R&D",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
MAX_KEYWORD_ID_LENGTH,
|
||||
buildKeywordTree,
|
||||
composeKeywordId,
|
||||
countKeywordNodes,
|
||||
filterKeywordTree,
|
||||
getParentKeywordId,
|
||||
hasChildKeywords,
|
||||
isKeywordDescendant,
|
||||
keywordLevels,
|
||||
normalizeKeywordLevel,
|
||||
} from "@/lib/keyword-nesting";
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
|
||||
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
|
||||
|
||||
// Work
|
||||
// Clients
|
||||
// Acme
|
||||
// Personal
|
||||
const KEYWORDS: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/personal", "Personal"),
|
||||
];
|
||||
|
||||
describe("normalizeKeywordLevel", () => {
|
||||
it("lowercases and folds unsupported characters into single dashes", () => {
|
||||
expect(normalizeKeywordLevel("My Custom Tag!")).toBe("my-custom-tag");
|
||||
expect(normalizeKeywordLevel(" Spaced Out ")).toBe("spaced-out");
|
||||
expect(normalizeKeywordLevel("--Trimmed--")).toBe("trimmed");
|
||||
});
|
||||
|
||||
it("treats a slash as part of the name, not as a level", () => {
|
||||
expect(normalizeKeywordLevel("Acme/R&D")).toBe("acme-r-d");
|
||||
});
|
||||
|
||||
it("returns an empty string when nothing usable is left", () => {
|
||||
expect(normalizeKeywordLevel(" ")).toBe("");
|
||||
expect(normalizeKeywordLevel("!!!")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeKeywordId", () => {
|
||||
it("returns a bare slug at the top level", () => {
|
||||
expect(composeKeywordId(null, "Work")).toBe("work");
|
||||
expect(composeKeywordId("", "Work")).toBe("work");
|
||||
});
|
||||
|
||||
it("appends the slug below the parent", () => {
|
||||
expect(composeKeywordId("work/clients", "Acme")).toBe("work/clients/acme");
|
||||
});
|
||||
|
||||
it("never produces a trailing separator for an unusable name", () => {
|
||||
expect(composeKeywordId("work", "!!!")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("keywordLevels", () => {
|
||||
it("splits an id into its levels", () => {
|
||||
expect(keywordLevels("work/clients/acme")).toEqual(["work", "clients", "acme"]);
|
||||
expect(keywordLevels("work")).toEqual(["work"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getParentKeywordId", () => {
|
||||
it("drops the last level", () => {
|
||||
expect(getParentKeywordId("work/clients/acme")).toBe("work/clients");
|
||||
});
|
||||
|
||||
it("returns null for a top-level tag", () => {
|
||||
expect(getParentKeywordId("work")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isKeywordDescendant", () => {
|
||||
it("matches anything below the ancestor", () => {
|
||||
expect(isKeywordDescendant("work/clients/acme", "work")).toBe(true);
|
||||
expect(isKeywordDescendant("work/clients", "work")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the ancestor itself or a shared name prefix", () => {
|
||||
expect(isKeywordDescendant("work", "work")).toBe(false);
|
||||
expect(isKeywordDescendant("workshop/tools", "work")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasChildKeywords", () => {
|
||||
it("reports whether any defined tag sits below the given one", () => {
|
||||
expect(hasChildKeywords("work", KEYWORDS)).toBe(true);
|
||||
expect(hasChildKeywords("work/clients", KEYWORDS)).toBe(true);
|
||||
expect(hasChildKeywords("work/clients/acme", KEYWORDS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAX_KEYWORD_ID_LENGTH", () => {
|
||||
it("leaves room for the `$label:` prefix within the 255-character keyword limit", () => {
|
||||
expect(MAX_KEYWORD_ID_LENGTH).toBe(248);
|
||||
expect("$label:".length + MAX_KEYWORD_ID_LENGTH).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildKeywordTree", () => {
|
||||
it("nests each tag under its parent and records the depth", () => {
|
||||
const [work] = buildKeywordTree(KEYWORDS);
|
||||
|
||||
expect(work.id).toBe("work");
|
||||
expect(work.depth).toBe(0);
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/clients", "work/personal"]);
|
||||
|
||||
const clients = work.children[0];
|
||||
expect(clients.depth).toBe(1);
|
||||
expect(clients.children.map((c) => c.id)).toEqual(["work/clients/acme"]);
|
||||
expect(clients.children[0].depth).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the manual order within a level", () => {
|
||||
const reordered = [KEYWORDS[0], KEYWORDS[3], KEYWORDS[1], KEYWORDS[2]];
|
||||
const [work] = buildKeywordTree(reordered);
|
||||
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/personal", "work/clients"]);
|
||||
});
|
||||
|
||||
it("keeps a tag whose parent is not defined at the root", () => {
|
||||
const orphan = buildKeywordTree([kw("work/clients/acme", "Acme")]);
|
||||
|
||||
expect(orphan).toHaveLength(1);
|
||||
expect(orphan[0].id).toBe("work/clients/acme");
|
||||
expect(orphan[0].depth).toBe(0);
|
||||
});
|
||||
|
||||
it("returns every tag as a root when no id describes a hierarchy", () => {
|
||||
const flat = buildKeywordTree([kw("red", "Red"), kw("blue", "Blue")]);
|
||||
|
||||
expect(flat.map((n) => n.id)).toEqual(["red", "blue"]);
|
||||
expect(flat.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterKeywordTree", () => {
|
||||
const tree = buildKeywordTree(KEYWORDS);
|
||||
|
||||
it("drops the nodes the predicate rejects", () => {
|
||||
const kept = filterKeywordTree(tree, (node) => node.id !== "work/personal");
|
||||
const [work] = kept;
|
||||
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
|
||||
});
|
||||
|
||||
it("keeps a rejected node when a descendant survives, so nothing is stranded", () => {
|
||||
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
|
||||
const [work] = kept;
|
||||
|
||||
expect(work.id).toBe("work");
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
|
||||
expect(work.children[0].children.map((c) => c.id)).toEqual(["work/clients/acme"]);
|
||||
});
|
||||
|
||||
it("keeps the depth of a surviving node so its indentation does not shift", () => {
|
||||
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
|
||||
|
||||
expect(kept[0].children[0].children[0].depth).toBe(2);
|
||||
});
|
||||
|
||||
it("returns nothing when the predicate rejects everything", () => {
|
||||
expect(filterKeywordTree(tree, () => false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the original tree untouched", () => {
|
||||
filterKeywordTree(tree, (node) => node.id === "work");
|
||||
|
||||
expect(countKeywordNodes(tree)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countKeywordNodes", () => {
|
||||
it("counts every level, not just the roots", () => {
|
||||
expect(countKeywordNodes(buildKeywordTree(KEYWORDS))).toBe(4);
|
||||
expect(countKeywordNodes([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
sortThreadGroups,
|
||||
getThreadParticipants,
|
||||
mergeThreadEmails,
|
||||
getEmailColorTag,
|
||||
getThreadColorTag,
|
||||
getEmailTagId,
|
||||
getEmailTagIds,
|
||||
getThreadTagId,
|
||||
getThreadTagIds,
|
||||
} from '../thread-utils';
|
||||
import type { Email, ThreadGroup } from '../jmap/types';
|
||||
|
||||
@@ -245,47 +247,71 @@ describe('mergeThreadEmails', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailColorTag', () => {
|
||||
it('returns label from $label: keyword', () => {
|
||||
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red');
|
||||
describe('getEmailTagIds', () => {
|
||||
it('gathers every tag set on the message', () => {
|
||||
expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true }))
|
||||
.toEqual(['red', 'work']);
|
||||
});
|
||||
|
||||
it('returns label from legacy $color: keyword', () => {
|
||||
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
|
||||
it('reads the legacy prefix alongside the current one', () => {
|
||||
expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']);
|
||||
});
|
||||
|
||||
it('returns null when no color keyword', () => {
|
||||
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined keywords', () => {
|
||||
expect(getEmailColorTag(undefined)).toBeNull();
|
||||
it('reports a tag written under both prefixes once', () => {
|
||||
expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']);
|
||||
});
|
||||
|
||||
it('ignores keywords set to false', () => {
|
||||
expect(getEmailColorTag({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
||||
expect(getEmailTagIds({ '$label:red': false, '$label:work': true })).toEqual(['work']);
|
||||
});
|
||||
|
||||
it('prefers $label: over $color: when both exist', () => {
|
||||
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||
});
|
||||
|
||||
it('handles custom keyword ids', () => {
|
||||
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||
});
|
||||
|
||||
it('returns null for empty keywords object', () => {
|
||||
expect(getEmailColorTag({})).toBeNull();
|
||||
it('is empty for an untagged message or none at all', () => {
|
||||
expect(getEmailTagIds({ $seen: true })).toEqual([]);
|
||||
expect(getEmailTagIds(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadColorTag', () => {
|
||||
describe('getEmailTagId', () => {
|
||||
it('returns label from $label: keyword', () => {
|
||||
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
|
||||
});
|
||||
|
||||
it('returns label from legacy $color: keyword', () => {
|
||||
expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red');
|
||||
});
|
||||
|
||||
it('returns null when no color keyword', () => {
|
||||
expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined keywords', () => {
|
||||
expect(getEmailTagId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores keywords set to false', () => {
|
||||
expect(getEmailTagId({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers $label: over $color: when both exist', () => {
|
||||
expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||
});
|
||||
|
||||
it('handles custom keyword ids', () => {
|
||||
expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||
});
|
||||
|
||||
it('returns null for empty keywords object', () => {
|
||||
expect(getEmailTagId({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadTagId', () => {
|
||||
it('returns first color found across thread emails', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('blue');
|
||||
expect(getThreadTagId(emails)).toBe('blue');
|
||||
});
|
||||
|
||||
it('returns null when no emails have color tags', () => {
|
||||
@@ -293,7 +319,7 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBeNull();
|
||||
expect(getThreadTagId(emails)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns first tag from earliest tagged email', () => {
|
||||
@@ -301,7 +327,7 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('red');
|
||||
expect(getThreadTagId(emails)).toBe('red');
|
||||
});
|
||||
|
||||
it('returns legacy tag from thread emails', () => {
|
||||
@@ -309,10 +335,41 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$color:green': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('green');
|
||||
expect(getThreadTagId(emails)).toBe('green');
|
||||
});
|
||||
|
||||
it('returns null for empty email array', () => {
|
||||
expect(getThreadColorTag([])).toBeNull();
|
||||
expect(getThreadTagId([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadTagIds', () => {
|
||||
it('gathers the tags of every message in the thread', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']);
|
||||
});
|
||||
|
||||
it('reports a tag shared by several messages once', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails)).toEqual(['red']);
|
||||
});
|
||||
|
||||
it('reads the legacy prefix alongside the current one', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$color:green': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']);
|
||||
});
|
||||
|
||||
it('is empty for an untagged or empty thread', () => {
|
||||
expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]);
|
||||
expect(getThreadTagIds([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { emailExportFilename, type EmailFilenameOptions } from "@/lib/download-filename";
|
||||
|
||||
export interface ForwardAsAttachmentEntry {
|
||||
blobId: string;
|
||||
name: string;
|
||||
type: "message/rfc822";
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ForwardAsAttachmentPayload {
|
||||
subject: string;
|
||||
attachment: ForwardAsAttachmentEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the subject and synthetic attachment entry for forwarding a
|
||||
* message as a message/rfc822 attachment instead of inline-quoted text
|
||||
* (e.g. reporting spam to an upstream gateway that expects the raw
|
||||
* original as an attachment, or preserving exact formatting/headers).
|
||||
*
|
||||
* Referenced by blobId, not re-uploaded - JMAP blobs are account-scoped,
|
||||
* not per-email, so the same blobId a message already has can be attached
|
||||
* to a brand new outgoing email directly.
|
||||
*
|
||||
* `filenameOptions`, when passed, carries the user's configured space/case/
|
||||
* diacritics transforms (see useSettingsStore's filenameSpaceReplacement
|
||||
* and friends) for consistency with "Export as .eml" / drag-out. Its
|
||||
* `template`, if any, is ignored: this attachment goes out to a possibly
|
||||
* external recipient (spam gateway, another person), so the filename is
|
||||
* always just "{date}-{subject}.eml" - never the user's own from/to naming
|
||||
* template, which could otherwise leak sender/recipient names into an
|
||||
* attachment filename visible to that recipient.
|
||||
*
|
||||
* Returns null when the email has no blobId (nothing to reference).
|
||||
*/
|
||||
export function buildForwardAsAttachmentPayload(
|
||||
email: Email,
|
||||
forwardPrefix: string,
|
||||
filenameOptions?: EmailFilenameOptions,
|
||||
): ForwardAsAttachmentPayload | null {
|
||||
if (!email.blobId) return null;
|
||||
|
||||
return {
|
||||
// Match the normal Forward flow's getInitialSubject(), which leaves the
|
||||
// subject blank rather than prefix-only when the original has none -
|
||||
// buildForwardSubject("", prefix) would otherwise return just the bare
|
||||
// prefix (e.g. "Fwd:") for a subject-less message.
|
||||
subject: email.subject ? buildForwardSubject(email.subject, forwardPrefix) : "",
|
||||
attachment: {
|
||||
blobId: email.blobId,
|
||||
name: emailExportFilename(email, { ...filenameOptions, template: "{date}-{subject}" }),
|
||||
type: "message/rfc822",
|
||||
size: email.size,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Naming a tag on screen.
|
||||
*
|
||||
* A nested tag is written out level by level - `Work/Clients/Acme` - and a flat
|
||||
* one is simply its own name, so nothing here asks the caller which kind it
|
||||
* has. `keywordRenderings` additionally offers progressively shorter forms for
|
||||
* a name with nowhere to fit, which `useShortenedText` measures against the
|
||||
* room actually available.
|
||||
*/
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
import { KEYWORD_SEPARATOR, keywordLevels } from "./keyword-nesting";
|
||||
|
||||
/** Stands in for one level left out of a name. */
|
||||
export const KEYWORD_SHORTENED_LEVEL = "..";
|
||||
|
||||
/** Stands in for a run of more than one level left out of a name. */
|
||||
export const KEYWORD_SHORTENED_RUN = "...";
|
||||
|
||||
/**
|
||||
* The display name of a tag, one entry per level, outermost first. A tag with
|
||||
* one level yields a single entry, so callers need not care either way.
|
||||
*
|
||||
* `nested` is the user's setting. With nesting off a slash carries no meaning,
|
||||
* so the id is one opaque token and the tag is named by its own label - nobody
|
||||
* who left the setting alone should find their tags rewritten because an id
|
||||
* happens to contain a slash, which can outlast turning nesting off, or arrive
|
||||
* through settings sync or another client.
|
||||
*
|
||||
* With nesting on, each level resolves to that tag's display name, falling back
|
||||
* to the raw level of the id when it has no definition - the settings list only
|
||||
* describes the tags this client knows about. Levels stay separate entries
|
||||
* because a display name may itself contain a slash, which is part of that one
|
||||
* name rather than a level of its own.
|
||||
*/
|
||||
export function formatKeywordLabels(
|
||||
id: string,
|
||||
keywords: KeywordDefinition[],
|
||||
nested: boolean,
|
||||
): string[] {
|
||||
const label = (levelId: string) => keywords.find((keyword) => keyword.id === levelId)?.label;
|
||||
if (!nested) return [label(id) ?? id];
|
||||
|
||||
const levels = keywordLevels(id);
|
||||
return levels.map((level, index) =>
|
||||
label(levels.slice(0, index + 1).join(KEYWORD_SEPARATOR)) ?? level,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The display name of a tag: `Work/Clients/Acme` for a nested one, its own name
|
||||
* otherwise. The general way to name a tag on screen.
|
||||
*/
|
||||
export function formatKeyword(
|
||||
id: string,
|
||||
keywords: KeywordDefinition[],
|
||||
nested: boolean,
|
||||
): string {
|
||||
return formatKeywordLabels(id, keywords, nested).join(KEYWORD_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way a name can be written, longest first: in full, then with an ever
|
||||
* longer run of intermediate levels replaced by `..`, collapsing to a single
|
||||
* `...` as soon as that run covers more than one level.
|
||||
*
|
||||
* The outermost and innermost levels always survive - between them they say
|
||||
* which branch a tag belongs to and which tag it is, which is exactly what a
|
||||
* trailing ellipsis destroys. A rendering that would not actually come out
|
||||
* shorter than the one before it (levels named `it`, say) is dropped, so
|
||||
* walking the list never makes the text grow.
|
||||
*/
|
||||
export function keywordRenderings(levels: string[]): string[] {
|
||||
const renderings = [levels.join(KEYWORD_SEPARATOR)];
|
||||
for (let shortened = 1; shortened <= levels.length - 2; shortened++) {
|
||||
const marker = shortened === 1 ? KEYWORD_SHORTENED_LEVEL : KEYWORD_SHORTENED_RUN;
|
||||
const rendering = [levels[0], marker, ...levels.slice(shortened + 1)]
|
||||
.join(KEYWORD_SEPARATOR);
|
||||
if (rendering.length < renderings[renderings.length - 1].length) {
|
||||
renderings.push(rendering);
|
||||
}
|
||||
}
|
||||
return renderings;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tag nesting.
|
||||
*
|
||||
* A tag is stored on the server as the JMAP keyword `$label:<id>`, where `id`
|
||||
* is a slug derived from the display name. Nesting reuses that single id: the
|
||||
* levels are joined with a forward slash, so `$label:work/clients` is the child
|
||||
* of `$label:work`. Keeping the hierarchy inside the id means the server stays
|
||||
* the source of truth for tag membership and existing lookups by keyword keep
|
||||
* working.
|
||||
*
|
||||
* RFC 8621 section 4.1.1 allows a keyword of 1-255 characters from the ASCII
|
||||
* range %x21-%x7e minus `( ) { ] % * " \`, so the separator is legal but the
|
||||
* length of a deep id is not free - `MAX_KEYWORD_ID_LENGTH` is the budget a
|
||||
* composed id has to stay within.
|
||||
*
|
||||
* Turning any of this into text for the screen lives in `keyword-format`.
|
||||
*/
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
import { KEYWORD_PREFIX } from "./thread-utils";
|
||||
|
||||
/** Separates parent from child inside a tag id. */
|
||||
export const KEYWORD_SEPARATOR = "/";
|
||||
|
||||
/** Longest keyword a JMAP server has to accept (RFC 8621, section 4.1.1). */
|
||||
export const MAX_KEYWORD_LENGTH = 255;
|
||||
|
||||
/** What is left for the id once the `$label:` prefix is spent. */
|
||||
export const MAX_KEYWORD_ID_LENGTH = MAX_KEYWORD_LENGTH - KEYWORD_PREFIX.length;
|
||||
|
||||
/** A tag definition placed in the hierarchy its id describes. */
|
||||
export interface KeywordNode extends KeywordDefinition {
|
||||
children: KeywordNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a display name to one level of an id: lowercase, and everything
|
||||
* outside `[a-z0-9_-]` folded to a single dash. The separator is not exempt -
|
||||
* a slash typed into the name is a literal part of that name, not a level.
|
||||
* The only slug function for tag ids; keep it the only one.
|
||||
*/
|
||||
export function normalizeKeywordLevel(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/** Builds the id a tag named `name` gets under `parentId` (null = top level). */
|
||||
export function composeKeywordId(parentId: string | null, name: string): string {
|
||||
const level = normalizeKeywordLevel(name);
|
||||
if (!parentId || !level) return level;
|
||||
return `${parentId}${KEYWORD_SEPARATOR}${level}`;
|
||||
}
|
||||
|
||||
/** Splits `work/clients/acme` into `["work", "clients", "acme"]`. */
|
||||
export function keywordLevels(id: string): string[] {
|
||||
return id.split(KEYWORD_SEPARATOR).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The id of the tag one level up, or null for a top-level tag. */
|
||||
export function getParentKeywordId(id: string): string | null {
|
||||
const index = id.lastIndexOf(KEYWORD_SEPARATOR);
|
||||
return index === -1 ? null : id.slice(0, index);
|
||||
}
|
||||
|
||||
/** True when `candidateId` sits anywhere below `ancestorId`. */
|
||||
export function isKeywordDescendant(candidateId: string, ancestorId: string): boolean {
|
||||
return candidateId.startsWith(`${ancestorId}${KEYWORD_SEPARATOR}`);
|
||||
}
|
||||
|
||||
/** True when any defined tag sits below `id`. */
|
||||
export function hasChildKeywords(id: string, keywords: KeywordDefinition[]): boolean {
|
||||
return keywords.some((keyword) => isKeywordDescendant(keyword.id, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Arranges tag definitions into the tree their ids describe, preserving the
|
||||
* user's manual order within each level.
|
||||
*
|
||||
* A tag whose direct parent is not defined stays at the root rather than being
|
||||
* hidden or grafted onto a grandparent; callers name such a root in full so the
|
||||
* missing level is still visible.
|
||||
*/
|
||||
export function buildKeywordTree(keywords: KeywordDefinition[]): KeywordNode[] {
|
||||
const nodes = new Map<string, KeywordNode>();
|
||||
for (const keyword of keywords) {
|
||||
nodes.set(keyword.id, { ...keyword, children: [], depth: 0 });
|
||||
}
|
||||
|
||||
const roots: KeywordNode[] = [];
|
||||
for (const keyword of keywords) {
|
||||
const node = nodes.get(keyword.id)!;
|
||||
const parentId = getParentKeywordId(keyword.id);
|
||||
const parent = parentId ? nodes.get(parentId) : undefined;
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const setDepth = (node: KeywordNode, depth: number) => {
|
||||
node.depth = depth;
|
||||
node.children.forEach((child) => setDepth(child, depth + 1));
|
||||
};
|
||||
roots.forEach((root) => setDepth(root, 0));
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prunes a tag tree down to the nodes worth showing.
|
||||
*
|
||||
* A node survives when the predicate accepts it or when any of its descendants
|
||||
* survives, so hiding a parent never strands the children below it. Depths are
|
||||
* left untouched: a kept node keeps the indentation of its original level even
|
||||
* when the level above it is only there to carry it.
|
||||
*/
|
||||
export function filterKeywordTree(
|
||||
nodes: KeywordNode[],
|
||||
isVisible: (node: KeywordNode) => boolean,
|
||||
): KeywordNode[] {
|
||||
const kept: KeywordNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const children = filterKeywordTree(node.children, isVisible);
|
||||
if (children.length > 0 || isVisible(node)) {
|
||||
kept.push({ ...node, children });
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Total number of nodes in a tag tree, at every level. */
|
||||
export function countKeywordNodes(nodes: KeywordNode[]): number {
|
||||
return nodes.reduce((total, node) => total + 1 + countKeywordNodes(node.children), 0);
|
||||
}
|
||||
@@ -430,21 +430,20 @@ async function doContactCreate(contact: ContactCard): Promise<ContactCard> {
|
||||
|
||||
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
|
||||
|
||||
// This salt acts as a constant context identifier for key derivation.
|
||||
// While hardcoded, security is maintained because the WebAuthn PRF extension
|
||||
// mixes this salt with the device's unique, hardware-bound private key.
|
||||
// Changing this string will result in a completely different derived secret.
|
||||
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1");
|
||||
|
||||
/**
|
||||
* Retrieves or creates a WebAuthn passkey and extracts its PRF secret.
|
||||
* This secret is typically used as a local master encryption key.
|
||||
*/
|
||||
async function doGetOrCreatePRF(
|
||||
masterCredentialIdBytes: number[] | undefined,
|
||||
pluginId: string,
|
||||
name?: string,
|
||||
displayName?: string
|
||||
displayName?: string,
|
||||
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
|
||||
|
||||
// To avoid a privileged plugin to access secret created from another privileged plugin,
|
||||
// we add the pluginID from manifest in salt.
|
||||
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1" + pluginId)
|
||||
|
||||
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
|
||||
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
|
||||
@@ -456,18 +455,18 @@ async function doGetOrCreatePRF(
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||
allowCredentials: [{ type: "public-key", id: credentialId }],
|
||||
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
|
||||
extensions: { prf: { eval: { first: PRF_SALT } } } as any
|
||||
extensions: { prf: { eval: { first: PRF_SALT } } }
|
||||
}
|
||||
}) as PublicKeyCredential;
|
||||
|
||||
// Extract the derived symmetric key from the authenticator's output
|
||||
const outputs = assertion.getClientExtensionResults();
|
||||
const prfSecret = (outputs as any).prf?.results?.first;
|
||||
const prfSecret = (outputs).prf?.results?.first;
|
||||
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
|
||||
|
||||
return {
|
||||
credentialId: masterCredentialIdBytes,
|
||||
prfSecret: Array.from(new Uint8Array(prfSecret))
|
||||
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -492,14 +491,14 @@ async function doGetOrCreatePRF(
|
||||
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
|
||||
userVerification: "required"
|
||||
},
|
||||
extensions: { prf: {} } as any // Request PRF extension support from the authenticator
|
||||
extensions: { prf: {} } // Request PRF extension support from the authenticator
|
||||
}
|
||||
}) as PublicKeyCredential;
|
||||
|
||||
const outputs = credential.getClientExtensionResults();
|
||||
|
||||
// Ensure the authenticator successfully enabled and supports the PRF extension
|
||||
const isPrfEnabled = (outputs as any).prf?.enabled;
|
||||
const isPrfEnabled = (outputs).prf?.enabled;
|
||||
if (!isPrfEnabled) {
|
||||
return 'The authenticator does not support or has rejected the PRF extension.';
|
||||
}
|
||||
@@ -516,20 +515,20 @@ async function doGetOrCreatePRF(
|
||||
userVerification: "required",
|
||||
extensions: {
|
||||
prf: { eval: { first: PRF_SALT } }
|
||||
} as any
|
||||
}
|
||||
}
|
||||
}) as PublicKeyCredential;
|
||||
|
||||
const assertionOutputs = assertion.getClientExtensionResults();
|
||||
|
||||
const prfSecret = (assertionOutputs as any).prf?.results?.first;
|
||||
const prfSecret = (assertionOutputs).prf?.results?.first;
|
||||
if (!prfSecret) {
|
||||
return 'Cannot get PRF secret from existing credential.';
|
||||
}
|
||||
|
||||
return {
|
||||
credentialId: Array.from(new Uint8Array(credential.rawId)),
|
||||
prfSecret: Array.from(new Uint8Array(prfSecret))
|
||||
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -738,7 +737,7 @@ export async function dispatchApiCall(
|
||||
);
|
||||
case 'upfiles.get' : return getFile(args[0] as string);
|
||||
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
|
||||
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string | undefined, args[2] as string | undefined);
|
||||
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string, args[2] as string | undefined, args[3] as string | undefined);
|
||||
|
||||
case 'contact.get': return doContactGet(args[0] as string);
|
||||
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
|
||||
|
||||
@@ -167,7 +167,7 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
settings: { ...manifest.settings },
|
||||
},
|
||||
webauthn: {
|
||||
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, name, displayName], 0)
|
||||
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, manifest.id, name, displayName], 0)
|
||||
},
|
||||
storage: {
|
||||
get: (key: string) => callApi('storage.get', [key]),
|
||||
|
||||
+30
-12
@@ -168,41 +168,59 @@ export const KEYWORD_PREFIX = "$label:";
|
||||
export const KEYWORD_PREFIX_LEGACY = "$color:";
|
||||
|
||||
/**
|
||||
* Gets all active label/color tag IDs from email keywords.
|
||||
* Gets every tag id set on a message.
|
||||
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||
* A tag written under both spellings is one tag, so it is returned once.
|
||||
*/
|
||||
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
|
||||
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
const tags = new Set<string>();
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
||||
tags.push(
|
||||
tags.add(
|
||||
key.startsWith(KEYWORD_PREFIX)
|
||||
? key.slice(KEYWORD_PREFIX.length)
|
||||
: key.slice(KEYWORD_PREFIX_LEGACY.length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets label/color tag from email keywords (if any).
|
||||
* Gets the first tag id set on a message, if any.
|
||||
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||
* @deprecated Use getEmailColorTags for multi-tag support.
|
||||
* @deprecated Use getEmailTagIds for multi-tag support.
|
||||
*/
|
||||
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
||||
const tags = getEmailColorTags(keywords);
|
||||
export function getEmailTagId(keywords: Record<string, boolean> | undefined): string | null {
|
||||
const tags = getEmailTagIds(keywords);
|
||||
return tags.length > 0 ? tags[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a thread has any color tag (returns first found).
|
||||
* The first tag id found anywhere in a thread, if any.
|
||||
*/
|
||||
export function getThreadColorTag(emails: Email[]): string | null {
|
||||
export function getThreadTagId(emails: Email[]): string | null {
|
||||
for (const email of emails) {
|
||||
const color = getEmailColorTag(email.keywords);
|
||||
const color = getEmailTagId(email.keywords);
|
||||
if (color) return color;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tag anywhere in a thread, deduplicated.
|
||||
*
|
||||
* A collapsed thread row stands in for all its messages, so it has to account
|
||||
* for all their tags - showing only the first message's would hide the rest
|
||||
* with nothing to indicate they exist.
|
||||
*/
|
||||
export function getThreadTagIds(emails: Email[]): string[] {
|
||||
const tags = new Set<string>();
|
||||
for (const email of emails) {
|
||||
for (const tag of getEmailTagIds(email.keywords)) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user