mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
Merge branch 'fx/read' into feat/stalwart-1.0
This commit is contained in:
@@ -1327,6 +1327,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"shared_tags": {
|
||||
"description": "Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.",
|
||||
"empty": "No shared tags yet.",
|
||||
"loading": "Loading…",
|
||||
"title": "Shared tags",
|
||||
"unsupported": "This mail server can't keep shared tags, so this mailbox shows your own tags."
|
||||
},
|
||||
"sharing": {
|
||||
"directory_failed": "Couldn't load the list of users and groups.",
|
||||
"directory_not_browsable": "Your server doesn't let you browse its user directory.",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const METADATA = 'urn:ietf:params:jmap:metadata';
|
||||
|
||||
const { state } = vi.hoisted(() => ({
|
||||
state: {
|
||||
session: null as unknown,
|
||||
requests: [] as Array<{ calls: Array<[string, Record<string, unknown>, string]>; using?: string[] }>,
|
||||
answer: null as null | ((calls: Array<[string, Record<string, unknown>, string]>) => unknown),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
serverUrl: 'https://mail.example.org',
|
||||
get currentSession() {
|
||||
return state.session;
|
||||
},
|
||||
request: vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>, using?: string[]) => {
|
||||
state.requests.push({ calls, using });
|
||||
return state.answer!(calls);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata';
|
||||
|
||||
const VENDOR = { supportsVendorNamespaces: true, supportsPrivate: true, namespaces: [], maxDepth: 8 };
|
||||
|
||||
// Stalwart 1.0.0 lists Metadata per account; 0.16 has none.
|
||||
function session(version: '1.0' | '0.16') {
|
||||
const caps = version === '1.0' ? { [METADATA]: { dataTypes: { Mailbox: VENDOR, Email: VENDOR } } } : {};
|
||||
return {
|
||||
capabilities: version === '1.0' ? { [METADATA]: {} } : {},
|
||||
accounts: {
|
||||
own: { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...caps } },
|
||||
team: { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...caps } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// As Stalwart 1.0.0 answers the query and the get (checked live 2026-10-29).
|
||||
function inboxWith(metadata: unknown) {
|
||||
return () => ({
|
||||
methodResponses: [
|
||||
['Mailbox/query', { accountId: 'team', ids: ['a'] }, 'q'],
|
||||
['Mailbox/get', { accountId: 'team', list: [{ id: 'a', ...(metadata === undefined ? {} : { metadata }) }] }, 'g'],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.requests = [];
|
||||
state.answer = null;
|
||||
resetBulwarkMetadataPauses();
|
||||
});
|
||||
|
||||
describe('getBulwarkMetadata', () => {
|
||||
it('sends nothing to an account that advertises no metadata (Stalwart 0.16)', async () => {
|
||||
state.session = session('0.16');
|
||||
expect(supportsBulwarkMetadata('team')).toBe(false);
|
||||
expect(await getBulwarkMetadata('team')).toBeNull();
|
||||
expect(state.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads the Inbox's Bulwark namespaces in a request of its own", async () => {
|
||||
state.session = session('1.0');
|
||||
state.answer = inboxWith({
|
||||
'org.bulwarkmail.tags': { tags: { urgent: { label: 'Urgent', color: 'red' } } },
|
||||
'com.example.other': { x: 1 },
|
||||
});
|
||||
|
||||
expect(await getBulwarkMetadata('team')).toEqual({ tags: { tags: { urgent: { label: 'Urgent', color: 'red' } } } });
|
||||
expect(state.requests).toHaveLength(1);
|
||||
const [{ calls, using }] = state.requests;
|
||||
expect(using).toContain(METADATA);
|
||||
expect(calls.map(([name]) => name)).toEqual(['Mailbox/query', 'Mailbox/get']);
|
||||
expect(calls[0][1]).toEqual({ accountId: 'team', filter: { role: 'inbox' } });
|
||||
expect(calls[1][1].properties).toEqual(['id', 'metadata']);
|
||||
});
|
||||
|
||||
it('takes an answer without the property for no metadata, and pauses the account', async () => {
|
||||
state.session = session('1.0');
|
||||
state.answer = inboxWith(undefined);
|
||||
expect(await getBulwarkMetadata('team')).toBeNull();
|
||||
expect(supportsBulwarkMetadata('team')).toBe(false);
|
||||
expect(await getBulwarkMetadata('team')).toBeNull();
|
||||
expect(state.requests).toHaveLength(1);
|
||||
// Another account is not paused with it.
|
||||
expect(supportsBulwarkMetadata('own')).toBe(true);
|
||||
});
|
||||
|
||||
it('pauses an account whose request fails (the pre-release build dropped it)', async () => {
|
||||
state.session = session('1.0');
|
||||
state.answer = () => { throw new Error('socket hang up'); };
|
||||
expect(await getBulwarkMetadata('team')).toBeNull();
|
||||
expect(supportsBulwarkMetadata('team')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -239,11 +239,18 @@ describe('JMAP Metadata in the request-building sources', () => {
|
||||
/(['"`])metadata(\/[^'"`\n]*)?\1/,
|
||||
];
|
||||
|
||||
// Stalwart 1.0.0 serves Metadata. The one module that reads it (a group's
|
||||
// shared tags) asks only accounts that advertise it, in a request of its
|
||||
// own; its tests cover that.
|
||||
const METADATA_READER = 'src/api/bulwark-metadata.ts';
|
||||
|
||||
it('never names a JMAP Metadata property, argument or capability in code', () => {
|
||||
const files = [...sourceFiles('src'), 'App.tsx'];
|
||||
expect(files).toContain('src/api/email.ts');
|
||||
expect(files).toContain(METADATA_READER);
|
||||
const hits: string[] = [];
|
||||
for (const file of files) {
|
||||
if (file === METADATA_READER) continue;
|
||||
const code = withoutComments(readFileSync(path.join(root, file), 'utf8'));
|
||||
for (const pattern of FORBIDDEN) {
|
||||
const match = code.match(pattern);
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript,
|
||||
} from '../sieve';
|
||||
import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media';
|
||||
import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata';
|
||||
import { parseSharedTags, SHARED_TAGS_KEY } from '../../lib/shared-tags';
|
||||
import { generateVCard, parseVCard } from '../../lib/vcard';
|
||||
import type { ContactCard } from '../types';
|
||||
|
||||
@@ -352,6 +354,43 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
|
||||
expect(first).toHaveProperty('myRights');
|
||||
}, 60_000);
|
||||
|
||||
// Shared tags (#1310) live in a group's Inbox metadata. The test login
|
||||
// is in no group, so this checks the shape on its own Inbox: written the
|
||||
// way the webmail writes it, read the way the app reads it.
|
||||
it('reads shared tags from Inbox metadata on 1.0 and asks nothing of 0.16', async () => {
|
||||
resetBulwarkMetadataPauses();
|
||||
const accountId = client.accountId;
|
||||
if (!is10()) {
|
||||
expect(supportsBulwarkMetadata(accountId)).toBe(false);
|
||||
expect(await getBulwarkMetadata(accountId)).toBeNull();
|
||||
return;
|
||||
}
|
||||
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:metadata'];
|
||||
const inbox = (await getMailboxes()).find((m) => m.role === 'inbox')!.id;
|
||||
const before = await getBulwarkMetadata(accountId);
|
||||
expect(before).not.toBeNull();
|
||||
const setTags = (value: unknown, path = '') => client.request([
|
||||
['Mailbox/set', { accountId, update: { [inbox]: { [`metadata/org.bulwarkmail.tags${path}`]: value } } }, '0'],
|
||||
], using);
|
||||
try {
|
||||
const written = await setTags({ tags: { [`live-${RUN}`]: { label: 'Live check', color: 'teal-dark', note: 'kept' } } });
|
||||
expect(written.methodResponses[0][1].updated).toHaveProperty(inbox);
|
||||
// One tag patched on its own, as the webmail saves a change.
|
||||
await setTags({ label: 'Second', color: 'blue' }, `/tags/second-${RUN}`);
|
||||
|
||||
resetBulwarkMetadataPauses();
|
||||
const entries = await getBulwarkMetadata(accountId);
|
||||
expect(parseSharedTags(entries?.[SHARED_TAGS_KEY])).toEqual([
|
||||
{ id: `live-${RUN}`, label: 'Live check', color: 'teal' },
|
||||
{ id: `second-${RUN}`, label: 'Second', color: 'blue' },
|
||||
]);
|
||||
} finally {
|
||||
await setTags(before?.[SHARED_TAGS_KEY] ?? null);
|
||||
}
|
||||
const after = await getBulwarkMetadata(accountId);
|
||||
expect(after?.[SHARED_TAGS_KEY]).toEqual(before?.[SHARED_TAGS_KEY]);
|
||||
}, 60_000);
|
||||
|
||||
it('creates, reads, validates and deletes a Sieve script', async () => {
|
||||
const content = `require ["fileinto"];\r\n# ${RUN} */\r\nif header :contains "subject" "${RUN}" { fileinto "INBOX"; }\r\n`;
|
||||
expect((await validateSieveScript(content)).isValid).toBe(true);
|
||||
|
||||
@@ -44,3 +44,28 @@ describe('buildFilter (aggregated views)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFilter: searching from All mail', () => {
|
||||
const all = [...MAILBOXES, mailbox('arch', 'archive'), mailbox('drafts', 'drafts'), mailbox('spam', 'junk')];
|
||||
|
||||
it('searches Sent, Archive and Drafts too, and only leaves Trash and Junk out', () => {
|
||||
expect(buildFilter(all, { view: 'all', query: 'budget' })).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailboxOtherThan: ['bin', 'spam'] }, { text: 'budget' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the All mail folders without a query, and Unread narrows to its list', () => {
|
||||
expect(buildFilter(all, { view: 'all' })).toEqual({
|
||||
operator: 'OR', conditions: [{ inMailbox: 'in' }, { inMailbox: 'projects' }],
|
||||
});
|
||||
expect(buildFilter(all, { view: 'unread', query: 'budget' })).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ operator: 'OR', conditions: [{ inMailbox: 'in' }, { inMailbox: 'projects' }] },
|
||||
{ notKeyword: '$seen' },
|
||||
{ text: 'budget' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Bulwark's own data in JMAP Metadata (RFC draft, urn:ietf:params:jmap:metadata)
|
||||
// of an account's Inbox: the shared tag definitions of a group account
|
||||
// (namespace `org.bulwarkmail.tags`). Port of the reading half of the
|
||||
// webmail's lib/jmap/bulwark-metadata.ts and client.getBulwarkMetadata.
|
||||
//
|
||||
// Stalwart 1.0.0 serves Metadata; 0.16 advertises none, and a pre-release
|
||||
// 1.0 build advertised it but dropped the connection on a Mailbox/get that
|
||||
// selects it (42751a6b). So this is the only module that asks for it: only
|
||||
// for an account whose capabilities offer vendor namespaces on mailboxes,
|
||||
// in a request of its own, and any failure pauses that account for ten
|
||||
// minutes. Every other Mailbox/get keeps its explicit property list.
|
||||
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
|
||||
export const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
|
||||
export const BULWARK_NAMESPACE = 'org.bulwarkmail';
|
||||
|
||||
const PAUSE_AFTER_FAILURE_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* Whether the account's capabilities offer vendor-namespaced metadata on
|
||||
* mailboxes. Says nothing about whether the server really implements it.
|
||||
*/
|
||||
export function advertisesMailboxMetadata(accountCapabilities: Record<string, unknown> | undefined): boolean {
|
||||
const cap = accountCapabilities?.[METADATA_CAPABILITY] as
|
||||
| { dataTypes?: Record<string, { supportsVendorNamespaces?: boolean }> }
|
||||
| undefined;
|
||||
return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true;
|
||||
}
|
||||
|
||||
/** Bulwark's entries of a `metadata` object, by key (`tags`, `template.<id>`). */
|
||||
export function bulwarkEntries(metadata: unknown): Record<string, unknown> {
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {};
|
||||
const prefix = `${BULWARK_NAMESPACE}.`;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [namespace, value] of Object.entries(metadata as Record<string, unknown>)) {
|
||||
if (namespace.startsWith(prefix)) out[namespace.slice(prefix.length)] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Accounts whose last read failed, by server and account, with when.
|
||||
const failedAt = new Map<string, number>();
|
||||
const pauseKey = (accountId: string) => `${jmapClient.serverUrl ?? ''}|${accountId}`;
|
||||
|
||||
/** Whether the app may ask this account for its Inbox metadata now. */
|
||||
export function supportsBulwarkMetadata(accountId: string): boolean {
|
||||
const failed = failedAt.get(pauseKey(accountId));
|
||||
if (failed !== undefined && Date.now() - failed < PAUSE_AFTER_FAILURE_MS) return false;
|
||||
const account = jmapClient.currentSession?.accounts?.[accountId] as
|
||||
| { accountCapabilities?: Record<string, unknown> }
|
||||
| undefined;
|
||||
return advertisesMailboxMetadata(account?.accountCapabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulwark's shared metadata entries of the account's Inbox, by key. Null
|
||||
* when the account has none to offer (0.16) or the read failed; no request
|
||||
* goes out for an account that doesn't advertise Metadata.
|
||||
*/
|
||||
export async function getBulwarkMetadata(accountId: string): Promise<Record<string, unknown> | null> {
|
||||
if (!supportsBulwarkMetadata(accountId)) return null;
|
||||
const property = 'metadata';
|
||||
try {
|
||||
const res = await jmapClient.request([
|
||||
['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q'],
|
||||
['Mailbox/get', {
|
||||
accountId,
|
||||
'#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' },
|
||||
properties: ['id', property],
|
||||
}, 'g'],
|
||||
], [CAPABILITIES.CORE, CAPABILITIES.MAIL, METADATA_CAPABILITY]);
|
||||
const get = res.methodResponses?.find((r) => r[0] === 'Mailbox/get');
|
||||
const inbox = (get?.[1] as { list?: Array<Record<string, unknown>> } | undefined)?.list?.[0];
|
||||
// An answer without the property is a server that doesn't keep it,
|
||||
// never "every entry was removed".
|
||||
if (!inbox || !(property in inbox)) throw new Error('No Inbox metadata in the answer');
|
||||
failedAt.delete(pauseKey(accountId));
|
||||
return bulwarkEntries(inbox[property]);
|
||||
} catch (err) {
|
||||
console.warn('[bulwark-metadata] unavailable for account', accountId, err);
|
||||
failedAt.set(pauseKey(accountId), Date.now());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the failure pauses (sign-out, tests). */
|
||||
export function resetBulwarkMetadataPauses(): void {
|
||||
failedAt.clear();
|
||||
}
|
||||
@@ -304,7 +304,15 @@ export function buildFilter(
|
||||
opts: UnifiedFetchOptions,
|
||||
): Record<string, unknown> | null {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
if (opts.view) {
|
||||
const q = opts.query?.trim();
|
||||
if (opts.view === 'all' && q) {
|
||||
// Searching from All mail searches every folder but Trash and Junk, the
|
||||
// default search scope: the All mail list leaves Sent, Archive and
|
||||
// Drafts out, and narrowing the search to it hid every sent reply
|
||||
// (webmail lib/unified-mailbox.ts searchCrossViewEmails).
|
||||
const skipped = mailboxes.filter((m) => m.role === 'trash' || m.role === 'junk' || m.role === 'spam');
|
||||
if (skipped.length > 0) conditions.push({ inMailboxOtherThan: skipped.map((m) => m.id) });
|
||||
} else if (opts.view) {
|
||||
const included = crossIncludedMailboxes(mailboxes);
|
||||
if (included.length === 0) return null;
|
||||
conditions.push(
|
||||
@@ -321,7 +329,6 @@ export function buildFilter(
|
||||
}
|
||||
// One condition per quoted phrase or `word*` (see splitSearchText), in the
|
||||
// same AND as the folder conditions.
|
||||
const q = opts.query?.trim();
|
||||
if (q) conditions.push(...textConditions(q));
|
||||
return conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions };
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Image as ImageIcon, ShieldCheck } from './icons';
|
||||
import type { Email } from '../api/types';
|
||||
import { extractCidRefs } from '../lib/email-html';
|
||||
import { extractCidRefs, isUserLinkNavigation } from '../lib/email-html';
|
||||
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
|
||||
import { buildQuoteCollapseScript } from '../lib/quote-collapse';
|
||||
import { bodyDocument } from '../lib/email-body-document';
|
||||
@@ -13,6 +13,7 @@ import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/b
|
||||
import { parseMailtoUrl } from '../lib/unsubscribe';
|
||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||
import { senderVerificationOf } from '../lib/email-headers';
|
||||
import { useHasContacts } from '../lib/capabilities';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
@@ -563,7 +564,15 @@ export default function EmailBodyView({
|
||||
}, [email, bodyOverride]);
|
||||
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||||
const text = picked.text;
|
||||
const trusted = isSenderContentTrusted(senderEmail, {
|
||||
// A From address the server's checks don't back may be forged: neither
|
||||
// honour an earlier "Always trust" for it nor offer one, or a forgery of a
|
||||
// trusted sender would load remote content (and trusting it would let the
|
||||
// next forgery do the same).
|
||||
const unverifiedSender = React.useMemo(
|
||||
() => senderVerificationOf(email) !== null,
|
||||
[email.headers, email.from],
|
||||
);
|
||||
const trusted = !unverifiedSender && isSenderContentTrusted(senderEmail, {
|
||||
isLocallyTrusted: isSenderTrusted,
|
||||
syncEnabled: syncTrustedSenders,
|
||||
trustedBookEmails: trustedSenderEmails,
|
||||
@@ -768,7 +777,7 @@ export default function EmailBodyView({
|
||||
<Text style={styles.bannerButtonText}>{t('email_viewer.load_external_content', 'Load images')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{senderEmail ? (
|
||||
{senderEmail && !unverifiedSender ? (
|
||||
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
|
||||
<ShieldCheck size={14} color={c.textSecondary} />
|
||||
<Text style={styles.bannerButtonText}>{t('email_viewer.trust_sender', 'Always trust this sender')}</Text>
|
||||
@@ -834,6 +843,11 @@ export default function EmailBodyView({
|
||||
// any other) navigation would replace the email body with no way
|
||||
// back, so it is refused; images are reachable via the chips.
|
||||
if (!url || url === 'about:blank') return true;
|
||||
// A message opens links only when the user taps one. The page
|
||||
// runs no script of its own, so nothing else should navigate;
|
||||
// iOS says how a navigation started, so anything but a tap is
|
||||
// refused there as well. Android always reports 'other'.
|
||||
if (!isUserLinkNavigation(request, Platform.OS)) return false;
|
||||
if (/^mailto:/i.test(url)) {
|
||||
openMailto(url);
|
||||
return false;
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
type NotificationMutes,
|
||||
} from '../lib/notification-mutes';
|
||||
import { serverSupportsEmailPush } from '../lib/push-notifications';
|
||||
import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon';
|
||||
|
||||
const CHEVRON_SLOT = 20;
|
||||
const INDENT_STEP = 12;
|
||||
@@ -64,41 +65,28 @@ const UNIFIED_ROLES: UnifiedRole[] = ['sent', 'drafts', 'archive', 'junk', 'tras
|
||||
// in for them (#495).
|
||||
const HIDDEN_WITH_VIRTUAL_ROW = new Set(['scheduled']);
|
||||
|
||||
// Distinct icons for the special-use roles (#288), then a name-based guess
|
||||
// for servers that don't set roles, then a plain folder.
|
||||
function iconFor(
|
||||
role: string | null | undefined,
|
||||
name: string | undefined,
|
||||
hasChildren: boolean,
|
||||
isExpanded: boolean,
|
||||
): AppIcon {
|
||||
switch (role) {
|
||||
case 'inbox': return Inbox;
|
||||
case 'sent': return Send;
|
||||
case 'drafts': return FileIcon;
|
||||
case 'trash': return Trash2;
|
||||
case 'junk':
|
||||
case 'spam': return Ban;
|
||||
case 'archive': return Archive;
|
||||
case 'important': return Flag;
|
||||
case 'flagged': return Star;
|
||||
case 'all': return Mails;
|
||||
case 'memos': return StickyNote;
|
||||
case 'scheduled': return Clock;
|
||||
case 'snoozed': return AlarmClock;
|
||||
case 'shared': return Users;
|
||||
default: break;
|
||||
}
|
||||
const lower = (name ?? '').toLowerCase();
|
||||
if (lower.includes('inbox')) return Inbox;
|
||||
if (lower.includes('sent')) return Send;
|
||||
if (lower.includes('draft')) return FileIcon;
|
||||
if (lower.includes('trash') || lower.includes('deleted')) return Trash2;
|
||||
if (lower.includes('junk') || lower.includes('spam')) return Ban;
|
||||
if (lower.includes('archive')) return Archive;
|
||||
if (lower.includes('star') || lower.includes('flag')) return Star;
|
||||
if (hasChildren) return isExpanded ? FolderOpen : Folder;
|
||||
return Folder;
|
||||
// Distinct icons for the special-use roles (#288), else a plain folder: by
|
||||
// role only, never by name (lib/mailbox-icon.ts, #80).
|
||||
const ICONS: Record<MailboxIconKind, AppIcon> = {
|
||||
inbox: Inbox,
|
||||
sent: Send,
|
||||
drafts: FileIcon,
|
||||
trash: Trash2,
|
||||
junk: Ban,
|
||||
archive: Archive,
|
||||
important: Flag,
|
||||
flagged: Star,
|
||||
all: Mails,
|
||||
memos: StickyNote,
|
||||
scheduled: Clock,
|
||||
snoozed: AlarmClock,
|
||||
shared: Users,
|
||||
folder: Folder,
|
||||
'folder-open': FolderOpen,
|
||||
};
|
||||
|
||||
function iconFor(role: string | null | undefined, hasChildren: boolean, isExpanded: boolean): AppIcon {
|
||||
return ICONS[mailboxIconKind(role, hasChildren, isExpanded)];
|
||||
}
|
||||
|
||||
const ROLE_COLOR_FIXED: Record<string, string> = {
|
||||
@@ -745,7 +733,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
};
|
||||
}, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]);
|
||||
|
||||
const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, undefined, false, false);
|
||||
const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, false, false);
|
||||
|
||||
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
@@ -1139,7 +1127,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
// tapping it only opens or closes that account's folders.
|
||||
const Icon = node.isAccountNode
|
||||
? Users
|
||||
: iconFor(node.role, node.name, hasChildren, isExpanded);
|
||||
: iconFor(node.role, hasChildren, isExpanded);
|
||||
const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId;
|
||||
return (
|
||||
<SidebarRow
|
||||
|
||||
@@ -84,6 +84,7 @@ export function MessageContent({
|
||||
onToggleStar={onToggleStar}
|
||||
onAddressPress={onAddressPress}
|
||||
compact={compact}
|
||||
jmapAccountId={jmapAccountId}
|
||||
/>
|
||||
{attachmentPosition === 'beside-sender' && chips}
|
||||
</View>
|
||||
|
||||
@@ -9,15 +9,18 @@ import { useColors } from '../../theme/colors';
|
||||
import SenderAvatar from '../SenderAvatar';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useKeywordsStore } from '../../stores/keywords-store';
|
||||
import { useAccountKeywordDefs } from '../../stores/keywords-store';
|
||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
|
||||
import {
|
||||
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
|
||||
getSenderVerification, describeSenderVerification,
|
||||
type AuthenticationResults, type EmailHeaderInfo,
|
||||
} from '../../lib/email-headers';
|
||||
import { formatSize } from '../../lib/attachment-display';
|
||||
import { isSmimeEmail } from '../../lib/smime';
|
||||
import { resolveEmailTags } from '../../lib/thread-utils';
|
||||
import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code';
|
||||
import { VerificationCodeChip } from './VerificationCodeChip';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
@@ -27,6 +30,8 @@ interface Props {
|
||||
onAddressPress: (address: EmailAddress) => void;
|
||||
/** Compact variant for collapsed thread cards. */
|
||||
compact?: boolean;
|
||||
/** JMAP account the message lives in (a group's shared tags name its tags). */
|
||||
jmapAccountId?: string;
|
||||
}
|
||||
|
||||
function addressLabel(a: EmailAddress): string {
|
||||
@@ -91,13 +96,14 @@ function DetailRow({ label, value, styles, mono }: { label: string; value?: stri
|
||||
* "Show details" panel (recipients & routing, authentication, identifiers,
|
||||
* mailing list, properties) the webmail viewer shows.
|
||||
*/
|
||||
export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact }: Props) {
|
||||
export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact, jmapAccountId }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||
const keywordDefs = useAccountKeywordDefs(jmapAccountId ?? email.jmapAccountId);
|
||||
const showVerificationCodes = useSettingsStore((s) => s.showVerificationCodes);
|
||||
const [showDetails, setShowDetails] = React.useState(false);
|
||||
React.useEffect(() => { setShowDetails(false); }, [email.id]);
|
||||
|
||||
@@ -106,6 +112,24 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
||||
const starred = !!email.keywords?.$flagged;
|
||||
const displayDate = emailDisplayDate(email);
|
||||
const spoofed = isAuthenticationSpoofed(info.auth);
|
||||
// The server's checks don't back the visible From domain: say so next to
|
||||
// the name and above the body, not only in the collapsed details.
|
||||
const verification = React.useMemo(
|
||||
() => getSenderVerification(info.auth, from?.email),
|
||||
[info.auth, from?.email],
|
||||
);
|
||||
const verificationText = verification ? describeSenderVerification(verification, t) : null;
|
||||
// The sign-in code of the message, offered for copying. Looked up again
|
||||
// only when the message or its loaded body changes, not on a keyword change.
|
||||
const verificationCode = React.useMemo(
|
||||
() => (showVerificationCodes && !compact
|
||||
? findVerificationCode(email.subject, verificationCodeBodyText(email))
|
||||
: null),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[showVerificationCodes, compact, email.id, email.subject, email.bodyValues],
|
||||
);
|
||||
const verificationColor = verification?.status === 'failed' ? c.error : c.warning;
|
||||
const verificationBg = verification?.status === 'failed' ? c.errorBg : c.warningBg;
|
||||
|
||||
// "via <identity>": the message was sent by one of the user's identities or
|
||||
// received at one of them (incl. +tag); hidden when the From can't be
|
||||
@@ -159,6 +183,15 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
||||
<Text style={styles.email} numberOfLines={1}>{from.email}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{verificationText && (
|
||||
<View
|
||||
style={[styles.badge, styles.verificationBadge, { backgroundColor: verificationBg }]}
|
||||
accessibilityLabel={verificationText.message}
|
||||
>
|
||||
<ShieldAlert size={11} color={verificationColor} />
|
||||
<Text style={[styles.badgeText, { color: verificationColor }]}>{verificationText.label}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!compact && (
|
||||
<>
|
||||
<AddressList label={t('email_viewer.to', 'To').toLowerCase()} addresses={email.to} onPress={onAddressPress} styles={styles} />
|
||||
@@ -234,6 +267,21 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
||||
</View>
|
||||
)}
|
||||
|
||||
{verificationCode && <VerificationCodeChip code={verificationCode} />}
|
||||
|
||||
{!compact && verificationText && (
|
||||
<View style={[styles.verificationNotice, { borderColor: verificationColor }]} accessibilityRole="alert">
|
||||
<ShieldAlert size={18} color={verificationColor} />
|
||||
<View style={styles.verificationBody}>
|
||||
<Text style={[styles.verificationLabel, { color: verificationColor }]}>{verificationText.label}</Text>
|
||||
<Text style={styles.verificationMessage}>{verificationText.message}</Text>
|
||||
<Text style={styles.verificationCaution}>
|
||||
{t('email_viewer.sender_check.caution', 'Be careful with links, attachments and requests for passwords or payment details.')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!compact && (
|
||||
<Pressable onPress={() => setShowDetails((v) => !v)} style={styles.detailsToggle} hitSlop={6}>
|
||||
{showDetails ? <ChevronUp size={14} color={c.primary} /> : <ChevronDown size={14} color={c.primary} />}
|
||||
@@ -345,6 +393,19 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.surfaceHover,
|
||||
},
|
||||
badgeDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
verificationBadge: { alignSelf: 'flex-start', marginTop: 4 },
|
||||
verificationNotice: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
verificationBody: { flex: 1, minWidth: 0, gap: 2 },
|
||||
verificationLabel: { ...typography.small, fontWeight: '600' },
|
||||
verificationMessage: { ...typography.caption, color: c.text },
|
||||
verificationCaution: { ...typography.caption, color: c.textSecondary },
|
||||
badgeText: { ...typography.small, color: c.textSecondary },
|
||||
detailsToggle: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-start' },
|
||||
detailsToggleText: { ...typography.caption, color: c.primary, fontWeight: '600' },
|
||||
|
||||
@@ -13,7 +13,9 @@ import { sendEmail } from '../../api/email';
|
||||
import { pruneEmailSubmissions } from '../../api/submissions';
|
||||
import { SubmissionQuotaError } from '../../api/jmap-result';
|
||||
import { sendFailureMessage } from '../../lib/send-failure-message';
|
||||
import { isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe } from '../../lib/unsubscribe';
|
||||
import {
|
||||
isValidUnsubscribeUrl, parseUnsubscribeMailto, isOneClickUnsubscribe, unsubscribeConfirmMessage,
|
||||
} from '../../lib/unsubscribe';
|
||||
import type { ListHeaders } from '../../lib/email-headers';
|
||||
import { findReceivingIdentity } from '../../lib/email-headers';
|
||||
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
|
||||
@@ -66,6 +68,12 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
||||
const unsub = list.listUnsubscribe;
|
||||
const method = unsub?.preferred;
|
||||
const url = method === 'http' ? unsub?.http : unsub?.mailto;
|
||||
// The sender wrote this URL: only one recipient and a short subject and
|
||||
// body are sent, and the confirmation shows all of them first.
|
||||
const mailtoFields = React.useMemo(
|
||||
() => (method === 'mailto' && url ? parseUnsubscribeMailto(url) : null),
|
||||
[method, url],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -76,7 +84,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
||||
return () => { cancelled = true; };
|
||||
}, [messageKey]);
|
||||
|
||||
if (hidden || !url || !method) return null;
|
||||
if (hidden || !url || !method || (method === 'mailto' && !mailtoFields)) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
dismissed.add(messageKey);
|
||||
@@ -100,7 +108,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
||||
await WebBrowser.openBrowserAsync(url);
|
||||
}
|
||||
} else {
|
||||
const fields = parseMailtoUrl(url);
|
||||
const fields = mailtoFields;
|
||||
if (!fields) throw new Error('invalid mailto');
|
||||
const identity = findReceivingIdentity(identities, email) ?? identities[0];
|
||||
if (!identity) throw new Error(t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available'));
|
||||
@@ -112,8 +120,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
||||
await sendEmail(
|
||||
{
|
||||
from: [{ name: identity.name, email: identity.email }],
|
||||
to: fields.to.map((address) => ({ email: address })),
|
||||
cc: fields.cc?.map((address) => ({ email: address })),
|
||||
to: [{ email: fields.to[0] }],
|
||||
subject: fields.subject ?? '',
|
||||
textBody: fields.body ?? '',
|
||||
},
|
||||
@@ -141,7 +148,10 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
||||
t('email_viewer.unsubscribe_banner.confirm_title', 'Unsubscribe from this sender?'),
|
||||
method === 'http'
|
||||
? t('email_viewer.unsubscribe_banner.confirm_message_http', 'The unsubscribe page will open in a new tab.')
|
||||
: t('email_viewer.unsubscribe_banner.confirm_message_mailto', 'An unsubscribe email will be sent to the sender.'),
|
||||
: unsubscribeConfirmMessage(
|
||||
t('email_viewer.unsubscribe_banner.confirm_message_mailto', 'An unsubscribe email will be sent to the sender.'),
|
||||
mailtoFields,
|
||||
),
|
||||
[
|
||||
{ text: t('email_viewer.unsubscribe_banner.cancel', 'Cancel'), style: 'cancel' },
|
||||
{ text: t('email_viewer.unsubscribe_banner.confirm_button', 'Confirm'), onPress: () => { void perform(); } },
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Check, Key } from '../icons';
|
||||
import type { Email } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
import { listVerificationCode } from '../../lib/verification-code';
|
||||
|
||||
/** The code a list row offers: found in the subject and preview of a fresh mail. */
|
||||
export function useListVerificationCode(email: Pick<Email, 'subject' | 'preview' | 'receivedAt'>): string | null {
|
||||
const enabled = useSettingsStore((s) => s.showVerificationCodes);
|
||||
const { subject, preview, receivedAt } = email;
|
||||
return React.useMemo(
|
||||
() => (enabled ? listVerificationCode({ subject, preview, receivedAt }) : null),
|
||||
[enabled, subject, preview, receivedAt],
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
/** A list row in selection mode: the tap selects the row instead. */
|
||||
disabled?: boolean;
|
||||
/** Spacing for a list row (below the preview). */
|
||||
inList?: boolean;
|
||||
}
|
||||
|
||||
/** The one-time code of a sign-in mail (lib/verification-code.ts); a tap copies it. */
|
||||
export function VerificationCodeChip({ code, disabled, inList }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) return undefined;
|
||||
const timer = setTimeout(() => setCopied(false), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [copied]);
|
||||
|
||||
const copy = () => {
|
||||
Clipboard.setStringAsync(code).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
toast.success(t('email_viewer.verification_code.copied', 'Code copied'));
|
||||
},
|
||||
() => toast.error(t('email_viewer.verification_code.copy_failed', 'Could not copy the code')),
|
||||
);
|
||||
};
|
||||
|
||||
const Icon = copied ? Check : Key;
|
||||
const label = t('email_viewer.verification_code.copy', 'Copy code {code}', { code });
|
||||
return (
|
||||
<View style={[styles.row, inList && styles.rowInList]}>
|
||||
<Pressable
|
||||
onPress={copy}
|
||||
disabled={disabled}
|
||||
hitSlop={6}
|
||||
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
testID="verification-code-chip"
|
||||
>
|
||||
<Icon size={12} color={copied ? c.success : c.warning} />
|
||||
<Text style={styles.code} numberOfLines={1}>{code}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
row: { flexDirection: 'row' },
|
||||
rowInList: { marginTop: spacing.xs },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 3,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
chipPressed: { backgroundColor: c.surfaceHover },
|
||||
code: { ...typography.caption, color: c.text, fontWeight: '600', fontFamily: 'monospace', letterSpacing: 0.5 },
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons'
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
|
||||
import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../../stores/keywords-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { supportsBulwarkMetadata } from '../../api/bulwark-metadata';
|
||||
import { DARK_COLORS } from '../../theme/tokens';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { discoverKeywords } from '../../api/keyword-discovery';
|
||||
@@ -203,10 +205,72 @@ export function KeywordSettings() {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<SharedTagsSection />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared tags of each group mailbox in the sidebar (#1310): what every
|
||||
* member sees on its mail, read from the group's Inbox metadata. Shown, not
|
||||
* edited here; Bulwark on the web edits them.
|
||||
*/
|
||||
function SharedTagsSection() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const sharedTags = useKeywordsStore((s) => s.sharedTags);
|
||||
const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags);
|
||||
const accounts = React.useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const m of mailboxes) {
|
||||
if (m.isShared && m.accountId && !seen.has(m.accountId)) seen.set(m.accountId, m.accountName ?? m.accountId);
|
||||
}
|
||||
return [...seen].map(([id, name]) => ({ id, name }));
|
||||
}, [mailboxes]);
|
||||
useEffect(() => {
|
||||
for (const account of accounts) void loadSharedTags(account.id);
|
||||
}, [accounts, loadSharedTags]);
|
||||
|
||||
if (accounts.length === 0) return null;
|
||||
const login = currentTagsLogin();
|
||||
return (
|
||||
<View style={styles.sharedBlock}>
|
||||
<Text style={styles.sharedTitle}>{t('shared_tags.title', 'Shared tags')}</Text>
|
||||
<Text style={styles.scanStatus}>
|
||||
{t('shared_tags.description', 'Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.')}
|
||||
</Text>
|
||||
{accounts.map((account) => {
|
||||
const entry = sharedTags[account.id];
|
||||
const tags = entry && entry.login === login ? entry.tags : null;
|
||||
return (
|
||||
<View key={account.id} style={styles.sharedAccount}>
|
||||
<Text style={styles.sharedAccountName}>{account.name}</Text>
|
||||
{tags === null ? (
|
||||
<Text style={styles.scanStatus}>
|
||||
{supportsBulwarkMetadata(account.id)
|
||||
? t('shared_tags.loading', 'Loading…')
|
||||
: t('shared_tags.unsupported', "This mail server can't keep shared tags, so this mailbox shows your own tags.")}
|
||||
</Text>
|
||||
) : tags.length === 0 ? (
|
||||
<Text style={styles.scanStatus}>{t('shared_tags.empty', 'No shared tags yet.')}</Text>
|
||||
) : (
|
||||
tags.map((kw) => (
|
||||
<View key={kw.id} style={styles.kwRow}>
|
||||
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
|
||||
<Text style={styles.kwLabel}>{kw.label}</Text>
|
||||
<Text style={styles.kwId}>$label:{kw.id}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface KeywordFormProps {
|
||||
initial?: Keyword;
|
||||
existingIds: string[];
|
||||
@@ -335,6 +399,10 @@ function makeStyles(c: ThemePalette) {
|
||||
outlineBtnText: { ...typography.caption, color: c.mutedForeground },
|
||||
scanStatus: { ...typography.caption, color: c.mutedForeground, paddingTop: spacing.xs },
|
||||
scanResults: { gap: spacing.sm },
|
||||
sharedBlock: { gap: spacing.sm, marginTop: spacing.lg },
|
||||
sharedTitle: { ...typography.bodySemibold, color: c.text },
|
||||
sharedAccount: { gap: spacing.sm },
|
||||
sharedAccountName: { ...typography.caption, color: c.textSecondary, fontWeight: '600' },
|
||||
form: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
|
||||
@@ -32,6 +32,7 @@ export function ReadingSettings() {
|
||||
const deleteAction = useSettingsStore((s) => s.deleteAction);
|
||||
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
|
||||
const showPreview = useSettingsStore((s) => s.showPreview);
|
||||
const showVerificationCodes = useSettingsStore((s) => s.showVerificationCodes);
|
||||
const clearSearchOnFolderChange = useSettingsStore((s) => s.clearSearchOnFolderChange);
|
||||
const disableThreading = useSettingsStore((s) => s.disableThreading);
|
||||
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||
@@ -193,6 +194,10 @@ export function ReadingSettings() {
|
||||
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('settings.email_behavior.show_verification_codes.label', "Show Verification Codes")} description={t('settings.email_behavior.show_verification_codes.description', "Offer the code from sign-in and confirmation emails for one-click copying, in the list for a day and in the message")}>
|
||||
<ToggleSwitch checked={showVerificationCodes} onChange={(v) => update('showVerificationCodes', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('settings.email_behavior.disable_threading.label', "Disable Conversation Grouping")} description={t('settings.email_behavior.disable_threading.description', "Show emails as individual messages instead of grouped by conversation")}>
|
||||
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
|
||||
</SettingItem>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
|
||||
|
||||
function extractScript(html: string): string {
|
||||
const m = html.match(/<script>([\s\S]*?)<\/script>/);
|
||||
const m = html.match(/<script(?: nonce="[0-9a-f]+")?>([\s\S]*?)<\/script>/);
|
||||
expect(m, 'page must contain an inline <script>').toBeTruthy();
|
||||
return m![1];
|
||||
}
|
||||
@@ -72,7 +72,18 @@ describe('editor CSP', () => {
|
||||
const html = buildEditorHtml(base);
|
||||
expect(html.indexOf('Content-Security-Policy')).toBeLessThan(html.indexOf('<body>'));
|
||||
expect(cspOf(html)).toContain("default-src 'none'");
|
||||
expect(cspOf(html)).toContain("script-src 'unsafe-inline'");
|
||||
});
|
||||
|
||||
// An `<img onerror>` in a quoted original or a paste must not run: only the
|
||||
// page's own script, which carries a fresh nonce, may.
|
||||
it('runs only its own script, never inline handlers', () => {
|
||||
const html = buildEditorHtml(base);
|
||||
const csp = cspOf(html);
|
||||
const nonce = csp.match(/script-src 'nonce-([0-9a-f]{32})'/)?.[1];
|
||||
expect(nonce).toBeTruthy();
|
||||
expect(csp).not.toMatch(/script-src[^;]*unsafe-inline/);
|
||||
expect(html).toContain(`<script nonce="${nonce}">`);
|
||||
expect(cspOf(buildEditorHtml(base))).not.toBe(csp);
|
||||
});
|
||||
|
||||
it('blocks remote images and fonts but keeps data: and blob: images', () => {
|
||||
@@ -84,7 +95,9 @@ describe('editor CSP', () => {
|
||||
|
||||
it('allows remote images when not blocking', () => {
|
||||
expect(buildEditorCsp(false)).toContain('img-src data: blob: https: http:');
|
||||
expect(cspOf(buildEditorHtml(base))).toBe(buildEditorCsp(false));
|
||||
const csp = cspOf(buildEditorHtml(base));
|
||||
const nonce = csp.match(/'nonce-([0-9a-f]+)'/)![1];
|
||||
expect(csp).toBe(buildEditorCsp(false, nonce));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseAuthenticationResults, parseSpamScore, parseSpamLLM, extractListHeaders,
|
||||
isAuthenticationSpoofed, headersToRecord, deriveHeaderInfo, deliveryDeltaMs, formatDelta,
|
||||
findReceivingIdentity,
|
||||
findReceivingIdentity, getSenderVerification, hasAlignedAuthenticationPass,
|
||||
describeSenderVerification, senderVerificationOf,
|
||||
} from '../email-headers';
|
||||
|
||||
describe('parseAuthenticationResults', () => {
|
||||
@@ -23,6 +24,71 @@ describe('parseAuthenticationResults', () => {
|
||||
const none = parseAuthenticationResults('spf=pass smtp.mailfrom=a.example; spf=none smtp.helo=b.example');
|
||||
expect(none.spf?.result).toBe('pass');
|
||||
});
|
||||
|
||||
// The envelope address and comments are sender-chosen text: a result
|
||||
// counts only where its method opens a `;`-separated part.
|
||||
it('ignores dkim= and dmarc= written into an envelope address or a comment', () => {
|
||||
const r = parseAuthenticationResults(
|
||||
'mx.example; spf=pass smtp.mailfrom="dkim=pass;dmarc=pass"@evil.example; '
|
||||
+ 'dkim=none (no signature; dkim=pass header.d=bank.example); dmarc=none header.from=bank.example',
|
||||
);
|
||||
expect(r.dkim?.result).toBe('none');
|
||||
expect(r.dmarc?.result).toBe('none');
|
||||
const local = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=dmarc=pass@evil.example');
|
||||
expect(local.dmarc).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes DKIM and DMARC from the receiving server's own (topmost) header only", () => {
|
||||
const r = parseAuthenticationResults([
|
||||
'mx.example; spf=pass smtp.mailfrom=evil.example; dkim=none; dmarc=none header.from=bank.example',
|
||||
'forged.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example',
|
||||
]);
|
||||
expect(r.dkim?.result).toBe('none');
|
||||
expect(r.dmarc?.result).toBe('none');
|
||||
});
|
||||
|
||||
it('lets a lower header escalate SPF to a failure but never pass it', () => {
|
||||
const r = parseAuthenticationResults([
|
||||
'mx.example; spf=none smtp.mailfrom=a.example',
|
||||
'other; spf=pass smtp.mailfrom=a.example; spf=fail smtp.mailfrom=b.example',
|
||||
]);
|
||||
expect(r.spf?.result).toBe('fail');
|
||||
expect(r.spf?.all?.map((e) => e.result)).toEqual(['none', 'fail']);
|
||||
});
|
||||
|
||||
it('keeps every DKIM signature so a later pass still counts', () => {
|
||||
const r = parseAuthenticationResults('mx; dkim=fail header.d=esp.example; dkim=pass header.d=shop.example');
|
||||
expect(r.dkim?.result).toBe('fail');
|
||||
expect(r.dkim?.all?.map((e) => e.domain)).toEqual(['esp.example', 'shop.example']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSenderVerification', () => {
|
||||
it("flags mail whose SPF passes only for the sender's own envelope domain", () => {
|
||||
const auth = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=evil.example; dkim=none; dmarc=none');
|
||||
expect(hasAlignedAuthenticationPass(auth, 'ceo@bank.example')).toBe(false);
|
||||
expect(getSenderVerification(auth, 'ceo@bank.example'))
|
||||
.toEqual({ status: 'unverified', domain: 'bank.example', sentFrom: 'evil.example' });
|
||||
});
|
||||
|
||||
it('reports a DMARC fail as failed', () => {
|
||||
const auth = parseAuthenticationResults('mx; spf=fail smtp.mailfrom=bank.example; dmarc=fail header.from=bank.example');
|
||||
expect(getSenderVerification(auth, 'ceo@bank.example')?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('accepts an aligned pass, a subdomain included, and judges nothing without results', () => {
|
||||
const spf = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounce.bank.example; dmarc=none');
|
||||
expect(getSenderVerification(spf, 'ceo@bank.example')).toBeNull();
|
||||
const dkim = parseAuthenticationResults('mx; dkim=fail header.d=esp.example; dkim=pass header.d=bank.example');
|
||||
expect(getSenderVerification(dkim, 'ceo@bank.example')).toBeNull();
|
||||
expect(getSenderVerification(parseAuthenticationResults('mx; iprev=pass'), 'a@bank.example')).toBeNull();
|
||||
expect(getSenderVerification(undefined, 'a@bank.example')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not count a HELO pass', () => {
|
||||
const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=bank.example; spf=none smtp.mailfrom=bank.example');
|
||||
expect(getSenderVerification(auth, 'a@bank.example')?.status).toBe('unverified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticationSpoofed', () => {
|
||||
@@ -69,6 +135,7 @@ describe('deriveHeaderInfo', () => {
|
||||
{ name: 'Disposition-Notification-To', value: 'Sender <s@x.example>' },
|
||||
{ name: 'X-Spam-Status', value: 'No, score=0.1' },
|
||||
{ name: 'Authentication-Results', value: 'mx; dmarc=fail header.from=x.example' },
|
||||
{ name: 'Authentication-Results', value: 'relay; dmarc=pass header.from=x.example' },
|
||||
],
|
||||
messageId: ['abc@x.example'],
|
||||
});
|
||||
@@ -108,3 +175,32 @@ describe('findReceivingIdentity', () => {
|
||||
expect(findReceivingIdentity([], { to: [] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeSenderVerification / senderVerificationOf', () => {
|
||||
const t = (_key: string, fallback?: string, params?: Record<string, string | number>) =>
|
||||
(fallback ?? '').replace(/\{(\w+)\}/g, (_m, k: string) => String(params?.[k] ?? ''));
|
||||
|
||||
it('names the claimed domain and the sending host', () => {
|
||||
const unverified = describeSenderVerification({ status: 'unverified', domain: 'bank.example', sentFrom: 'evil.example' }, t);
|
||||
expect(unverified.label).toBe('Unverified sender');
|
||||
expect(unverified.message).toContain('bank.example');
|
||||
expect(unverified.message).toContain('evil.example');
|
||||
const failed = describeSenderVerification({ status: 'failed', domain: 'bank.example' }, t);
|
||||
expect(failed.label).toBe('Sender check failed');
|
||||
expect(failed.message).toContain('may be forged');
|
||||
});
|
||||
|
||||
it('reads the check from the raw headers of a message', () => {
|
||||
const forged = {
|
||||
from: [{ email: 'ceo@bank.example' }],
|
||||
headers: [{ name: 'Authentication-Results', value: 'mx; spf=pass smtp.mailfrom=evil.example; dkim=none; dmarc=none' }],
|
||||
};
|
||||
expect(senderVerificationOf(forged)?.status).toBe('unverified');
|
||||
const genuine = {
|
||||
from: [{ email: 'ceo@bank.example' }],
|
||||
headers: [{ name: 'Authentication-Results', value: 'mx; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass' }],
|
||||
};
|
||||
expect(senderVerificationOf(genuine)).toBeNull();
|
||||
expect(senderVerificationOf({ from: [{ email: 'a@b.example' }], headers: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
hasNativeDarkMode,
|
||||
sniffImageMime,
|
||||
TRANSPARENT_BLOCKED_PIXEL,
|
||||
isUserLinkNavigation,
|
||||
} from '../email-html';
|
||||
|
||||
describe('stripDangerousTags', () => {
|
||||
@@ -46,6 +47,46 @@ describe('stripDangerousTags', () => {
|
||||
expect(stripDangerousTags('<a href="javascript:alert(1)">x</a>')).toContain('#blocked');
|
||||
expect(stripDangerousTags('<a href="java\nscript:alert(1)">x</a>')).toContain('#blocked');
|
||||
});
|
||||
|
||||
// GHSA-g35q-3gpc-x2rr: the browser starts a new attribute after a slash or
|
||||
// right after a closing quote, with no whitespace in between.
|
||||
it('drops on* handlers that follow a slash or a quote', () => {
|
||||
for (const input of [
|
||||
'<img src="x"/onerror=alert(1)>',
|
||||
'<img/onerror=alert(1) src=x>',
|
||||
'<img src="x"onerror="alert(1)">',
|
||||
"<img src='x'/onerror='alert(1)'>",
|
||||
'<body/onload=alert(1)>',
|
||||
'<img src=x ONERROR=alert(1)>',
|
||||
]) {
|
||||
expect(stripDangerousTags(input), input).not.toMatch(/onerror|onload/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads an attribute after a slash as a new, URI-checked attribute', () => {
|
||||
expect(stripDangerousTags('<a title="x"/href="javascript:alert(1)">l</a>')).toContain('href="#blocked"');
|
||||
});
|
||||
|
||||
it('leaves no tag hidden behind a stray quote in an attribute name', () => {
|
||||
expect(stripDangerousTags('<img x"=y onerror=alert(1) src=z>')).not.toMatch(/onerror/i);
|
||||
});
|
||||
|
||||
it('does not read tags inside a stylesheet, where the browser sees text', () => {
|
||||
const out = stripDangerousTags('<style>/*<a b="*/</style><img src=x onerror=alert(1) c="">');
|
||||
expect(out).toContain('<style>/*<a b="*/</style>');
|
||||
expect(out).not.toMatch(/onerror/i);
|
||||
});
|
||||
|
||||
it('ends comments where the browser does', () => {
|
||||
expect(stripDangerousTags('a<!-- x --!><img src=x onerror=alert(1)>')).not.toMatch(/onerror/i);
|
||||
expect(stripDangerousTags('<p>a</p><!--> <img src=x onerror=alert(1)>')).not.toMatch(/onerror/i);
|
||||
});
|
||||
|
||||
it('writes a < that starts no tag as text and keeps ordinary markup', () => {
|
||||
expect(stripDangerousTags('<p>1 < 2</p>')).toBe('<p>1 < 2</p>');
|
||||
expect(stripDangerousTags('<p class="a" data-x=\'b "c"\'>t</p><br/>'))
|
||||
.toBe('<p class="a" data-x="b "c"">t</p><br />');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExternalResourceUrl', () => {
|
||||
@@ -126,6 +167,21 @@ describe('blockExternalResources', () => {
|
||||
expect(html).not.toContain('https://t');
|
||||
});
|
||||
|
||||
it('keeps CSS-escaped markup inside the scrubbed sheet', () => {
|
||||
const { html } = prepareEmailHtml(
|
||||
'<style>.a{background:url(https://t/x.png)} \\3c /style\\3e \\3c img src=x onerror=alert(1)\\3e </style><p>x</p>',
|
||||
{ blockRemoteImages: true },
|
||||
);
|
||||
expect(html).not.toMatch(/<img/i);
|
||||
expect(html).not.toMatch(/<\/style>\s*<img/i);
|
||||
});
|
||||
|
||||
it('finds the image behind a stray quote, as the browser does', () => {
|
||||
const { html, blocked } = blockExternalResources('<img x"=y src=https://t/a.png>');
|
||||
expect(blocked).toBe(true);
|
||||
expect(html).toContain('data-blocked-src="https://t/a.png"');
|
||||
});
|
||||
|
||||
it('reports nothing blocked for a local-only body', () => {
|
||||
const { html, blocked } = blockExternalResources('<p style="color:red">hi</p><img src="cid:x">');
|
||||
expect(blocked).toBe(false);
|
||||
@@ -144,6 +200,16 @@ describe('prepareEmailHtml', () => {
|
||||
expect(open.html).toContain('img-src data: cid: https: http:');
|
||||
});
|
||||
|
||||
// The bridge scripts are injected by the WebView, so the page itself never
|
||||
// needs to run a script or an inline handler.
|
||||
it('runs no script or inline handler from the page in either mode', () => {
|
||||
for (const blockRemoteImages of [true, false]) {
|
||||
expect(prepareEmailHtml('<p>x</p>', { blockRemoteImages }).html).toContain("script-src 'none'");
|
||||
}
|
||||
expect(wrapPlainTextEmail('x')).toContain("script-src 'none'");
|
||||
expect(prepareEmailHtml('<p>x</p>').html).not.toMatch(/<script/i);
|
||||
});
|
||||
|
||||
it('evaluates native dark mode once and reports the inversion decision', () => {
|
||||
const native = prepareEmailHtml('<style>@media (prefers-color-scheme: dark){body{color:#fff}}</style><p>x</p>', { isDark: true });
|
||||
expect(native.hasNativeDark).toBe(true);
|
||||
@@ -263,3 +329,17 @@ describe('reading width on a wide pane', () => {
|
||||
expect(plain).toContain('@media (min-width: 840px) { body { max-width: 80ch; } }');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUserLinkNavigation', () => {
|
||||
it('opens only tapped links in the top frame on iOS', () => {
|
||||
expect(isUserLinkNavigation({ navigationType: 'click', isTopFrame: true }, 'ios')).toBe(true);
|
||||
expect(isUserLinkNavigation({ navigationType: 'other', isTopFrame: true }, 'ios')).toBe(false);
|
||||
expect(isUserLinkNavigation({ navigationType: 'formsubmit' }, 'ios')).toBe(false);
|
||||
expect(isUserLinkNavigation({ navigationType: 'click', isTopFrame: false }, 'ios')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps taps working on Android, which always reports other', () => {
|
||||
expect(isUserLinkNavigation({ navigationType: 'other', isTopFrame: true }, 'android')).toBe(true);
|
||||
expect(isUserLinkNavigation({ navigationType: 'other', isTopFrame: false }, 'android')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
[
|
||||
{
|
||||
"sender": "AliExpress",
|
||||
"subject": "Ihr AliExpress-Verifizierungscode",
|
||||
"preview": "\nHallo Bitte den 6-stelligen Code auf der nachfolgenden E-Mail-Verifizierungsseite eingeben:\n 206757\nDenken Sie daran: Schützen Sie sich vor Missbrauch und bewahren Sie diesen einmaligen Verifizierungscode vertraulich auf.\nVielen Dank,\nDas AliExpress-T...",
|
||||
"body": "Hallo Bitte den 6-stelligen Code auf der nachfolgenden E-Mail-Verifizierungsseite eingeben:\n206757\n Denken Sie daran: Schützen Sie sich vor Missbrauch und bewahren Sie diesen einmaligen Verifizierungscode vertraulich auf.\nVielen Dank,\n Das AliExpress-Team",
|
||||
"code": "206757"
|
||||
},
|
||||
{
|
||||
"sender": "Anthropic",
|
||||
"subject": "Your verification code is 177945",
|
||||
"preview": "Sign in to Claude.ai177945Copy and paste the temporary verification code to sign in. If you didn't try to sign in, you can safely ignore this email.Claude.ai • Research • Product • Company",
|
||||
"body": "Sign in to Claude.ai\n177945\nCopy and paste the temporary verification code to sign in. If you didn't try to sign in, you can safely ignore this email.\nClaude.ai <https://url8792.mail.anthropic.com/link?t=0f3a9c21> • Research <https://url8792.mail.anthropic.com/link?t=0f3a9c21> • Product <https://url8792.mail.anthropic.com/link?t=0f3a9c21> • Company <https://url8792.mail.anthropic.com/link?t=0f3a9c21>",
|
||||
"code": "177945"
|
||||
},
|
||||
{
|
||||
"sender": "Volkswagen Group",
|
||||
"subject": "Einmalkennwort für die Verifizierung des Kandidatenkontos",
|
||||
"preview": " Hallo,\n\nIhr Passcode für die Kontoverifizierung lautet 854857\n\nGeben Sie diesen Passcode auf der Seite „Karrierechancen – Konto erstellen“ ein, um Ihr Konto zu verifizieren.\n\nDieser Passcode ist 10 Minuten lang gültig.",
|
||||
"body": "Hallo,\n\nIhr Passcode für die Kontoverifizierung lautet 854857\n\nGeben Sie diesen Passcode auf der Seite „Karrierechancen – Konto erstellen“ ein, um Ihr Konto zu verifizieren.\n\nDieser Passcode ist 10 Minuten lang gültig.",
|
||||
"code": "854857"
|
||||
},
|
||||
{
|
||||
"sender": "Epic Games",
|
||||
"subject": "Dein Zwei-Faktor-Anmeldecode",
|
||||
"preview": "\u00a0 \u00a0 \u00a0 Dein Zwei-Faktor-Anmeldecode: 533964 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 Hallo alexm,\n\n\u00a0 \u00a0 Du hast kürzlich versucht, dich von einem neuen Gerät, Browser oder Standort anzumelden. Verwende bitte zur Vervollständigung deiner Anmeldung den obigen Code. Erfahre hi...",
|
||||
"body": "Dein Zwei-Faktor-Anmeldecode:\n 533964\n Hallo alexm,\n\n Du hast kürzlich versucht, dich von einem neuen Gerät, Browser oder Standort anzumelden. Verwende bitte zur Vervollständigung deiner Anmeldung den obigen Code. Erfahre hier mehr <https://accts.epicgames.com/link?t=0f3a9c21> .\n\n Wenn die Anfrage nicht von dir stammt, bitte ändere dein Passwort. <https://accts.epicgames.com/link?t=0f3a9c21> .\n\n Vielen Dank!\nDas Epic Games-Team\n Brauchst du Hilfe? help.epicgames.com <https://accts.epicgames.com/link?t=0f3a9c21>\n © 2024 Epic Games, Inc. Alle Rechte vorbehalten. Epic, Epic Games, das Logo von Epic Games, Unreal, Unreal Engine, das Logo von Unreal Engine, Epic Games Store und das Logo von Epic ",
|
||||
"code": "533964"
|
||||
},
|
||||
{
|
||||
"sender": "Bolt",
|
||||
"subject": "0371 ist dein Bestätigungscode",
|
||||
"preview": "Lass uns deinen Account reaktivieren., Alex! Verwende den unten stehenden Code in der App. 0371\nDer Code läuft ab in 15 Min..\nDu hast diesen Code nicht angefordert? Hier klicken Service provider Bolt Operations OÜ Vana-Lõuna 15, 10135 Tallinn, Eston...",
|
||||
"body": "Lass uns deinen Account reaktivieren., Alex! Verwende den unten stehenden Code in der App.\n 0371\n Der Code läuft ab in 15 Min..\nDu hast diesen Code nicht angefordert? Hier klicken <https://3zf1wp45.r.eu-central-1.awstrack.me/link?t=0f3a9c21>\nService provider Bolt Operations OÜ\nVana-Lõuna 15, 10135 Tallinn, Estonia\nGemacht mit\nDu bist dir nicht sicher, warum du diese E-Mail erhalten hast? <https://3zf1wp45.r.eu-central-1.awstrack.me/link?t=0f3a9c21>",
|
||||
"code": "0371"
|
||||
},
|
||||
{
|
||||
"sender": "Sendcloud",
|
||||
"subject": "[Sendcloud] E-Mail-Adresse bestätigen",
|
||||
"preview": "Diese E-Mail enthält den E-Mail-Bestätigungscode, den Sie verwenden können, um Ihre E-Mail-Adresse bei Sendcloud zu verifizieren. Der Bestätigungscode ist unten aufgeführt. Bitte geben Sie ihn in Ihr geöffnetes Browserfenster ein.\n\n37CCHC",
|
||||
"body": "E-Mail-Adresse bestätigen\n Welcome to Sendcloud and thank you for your sign-up! In order to complete your sign-up, we are asking you to verify your email address. The email verification code is listed below. Please enter it in your open browser window.\n37CCHC\n Beste Grüße,\n Sendcloud\n Sendcloud\ncontact@sendcloud.com",
|
||||
"code": "37CCHC"
|
||||
},
|
||||
{
|
||||
"sender": "TP-Link",
|
||||
"subject": "Tapo-Konto-Verifizierungscode",
|
||||
"preview": " Hallo,Ihr Tapo-Konto-Verifizierungscode lautet: 244027 Dieser Code läuft in 10 Minuten ab. Geben Sie den Code nicht an Dritte weiter.\n\nWenn Sie den Code nicht angefordert haben, versucht möglicherweise jemand, auf Ihr Konto zuzugreifen. Sie können I...",
|
||||
"body": "Hallo,\nIhr Tapo-Konto-Verifizierungscode lautet:\n244027\n Dieser Code läuft in 10 Minuten ab. Geben Sie den Code nicht an Dritte weiter.\n\n Wenn Sie den Code nicht angefordert haben, versucht möglicherweise jemand, auf Ihr Konto zuzugreifen. Sie können Ihr Passwort ändern, um Ihr Konto zu sichern.",
|
||||
"code": "244027"
|
||||
},
|
||||
{
|
||||
"sender": "BundID",
|
||||
"subject": "BundID: Ihr angeforderter Sicherheitscode",
|
||||
"preview": "\n\nHallo, \nIhr persönlicher Code für Ihre Aktion bei der BundID lautet:\n\n\n749054\n\n\nGeben Sie diesen Code ein, um die Aktion sicher abzuschließen. Bitte teilen \nSie den Code nicht mit Dritten.\n\n\nMit freundlichen Grüßen\n\nBundID \n\n\n\nBitte antworten Sie...",
|
||||
"body": "Hallo,\nIhr persönlicher Code für Ihre Aktion bei der BundID lautet:\n\n749054\n\nGeben Sie diesen Code ein, um die Aktion sicher abzuschließen. Bitte teilen Sie den Code nicht mit Dritten.\n\nMit freundlichen Grüßen\nBundID\n\nBitte antworten Sie nicht auf diese E-Mail. Das Postfach wird nicht überwacht und Sie werden keine Antwort erhalten. Wenn Sie Fragen oder Anmerkungen haben, kontaktieren Sie uns bitte über dieses Formular: Kontaktformular <https://id.bund.de/link?t=0f3a9c21>",
|
||||
"code": "749054"
|
||||
},
|
||||
{
|
||||
"sender": "Lieferando",
|
||||
"subject": "Dein Bestätigungscode",
|
||||
"preview": "Gib diesen Code ein, um dich bei Lieferando anzumelden. \u00a0\u200c\u00a0\u200c\u00a0\u200c\u00a0\u200c\u00a0\u200c\u00a0\u200c...",
|
||||
"body": "Gib diesen Code ein, um dich bei Lieferando anzumelden.\n \u200c \u200c \u200c \u200c \u200c \u200c \n Dein Bestätigungscode\n Gib diesen Code ein, um dich anzumelden:\n C5EX8R\n Dieser Code ist für 10 Minuten gültig.\n\n Gib diesen Code nicht an Dritte weiter.\n Lieferando\n Beachte bitte, dass Lieferando dich niemals nach deinen Kontodaten fragen wird.\nDerartige Anfragen solltest du einfach löschen.\nWir empfehlen außerdem, dein Passwort regelmäßig zu ändern.\n Lieferando | Lieferando ist ein Angebot von Takeaway.com\nJust Eat Take\u200baway\u200b.com N.V. | Piet Heinkade 61 | 1019 GM Ams\u00adter\u00addam",
|
||||
"code": "C5EX8R"
|
||||
},
|
||||
{
|
||||
"sender": "PPSPY",
|
||||
"subject": "Complete your registration",
|
||||
"preview": "Email Verification\nYour email verification code is :\n5370\nPlease copy this activation code and enter this code in the page to activate your account.\n",
|
||||
"body": "Email Verification\nYour email verification code is :\n 5370\nPlease copy this activation code and enter this code in the page to activate your account.",
|
||||
"code": "5370"
|
||||
},
|
||||
{
|
||||
"sender": "Messe Frankfurt",
|
||||
"subject": "Messe-Login: Bestätigen Sie Ihre Aktion",
|
||||
"preview": "Guten Tag,\n\nzum Abschluss des Vorgangs ist die Eingabe eines Bestätigungscodes notwendig. Ihr Code lautet:\n\n937480\n\nViele Grüße\nIhr Messe-Login-Team\n--\nMesse Frankfurt GmbH\nLudwig-Erhard-Anlage 1\n60327 Frankfurt am Main\nwww.messefrankfurt.com\nmesse-l...",
|
||||
"body": "Guten Tag,\n\n zum Abschluss des Vorgangs ist die Eingabe eines Bestätigungscodes notwendig. Ihr Code lautet:\n937480\n\n Viele Grüße\n Ihr Messe-Login-Team\n --\n Messe Frankfurt GmbH\n Ludwig-Erhard-Anlage 1\n 60327 Frankfurt am Main\nwww.messefrankfurt.com <https://www.messefrankfurt.com/link?t=0f3a9c21>\nmesse-login@messefrankfurt.com\n\n Geschäftsführung: Wolfgang Marzin (Vorsitzender), Detlef Braun\n Amtsgericht Frankfurt am Main HRB 58331\n\n Messe-Login ist ein Service der Messe Frankfurt",
|
||||
"code": "937480"
|
||||
},
|
||||
{
|
||||
"sender": "Planauskunft",
|
||||
"subject": "Registrierung erfolgreich",
|
||||
"preview": "Registrierung erfolgreich Lovion Registrierung erfolgreich Bitte klicken Sie auf den nachfolgenden Link, um Ihre Registrierung abzuschließen:\nhttps://planauskunft.lsw.de/link?t=0f3a9c21\nAlte...",
|
||||
"body": "Registrierung erfolgreich\n Lovion\n Registrierung erfolgreich\n Bitte klicken Sie auf den nachfolgenden Link, um Ihre Registrierung abzuschließen:\nhttps://planauskunft.lsw.de/link?t=0f3a9c21\n Alternativ können Sie den Code b41c9d07-2385-46a1 auf dieser Seite verwenden:\nhttps://planauskunft.lsw.de/link?t=0f3a9c21\nCopyright © Lovion GmbH <https://lovion.de/link?t=0f3a9c21>\nPhoenixseestraße 6, 44263 Dortmund",
|
||||
"code": "b41c9d07-2385-46a1"
|
||||
},
|
||||
{
|
||||
"sender": "Bitwarden",
|
||||
"subject": "Your Bitwarden Verification Code",
|
||||
"preview": "To finish logging in, enter this verification code: 083447\n\nIf this was not you, take these immediate steps to secure your account in the web app:\n\nDeauthorize unrecognized devices\n\nChange your master password\n\nTurn on two-step login\n\nAccount : info@lin...",
|
||||
"body": "To finish logging in, enter this verification code: 083447\n\n If this was not you, take these immediate steps to secure your account in the web app <https://vault.bitwarden.com/link?t=0f3a9c21> :\nDeauthorize unrecognized devices\nChange your master password\nTurn on two-step login\n\nAccount: alex@example.org\nDate: Monday, December 8, 2025 at 11:01 PM UTC\nIP: 93.231.39.68\nDeviceType: Firefox\n © 2025 Bitwarden Inc.",
|
||||
"code": "083447"
|
||||
},
|
||||
{
|
||||
"sender": "Mozilla",
|
||||
"subject": "Verwenden Sie 347147 zur Anmeldung",
|
||||
"preview": "\n\nHaben Sie sich bei Firefox for Android angemeldet?\n\nHelfen Sie uns, Ihr Konto zu schützen, indem Sie Ihre Anmeldung genehmigen:\n\nFirefox Mobile auf Android 16\n\nFreitag, 12. Dez. 2025\n10:49:27 (CET)\n\nWenn ja, ist hier Ihr Autorisierungscode:\n\n347147\n\n...",
|
||||
"body": "Dieser Code läuft in 5 Minuten ab.\nHaben Sie sich bei Firefox for Android angemeldet?\nHelfen Sie uns, Ihr Konto zu schützen, indem Sie Ihre Anmeldung genehmigen:\nFirefox Mobile auf Android 16\n Freitag, 12. Dez. 2025\n 10:49:27 (CET)\nWenn ja, ist hier Ihr Autorisierungscode:\n347147\nEr läuft in 5 Minuten ab.\nWenn Sie diese Maßnahme nicht ausgelöst haben, ändern Sie sofort Ihr Passwort <https://accounts.firefox.com/link?t=0f3a9c21> . Weitere Informationen erhalten Sie bei der Mozilla-Hilfe <https://support.mozilla.org/link?t=0f3a9c21> .\nMozilla. 149 New Montgomery St, 4th Floor, San Francisco, CA 94105\nDatenschutzhinweis von Mozilla-Konten <https://www.mozilla.org/link?t=0f3a9c21>\nNutzungsbedingungen von Mozilla-Konten <https://www.mozilla.org/link?t=0f3a9c21>",
|
||||
"code": "347147"
|
||||
},
|
||||
{
|
||||
"sender": "Bluesky",
|
||||
"subject": "Password Reset Requested",
|
||||
"preview": "Bluesky [https://bsky.social/link?t=0f3a9c21]\n\n\nRESET PASSWORD\n\nWe received a request to reset the password for the account\n@alex.example.org.\n\nNJF63-SW7S7\n\nTo choose a new password, please enter the code above in the app a...",
|
||||
"body": "We received a request to reset the password for the account @alex.example.org.\n \u200c\u200b\u200d\u200e\u200f \u200c\u200b\u200d\u200e\u200f \nReset password\nWe received a request to reset the password for the account @ testacc.example.org .\nNJF63-SW7S7\nTo choose a new password, please enter the code above in the app along with your new password.\nBluesky <https://bsky.app/link?t=0f3a9c21> , the social internet",
|
||||
"code": "NJF63-SW7S7"
|
||||
},
|
||||
{
|
||||
"sender": "Alibaba",
|
||||
"subject": "548980 ist Ihr Bestätigungscode auf Alibaba.com",
|
||||
"preview": "\u00a0\n\n\u200c \u200c \u200c \u200c \u200c \u200c ...",
|
||||
"body": "\u200c \u200c \u200c \u200c \u200c \u200c \n Hi, Alex Muster,\n Um den Überprüfungsprozess abzuschließen, geben Sie den folgenden Code in unsere App oder Website ein:\n 548980\n Bitte bewahren Sie Ihre persönlichen Daten sicher auf, um einen unbefugten Zugriff auf Ihr Konto zu verhindern.\n Wenn Sie keinen Bestätigungscode von uns angefordert haben, können Sie diese Nachricht ignorieren.\n Antworten Sie nicht auf diese automatisch generierte E-Mail-Nachricht.\n Alle Rechte vorbehalten. © 1999-2026 Alibaba.com",
|
||||
"code": "548980"
|
||||
},
|
||||
{
|
||||
"sender": "MediaMarkt",
|
||||
"subject": "Dein Verifizierungscode lautet: 304627",
|
||||
"preview": "Bestätigen dein neues MediaMarkt-Saturn Onlinekonto\nBitte gebe folgenden Bestätigungs-Code ein: 3 0 4 6 2 7 Gebe diesen Code aus Sicherheitsgründen nicht an Dritte weiter, um dein Kundenkonto vor einem unbefugten Zugriff zu schützen. Nach Eingabe de...",
|
||||
"body": "Bestätigen dein neues MediaMarkt-Saturn Onlinekonto\n Bitte gebe folgenden Bestätigungs-Code ein:\n 3 0 4 6 2 7\n Gebe diesen Code aus Sicherheitsgründen nicht an Dritte weiter, um dein Kundenkonto vor einem unbefugten Zugriff zu schützen.\n Nach Eingabe des Codes kannst du deine Registrierung abschließen.\n\n Falls du die Anfrage nicht gestellt hast, ignoriere diese E-Mail einfach.\n\n Dein MediaMarkt-Saturn Team\nwww.mediamarkt.de <https://www.mediamarkt.de/link?t=0f3a9c21>\n Media-Saturn Deutschland GmbH\n Media-Saturn-Straße 1\n D-85053 Ingolstadt\n E-Mail: datenschutz@mediamarkt.de\n Tel: 0221/22243-333\n Geschäftsführer: Bas Eijssink, Mario Pieper, Thomas Fernkorn, Tom Alexander Rauchut, Carsten Geilert\n Eingetragen beim Amtsgericht Ingolstadt unter HRB3429\n\nDatenschutzhinweise <https://www.mediamarkt.de/link?t=0f3a9c21>",
|
||||
"code": "304627"
|
||||
},
|
||||
{
|
||||
"sender": "No-IP",
|
||||
"subject": "No-IP Verification Code: 325682",
|
||||
"preview": "\nAt No-IP we take security seriously. A one time account verification PIN has been issued for extra security confirmation. This PIN is only valid for 10 minutes.\n\n\n325682\n\nIf this wasn't you, please change your password immediately to secure your a...",
|
||||
"body": "No-IP Verification Code\nFor account security purposes, please enter the following verification on our website:\n325682\nVerification code will expire in 10 minutes.\nIf this wasn't you, please change your password immediately to secure your account. We recommending enabling Two-Factor Authentication. If you have any questions or need assistance please open a Support-Ticket öffnen. or give us a call at <https://url9788.noip.com/link?t=0f3a9c21> 775.853.1883 <tel:7758531883> .\n No-IP\n 425 Maestro Dr. Suite 200\n Reno, Nevada 89511 USA\n +1 775-853-1883\n ©1999-2026 Vitalwerks Internet Solutions, LLC dba No-IP\n ©2026 No-IP Technologies, LLC All Rights Reserved.",
|
||||
"code": "325682"
|
||||
},
|
||||
{
|
||||
"sender": "Chefplatz",
|
||||
"subject": "Anmelden auf Chefplatz.de",
|
||||
"preview": "Bestätige deine Registrierung Hallo,\n\nVielen Dank für deine Registrierung. Hier ist dein 6-stelliger Bestätigungscode:\n 732888Gib diesen Code in der App ein, um deine Registrierung abzuschließen.\nWenn du diese Registrierung nicht angefordert hast, k...",
|
||||
"body": "Bestätige deine Registrierung\n Hallo,\n\n Vielen Dank für deine Registrierung. Hier ist dein 6-stelliger Bestätigungscode:\n732888\n Gib diesen Code in der App ein, um deine Registrierung abzuschließen.\n Wenn du diese Registrierung nicht angefordert hast, kannst du diese Nachricht ignorieren.",
|
||||
"code": "732888"
|
||||
},
|
||||
{
|
||||
"sender": "Wikipedia",
|
||||
"subject": "Your Wikipedia login verification code",
|
||||
"preview": "AlexMuster, it looks like you are trying to log in from a new device on Wikipedia.\n\nYour verification code is:\n\n253367\n\nThis code is required to access your account. It will expire in 1 day. For your security, do not share it with anyone.\n\nIf this wasn...",
|
||||
"body": "AlexMuster, it looks like you are trying to log in from a new device on Wikipedia.\nYour verification code is:\n 253367\nThis code is required to access your account. It will expire in 1 day. For your security, do not share it with anyone.\nIf this wasn’t you:\nThis email was sent because someone attempted to log in to your account. The login attempt included your correct account name and password.\nIf this was not you, you should change your password now.\nFor more information, see https://www.mediawiki.org/link?t=0f3a9c21",
|
||||
"code": "253367"
|
||||
},
|
||||
{
|
||||
"sender": "Microsoft",
|
||||
"subject": "Ihr Einmalcode",
|
||||
"preview": "Hallo alex@example.org,\n\nwir haben Ihre Anforderung für einen Einmalcode für Ihr Microsoft-Konto erhalten.\n\nIhr Einmalcode lautet: 992771\n\nGeben Sie diesen Code nur auf einer offiziellen Website oder App ein. Teilen Sie ihn mit niemandem. Außerhalb...",
|
||||
"body": "Hallo alex@example.org,\n\nwir haben Ihre Anforderung für einen Einmalcode für Ihr Microsoft-Konto erhalten.\n\nIhr Einmalcode lautet: 992771\n\nGeben Sie diesen Code nur auf einer offiziellen Website oder App ein. Teilen Sie ihn mit niemandem. Außerhalb einer offiziellen Plattform werden wir nie danach fragen.\n\nMit freundlichen Grüßen\nIhr Microsoft-Konto-Team\nDatenschutzerklärung\u200b: https://go.microsoft.com/link?t=0f3a9c21 | Impressum: https://aka.ms/link?t=0f3a9c21\nMicrosoft Corporation, One Microsoft Way, Redmond, WA 98052\n",
|
||||
"code": "992771"
|
||||
},
|
||||
{
|
||||
"sender": "Tuya",
|
||||
"subject": "【Tuya】Verification Code",
|
||||
"preview": "Dear user\nWe appreciate that you have applied for an account on Tuya IoT Platform,and we look forward to cooperating with you!To research and develop smart products on the platform,please copy the verification code and fill in the registration page.\nVer...",
|
||||
"body": "Dear user\nWe appreciate that you have applied for an account on Tuya IoT Platform,and we look forward to cooperating with you!To research and develop smart products on the platform,please copy the verification code and fill in the registration page.\nVerification code:\n261765\nIf the failure persists, please send this email to service@tuya.com ,we will take care of it ASAP.\n If you do not wish to receive such emails, please click here to unsubscribe. <https://mail-unsubscribe-cn.ismartlife.me/link?t=0f3a9c21>",
|
||||
"code": "261765"
|
||||
},
|
||||
{
|
||||
"sender": "otelo",
|
||||
"subject": "IHR OTELO PASSWORT",
|
||||
"preview": "IHR OTELO PASSWORT\n\nHallo Alex\u00a0Muster,\n\nSie haben einen Code zum Zurücksetzen Ihres Passworts angefordert.\n\nMit dem folgenden Code können Sie Ihr Passwort zurücksetzen und ein neues festlegen:\n\n026751\n\nSicherheitshinweis: Ihre Zugangsdaten und Passw...",
|
||||
"body": "IHR OTELO PASSWORT\n\nHallo Alex Muster,\nSie haben einen Code zum Zurücksetzen Ihres Passworts angefordert.\n\nMit dem folgenden Code können Sie Ihr Passwort zurücksetzen und ein neues festlegen:\n\n026751\n\nSicherheitshinweis : Ihre Zugangsdaten und Passwörter sind vertraulich. Wir werden Sie niemals per E-Mail, Telefon oder auf anderen Wegen nach diesen Informationen fragen. Geben Sie diesen Code und Ihre Zugangsdaten nur auf der offiziellen otelo-Website oder in der otelo-App ein. Bei verdächtigen Anfragen kontaktieren Sie uns bitte direkt.\n\nFalls Sie die Zurücksetzung nicht angefordert haben, können Sie diese E-Mail ignorieren.\n\nFreundliche Grüße\nIhr otelo-Team\nEINFACH MEHR. EINFACH FAIR. EINFA",
|
||||
"code": "026751"
|
||||
},
|
||||
{
|
||||
"sender": "IKEA",
|
||||
"subject": "Einmaliger Code für den Zugriff auf dein IKEA Konto.",
|
||||
"preview": "Hej,bitte gib die sechs Ziffern unten in die Ansicht ein, in der du den IKEA Einmalcode angefordert hast.724297Dieser Einmalcode ist nur 5 Minuten gültig.Vielen DankDein IKEA TeamWunderbarer Alltag ( https://www.instagram.com/link?t=0f3a9c21 ) ( https...",
|
||||
"body": "Hej,\nbitte gib die sechs Ziffern unten in die Ansicht ein, in der du den IKEA Einmalcode angefordert hast.\n724297\nDieser Einmalcode ist nur 5 Minuten gültig.\nVielen Dank\n\nDein IKEA Team\nWunderbarer Alltag\nEntdecke Neuigkeiten und Angebote in deinem IKEA Einrichtungshaus <https://www.ikea.com/link?t=0f3a9c21>\nKontaktiere uns <https://www.ikea.com/link?t=0f3a9c21> | Allgemeine Liefer- und Zahlungsbedingungen <https://www.ikea.com/link?t=0f3a9c21> | Datenschutzerklärung <https://www.ikea.com/link?t=0f3a9c21>\n© Inter IKEA Systems B.V. 1999-2025",
|
||||
"code": "724297"
|
||||
},
|
||||
{
|
||||
"sender": "HA Remote",
|
||||
"subject": "Verify your HA Remote email",
|
||||
"preview": "Your HA Remote verification code is 811083.\n\nThis code expires in 15 minutes.\nOpen https://home.ctech.media/link?t=0f3a9c21 to continue.",
|
||||
"body": "Your HA Remote verification code is:\n811083\nThis code expires in 15 minutes.\nOpen HA Remote <https://home.ctech.media/link?t=0f3a9c21> to continue.",
|
||||
"code": "811083"
|
||||
},
|
||||
{
|
||||
"sender": "Registercheck",
|
||||
"subject": "Bestätigen Sie Ihre E-Mail-Adresse",
|
||||
"preview": "E-Mail-Adresse bestätigen\n\u200a\nWir müssen Ihre E-Mail-Adresse alex@example.org verifizieren, bevor Sie auf\nIhr Konto zugreifen können. Geben Sie den unten stehenden Code in Ihrem\ngeöffneten Browserfenster ein.\n\u200a\n293089\n\nDieser Code läuft in 10 ...",
|
||||
"body": "Ihr Bestätigungscode lautet 293089. Dieser Code läuft in 10 Minuten ab. Falls Sie sich nicht bei Registercheck angemeldet haben, können Sie diese E-Mail ignorieren.\nE-Mail-Adresse bestätigen\nWir müssen Ihre E-Mail-Adresse alex@example.org verifizieren, bevor Sie auf Ihr Konto zugreifen können. Geben Sie den unten stehenden Code in Ihrem geöffneten Browserfenster ein.\n293089\nDieser Code läuft in 10 Minuten ab.\nFalls Sie sich nicht bei Registercheck angemeldet haben, können Sie diese E-Mail ignorieren. Möglicherweise hat jemand anderes versehentlich Ihre E-Mail-Adresse eingegeben.",
|
||||
"code": "293089"
|
||||
},
|
||||
{
|
||||
"sender": "REWE",
|
||||
"subject": "863733 ist dein Bestätigungscode",
|
||||
"preview": "Unsere aktuellen Aktionen und Neuigkeiten zur kommenden Woche!\n \nHallo,\ndiese E-Mailwird ausschließlich in einer HTML-Version versendet, die du über den folgenden Link erreichst:\n \nhttps://epost3.rewe.de/link?t=0f3a9c21",
|
||||
"body": "Damit kommst du in dein REWE Konto.\nSollte diese E-Mail nicht optimal dargestellt werden, klicke bitte hier <https://epost3.rewe.de/link?t=0f3a9c21>\nDein REWE Konto <https://epost3.rewe.de/link?t=0f3a9c21>\nHallo Alex,\ngib diesen Code ein, um dich in deinem REWE Konto anzumelden:\n\n863733\nDamit dein REWE Konto sicher bleibt, verliert der Code nach 10 Minuten seine Funktion. Aber keine Eile - wir schicken dir einen neuen, wann immer du möchtest.\n\nViele Grüße\ndein REWE Team\nDu hast noch Fragen? Wir sind für dich da.\nWir liefern dir Antworten und Lösungsvorschläge.\nHäufige Fragen und Antworten.\nZum Hilfecenter <https://epost3.rewe.de/link?t=0f3a9c21>\nMelde dich bei uns.\nZum Kontaktbereich <https://epost3.rewe.de/link?t=0f3a9c21>\nDatenschutz <https://epost3.rewe.de/link?t=0f3a",
|
||||
"code": "863733"
|
||||
},
|
||||
{
|
||||
"sender": "Telekom Geschäftskunden",
|
||||
"subject": "Bitte bestätigen Sie Ihre E-Mail-Adresse",
|
||||
"preview": "<table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" style=\"min-width: 100%; \" class=\"stylingblock-content-wrapper\"><tr><td class=\"stylingblock-content-wrapper camarker-inner\"><br>\nHallo,<br>\n<br>\ngeben Sie bitte den folgenden Best&au...",
|
||||
"body": "Bitte bestätigen Sie Ihre E-Mail-Adresse\n\nGuten Tag,\n\ngeben Sie bitte den folgenden Bestätigungscode in das Eingabefenster ein, das Ihnen angezeigt wird:\n\nF17DC\n\nZu Ihrer eigenen Sicherheit können wir so feststellen, dass die von Ihnen genannte E-Mail-Adresse korrekt ist und Sie Zugriff auf das Postfach haben.\nFalls Sie nicht fortfahren wollen, können Sie diese E-Mail einfach ignorieren.\n\nMit freundlichen Grüßen\nIhre Telekom\n\n© 2026 Telekom Deutschland GmbH\n\nDatenschutz <https://www.telekom.de/link?t=0f3a9c21> | gesetzliche Pflichtangaben <https://www.telekom.de/link?t=0f3a9c21>\nDies ist eine automatisch generierte Nachricht, bitte antworten Sie nicht auf diese E-Mail.",
|
||||
"code": "F17DC"
|
||||
},
|
||||
{
|
||||
"sender": "Slack",
|
||||
"subject": "Slack confirmation code: 6ZA-BRE",
|
||||
"preview": "Confirm your email address\n\nHere’s your confirmation code. You can copy it into the open browser window or click the link below to confirm this email address.\n\n6ZA-BRE\n\nConfirm and Sign In [https://go.slack.com/link?t=0f3a9c21",
|
||||
"body": "Confirm your email address. Here’s your confirmation code. You can copy it into the open browser window or click the link below to confirm this email address.\n \u200c \u200c \u200c \u200c \u200c \u200c \nConfirm your email address\n Here’s your confirmation code. You can copy it into the open browser window or click the link below to confirm this email address.\n6ZA-BRE\nConfirm and Sign In <https://go.slack.com/link?t=0f3a9c21>\nIf you didn’t request this email, there’s nothing to worry about — you can safely ignore it.\nOur Blog <https://slackhq.com/link?t=0f3a9c21> | Policies <https://slack.com/link?t=0f3a9c21> | Help Center <https://slack.com/link?t=0f3a9c21> | Slack Community <https://slack.com/link?t=0f3a9c21>\n\n©2026 Slack Technologies, LLC, a Salesforce company.\n415 Mission Street, 3rd Floor, San Francisco, CA 94105\n\nAll rights reserved.",
|
||||
"code": "6ZA-BRE"
|
||||
},
|
||||
{
|
||||
"sender": "LinkedIn",
|
||||
"subject": "Here's your verification code 001029",
|
||||
"preview": "Hi Alex,\n\nEnter the 6-digit code below to verify your identity and regain access to your LinkedIn account.\n\n001029\n\nThanks for helping us keep your account secure.\n\nThe LinkedIn Team\n\n----------------------------------------\n\nThis email was intended fo...",
|
||||
"body": "Please verify it's you.\n \u034f \u034f \u034f \u034f \u034f \u034f \nEnter the 6-digit code below to verify your identity and regain access to your LinkedIn account.\n001029\nThanks for helping us keep your account secure.\nThe LinkedIn Team\nWhen and where this happened:\nDate: August 28, 2026 at 7:54 PM GMT\nOperating System: Windows\nBrowser: Firefox\nApproximate Location: Wolfenbuttel, Niedersachsen, Germany\n Never miss an update with the LinkedIn app\n This email was intended for Alex Muster (“Jack of all trades, master of none, but oftentimes better than a master of one.”)\nLearn why we included this. <https://www.linkedin.com/link?t=0f3a9c21>\nYou are receiving LinkedIn notification emails.\nHelp <https://www.linkedin.com/lin",
|
||||
"code": "001029"
|
||||
},
|
||||
{
|
||||
"sender": "Amazon",
|
||||
"subject": "amazon.de: Sign-in attempt",
|
||||
"preview": "\n \n \nAlex, Someone is attempting to sign in to your account. When: Aug 30, 2026 12:37 PM Central European Summer Time Device: Mozilla Firefox Windows (Desktop) Near: Berlin, Germany\nIf this was you, your verification code is:\n331543\n \n\n\n\nIf you ...",
|
||||
"body": "Alex,\n\nSomeone is attempting to sign in to your account.\n\nWhen: Aug 30, 2026 12:37 PM Central European Summer Time\n Device: Mozilla Firefox Windows (Desktop)\n Near: Berlin, Germany\nIf this was you, your verification code is:\n331543\nIf you didn't request it: click here to deny. <https://www.amazon.de/link?t=0f3a9c21>\n Don’t share it with others.\nAmazon.de is a trading name for Amazon EU Sarl, for Amazon Europe Core Sarl, for Amazon Media EU Sarl and for Amazon EU Sarl, all of which have their registered office at 38 avenue John F. Kennedy, L-1855 Luxembourg and for the Amazon Digital Germany GmbH, with registered office at Domagkstr. 28, 80807 Munich, Germany. ©2026 Amazon.com, Inc. or its affiliates. Amazon and all ",
|
||||
"code": "331543"
|
||||
},
|
||||
{
|
||||
"sender": "Telekom Login",
|
||||
"subject": "Ihr Bestätigungscode",
|
||||
"preview": "img{display:block}#outlook a{padding:0}body{width:100%!important} body{-webkit-text-size-adjust:none}a a:link,a:visited{color:#369;font-weight:400;text-decoration:underline}.footerTable--hintText a,.footerTable--lastRowLinks a,.postContentDisclaimer a,i...",
|
||||
"body": "Guten Tag,\n geben Sie den folgenden Bestätigungscode in das Eingabefeld auf der aktuellen Seite Ihres Browsers ein:\n311213\n Geben Sie diesen Code nicht an andere weiter. Durch die Eingabe bestätigen Sie, dass diese E-Mail-Adresse korrekt ist und Sie Zugriff auf das Postfach haben.\n Falls Sie diese Aktion nicht selbst veranlasst haben, können Sie diese E-Mail einfach ignorieren und müssen nichts weiter tun.\n Freundliche Grüße\n Ihre Telekom\n Die gesetzlichen Pflichtangaben finden Sie unter:\nwww.telekom.de/pflichtangaben <https://www.telekom.de/link?t=0f3a9c21>\n 2-KO-IAM-AM2-1\nTelekom Deutschland GmbH\nHilfe & Service <https://www.telekom.de/link?t=0f3a9c21> Datenschutz <https://www.telekom.de/l",
|
||||
"code": "311213"
|
||||
},
|
||||
{
|
||||
"sender": "Kleinanzeigen",
|
||||
"subject": "Passwort zurücksetzen",
|
||||
"preview": "\n\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tPasswort zurücksetzen | Dein Code 704626\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tHallo,\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tdu möchtest dein ...",
|
||||
"body": "Passwort zurücksetzen | Dein Code 704626\n Hallo,\n\n du möchtest dein Passwort für dein Kleinanzeigen-Konto zurücksetzen.\n\n Gib diesen Code auf Kleinanzeigen ein:\n\n 704626\n\n Der Code ist 5 Minuten gültig.\n\n Du hast keine Zurücksetzung angefordert? Dann kannst du diese E-Mail ignorieren.\n Bis bald auf Kleinanzeigen!\n Bitte antworte nicht auf diese E-Mail.\n Wenn du Fragen hast, hilft dir unser Hilfebereich <https://themen.kleinanzeigen.de/link?t=0f3a9c21> weiter.\nkleinanzeigen.de GmbH, Dernburgstrasse 50, 14057 Berlin, Deutschland <https://www.kleinanzeigen.de/link?t=0f3a9c21>\n Geschäftsführer: Paul Heimann, HRB-Nr.: 272623 B,\n Amtsgericht Charlottenburg, Sitz der Gesellschaft: Berlin\nAllgemein",
|
||||
"code": "704626"
|
||||
},
|
||||
{
|
||||
"sender": "TikTok Shop",
|
||||
"subject": "TikTok Shop Partner Center verification code",
|
||||
"preview": "Dear Partner,\nTo verify your account, enter this code in TikTok Shop :\n 24VURS\nVerification codes expire after 2 hours.\nDo not forward or give this code to anyone.\nThis is an automatically generated email. Replies to this email address aren't monitored....",
|
||||
"body": "Dear Partner,\nTo verify your account, enter this code in TikTok Shop :\n24VURS\nVerification codes expire after 2 hours.\nDo not forward or give this code to anyone.\nThis is an automatically generated email. Replies to this email address aren't monitored.\n\nBest regards,\nTikTok Shop Partner Center",
|
||||
"code": "24VURS"
|
||||
},
|
||||
{
|
||||
"sender": "Steam",
|
||||
"subject": "Ihr Steam-Account: Zugriff von einem neuen Web- oder Mobilgerät",
|
||||
"preview": "\nHallo alexm,\n\nEs scheint, als ob Sie versuchen, sich über ein neues Gerät anzumelden. Bitte\nverwenden Sie den folgenden Steam-Guard-Code, um auf Ihren Account zuzugreifen:\n\nAnfrage von\nCalifornia, Vereinigte Staaten von Amerika\n\nAnmeldecode\nY9DG...",
|
||||
"body": "alexm,\nEs scheint, als ob Sie versuchen, sich über ein neues Gerät anzumelden. Bitte verwenden Sie den folgenden Steam-Guard-Code, um auf Ihren Account zuzugreifen:\n Anfrage von\n California, Vereinigte Staaten von Amerika\n Y9DGP\n\nDas waren nicht Sie?\nDiese E-Mail wurde gesendet, weil jemand versucht hat, sich auf Ihrem Steam-Account anzumelden. Beim Anmeldeversuch wurde der korrekte Name und das korrekte Passwort Ihres Accounts verwendet.\n\n Falls dieser Anmeldeversuch nicht von Ihnen vorgenommen wurde, sollten Sie Ihr Steam-Passwort umgehend zurücksetzen <https://help.steampowered.com/link?t=0f3a9c21> .\n\n Der in dieser E-Mail enthaltene Anmeldecode wird benötigt, um auf Ihren Account zuzugreifen. Teilen Sie diese",
|
||||
"code": "Y9DGP"
|
||||
},
|
||||
{
|
||||
"sender": "Google",
|
||||
"subject": "Google-Bestätigungscode",
|
||||
"preview": "Google-Bestätigungscode\n\n\n\n\n\n\n\n\n\nLieber Google-Nutzer,\n\nwir haben eine Anfrage für den Zugriff auf Ihr Google-Konto \nalex@example.org über Ihre E-Mail-Adresse erhalten. Ihr \nGoogle-Bestätigungscode lautet:\n\n\n067647\n\n\nFalls Sie diesen Code ...",
|
||||
"body": "Google-Bestätigungscode\nLieber Google-Nutzer,\nwir haben eine Anfrage für den Zugriff auf Ihr Google-Konto alex@example.org über Ihre E-Mail-Adresse erhalten. Ihr Google-Bestätigungscode lautet:\n067647\nFalls Sie diesen Code nicht angefordert haben, versucht möglicherweise eine andere Person, auf das Google-Konto alex@example.org zuzugreifen. Geben Sie diesen Code nicht weiter.\nSie haben diese Nachricht erhalten, weil diese E-Mail-Adresse als Wiederherstellungs-E-Mail-Adresse für das Google-Konto alex@example.org angegeben wurde. Sollte dies nicht stimmen, klicken Sie bitte hier <https://accounts.google.com/link?t=0f3a9c21> , um Ihre E-Mail-Adresse aus diesem Google-Konto zu entfernen.\nMit ",
|
||||
"code": "067647"
|
||||
},
|
||||
{
|
||||
"sender": "ChatGPT",
|
||||
"subject": "Your temporary ChatGPT login code",
|
||||
"preview": "\nEnter this temporary verification code to continue:\n714810\nIf you were not trying to log in to ChatGPT, please reset your password.\n\nBest,\nThe ChatGPT team\n\n ChatGPT\nHelp center\n",
|
||||
"body": "Enter this temporary verification code to continue:\n 714810\n If you were not trying to log in to ChatGPT, please reset your password <https://url3243.email.openai.com/link?t=0f3a9c21> .\n\nBest,\nThe ChatGPT team\nChatGPT <https://url3243.email.openai.com/link?t=0f3a9c21>\nHelp center <https://url3243.email.openai.com/link?t=0f3a9c21>",
|
||||
"code": "714810"
|
||||
},
|
||||
{
|
||||
"sender": "Facebook",
|
||||
"subject": "86771674 ist dein Facebook-Code.",
|
||||
"preview": "Hallo Alex,\n\nNur noch ein Schritt, um dein Konto zu verifizieren\n\nHallo Alex,Wir haben von jemandem, der Facebook verwendet, eine Anfrage zur Verifizierung deines Kontos erhalten. Gib diesen Code ein:86771674Teile diesen Code mit niemandem.Wenn dich j...",
|
||||
"body": "Alex, nur noch ein Schritt, um dein Konto zu verifizieren.\nAlex Muster\nNur noch ein Schritt, um dein Konto zu verifizieren\nHallo Alex,\nWir haben von jemandem, der Facebook verwendet, eine Anfrage zur Verifizierung deines Kontos erhalten. Gib diesen Code ein:\n86771674\nTeile diesen Code mit niemandem.\n\nWenn dich jemand nach diesem Code fragt\nTeile diesen Code mit niemandem, insbesondere nicht, wenn dir die Person sagt, dass sie für Facebook oder Meta arbeitet. Möglicherweise handelt es sich um einen Versuch, dein Konto zu hacken.\n\nDie Anfrage kam nicht von dir?\nWenn du diese E-Mail erhalten hast, aber gar nicht versuchst, dein Konto zu verifizieren, teile uns das mit <https://www.facebook.com/link?t=0f3a9c21> . Solange du diesen Code mit niemandem tei",
|
||||
"code": "86771674"
|
||||
},
|
||||
{
|
||||
"sender": "Autodesk",
|
||||
"subject": "Einmaliger Passcode",
|
||||
"preview": "Autodesk ( https://u51843908.ct.sendgrid.net/link?t=0f3a9c21",
|
||||
"body": "Einmaliger Passcode\n Hi Alex Muster!\n\n Geben Sie den einmaligen Passcode ein, um die Aktion für Ihr Autodesk-Konto abzuschließen.\n\nCode:\n747189\n\nGeben Sie diesen Code nicht an Dritte weiter und leiten Sie diese E-Mail nicht weiter.\nAutodesk, Inc. • One Market, Ste. 400 San Francisco, CA 94105, USA\n© Autodesk, Inc. All rights reserved. Rechtliche Hinweise & Marken <https://u51843908.ct.sendgrid.net/link?t=0f3a9c21> . Autodesk-Datenschutzerklärung <https://u51843908.ct.sendgrid.net/link?t=0f3a9c21>\nDies ist eine vertragsrelevante E-Mail.\nAntworten Sie nicht auf diese E-Mail. Antworten auf diese E-Mail werden weder gelesen noch beantwortet.\nAutodesk und das Autodesk-Logo sind in den USA und/ode",
|
||||
"code": "747189"
|
||||
},
|
||||
{
|
||||
"sender": "Booking.com",
|
||||
"subject": "Booking.com\u00a0– HUK6QR ist Ihr Bestätigungscode",
|
||||
"preview": " Booking.com\n\nE-Mail-Adresse bestätigen\n\n Guten Tag alex@example.org,\n\n Dies ist Ihr individueller Code, mit dem Sie Ihre E-Mail-Adresse\n bestätigen können..\n HUK6QR\n\n Bitte beachten Sie, dass dieser Code nur einmal verwendet werden kann...",
|
||||
"body": "E-Mail-Adresse bestätigen\nGuten Tag alex@example.org,\nDies ist Ihr individueller Code, mit dem Sie Ihre E-Mail-Adresse bestätigen können..\nHUK6QR\n Bitte beachten Sie, dass dieser Code nur einmal verwendet werden kann und nach 10 Minuten verfällt. Geben Sie den Code nicht an Dritte weiter.\nWenn Sie keinen Bestätigungscode für alex@example.org angefordert haben, können Sie diese E-Mail ignorieren.\n Urheberrecht © 1996–2026 Booking.com. Alle Rechte vorbehalten.\n Diese E-Mail wurde von Booking.com gesendet, Oosterdokskade 163, 1011 DL Amsterdam, Niederlande\nDatenschutz und Cookies <https://www.booking.com/link?t=0f3a9c21> | Kundenservice <https://www.booking.com/link?t=0f3a9c21>",
|
||||
"code": "HUK6QR"
|
||||
},
|
||||
{
|
||||
"sender": "Instagram",
|
||||
"subject": "557 260 is your Instagram recovery code",
|
||||
"preview": "\u00a0\u00a0\u00a0\u00a0Hi alex.muster,\nHere are three easy ways to get back into Instagram.\n\u00a0\u00a0\u00a0\u00a0Log\u00a0in\u00a0without\u00a0password\u00a0\u00a0\u00a0\u00a0Reset\u00a0your\u00a0password\u00a0\u00a0\u00a0\u00a0\u00a0Or use this code in the app:557 260If you didn’t request a login link or a password reset, you can ign...",
|
||||
"body": "Hi alex.muster,\nHere are three easy ways to get back into Instagram.\nLog in without password <https://www.instagram.com/link?t=0f3a9c21>\nReset your password <https://instagram.com/link?t=0f3a9c21>\nOr use this code in the app:\n557 260\nIf you didn’t request a login link or a password reset, you can ignore this message and learn more about why you may have received it <https://help.instagram.com/link?t=0f3a9c21> .\n\nOnly people who know your Instagram password or click the login link in this email can log into your account.\n\n© Instagram. Meta Platforms, Inc., 1601 Willow Road, Menlo Park, CA 94025\nThis message was sent to alex@example.org and intended for alex.muster. Not your profile? Remove your email <https://instagram.com/link?t=0f3a9c21> from this account.",
|
||||
"code": "557260"
|
||||
},
|
||||
{
|
||||
"sender": "STACKIT",
|
||||
"subject": "Verify your email",
|
||||
"preview": "Verify your email. Dear STACKIT customer, To verify your email and activate your account, click to the button below. Verify your email or use the temporary code below Verification code 08MPI6 This code is valid for 1 h. from the time this email was sent...",
|
||||
"body": "Verify your email\nDear STACKIT customer,\nTo verify your email and activate your account, click to the button below.\nVerify your email <https://accounts.stackit.cloud/link?t=0f3a9c21>\nor use the temporary code below\nVerification code\n08MPI6\nThis code is valid for 1 h from the time this email was sent.\nBest regards\nYour STACKIT team\nSTACKIT – EINFACH. SICHER. STABIL.\nSTACKIT GmbH & Co. KG - Stiftsbergstraße 1 D-74172 Neckarsulm\nView our data privacy policy. <https://www.stackit.de/link?t=0f3a9c21>",
|
||||
"code": "08MPI6"
|
||||
},
|
||||
{
|
||||
"sender": "Schramme Media",
|
||||
"subject": "123277 — E-Mail-Adresse bestätigen · Schramme Media & Development",
|
||||
"preview": "Hallo test,\n\nvielen Dank für deine Registrierung bei Schramme Media & Development. Bitte bestätige deine E-Mail-Adresse mit folgendem Code:\n\nCode: 123277\n\nDieser Code ist 15 Minuten gültig. Falls du diese Aktion nicht angefordert hast, ignoriere dies...",
|
||||
"body": "Schramme Media & Development\nE-Mail-Adresse bestätigen\nHallo test,\nvielen Dank für deine Registrierung bei Schramme Media & Development. Bitte bestätige deine E-Mail-Adresse mit folgendem Code:\n 123277\n Dieser Code ist 15 Minuten gültig. Falls du diese Aktion nicht angefordert hast, kannst du diese E-Mail ignorieren.",
|
||||
"code": "123277"
|
||||
},
|
||||
{
|
||||
"sender": "UNiDAYS",
|
||||
"subject": "292326 ist dein UNiDAYS-Passcode",
|
||||
"preview": "EINMALIGER PASSCODE\n\n292326\n\nHier ist dein Passcode.\n\n----------------------------------------\n\nWenn du Hilfe zu deiner UNiDAYS-iD benötigst, wende dich bitte an Support https://www.myunidays.com/link?t=0f3a9c21",
|
||||
"body": "Einmaliger Passcode\n292326\nHier ist dein Passcode.\nWenn du Hilfe zu deiner UNiDAYS-iD benötigst, wende dich bitte an Support <https://www.myunidays.com/link?t=0f3a9c21>",
|
||||
"code": "292326"
|
||||
},
|
||||
{
|
||||
"sender": "Amazon Visa",
|
||||
"subject": "Bestätigungscode",
|
||||
"preview": "<!DOCTYPE html>\n<html lang=\"de\" style=\"width:100%;height:100%;\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\">\n <meta name=\"x-apple-disa...",
|
||||
"body": "Informationen zu deiner Amazon Visa\n\n \u034f\u200c \u034f\u200c \u034f\u200c \u034f\u200c \n \u034f\u200c \u034f\u200c \u034f\u200c \u034f\u200c \n Zinia heißt jetzt Openbank Pay!\n Dein Bestätigungscode lautet 1699\n\n Hallo Alex ,\n\nder von dir angeforderte Code zur Bestätigung des Vorgangs lautet:\n1699\n Das warst nicht du? Überprüfe deine Sicherheitseinstellungen.\n\nViele Grüße\ndein Amazon Visa Kundenservice\nvon Openbank Pay\n\n Wenn du Openbank kontaktieren möchtest, schicke deine Anfrage bitte nicht an diese automatische Adresse, sondern an amazon-visa.de@openbankpay.com\n\nOPEN BANK, S.A. , Av. de Cantabria s/n, 28660 Boadilla del Monte, Spanien\nDu hast noch Fragen? Weitere Infos findest du in den FAQs. <https://www.openbankpay.com/link?t=0f3a9c21> Du hast noch Fragen? Weite",
|
||||
"code": "1699"
|
||||
},
|
||||
{
|
||||
"sender": "Klarna",
|
||||
"subject": "093761 is your 6-digit code",
|
||||
"preview": "093761 is your 6-digit code\n\nhttps://www.klarna.com/link?t=0f3a9c21\n\n\u00a0\n\n\nYour 6-digit code\n\n\u00a0\n\nHi, your 6-digit code is:\n\n\u00a0\n\n093761\n\n\u00a0\n\nUse this code to complete the verification process in the app or website.\n\n\u00a0\n\nDo not share this code. Klarna representatives wi...",
|
||||
"body": "093761 is your 6-digit code\nYour 6-digit code\nHi, your 6-digit code is:\n093761\n Use this code to complete the verification process in the app or website.\nDo not share this code. Klarna representatives will never reach out to you to verify this code over the phone or SMS.\nThe code is valid for 10 minutes.\n Fraudsters may pretend to be Klarna. Never share login details or codes.\nDidn't request a verification code?\n If you suspect someone else requested this code, lock your account <https://klarna.com/link?t=0f3a9c21> immediately.\nHave a question?\nGet 24/7 support and find answers to your questions with our customer service assistant.\nChat with us <https://app.klarna.com/link?t=0f3a9c21>\nKlarn",
|
||||
"code": "093761"
|
||||
},
|
||||
{
|
||||
"sender": "KI Palooza",
|
||||
"subject": "Your Login Passcode for KI Palooza 2026",
|
||||
"preview": " \u00a0 \u00a0Hey a a!\nWillkommen zur KI Palooza 2026\nDein Passcode\n551471\nKI Palooza 2026 Sep 29 2026 - Sep 30 2026\nHallo a a,\n\n\nWir schreiben dir weil wir eine Anfrage zur Anmeldung zu deinem Konto bekommen haben. Verwende den untenstehenden Passcode um dich ...",
|
||||
"body": "Hey a a!\nWillkommen zur KI Palooza 2026\nDein Passcode\n551471\nKI Palooza 2026\nSep 29 2026 - Sep 30 2026\nHallo a a,\n\nWir schreiben dir weil wir eine Anfrage zur Anmeldung zu deinem Konto bekommen haben. Verwende den untenstehenden Passcode um dich in der App einzuloggen.\nDein Passcode ist\n551471\nDein QR Code\n\nPrüfe dein Profil und lege los!\nZu deinem Profil <https://kipalooza2025.eventify.io/link?t=0f3a9c21>\nDownload KI Palooza 2026 Mobile App for\niOS and Android\nMake sure you download KI Palooza 2026 app. Easily connect with other attendees, view event schedules, speakers, get news updates, bookmark your favourites, and post on social feed.\nWillkommen zur KI Palooza 2026\nDein Passcode : 55147",
|
||||
"code": "551471"
|
||||
},
|
||||
{
|
||||
"sender": "X",
|
||||
"subject": "Your X confirmation code is 5aem5f5z",
|
||||
"preview": "X\n\nWe noticed an attempt to log in to your account @alexm that seems suspicious. Was this you?\n\n\n\nFalls du das warst\nJust to be safe, to log in to this account you’ll need to confirm this is really you by entering the following single-use code.\n\n5...",
|
||||
"body": "We noticed an attempt to log in to your account @alexm that seems suspicious. Was this you?\nFalls du das warst\n Just to be safe, to log in to this account you’ll need to confirm this is really you by entering the following single-use code.\n5aem5f5z\nFalls du das nicht warst\n Complete these steps now to protect your account.\nChange your password. <https://t.co/link?t=0f3a9c21> You'll be logged out of all your active X sessions except the one you're using at this time.\nReview the apps <https://t.co/link?t=0f3a9c21> that have access to your account and revoke access to any unfamiliar apps. Learn more <https://help.x.com/link?t=0f3a9c21> .\nHilfe <https://t.co/link?t=0f3a9c21> | Nicht mein Account <https://twitter.com/link?t=0f3a9c21",
|
||||
"code": "5aem5f5z"
|
||||
},
|
||||
{
|
||||
"sender": "Vaultwarden",
|
||||
"subject": "Vaultwarden Login Verification Code",
|
||||
"preview": "Your two-step verification code is: 7030417738\n\nUse this code to complete logging in with Vaultwarden.\n\n===\nGithub: https://github.com/link?t=0f3a9c21",
|
||||
"body": "Your two-step verification code is: 7030417738\n Use this code to complete logging in with Vaultwarden.",
|
||||
"code": "7030417738"
|
||||
},
|
||||
{
|
||||
"sender": "fonial",
|
||||
"subject": "PIN-Verifizierung",
|
||||
"preview": "\nGuten Tag Alex Muster,\nhiermit erhalten Sie Ihre persönliche PIN zur Verifizierung des Kundenkonto-Logins:\n 615233\nDiese PIN ist 10 Minuten gültig und läuft um 19:13 Uhr ab.\nBitte beachten Sie, dass der Link nur auf dem Gerät funktioniert, dass Sie ...",
|
||||
"body": "Guten Tag Alex Muster,\nhiermit erhalten Sie Ihre persönliche PIN zur Verifizierung des Kundenkonto-Logins:\n615233 <https://kundenkonto.fonial.de/link?t=0f3a9c21>\nDiese PIN ist 10 Minuten gültig und läuft um 19:13 Uhr ab.\n Bitte beachten Sie, dass der Link nur auf dem Gerät funktioniert, dass Sie zum Login benutzt haben. Lesen Sie diese E-Mail auf einem anderen Gerät, z. B. Ihrem Smartphone? Geben Sie die PIN bitte von Hand in das Formularfeld ein, anstatt auf den Link zu klicken.\nGeben Sie diese PIN niemals weiter!\n Mitarbeiter von fonial fragen niemals nach dieser PIN. Teilen Sie diese daher nie mit fonial oder Dritten – es könnte sich um einen Versuch handeln unberechtigt in Ihr Kundenkont",
|
||||
"code": "615233"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { mailboxIconKind } from '../mailbox-icon';
|
||||
|
||||
describe('mailboxIconKind', () => {
|
||||
it('gives each special-use role its icon', () => {
|
||||
expect(mailboxIconKind('sent', false, false)).toBe('sent');
|
||||
expect(mailboxIconKind('junk', false, false)).toBe('junk');
|
||||
expect(mailboxIconKind('spam', false, false)).toBe('junk');
|
||||
expect(mailboxIconKind('trash', true, true)).toBe('trash');
|
||||
});
|
||||
|
||||
// native #80: a sieve-sorted folder "web/sentry" got the Sent icon.
|
||||
it('gives a user folder a folder icon whatever its name says', () => {
|
||||
for (const name of ['sentry', 'Sent Items', 'Drafts old', 'memory', 'spam-reports', 'archive 2019']) {
|
||||
expect(mailboxIconKind(null, false, false), name).toBe('folder');
|
||||
}
|
||||
expect(mailboxIconKind(undefined, true, true)).toBe('folder-open');
|
||||
expect(mailboxIconKind(null, true, false)).toBe('folder');
|
||||
});
|
||||
|
||||
it('is what the sidebar uses, with no guess from the folder name', () => {
|
||||
const source = readFileSync(path.join(process.cwd(), 'src/components/SidebarDrawer.tsx'), 'utf8');
|
||||
expect(source).toContain('mailboxIconKind(');
|
||||
expect(source).not.toMatch(/lower\.includes\('(?:sent|inbox|draft|trash|junk|archive|star)'\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { keywordColorFromPalette, mergeSharedTags, parseSharedTags } from '../shared-tags';
|
||||
|
||||
describe('parseSharedTags', () => {
|
||||
// The map shape the webmail writes (lib/shared-tags.ts).
|
||||
it('reads the map by tag id, sorted by name, keeping unknown fields out', () => {
|
||||
expect(parseSharedTags({
|
||||
tags: {
|
||||
urgent: { label: 'Urgent', color: 'red-dark', visibility: 'show', by: 'x' },
|
||||
billing: { label: ' Billing ', color: 'teal' },
|
||||
unnamed: { color: 'blue' },
|
||||
},
|
||||
version: 2,
|
||||
})).toEqual([
|
||||
{ id: 'billing', label: 'Billing', color: 'teal' },
|
||||
{ id: 'unnamed', label: 'unnamed', color: 'blue' },
|
||||
{ id: 'urgent', label: 'Urgent', color: 'red' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the earlier list and bare-array shapes, once per id', () => {
|
||||
const list = [{ id: 'a', label: 'A', color: 'green' }, { id: 'A', label: 'dup' }, { label: 'no id' }];
|
||||
expect(parseSharedTags({ list })).toEqual([{ id: 'a', label: 'A', color: 'green' }]);
|
||||
expect(parseSharedTags(list)).toEqual([{ id: 'a', label: 'A', color: 'green' }]);
|
||||
});
|
||||
|
||||
it('drops ids no keyword can hold and caps long names', () => {
|
||||
const tags = parseSharedTags({
|
||||
tags: { 'has space': { label: 'x' }, 'a*b': { label: 'y' }, ok: { label: 'z'.repeat(50) } },
|
||||
});
|
||||
expect(tags.map((t) => t.id)).toEqual(['ok']);
|
||||
expect(tags[0].label).toHaveLength(30);
|
||||
});
|
||||
|
||||
it('reads nothing from junk', () => {
|
||||
expect(parseSharedTags(undefined)).toEqual([]);
|
||||
expect(parseSharedTags('tags')).toEqual([]);
|
||||
expect(parseSharedTags({ tags: [1, 2] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keywordColorFromPalette', () => {
|
||||
it("maps the webmail's shades onto the app's colours and unknown ones to grey", () => {
|
||||
expect(keywordColorFromPalette('blue-light')).toBe('blue');
|
||||
expect(keywordColorFromPalette('amber')).toBe('amber');
|
||||
expect(keywordColorFromPalette('magenta')).toBe('gray');
|
||||
expect(keywordColorFromPalette(7)).toBe('gray');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSharedTags', () => {
|
||||
it('lets the shared definition name a tag and keeps the own ones nobody shared', () => {
|
||||
const shared = [{ id: 'Red', label: 'Escalated', color: 'red' as const }];
|
||||
const personal = [
|
||||
{ id: 'red', label: 'Red', color: 'red' as const },
|
||||
{ id: 'blue', label: 'Blue', color: 'blue' as const },
|
||||
];
|
||||
expect(mergeSharedTags(shared, personal)).toEqual([
|
||||
{ id: 'Red', label: 'Escalated', color: 'red' },
|
||||
{ id: 'blue', label: 'Blue', color: 'blue' },
|
||||
]);
|
||||
expect(mergeSharedTags([], personal)).toBe(personal);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,33 @@ describe('parseTnef', () => {
|
||||
expect(res.htmlBody).toBe('<p>hi</p>');
|
||||
expect(res.attachments[0]).toMatchObject({ name: 'report.pdf', mimeType: 'application/pdf' });
|
||||
});
|
||||
// A sender-chosen value count of 2^32 - 1 over a block with no room for a
|
||||
// single value used to spin the JS thread on reads that never advance.
|
||||
it('does not spin on a value count the block cannot hold', () => {
|
||||
const mapiVar = [
|
||||
...u32(1),
|
||||
0x02, 0x01, 0x01, 0x00, // PT_BINARY, id 0x0001
|
||||
...u32(0xffffffff),
|
||||
0x00, 0x00,
|
||||
];
|
||||
const mapiFixed = [
|
||||
...u32(1),
|
||||
0x99, 0x10, 0x01, 0x00, // multi-value of an unknown fixed type
|
||||
...u32(0xffffffff),
|
||||
0x00, 0x00,
|
||||
];
|
||||
for (const mapi of [mapiVar, mapiFixed]) {
|
||||
const data = new Uint8Array([
|
||||
...u32(0x223e9f78), 0x00, 0x00,
|
||||
...attr(0x01, 0x0002800c, bytes('Hello body')),
|
||||
...attr(0x01, 0x00069003, mapi),
|
||||
]);
|
||||
const started = Date.now();
|
||||
const res = parseTnef(data);
|
||||
expect(Date.now() - started).toBeLessThan(1000);
|
||||
expect(res.body).toBe('Hello body');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTnefAttachment', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseUnsubscribeUrls, isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe,
|
||||
parseUnsubscribeMailto, unsubscribeConfirmMessage,
|
||||
} from '../unsubscribe';
|
||||
|
||||
describe('parseUnsubscribeUrls', () => {
|
||||
@@ -46,3 +47,36 @@ describe('isOneClickUnsubscribe', () => {
|
||||
expect(isOneClickUnsubscribe(undefined, 'https://x/u')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUnsubscribeMailto', () => {
|
||||
it('sends to the one address in the URL and ignores to= and cc= fields', () => {
|
||||
expect(parseUnsubscribeMailto('mailto:unsub@list.example?to=victim@corp.example&cc=boss@corp.example&subject=stop'))
|
||||
.toEqual({ to: ['unsub@list.example'], subject: 'stop', body: undefined });
|
||||
});
|
||||
|
||||
it('refuses a list of addresses or no address in the address part', () => {
|
||||
expect(parseUnsubscribeMailto('mailto:a@list.example,b@corp.example')).toBeNull();
|
||||
expect(parseUnsubscribeMailto('mailto:?to=a@list.example')).toBeNull();
|
||||
expect(parseUnsubscribeMailto('mailto:?cc=a@list.example')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the subject on one line and caps subject and body', () => {
|
||||
const long = 'x'.repeat(2000);
|
||||
const r = parseUnsubscribeMailto(`mailto:u@list.example?subject=a%0D%0ABcc:%20x@y.example%20${long}&body=${long}`);
|
||||
expect(r?.subject).not.toMatch(/[\r\n]/);
|
||||
expect(r?.subject?.length).toBe(200);
|
||||
expect(r?.body?.length).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsubscribeConfirmMessage', () => {
|
||||
it('shows the address, subject and body that will be sent', () => {
|
||||
const msg = unsubscribeConfirmMessage('An unsubscribe email will be sent.', {
|
||||
to: ['unsub@list.example'], subject: 'stop', body: 'remove me',
|
||||
});
|
||||
expect(msg).toContain('unsub@list.example');
|
||||
expect(msg).toContain('stop');
|
||||
expect(msg).toContain('remove me');
|
||||
expect(unsubscribeConfirmMessage('Intro', null)).toBe('Intro');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findVerificationCode,
|
||||
htmlMayNameACode,
|
||||
listVerificationCode,
|
||||
verificationCodeBodyText,
|
||||
VERIFICATION_CODE_MAX_AGE_MS,
|
||||
} from '../verification-code';
|
||||
// Real sign-in and confirmation mails as Stalwart 0.16.23 serves them: the
|
||||
// list's subject and preview, and the body text the reader derives from the
|
||||
// HTML part. Names, addresses and link tokens are replaced.
|
||||
import mails from './fixtures/verification-code-mails.json';
|
||||
|
||||
// Mail whose code is neither in the subject nor in the 256-character
|
||||
// preview: its body opens with CSS, raw HTML or padding, or the preview
|
||||
// ends before (or in the middle of) the code.
|
||||
const NOT_IN_PREVIEW = new Set([
|
||||
'Lieferando', 'Planauskunft', 'Tuya', 'Telekom Geschäftskunden', 'Telekom Login', 'Steam', 'Autodesk',
|
||||
'Amazon Visa',
|
||||
]);
|
||||
|
||||
describe('findVerificationCode on real mail', () => {
|
||||
it.each(mails.map((m) => [m.sender, m] as const))('finds the code of %s in the reader', (_, mail) => {
|
||||
expect(findVerificationCode(mail.subject, mail.body)).toBe(mail.code);
|
||||
});
|
||||
|
||||
it.each(mails.map((m) => [m.sender, m] as const))('finds the code of %s in the list, or nothing', (sender, mail) => {
|
||||
expect(findVerificationCode(mail.subject, mail.preview)).toBe(NOT_IN_PREVIEW.has(sender) ? null : mail.code);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findVerificationCode', () => {
|
||||
it('copies grouped digits without the gaps', () => {
|
||||
expect(findVerificationCode('557 260 is your Instagram recovery code', '')).toBe('557260');
|
||||
expect(findVerificationCode('', 'Bitte gebe folgenden Bestätigungs-Code ein: 3 0 4 6 2 7 Gebe diesen')).toBe('304627');
|
||||
expect(findVerificationCode('', 'Your code: 123-456')).toBe('123456');
|
||||
});
|
||||
|
||||
it('keeps the hyphen of a lettered code', () => {
|
||||
expect(findVerificationCode('Slack confirmation code: 6ZA-BRE', '')).toBe('6ZA-BRE');
|
||||
});
|
||||
|
||||
it('does not offer a code the preview cut in half', () => {
|
||||
expect(findVerificationCode('', 'Bitte verwenden Sie den folgenden Steam-Guard-Code:\n\nAnmeldecode\nY9DG...')).toBeNull();
|
||||
expect(findVerificationCode('', 'Your code is 1234...')).toBeNull();
|
||||
});
|
||||
|
||||
it('finds digits run into the words around them', () => {
|
||||
expect(findVerificationCode('', 'Gib diesen Code ein:86771674Teile diesen Code mit niemandem.')).toBe('86771674');
|
||||
expect(findVerificationCode('', 'Sign in to Claude.ai177945Copy and paste the temporary verification code')).toBe('177945');
|
||||
});
|
||||
|
||||
it('does not take a group of digits out of an id', () => {
|
||||
expect(findVerificationCode('', 'Your verification code was sent. Request ffacd0b43b97d-4887a30a5a2si2098f4f')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not read a status code as the keyword of a one-time code', () => {
|
||||
expect(findVerificationCode('', 'Rejected with code 550 (5.1.1) after 48291733 ms')).toBeNull();
|
||||
expect(findVerificationCode('', 'Antwort mit Code 421: Server ausgelastet, Sitzung 48291733 beendet')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the code in the subject', () => {
|
||||
expect(findVerificationCode('Your code is 482913', 'Your verification code for account 5512345 is below')).toBe('482913');
|
||||
});
|
||||
|
||||
it('takes the code the keyword introduces over other numbers near it', () => {
|
||||
expect(findVerificationCode('', 'Your verification code for account 12345678 is 482913.')).toBe('482913');
|
||||
expect(findVerificationCode('', 'Dein Passcode\n551471\nKI Palooza 2026 Sep 29 2026 - Sep 30 2026')).toBe('551471');
|
||||
});
|
||||
|
||||
it('accepts a year-shaped code only when it is presented as the code', () => {
|
||||
expect(findVerificationCode('', 'Your Login Passcode for KI Palooza 2026')).toBeNull();
|
||||
expect(findVerificationCode('', 'Your code is 2013')).toBe('2013');
|
||||
});
|
||||
|
||||
it('reads keywords in other languages', () => {
|
||||
expect(findVerificationCode('', 'Tu código de verificación es 482913')).toBe('482913');
|
||||
expect(findVerificationCode('', 'Twój kod weryfikacyjny: 482913')).toBe('482913');
|
||||
expect(findVerificationCode('', 'Ваш код подтверждения: 482913')).toBe('482913');
|
||||
expect(findVerificationCode('', '您的验证码是 482913')).toBe('482913');
|
||||
expect(findVerificationCode('', 'Din engangskode er 482913')).toBe('482913');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an order confirmation', 'Deine Bestellung Nr. 302-4829175-1937264',
|
||||
'Vielen Dank für deine Bestellung vom 26.09.2026. Bestellnummer: 302-4829175-1937264 Summe: 49,99 € Deine Kundennummer: 5512345.'],
|
||||
['a shipping notice with a tracking code', 'Ihr Paket ist unterwegs',
|
||||
'Sendungsnummer 00340434161234567890. Mit dem Tracking-Code JJD0003900012 können Sie Ihre Sendung verfolgen. Zustellung am 28.09.2026 zwischen 10:00 und 14:00 Uhr.'],
|
||||
['a promotion', 'Nur heute: 25 % auf alles',
|
||||
'Mit dem Gutscheincode HERBST25 sparst du 25 %. Or use code THANKS10 for 10% off. Use promo code FALL2026 at checkout. © 2026 Shop GmbH, 10115 Berlin'],
|
||||
['a code review notification', 'Re: [bulwarkmail/webmail] Show verification codes (PR #1043)',
|
||||
'The code at line 1042 should use the shared helper, see commit 5f3e2a1 and issue #1039. Claude Code 2.1.14 fixed it.'],
|
||||
['an invoice', 'Ihre Rechnung RE-2026-10432',
|
||||
'Rechnungsbetrag 119,00 EUR. Bitte überweisen Sie bis 10.10.2026 auf IBAN DE89 3704 0044 0532 0130 00, BIC COBADEFFXXX.'],
|
||||
['a sign-in alert without a code', 'Neue Anmeldung bei deinem Konto',
|
||||
'Neue Anmeldung von Windows 11 um 14:32 Uhr in Berlin. Du musst keinen Code eingeben. Rufen Sie uns an unter +49 800 3301000 oder 0800 330 1000.'],
|
||||
['a code of conduct update', 'Updates to our Code of Conduct',
|
||||
'We updated our Code of Conduct effective 1 October 2026. Contact us at 1 Market St, San Francisco, CA 94105.'],
|
||||
['a card notice', 'Ihre Kreditkarte',
|
||||
'Geben Sie niemals Ihren Sicherheitscode oder Ihre PIN weiter. Ihre Karte endet auf 4821. Meeting-ID: 845 1234 5678'],
|
||||
['a QR code', 'Deine Mitgliedskarte', 'Scanne den QR-Code in der App. Deine Mitgliedsnummer 20394857.'],
|
||||
['a kernel log with an oops dump', 'Kernel warnings on host',
|
||||
'kern :warn : [Tue Oct 13 15:37:13 2026] 2026-10-13T15:37:13,834096-04:00 Code: 48 8b 45 00 0f 0b\nkern :warn : [Tue Oct 13 15:37:13 2026] 2026-10-13T15:37:13,834096-04:00 Tainted: [P]=PROPRIETARY_MODULE, [W]=WARN, [O]=OOT_MODULE'],
|
||||
['a code expiry timestamp', 'Your access code', 'Your code expires 2026-10-13T15:37:13Z.'],
|
||||
['a delivery report from Gmail', 'Successfully delivered message',
|
||||
"Your message has been successfully delivered to the following recipients:\n\n<x@gmail.com> (delivered to 'gmail-smtp-in.l.google.com' with code 250 (2.1.5) 'OK ffacd0b43b97d-4887a30a5a2si20984394f4f.30 - gsmtp')"],
|
||||
['a delivery report from Outlook', 'Successfully delivered message',
|
||||
"<x@example.com> (delivered to 'example-com.mail.protection.outlook.com' with code 250 (2.6.0) '2.6.0 <a1b2@mail.example.org> [InternalId=21045339521540, Hostname=PAXPR03MB8065.eurprd03.prod.outlook.com] 12825 bytes in 0.108, 115.431 KB/sec Queued mail for delivery')"],
|
||||
])('finds nothing in %s', (_, subject, text) => {
|
||||
expect(findVerificationCode(subject, text)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listVerificationCode', () => {
|
||||
const now = Date.parse('2026-09-27T12:00:00Z');
|
||||
const row = (receivedAt: string) => ({ subject: 'Your code is 482913', preview: '', receivedAt });
|
||||
|
||||
it('shows the code of a fresh mail', () => {
|
||||
expect(listVerificationCode(row('2026-09-27T11:58:00Z'), now)).toBe('482913');
|
||||
});
|
||||
|
||||
it('skips mail older than a day', () => {
|
||||
expect(listVerificationCode(row(new Date(now - VERIFICATION_CODE_MAX_AGE_MS - 1000).toISOString()), now)).toBeNull();
|
||||
});
|
||||
|
||||
it('allows for a server clock a little ahead', () => {
|
||||
expect(listVerificationCode(row('2026-09-27T12:02:00Z'), now)).toBe('482913');
|
||||
expect(listVerificationCode(row('2026-09-28T12:00:00Z'), now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verificationCodeBodyText', () => {
|
||||
const part = (partId: string, type: string) => ({ partId, blobId: `blob-${partId}`, size: 1, type });
|
||||
const value = (v: string) => ({ value: v, isEncodingProblem: false, isTruncated: false });
|
||||
|
||||
it('reads the HTML part as the reader shows it, without its stylesheet', () => {
|
||||
const text = verificationCodeBodyText({
|
||||
htmlBody: [part('2', 'text/html')],
|
||||
textBody: [part('1', 'text/plain')],
|
||||
bodyValues: {
|
||||
1: value('This email is only available as HTML.'),
|
||||
2: value('<html><body><style>.x{width:600px;color:#123456}</style><table><tr><td>Verification code:</td></tr><tr><td><b>261765</b></td></tr></table></body></html>'),
|
||||
},
|
||||
});
|
||||
expect(text).not.toContain('600px');
|
||||
expect(findVerificationCode('Verification Code', text)).toBe('261765');
|
||||
});
|
||||
|
||||
// Opening a big HTML mail spent most of its script time turning the HTML
|
||||
// into text to look for a code. Without any word that names a code there is
|
||||
// nothing to find, so that step is skipped; it must never skip a mail that
|
||||
// has one, however the word is written.
|
||||
const html = (body: string) => verificationCodeBodyText({
|
||||
htmlBody: [part('1', 'text/html')],
|
||||
textBody: [],
|
||||
bodyValues: { 1: value(`<html><body>${body}</body></html>`) },
|
||||
});
|
||||
|
||||
it('skips reading HTML that names no code', () => {
|
||||
const newsletter = '<h2>Vacation policy update #998</h2><table><tr><td>row-1</td><td>0.16487150</td></tr></table><p>Lorem ipsum 482910 commodo</p>';
|
||||
expect(htmlMayNameACode(newsletter)).toBe(false);
|
||||
expect(html(newsletter)).toBe('');
|
||||
});
|
||||
|
||||
it('still reads HTML whose code word is marked up, escaped or padded', () => {
|
||||
expect(findVerificationCode('', html('Your verification <b>code</b> is 482910'))).toBe('482910');
|
||||
expect(findVerificationCode('', html('Tu código de verificación es 482910'))).toBe('482910');
|
||||
expect(findVerificationCode('', html('Ваш код: 482910'))).toBe('482910');
|
||||
expect(findVerificationCode('', html('Your verification co­de is 482910'))).toBe('482910');
|
||||
expect(findVerificationCode('', html('Ihre mTAN lautet 482910'))).toBe('482910');
|
||||
});
|
||||
|
||||
it('reads plain-text mail', () => {
|
||||
const text = verificationCodeBodyText({
|
||||
htmlBody: [part('1', 'text/plain')],
|
||||
textBody: [part('1', 'text/plain')],
|
||||
bodyValues: { 1: value('Your HA Remote verification code is 811083.') },
|
||||
});
|
||||
expect(findVerificationCode('', text)).toBe('811083');
|
||||
});
|
||||
});
|
||||
|
||||
// A sender's bytes drive these regexes on the UI thread, for every opened
|
||||
// mail and every list row. Each of these inputs took seconds to minutes
|
||||
// before the patterns were anchored; all must stay linear.
|
||||
describe('crafted mail cannot stall the code search', () => {
|
||||
const BUDGET_MS = 250;
|
||||
const part = (partId: string, type: string) => ({ partId, blobId: `blob-${partId}`, size: 1, type });
|
||||
const value = (v: string) => ({ value: v, isEncodingProblem: false, isTruncated: false });
|
||||
function timed(fn: () => unknown): number {
|
||||
const started = performance.now();
|
||||
fn();
|
||||
return performance.now() - started;
|
||||
}
|
||||
const htmlMail = (body: string) => ({
|
||||
htmlBody: [part('1', 'text/html')],
|
||||
textBody: [],
|
||||
bodyValues: { 1: value(body) },
|
||||
});
|
||||
|
||||
it('a megabyte of unclosed <style> tags', () => {
|
||||
const mail = htmlMail('<style>'.repeat(150_000));
|
||||
expect(timed(() => verificationCodeBodyText(mail))).toBeLessThan(BUDGET_MS);
|
||||
});
|
||||
|
||||
it('a body of < without >', () => {
|
||||
const mail = htmlMail('code '.concat('<'.repeat(200_000)));
|
||||
expect(timed(() => verificationCodeBodyText(mail))).toBeLessThan(BUDGET_MS);
|
||||
expect(timed(() => htmlMayNameACode('<'.repeat(200_000)))).toBeLessThan(BUDGET_MS);
|
||||
});
|
||||
|
||||
it('one long word, glued digit groups, and a long subject', () => {
|
||||
expect(timed(() => findVerificationCode('', 'code ' + 'a'.repeat(50_000)))).toBeLessThan(BUDGET_MS);
|
||||
expect(timed(() => findVerificationCode('', 'code ' + 'a1234'.repeat(10_000)))).toBeLessThan(BUDGET_MS);
|
||||
expect(timed(() => findVerificationCode('code ' + 'a'.repeat(200_000), ''))).toBeLessThan(BUDGET_MS);
|
||||
expect(timed(() => findVerificationCode('', 'code ' + 'a'.repeat(20_000) + '...'))).toBeLessThan(BUDGET_MS);
|
||||
});
|
||||
|
||||
it('still finds the code after the trims', () => {
|
||||
expect(findVerificationCode('Your code is 177945 ' + 'x'.repeat(2_000), '')).toBe('177945');
|
||||
expect(findVerificationCode('', 'Claude.ai177945Copy this code')).toBe('177945');
|
||||
expect(findVerificationCode('', 'Your code: 482910 reply to noreply@example.com')).toBe('482910');
|
||||
expect(findVerificationCode('', 'Your code: 48291...')).toBeNull();
|
||||
});
|
||||
});
|
||||
+11
-4
@@ -10,6 +10,7 @@
|
||||
// page verbatim; write escape-free code or double the backslash.
|
||||
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
import { randomHex } from './random';
|
||||
|
||||
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
||||
// user types, and the parent ScrollView handles overflow.
|
||||
@@ -27,14 +28,19 @@ export const CHANGE_THROTTLE_MS = 400;
|
||||
* what the composer produces itself - `data:` (inline and hydrated `cid:`
|
||||
* images) and `blob:` (pasted images) - so a quoted original's tracking
|
||||
* pixels and remote images don't fire just because it is replied to.
|
||||
*
|
||||
* Scripts need the page's `nonce`: the editor's own script carries it, and
|
||||
* inline handlers in seeded or pasted HTML (a quoted `<img onerror=…>`)
|
||||
* don't run. Commands from the app arrive through injectJavaScript, which
|
||||
* page CSP doesn't govern.
|
||||
*/
|
||||
export function buildEditorCsp(blockRemoteImages: boolean): string {
|
||||
export function buildEditorCsp(blockRemoteImages: boolean, nonce?: string): string {
|
||||
const resources = blockRemoteImages
|
||||
? ['img-src data: blob:', 'font-src data:', "media-src 'none'"]
|
||||
: ['img-src data: blob: https: http:', 'font-src data: https: http:', 'media-src data: https: http:'];
|
||||
return [
|
||||
"default-src 'none'",
|
||||
"script-src 'unsafe-inline'",
|
||||
nonce ? `script-src 'nonce-${nonce}'` : "script-src 'none'",
|
||||
"style-src 'unsafe-inline'",
|
||||
...resources,
|
||||
"base-uri 'none'",
|
||||
@@ -108,13 +114,14 @@ export function buildEditorHtml(opts: {
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
const nonce = randomHex(16);
|
||||
const placeholderJson = JSON.stringify(placeholder);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${buildEditorCsp(blockRemoteImages)}" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${buildEditorCsp(blockRemoteImages, nonce)}" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<style>
|
||||
@@ -167,7 +174,7 @@ export function buildEditorHtml(opts: {
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" contenteditable="true" dir="auto" data-placeholder=${placeholderJson}></div>
|
||||
<script>
|
||||
<script nonce="${nonce}">
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastHtml = null;
|
||||
|
||||
+293
-32
@@ -38,9 +38,16 @@ export interface SpfEntry {
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
export interface DkimEntry {
|
||||
result: DkimResult;
|
||||
domain?: string;
|
||||
selector?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticationResults {
|
||||
spf?: { result: SpfResult; domain?: string; all?: SpfEntry[] };
|
||||
dkim?: { result: DkimResult; domain?: string; selector?: string };
|
||||
/** The first signature's result; `all` lists every one when there are several. */
|
||||
dkim?: DkimEntry & { all?: DkimEntry[] };
|
||||
dmarc?: { result: DmarcResult; domain?: string; policy?: DmarcPolicy };
|
||||
iprev?: { result: 'pass' | 'fail'; ip?: string };
|
||||
}
|
||||
@@ -98,35 +105,274 @@ const SPF_SEVERITY: Record<SpfResult, number> = {
|
||||
*/
|
||||
export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
|
||||
if (!auth) return false;
|
||||
// DMARC aligns the visible From with SPF/DKIM, so a DMARC fail is the
|
||||
// strongest single spoofing signal.
|
||||
if (auth.dmarc?.result === 'fail') return true;
|
||||
if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true;
|
||||
// Otherwise a hard SPF fail with no valid DKIM signature means the sender
|
||||
// isn't authorized for the envelope domain.
|
||||
if (auth.spf?.result === 'fail' && !hasDkimPass(auth)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Parse an Authentication-Results header into SPF, DKIM, DMARC, iprev results. */
|
||||
export function parseAuthenticationResults(header: string): AuthenticationResults {
|
||||
function hasDkimPass(auth: AuthenticationResults): boolean {
|
||||
return auth.dkim?.result === 'pass' || !!auth.dkim?.all?.some((entry) => entry.result === 'pass');
|
||||
}
|
||||
|
||||
function domainOf(address: string): string | undefined {
|
||||
const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, '');
|
||||
return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `candidate` is the From domain or a parent/child of it: the
|
||||
* relaxed alignment DMARC applies, without a public-suffix list.
|
||||
*/
|
||||
function alignedWith(fromDomain: string, candidate: string | undefined): boolean {
|
||||
const other = candidate ? domainOf(`@${candidate}`) : undefined;
|
||||
if (!other) return false;
|
||||
return other === fromDomain || other.endsWith(`.${fromDomain}`) || fromDomain.endsWith(`.${other}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* An SPF pass for the MAIL FROM identity whose domain aligns with the From
|
||||
* domain. Any sender can pass SPF for a domain of their own and still write
|
||||
* someone else's address in From; only an aligned pass says anything about
|
||||
* the visible sender. A HELO pass never does.
|
||||
*/
|
||||
function hasAlignedSpfPass(auth: AuthenticationResults, fromDomain: string): boolean {
|
||||
if (auth.spf?.all) {
|
||||
return auth.spf.all.some((entry) => entry.identity !== 'helo' && entry.result === 'pass' && alignedWith(fromDomain, entry.domain));
|
||||
}
|
||||
return auth.spf?.result === 'pass' && alignedWith(fromDomain, auth.spf.domain);
|
||||
}
|
||||
|
||||
/** A DKIM pass for a signing domain that aligns with the From domain. */
|
||||
function hasAlignedDkimPass(auth: AuthenticationResults, fromDomain: string): boolean {
|
||||
const entries = auth.dkim?.all ?? (auth.dkim ? [auth.dkim] : []);
|
||||
return entries.some((entry) => entry.result === 'pass' && alignedWith(fromDomain, entry.domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the receiving server's checks tie the message to the visible From
|
||||
* domain: an aligned DMARC, SPF or DKIM pass. Shared by the sender
|
||||
* verification notice and the calendar invitation trust assessment.
|
||||
*/
|
||||
export function hasAlignedAuthenticationPass(auth: AuthenticationResults | undefined, fromEmail: string | undefined): boolean {
|
||||
if (!auth || !fromEmail) return false;
|
||||
const domain = domainOf(fromEmail);
|
||||
if (!domain) return false;
|
||||
return auth.dmarc?.result === 'pass' || hasAlignedSpfPass(auth, domain) || hasAlignedDkimPass(auth, domain);
|
||||
}
|
||||
|
||||
export interface SenderVerification {
|
||||
/**
|
||||
* `failed`: the message fails the From domain's checks (see
|
||||
* isAuthenticationSpoofed). `unverified`: no SPF or DKIM pass ties the
|
||||
* message to the domain in From.
|
||||
*/
|
||||
status: 'failed' | 'unverified';
|
||||
/** Domain of the visible From address. */
|
||||
domain: string;
|
||||
/** Envelope (MAIL FROM) host, when it differs from `domain`. */
|
||||
sentFrom?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the receiving server's checks back the visible From domain.
|
||||
* Returns null when they do, or when there are no results to judge by.
|
||||
*
|
||||
* DMARC alone can't answer this: mail-auth (Stalwart) only checks alignment
|
||||
* once SPF or DKIM passes, so a message that passes neither reports
|
||||
* `dmarc=none` even when the From domain publishes a policy. That is the
|
||||
* plainest kind of forgery, so it gets its own verdict here.
|
||||
*/
|
||||
export function getSenderVerification(
|
||||
auth: AuthenticationResults | undefined,
|
||||
fromEmail: string | undefined,
|
||||
): SenderVerification | null {
|
||||
if (!auth || !fromEmail || (!auth.spf && !auth.dkim && !auth.dmarc)) return null;
|
||||
const domain = domainOf(fromEmail);
|
||||
if (!domain) return null;
|
||||
|
||||
// DMARC only counts the MAIL FROM identity; a HELO pass proves nothing
|
||||
// about who wrote the message.
|
||||
const mailFrom = auth.spf?.all?.find((entry) => entry.identity === 'mailfrom');
|
||||
const envelope = mailFrom?.domain ?? auth.spf?.domain;
|
||||
const envelopeDomain = envelope ? domainOf(envelope) : undefined;
|
||||
const sentFrom = envelopeDomain && envelopeDomain !== domain ? envelopeDomain : undefined;
|
||||
|
||||
if (isAuthenticationSpoofed(auth)) return { status: 'failed', domain, sentFrom };
|
||||
// Only a pass that aligns with the From domain counts: `spf=pass` for the
|
||||
// sender's own envelope domain under someone else's From is the forgery
|
||||
// this notice exists for, and a DKIM signature by a third domain is no
|
||||
// better.
|
||||
if (hasAlignedAuthenticationPass(auth, fromEmail)) return null;
|
||||
return { status: 'unverified', domain, sentFrom };
|
||||
}
|
||||
|
||||
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** The badge label and the sentence the reader shows for a sender check (webmail email-viewer.tsx). */
|
||||
export function describeSenderVerification(
|
||||
verification: SenderVerification,
|
||||
t: Translate,
|
||||
): { label: string; message: string } {
|
||||
const { status, domain, sentFrom } = verification;
|
||||
if (status === 'failed') {
|
||||
return {
|
||||
label: t('email_viewer.sender_check.failed_label', 'Sender check failed'),
|
||||
message: sentFrom
|
||||
? t('email_viewer.sender_check.failed_sent_from', "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.", { domain, host: sentFrom })
|
||||
: t('email_viewer.sender_check.failed', "This message claims to be from {domain}, but it failed that domain's sender checks. It may be forged.", { domain }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: t('email_viewer.sender_check.unverified_label', 'Unverified sender'),
|
||||
message: sentFrom
|
||||
? t('email_viewer.sender_check.unverified_sent_from', 'This message claims to be from {domain}, but it was sent from {host} and has no valid signature from {domain}.', { domain, host: sentFrom })
|
||||
: t('email_viewer.sender_check.unverified', "This message claims to be from {domain}, but nothing confirms that. It has no valid signature from {domain} and didn't come from a server {domain} authorizes.", { domain }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The sender check for a message straight from its raw headers, for views
|
||||
* that don't hold the parsed header info (the body view's trust gate).
|
||||
*/
|
||||
export function senderVerificationOf(email: Pick<Email, 'headers' | 'from'>): SenderVerification | null {
|
||||
const values = headerValues(email.headers, 'Authentication-Results');
|
||||
if (values.length === 0) return null;
|
||||
return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email);
|
||||
}
|
||||
|
||||
interface ResInfo {
|
||||
method: string;
|
||||
result: string;
|
||||
props: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split one Authentication-Results header into its `;`-separated parts
|
||||
* (RFC 8601), dropping comments. A `;` inside a quoted string or a comment
|
||||
* does not split: both can carry sender-chosen text such as the envelope
|
||||
* address.
|
||||
*/
|
||||
function splitResinfo(header: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
let depth = 0;
|
||||
let quoted = false;
|
||||
for (let i = 0; i < header.length; i++) {
|
||||
const c = header[i];
|
||||
if (c === '\\' && (quoted || depth > 0)) {
|
||||
if (depth === 0) current += c + (header[i + 1] ?? '');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (quoted) {
|
||||
current += c;
|
||||
if (c === '"') quoted = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '(') {
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (depth > 0) {
|
||||
if (c === ')' && --depth === 0) current += ' ';
|
||||
continue;
|
||||
}
|
||||
if (c === '"') quoted = true;
|
||||
if (c === ';') {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += c;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.map((part) => part.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const METHOD_RE = /^([a-z0-9][a-z0-9_-]*)(?:\/\d+)?\s*=\s*([a-z]+)(?=\s|$)/i;
|
||||
const PROP_RE = /\s*([^\s=]+)\s*=\s*("(?:[^"\\]|\\.)*"|\S*)/y;
|
||||
|
||||
/**
|
||||
* Read one resinfo: the method must open the part, so a `dmarc=pass` that
|
||||
* appears inside a property value (say an envelope local part in
|
||||
* `smtp.mailfrom=`) is never taken for a result.
|
||||
*/
|
||||
function parseResinfo(part: string): ResInfo | null {
|
||||
const match = METHOD_RE.exec(part);
|
||||
if (!match) return null;
|
||||
const props: Record<string, string> = {};
|
||||
const rest = part.slice(match[0].length);
|
||||
PROP_RE.lastIndex = 0;
|
||||
let prop: RegExpExecArray | null;
|
||||
while ((prop = PROP_RE.exec(rest)) !== null && prop[0].length > 0) {
|
||||
const key = prop[1].toLowerCase();
|
||||
let value = prop[2];
|
||||
if (value.startsWith('"')) value = value.slice(1, -1).replace(/\\(.)/g, '$1');
|
||||
if (!(key in props)) props[key] = value;
|
||||
}
|
||||
return { method: match[1].toLowerCase(), result: match[2].toLowerCase(), props };
|
||||
}
|
||||
|
||||
function parseResinfos(header: string): ResInfo[] {
|
||||
return splitResinfo(header)
|
||||
.map(parseResinfo)
|
||||
.filter((info): info is ResInfo => info !== null);
|
||||
}
|
||||
|
||||
const DMARC_SEVERITY: Record<string, number> = {
|
||||
fail: 3,
|
||||
permerror: 2,
|
||||
temperror: 2,
|
||||
none: 1,
|
||||
pass: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse Authentication-Results headers into SPF, DKIM, DMARC and iprev
|
||||
* results (port of the webmail's parser, 8f7124c87).
|
||||
*
|
||||
* Pass the headers in message order. The topmost one is the receiving
|
||||
* server's own; anything below it may have been written by the sender, so
|
||||
* DKIM, DMARC and iprev come from the topmost header only, and the others
|
||||
* can only escalate SPF to a failure, never supply a pass. A method counts
|
||||
* only where it opens a `;`-separated part, so `dkim=pass` written into a
|
||||
* comment or an envelope address is not a result.
|
||||
*/
|
||||
export function parseAuthenticationResults(headers: string | readonly string[]): AuthenticationResults {
|
||||
const results: AuthenticationResults = {};
|
||||
const list = typeof headers === 'string' ? [headers] : headers;
|
||||
const perHeader = list.map(parseResinfos);
|
||||
const own = perHeader[0] ?? [];
|
||||
const foreign = perHeader.slice(1).flat();
|
||||
|
||||
// A single header can carry more than one SPF result when the server
|
||||
// evaluates multiple identities (HELO and MAIL FROM). Collect them all so a
|
||||
// hard fail on any identity isn't softened to an ambiguous state recorded
|
||||
// for another one.
|
||||
const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g;
|
||||
const spfResults: SpfEntry[] = [];
|
||||
let spfM: RegExpExecArray | null;
|
||||
while ((spfM = spfRegex.exec(header)) !== null) {
|
||||
spfResults.push({
|
||||
result: spfM[1] as SpfResult,
|
||||
identity: spfM[2] as SpfEntry['identity'],
|
||||
domain: spfM[3],
|
||||
});
|
||||
}
|
||||
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
|
||||
const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror;
|
||||
const toSpfEntry = (info: ResInfo): SpfEntry => {
|
||||
const identity = info.props['smtp.mailfrom'] !== undefined
|
||||
? 'mailfrom'
|
||||
: info.props['smtp.helo'] !== undefined ? 'helo' : undefined;
|
||||
return {
|
||||
result: info.result as SpfResult,
|
||||
identity,
|
||||
domain: identity ? info.props[`smtp.${identity}`] || undefined : undefined,
|
||||
};
|
||||
};
|
||||
const spfResults: SpfEntry[] = [
|
||||
...own.filter((info) => info.method === 'spf').map(toSpfEntry),
|
||||
...foreign.filter((info) => info.method === 'spf').map(toSpfEntry).filter((e) => isFailure(e.result)),
|
||||
];
|
||||
if (spfResults.length > 0) {
|
||||
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
|
||||
// MAIL FROM is the primary SPF identity. Another identity (HELO) may only
|
||||
// escalate the headline to a genuine failure state - a HELO `none` or
|
||||
// `neutral` must not downgrade a MAIL FROM `pass`.
|
||||
const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror;
|
||||
let primary = spfResults.find((e) => e.identity === 'mailfrom') ?? spfResults[0];
|
||||
for (const cur of spfResults) {
|
||||
if (isFailure(cur.result) && severity(cur.result) > severity(primary.result)) {
|
||||
@@ -140,29 +386,44 @@ export function parseAuthenticationResults(header: string): AuthenticationResult
|
||||
};
|
||||
}
|
||||
|
||||
const dkimMatch = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s;]+))?(?:\s+header\.s=([^\s;]+))?/);
|
||||
if (dkimMatch) {
|
||||
// A message can carry several signatures (the author's domain and the
|
||||
// sending service's). The first one stays the headline; keep them all so
|
||||
// a pass further down still counts.
|
||||
const dkimResults: DkimEntry[] = own
|
||||
.filter((info) => info.method === 'dkim')
|
||||
.map((info) => ({
|
||||
result: info.result as DkimResult,
|
||||
domain: info.props['header.d'],
|
||||
selector: info.props['header.s'],
|
||||
}));
|
||||
if (dkimResults.length > 0) {
|
||||
results.dkim = {
|
||||
result: dkimMatch[1] as DkimResult,
|
||||
domain: dkimMatch[2],
|
||||
selector: dkimMatch[3],
|
||||
...dkimResults[0],
|
||||
...(dkimResults.length > 1 ? { all: dkimResults } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const dmarcMatch = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s;]+))?(?:\s+policy\.dmarc=(\w+))?/);
|
||||
if (dmarcMatch) {
|
||||
// One DMARC verdict per message; should a header carry several, the most
|
||||
// severe stands.
|
||||
const dmarc = own
|
||||
.filter((info) => info.method === 'dmarc')
|
||||
.reduce<ResInfo | undefined>(
|
||||
(worst, info) => (!worst || (DMARC_SEVERITY[info.result] ?? -1) > (DMARC_SEVERITY[worst.result] ?? -1) ? info : worst),
|
||||
undefined,
|
||||
);
|
||||
if (dmarc) {
|
||||
results.dmarc = {
|
||||
result: dmarcMatch[1] as DmarcResult,
|
||||
domain: dmarcMatch[2],
|
||||
policy: dmarcMatch[3] as DmarcPolicy | undefined,
|
||||
result: dmarc.result as DmarcResult,
|
||||
domain: dmarc.props['header.from'],
|
||||
policy: dmarc.props['policy.dmarc'] as DmarcPolicy | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/);
|
||||
if (iprevMatch) {
|
||||
const iprev = own.find((info) => info.method === 'iprev');
|
||||
if (iprev) {
|
||||
results.iprev = {
|
||||
result: iprevMatch[1] as 'pass' | 'fail',
|
||||
ip: iprevMatch[2],
|
||||
result: iprev.result as 'pass' | 'fail',
|
||||
ip: iprev.props['policy.iprev'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,8 +522,8 @@ export function deriveHeaderInfo(email: Pick<Email, 'headers' | 'messageId'>): E
|
||||
const headers = email.headers;
|
||||
const authHeaders = headerValues(headers, 'Authentication-Results');
|
||||
// The last hop's results are prepended, so the first header is the
|
||||
// receiving server's own verdict.
|
||||
const auth = authHeaders.length ? parseAuthenticationResults(authHeaders.join('; ')) : undefined;
|
||||
// receiving server's own verdict; the parser trusts only that one.
|
||||
const auth = authHeaders.length ? parseAuthenticationResults(authHeaders) : undefined;
|
||||
|
||||
const spamRaw = headerValue(headers, 'X-Spam-Status')
|
||||
?? headerValue(headers, 'X-Spam-Result')
|
||||
|
||||
+270
-64
@@ -9,12 +9,11 @@
|
||||
// initial about:blank doc - links are opened in the OS browser / composer.
|
||||
// 3. This module pre-strips known-dangerous tags and on* attributes so the
|
||||
// rendered DOM is also clean. Regex stripping is imperfect; it is here
|
||||
// as belt-and-suspenders, not the primary defense. `script-src
|
||||
// 'unsafe-inline'` has to stay on for the injected measurement bridge,
|
||||
// so a parser-differential bypass of the regex stripper would execute in
|
||||
// the WebView. The blast radius is small: the page has no origin, no
|
||||
// storage, no cookies, and the bridge only accepts height/swipe/zoom
|
||||
// strings - but it is the residual risk of not using a real HTML parser.
|
||||
// as belt-and-suspenders, not the primary defense. The attribute pass
|
||||
// reads tags the way the browser's tokenizer does, and the page CSP is
|
||||
// `script-src 'none'` (the bridge scripts are injected by the WebView,
|
||||
// which CSP doesn't govern), so an inline handler that slipped through
|
||||
// would still not run.
|
||||
//
|
||||
// Tag/attribute lists mirror `lib/email-sanitization.ts` from the webmail.
|
||||
// `<style>` is kept on purpose: the WebView is an isolated document, so the
|
||||
@@ -67,54 +66,260 @@ export const TRANSPARENT_BLOCKED_PIXEL =
|
||||
const RASTER_IMAGE_DATA_URI =
|
||||
/^data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)[;,]/i;
|
||||
|
||||
// Matches one start tag, keeping quoted attribute values (which may contain
|
||||
// `>`) intact. Group 1 = tag name, group 2 = raw attribute text.
|
||||
const TAG_RE = /<([a-z][a-z0-9-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
|
||||
// One attribute inside a tag's attribute text.
|
||||
const ATTR_RE = /([^\s=/]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?/g;
|
||||
// ─── Tag tokenizer ───────────────────────────────────────────────────────
|
||||
//
|
||||
// The attribute passes read markup the way the browser's HTML tokenizer does
|
||||
// (WHATWG HTML §13.2.5), not with a tag regex. A regex that disagrees with
|
||||
// the browser about where a tag or an attribute starts is a bypass: the old
|
||||
// one wanted whitespace before `on*`, so `<img src="x"/onerror=…>` kept its
|
||||
// handler, and a stray quote could hide a whole tag from it. Every tag is
|
||||
// rebuilt in one canonical form (`name="value"`, no stray quotes), comments
|
||||
// and bogus comments are dropped, and a `<` that starts no tag is written as
|
||||
// `<`, so later passes and the WebView see the same tags this one did.
|
||||
|
||||
interface ParsedAttr {
|
||||
name: string;
|
||||
/** Unquoted value, or null for a boolean attribute. */
|
||||
/** Raw value (character references intact), or null for a boolean attribute. */
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
function parseAttrs(raw: string): ParsedAttr[] {
|
||||
const out: ParsedAttr[] = [];
|
||||
ATTR_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ATTR_RE.exec(raw)) !== null) {
|
||||
if (!m[1]) continue;
|
||||
const rawValue = m[2];
|
||||
if (rawValue === undefined) {
|
||||
out.push({ name: m[1], value: null });
|
||||
interface ParsedTag {
|
||||
/** Tag name as written. */
|
||||
name: string;
|
||||
attrs: ParsedAttr[];
|
||||
selfClosing: boolean;
|
||||
/** Index just past the closing `>`. */
|
||||
end: number;
|
||||
}
|
||||
|
||||
function isHtmlSpace(ch: string | undefined): boolean {
|
||||
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\f' || ch === '\r';
|
||||
}
|
||||
|
||||
function isAsciiAlpha(ch: string | undefined): boolean {
|
||||
if (!ch) return false;
|
||||
const code = ch.charCodeAt(0) | 0x20;
|
||||
return code >= 97 && code <= 122;
|
||||
}
|
||||
|
||||
/** The browser lowercases tag and attribute names in ASCII only. */
|
||||
function asciiLower(s: string): string {
|
||||
return s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a start or end tag whose name begins at `i`. Returns null when the
|
||||
* input ends inside the tag: the browser drops such a tag and everything
|
||||
* after it.
|
||||
*/
|
||||
function readTag(html: string, i: number): ParsedTag | null {
|
||||
const n = html.length;
|
||||
let j = i;
|
||||
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '/' && html[j] !== '>') j++;
|
||||
const name = html.slice(i, j);
|
||||
const attrs: ParsedAttr[] = [];
|
||||
while (j < n) {
|
||||
const ch = html[j];
|
||||
if (isHtmlSpace(ch)) { j++; continue; }
|
||||
if (ch === '>') return { name, attrs, selfClosing: false, end: j + 1 };
|
||||
if (ch === '/') {
|
||||
// A slash only self-closes right before `>`; anywhere else it just
|
||||
// separates attributes, which is what `<img/onerror=…>` relies on.
|
||||
j++;
|
||||
if (html[j] === '>') return { name, attrs, selfClosing: true, end: j + 1 };
|
||||
continue;
|
||||
}
|
||||
// Attribute name. Its first character may be `=`; after that it runs to
|
||||
// whitespace, `/`, `>` or `=`. Quotes and `<` are part of the name.
|
||||
const start = j;
|
||||
j++;
|
||||
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '/' && html[j] !== '>' && html[j] !== '=') j++;
|
||||
const attrName = html.slice(start, j);
|
||||
while (j < n && isHtmlSpace(html[j])) j++;
|
||||
if (html[j] !== '=') {
|
||||
attrs.push({ name: attrName, value: null });
|
||||
continue;
|
||||
}
|
||||
j++;
|
||||
while (j < n && isHtmlSpace(html[j])) j++;
|
||||
if (j >= n) return null;
|
||||
const q = html[j];
|
||||
if (q === '"' || q === "'") {
|
||||
const close = html.indexOf(q, j + 1);
|
||||
if (close < 0) return null;
|
||||
attrs.push({ name: attrName, value: html.slice(j + 1, close) });
|
||||
j = close + 1;
|
||||
} else if (q === '>') {
|
||||
attrs.push({ name: attrName, value: '' });
|
||||
} else {
|
||||
const q = rawValue[0];
|
||||
const unquoted = (q === '"' || q === "'") ? rawValue.slice(1, -1) : rawValue;
|
||||
out.push({ name: m[1], value: unquoted });
|
||||
const s = j;
|
||||
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '>') j++;
|
||||
attrs.push({ name: attrName, value: html.slice(s, j) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Elements whose content the tokenizer reads as text up to their end tag.
|
||||
// Only `style` and `title` survive the sanitiser; the rest are listed so a
|
||||
// forbidden one is dropped together with its content.
|
||||
const RAW_TEXT_TAGS = new Set([
|
||||
'style', 'title', 'script', 'textarea', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript',
|
||||
]);
|
||||
|
||||
// Attribute names written in a form that every later pass reads the same way.
|
||||
const SAFE_ATTR_NAME = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
|
||||
|
||||
function serializeAttrs(attrs: ParsedAttr[]): string {
|
||||
return attrs
|
||||
.map((a) => (a.value === null ? a.name : `${a.name}="${a.value.replace(/"/g, '"')}"`))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function serializeTag(name: string, attrs: ParsedAttr[], selfClosing: boolean): string {
|
||||
const body = serializeAttrs(attrs);
|
||||
return `<${name}${body ? ' ' + body : ''}${selfClosing ? ' /' : ''}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize `html` and rebuild every start tag through `fn`, which gets the
|
||||
* ASCII-lowercased tag name and the attributes (first of each name only, as
|
||||
* the browser keeps them) and returns the list to write, `null` to keep the
|
||||
* attributes unchanged, or `false` to drop the tag. End tags are written
|
||||
* without attributes; comments, doctypes and bogus comments are dropped.
|
||||
*/
|
||||
function rewriteTags(
|
||||
html: string,
|
||||
fn: (tag: string, attrs: ParsedAttr[]) => ParsedAttr[] | null | false,
|
||||
): string {
|
||||
const n = html.length;
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < n) {
|
||||
const lt = html.indexOf('<', i);
|
||||
if (lt < 0) {
|
||||
out += html.slice(i);
|
||||
break;
|
||||
}
|
||||
out += html.slice(i, lt);
|
||||
const next = html[lt + 1];
|
||||
|
||||
if (isAsciiAlpha(next)) {
|
||||
const tag = readTag(html, lt + 1);
|
||||
if (!tag) break;
|
||||
const lower = asciiLower(tag.name);
|
||||
const seen = new Set<string>();
|
||||
const attrs = tag.attrs.filter((a) => {
|
||||
const key = asciiLower(a.name);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
const result = fn(lower, attrs);
|
||||
i = tag.end;
|
||||
if (result !== false) out += serializeTag(tag.name, result ?? attrs, tag.selfClosing);
|
||||
if (RAW_TEXT_TAGS.has(lower)) {
|
||||
// Copy the element's text verbatim up to its end tag, which the loop
|
||||
// then reads as a tag. A dropped element loses its text too.
|
||||
const close = new RegExp(`</${lower}[\\t\\n\\f\\r />]`, 'gi');
|
||||
close.lastIndex = i;
|
||||
const m = close.exec(html);
|
||||
const textEnd = m ? m.index : n;
|
||||
if (result !== false) out += html.slice(i, textEnd);
|
||||
i = textEnd;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === '/') {
|
||||
const after = html[lt + 2];
|
||||
if (isAsciiAlpha(after)) {
|
||||
const tag = readTag(html, lt + 2);
|
||||
if (!tag) break;
|
||||
if (fn(asciiLower(tag.name), []) !== false) out += `</${tag.name}>`;
|
||||
i = tag.end;
|
||||
continue;
|
||||
}
|
||||
if (after === '>') {
|
||||
i = lt + 3;
|
||||
continue;
|
||||
}
|
||||
if (after === undefined) {
|
||||
out += '</';
|
||||
break;
|
||||
}
|
||||
// Bogus comment up to the next `>`.
|
||||
const gt = html.indexOf('>', lt + 2);
|
||||
i = gt < 0 ? n : gt + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === '!') {
|
||||
if (html.startsWith('<!--', lt)) {
|
||||
// A comment ends at `-->` or `--!>`; `<!-->` and `<!--->` end at once.
|
||||
const k = lt + 4;
|
||||
if (html[k] === '>') { i = k + 1; continue; }
|
||||
if (html.startsWith('->', k)) { i = k + 2; continue; }
|
||||
const a = html.indexOf('-->', k);
|
||||
const b = html.indexOf('--!>', k);
|
||||
if (a < 0 && b < 0) { i = n; continue; }
|
||||
i = a >= 0 && (b < 0 || a < b) ? a + 3 : b + 4;
|
||||
continue;
|
||||
}
|
||||
// Doctype, CDATA outside foreign content and other bogus comments all
|
||||
// end at the first `>`.
|
||||
const gt = html.indexOf('>', lt + 2);
|
||||
i = gt < 0 ? n : gt + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === '?') {
|
||||
const gt = html.indexOf('>', lt + 2);
|
||||
i = gt < 0 ? n : gt + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A `<` that starts no tag is text.
|
||||
out += '<';
|
||||
i = lt + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rebuild every start tag through `fn`, which may edit its attribute list. */
|
||||
function mapTags(
|
||||
html: string,
|
||||
fn: (tag: string, attrs: ParsedAttr[]) => ParsedAttr[] | null,
|
||||
): string {
|
||||
return html.replace(TAG_RE, (full, tag: string, rawAttrs: string) => {
|
||||
const attrs = parseAttrs(rawAttrs);
|
||||
const next = fn(tag.toLowerCase(), attrs);
|
||||
if (next === null) return full;
|
||||
const selfClosing = /\/\s*$/.test(rawAttrs);
|
||||
const body = serializeAttrs(next);
|
||||
return `<${tag}${body ? ' ' + body : ''}${selfClosing ? ' /' : ''}>`;
|
||||
return rewriteTags(html, fn);
|
||||
}
|
||||
|
||||
const FORBIDDEN_TAG_SET = new Set(FORBIDDEN_TAGS);
|
||||
// `on*` handlers, and the rarer `formmethod`, `srcdoc`, `ping` families that
|
||||
// have a side-channel.
|
||||
const DANGEROUS_ATTR = /^(?:on|srcdoc$|ping$|formmethod$|formenctype$|formtarget$|form$)/;
|
||||
const URI_ATTRS = new Set(['href', 'src', 'action', 'xlink:href', 'formaction', 'poster', 'background']);
|
||||
|
||||
/** Drop forbidden tags and dangerous attributes, neutralise unsafe URIs. */
|
||||
function sanitizeTags(html: string): string {
|
||||
return rewriteTags(html, (tag, attrs) => {
|
||||
if (FORBIDDEN_TAG_SET.has(tag)) return false;
|
||||
const kept: ParsedAttr[] = [];
|
||||
for (const attr of attrs) {
|
||||
if (!SAFE_ATTR_NAME.test(attr.name)) continue;
|
||||
const name = asciiLower(attr.name);
|
||||
if (DANGEROUS_ATTR.test(name)) continue;
|
||||
if (attr.value !== null && URI_ATTRS.has(name)) {
|
||||
kept.push({ name: attr.name, value: safeUri(attr.value) });
|
||||
} else if (attr.value !== null && name === 'srcset') {
|
||||
// srcset is a list: check every candidate's scheme individually so
|
||||
// an allowed first candidate can't wave a `data:image/svg+xml` through.
|
||||
kept.push({ name: attr.name, value: safeSrcset(attr.value) });
|
||||
} else {
|
||||
kept.push(attr);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,31 +342,10 @@ export function stripDangerousTags(html: string): string {
|
||||
}
|
||||
} while (out !== prev);
|
||||
|
||||
// Strip inline event handlers - both `on*` and the rarer `formmethod`,
|
||||
// `srcdoc`, `ping` attribute families that have a side-channel.
|
||||
const dangerousAttrs = '(?:on[a-z]+|srcdoc|ping|formmethod|formenctype|formtarget|form)';
|
||||
out = out.replace(new RegExp(`\\s+${dangerousAttrs}\\s*=\\s*"[^"]*"`, 'gi'), '');
|
||||
out = out.replace(new RegExp(`\\s+${dangerousAttrs}\\s*=\\s*'[^']*'`, 'gi'), '');
|
||||
out = out.replace(new RegExp(`\\s+${dangerousAttrs}\\s*=\\s*[^\\s>]+`, 'gi'), '');
|
||||
|
||||
// Neutralize dangerous URI schemes. Allow raster `data:image/*` and `cid:`.
|
||||
out = out.replace(
|
||||
/(\b(?:href|src|action|xlink:href|formaction|poster|background)\s*=\s*)(")([^"]*)(")/gi,
|
||||
(_m, pre, q1, value, q2) => `${pre}${q1}${safeUri(value)}${q2}`,
|
||||
);
|
||||
out = out.replace(
|
||||
/(\b(?:href|src|action|xlink:href|formaction|poster|background)\s*=\s*)(')([^']*)(')/gi,
|
||||
(_m, pre, q1, value, q2) => `${pre}${q1}${safeUri(value)}${q2}`,
|
||||
);
|
||||
|
||||
// srcset is a list: check every candidate's scheme individually so an
|
||||
// allowed first candidate can't wave a `data:image/svg+xml` through.
|
||||
out = out.replace(
|
||||
/(\bsrcset\s*=\s*)(["'])([^"']*)\2/gi,
|
||||
(_m, pre, q, value: string) => `${pre}${q}${safeSrcset(value)}${q}`,
|
||||
);
|
||||
|
||||
return out;
|
||||
// Then read what is left the way the browser will and rebuild every tag:
|
||||
// dangerous attributes and unsafe URIs go whatever precedes them, and
|
||||
// whatever the passes above left half-cut is settled one way.
|
||||
return sanitizeTags(out);
|
||||
}
|
||||
|
||||
function decodeUriEntities(raw: string): string {
|
||||
@@ -409,8 +593,13 @@ export function blockExternalResources(html: string): BlockExternalResult {
|
||||
|
||||
// <style> block CSS.
|
||||
out = out.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style\s*>)/gi, (_m, open, css: string, close) => {
|
||||
const cleaned = stripExternalStyleSheetCss(css);
|
||||
if (cleaned !== css) blocked = true;
|
||||
const stripped = stripExternalStyleSheetCss(css);
|
||||
if (stripped === css) return `${open}${css}${close}`;
|
||||
blocked = true;
|
||||
// The cleaned sheet comes back with its CSS escapes decoded, so an
|
||||
// escaped `\3c /style\3e <img onerror=…>` would turn into markup that
|
||||
// leaves the sheet. Write every `<` as the CSS escape again.
|
||||
const cleaned = stripped.replace(/</g, '\\3c ');
|
||||
return `${open}${cleaned}${close}`;
|
||||
});
|
||||
|
||||
@@ -595,10 +784,12 @@ export interface PreparedEmailHtml {
|
||||
}
|
||||
|
||||
function buildCsp(strict: boolean): string {
|
||||
// Strict CSP. Inline <script> and handlers from the email are stripped by
|
||||
// `stripDangerousTags`, so script-src only needs to allow our own injected
|
||||
// measurement bridge - 'unsafe-inline' is the minimum that lets RN WebView's
|
||||
// injected script run on Android (where page CSP can apply to evaluateJs).
|
||||
// Strict CSP. The document carries no script of its own: the measurement,
|
||||
// swipe, zoom and quote bridges arrive as react-native-webview's injected
|
||||
// JavaScript (evaluateJavascript on Android, WKUserScript/evaluateJavaScript
|
||||
// on iOS), which page CSP doesn't govern, and they attach listeners with
|
||||
// addEventListener. So script-src is 'none': if markup ever slips past
|
||||
// `stripDangerousTags`, its inline handlers and scripts still don't run.
|
||||
//
|
||||
// In blocking mode img/media/font are restricted to data: only - the
|
||||
// network-level backstop for every tracking vector, including ones the
|
||||
@@ -608,7 +799,7 @@ function buildCsp(strict: boolean): string {
|
||||
: ['img-src data: cid: https: http:', 'font-src data: https: http:', 'media-src data: https: http:'];
|
||||
return [
|
||||
"default-src 'none'",
|
||||
"script-src 'unsafe-inline'",
|
||||
"script-src 'none'",
|
||||
"style-src 'unsafe-inline'",
|
||||
...resources,
|
||||
"base-uri 'none'",
|
||||
@@ -754,3 +945,18 @@ export function sniffImageMime(bytes: Uint8Array, fallback?: string): string {
|
||||
if (f.startsWith('image/')) return f;
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a navigation request from the message WebView is the user tapping
|
||||
* a link in the top document. iOS reports how a navigation started; Android
|
||||
* leaves `navigationType` at 'other' for everything, so there only the frame
|
||||
* check applies.
|
||||
*/
|
||||
export function isUserLinkNavigation(
|
||||
request: { navigationType?: string; isTopFrame?: boolean },
|
||||
platform: string,
|
||||
): boolean {
|
||||
if (request.isTopFrame === false) return false;
|
||||
if (platform === 'ios') return request.navigationType === 'click';
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Which icon a folder in the sidebar gets: one per special-use role (#288),
|
||||
// else a plain folder. By role only, as the webmail does
|
||||
// (components/layout/sidebar.tsx): a guess from the name gave a user folder
|
||||
// "web/sentry" the Sent icon (native #80, webmail #1235), and a folder that
|
||||
// holds no sent mail must not look like one that does.
|
||||
|
||||
export type MailboxIconKind =
|
||||
| 'inbox' | 'sent' | 'drafts' | 'trash' | 'junk' | 'archive' | 'important' | 'flagged'
|
||||
| 'all' | 'memos' | 'scheduled' | 'snoozed' | 'shared' | 'folder' | 'folder-open';
|
||||
|
||||
const ROLE_ICONS: Record<string, MailboxIconKind> = {
|
||||
inbox: 'inbox',
|
||||
sent: 'sent',
|
||||
drafts: 'drafts',
|
||||
trash: 'trash',
|
||||
junk: 'junk',
|
||||
spam: 'junk',
|
||||
archive: 'archive',
|
||||
important: 'important',
|
||||
flagged: 'flagged',
|
||||
all: 'all',
|
||||
memos: 'memos',
|
||||
scheduled: 'scheduled',
|
||||
snoozed: 'snoozed',
|
||||
shared: 'shared',
|
||||
};
|
||||
|
||||
export function mailboxIconKind(
|
||||
role: string | null | undefined,
|
||||
hasChildren: boolean,
|
||||
isExpanded: boolean,
|
||||
): MailboxIconKind {
|
||||
const byRole = role ? ROLE_ICONS[role] : undefined;
|
||||
if (byRole) return byRole;
|
||||
return hasChildren && isExpanded ? 'folder-open' : 'folder';
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Shared tags of a group account: the tag names and colours every member of
|
||||
// a shared mailbox sees, kept in the metadata of the group's Inbox
|
||||
// (namespace `org.bulwarkmail.tags`, src/api/bulwark-metadata.ts). Port of
|
||||
// the reading half of the webmail's lib/shared-tags.ts (#1310, #470).
|
||||
//
|
||||
// Keywords in a group account are the same for every member, so
|
||||
// `$label:urgent` on a team message is the team's tag; the shared
|
||||
// definitions name it for everyone. Stored as a map by tag id,
|
||||
// `{ "tags": { "<id>": { "label", "color", ... } } }`; the earlier
|
||||
// `{ "list": [...] }` and bare-array shapes are read too. The app only
|
||||
// reads them: editing them is the webmail's (Settings > Tags).
|
||||
|
||||
import { colors } from '../theme/tokens';
|
||||
import type { KeywordColor, KeywordDef } from '../stores/keywords-store';
|
||||
|
||||
// keywords-store's FALLBACK_KEYWORD_COLOR; not imported, that store imports this module.
|
||||
const FALLBACK_KEYWORD_COLOR: KeywordColor = 'gray';
|
||||
|
||||
/** The metadata key (namespace `org.bulwarkmail.tags`). */
|
||||
export const SHARED_TAGS_KEY = 'tags';
|
||||
|
||||
/** Longest name a shared tag keeps, as in the webmail's tag editor. */
|
||||
export const MAX_SHARED_TAG_LABEL = 30;
|
||||
|
||||
// RFC 8621 keywords are at most 128 bytes, `$label:` included.
|
||||
const MAX_KEYWORD_ID_LENGTH = 128 - '$label:'.length;
|
||||
|
||||
/**
|
||||
* Characters RFC 8621 keywords can't hold (and whitespace), so a tag id
|
||||
* from the server that could never be on a message is dropped.
|
||||
*/
|
||||
const BAD_ID_CHARS = /[\s(){\]%*"\\]/;
|
||||
|
||||
function isKeywordSafe(id: string): boolean {
|
||||
if (!id || id.length > MAX_KEYWORD_ID_LENGTH || BAD_ID_CHARS.test(id)) return false;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const code = id.charCodeAt(i);
|
||||
if (code < 0x20 || code === 0x7f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/**
|
||||
* The app's colour for a webmail palette key. The webmail has a light and a
|
||||
* dark shade of each colour (`blue-light`, `blue-dark`); the app has one.
|
||||
* A colour neither knows shows grey.
|
||||
*/
|
||||
export function keywordColorFromPalette(color: unknown): KeywordColor {
|
||||
if (typeof color !== 'string') return FALLBACK_KEYWORD_COLOR;
|
||||
const base = color.replace(/-(?:light|dark)$/, '');
|
||||
return Object.prototype.hasOwnProperty.call(colors.tags, base) ? base as KeywordColor : FALLBACK_KEYWORD_COLOR;
|
||||
}
|
||||
|
||||
/** The raw entry of each tag by id, from any of the stored shapes. */
|
||||
function entriesOf(value: unknown): Record<string, Record<string, unknown>> {
|
||||
const entries: Record<string, Record<string, unknown>> = {};
|
||||
if (isObject(value) && isObject(value.tags)) {
|
||||
for (const [id, entry] of Object.entries(value.tags)) {
|
||||
if (isKeywordSafe(id) && isObject(entry)) entries[id] = entry;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
const items = Array.isArray(value) ? value : isObject(value) && Array.isArray(value.list) ? value.list : [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (!isObject(item) || typeof item.id !== 'string') continue;
|
||||
const id = item.id.trim();
|
||||
if (!isKeywordSafe(id) || seen.has(id.toLowerCase())) continue;
|
||||
seen.add(id.toLowerCase());
|
||||
entries[id] = item;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** The shared definitions in the namespace's value, by name. */
|
||||
export function parseSharedTags(value: unknown): KeywordDef[] {
|
||||
return Object.entries(entriesOf(value))
|
||||
.map(([id, entry]) => {
|
||||
const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : '';
|
||||
return { id, label: label || id, color: keywordColorFromPalette(entry.color) };
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a message of a group account is tagged with: the shared definitions
|
||||
* first, then the member's own for ids nobody shared, so a tag on a team
|
||||
* message never loses its name just because the team didn't define it.
|
||||
* Ids compare without case, as keywords do.
|
||||
*/
|
||||
export function mergeSharedTags(shared: readonly KeywordDef[], personal: readonly KeywordDef[]): KeywordDef[] {
|
||||
if (shared.length === 0) return personal as KeywordDef[];
|
||||
const named = new Set(shared.map((d) => d.id.toLowerCase()));
|
||||
return [...shared, ...personal.filter((d) => !named.has(d.id.toLowerCase()))];
|
||||
}
|
||||
+18
-3
@@ -165,6 +165,15 @@ function decodeMAPIString(data: Uint8Array, propType: number): string {
|
||||
return decodeUtf8(data.subarray(0, len));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a multi-value count. Every value takes at least four bytes, so a count
|
||||
* larger than what is left of the block is a lie; clamp it rather than loop
|
||||
* up to 2^32 times on the JS thread.
|
||||
*/
|
||||
function readValueCount(r: BinaryReader): number {
|
||||
return Math.min(r.readUint32LE(), Math.floor(r.remaining / 4));
|
||||
}
|
||||
|
||||
function parseMAPIProps(data: Uint8Array): Map<number, { type: number; value: Uint8Array | number | null }> {
|
||||
const props = new Map<number, { type: number; value: Uint8Array | number | null }>();
|
||||
const r = new BinaryReader(data);
|
||||
@@ -198,19 +207,25 @@ function parseMAPIProps(data: Uint8Array): Map<number, { type: number; value: Ui
|
||||
|
||||
if (isVarLengthType(baseType)) {
|
||||
if (r.remaining < 4) break;
|
||||
const valueCount = r.readUint32LE();
|
||||
const valueCount = readValueCount(r);
|
||||
let lastValue: Uint8Array | null = null;
|
||||
for (let j = 0; j < valueCount && r.remaining > 0; j++) {
|
||||
lastValue = readMAPIVarValue(r);
|
||||
const value = readMAPIVarValue(r);
|
||||
// A truncated value consumes nothing; retrying it would spin on the
|
||||
// same bytes for as many rounds as the sender's count asks for.
|
||||
if (value === null) break;
|
||||
lastValue = value;
|
||||
}
|
||||
if (!isMultiValue && lastValue) {
|
||||
props.set(propID, { type: propType, value: lastValue });
|
||||
}
|
||||
} else if (isMultiValue) {
|
||||
if (r.remaining < 4) break;
|
||||
const valueCount = r.readUint32LE();
|
||||
const valueCount = readValueCount(r);
|
||||
for (let j = 0; j < valueCount && r.remaining > 0; j++) {
|
||||
const before = r.remaining;
|
||||
readMAPIFixedValue(r, baseType);
|
||||
if (r.remaining >= before) break;
|
||||
}
|
||||
} else {
|
||||
const value = readMAPIFixedValue(r, baseType);
|
||||
|
||||
@@ -117,3 +117,55 @@ export function parseMailtoUrl(url: string): MailtoFields | null {
|
||||
if (to.length === 0 && cc.length === 0) return null;
|
||||
return { to, cc: cc.length ? cc : undefined, subject, body };
|
||||
}
|
||||
|
||||
const UNSUBSCRIBE_SUBJECT_MAX = 200;
|
||||
const UNSUBSCRIBE_BODY_MAX = 500;
|
||||
|
||||
export interface UnsubscribeMailto {
|
||||
to: [string];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a List-Unsubscribe mailto: URL for a one-tap send from the user's
|
||||
* own account (port of the webmail's parseUnsubscribeMailto, f0aa0eeda).
|
||||
* The newsletter's sender wrote this URL, so it is held to what an
|
||||
* unsubscribe request needs: exactly one recipient, taken from the address
|
||||
* part (`to=` and `cc=` query fields are ignored, a list of addresses is
|
||||
* refused), a single-line subject and a short body. The confirmation shows
|
||||
* all three before anything is sent.
|
||||
*/
|
||||
export function parseUnsubscribeMailto(url: string): UnsubscribeMailto | null {
|
||||
const parsed = parseMailtoUrl(url);
|
||||
if (!parsed) return null;
|
||||
|
||||
const rest = url.slice(7);
|
||||
const queryIndex = rest.indexOf('?');
|
||||
const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex);
|
||||
const addresses = addressPart.split(',').filter((a) => a.trim() !== '');
|
||||
if (addresses.length !== 1) return null;
|
||||
let to: string;
|
||||
try {
|
||||
to = decodeURIComponent(addresses[0]).trim();
|
||||
} catch {
|
||||
to = addresses[0].trim();
|
||||
}
|
||||
if (!isValidEmail(to)) return null;
|
||||
|
||||
const subject = parsed.subject?.replace(/[\r\n]+/g, ' ').trim().slice(0, UNSUBSCRIBE_SUBJECT_MAX) || undefined;
|
||||
const body = parsed.body?.slice(0, UNSUBSCRIBE_BODY_MAX) || undefined;
|
||||
return { to: [to], subject, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* The mailto confirmation text: the explanation, then the address, subject
|
||||
* and body that will be sent, so nothing leaves the account unseen.
|
||||
*/
|
||||
export function unsubscribeConfirmMessage(intro: string, fields: UnsubscribeMailto | null): string {
|
||||
if (!fields) return intro;
|
||||
const lines = [intro, '', fields.to[0]];
|
||||
if (fields.subject) lines.push(fields.subject);
|
||||
if (fields.body) lines.push('', fields.body);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
// Port of the webmail's lib/verification-code.ts (3ed484df and later).
|
||||
import type { Email } from '../api/types';
|
||||
import { pickEmailBody, selectRenderableHtml } from './email-body';
|
||||
|
||||
/**
|
||||
* Finds the one-time code in a sign-in or confirmation mail ("Your
|
||||
* verification code is 177945", "HUK6QR ist Ihr Bestätigungscode"), so the
|
||||
* list and the reader can offer it for copying.
|
||||
*
|
||||
* A token only counts when it looks like a code and sits near a word that
|
||||
* names one (code, Bestätigungscode, Passcode, PIN, OTP, ...). Mail is full
|
||||
* of numbers — dates, prices, postcodes, order and phone numbers — and a
|
||||
* wrong chip is worse than none, so anything doubtful is left out.
|
||||
*/
|
||||
|
||||
// A code this far after its keyword ("Der Bestätigungscode ist unten
|
||||
// aufgeführt. Bitte geben Sie ihn ... ein. 37CCHC") still counts, one this far
|
||||
// before it ("548980 ist Ihr Bestätigungscode") too.
|
||||
const MAX_AFTER = 120;
|
||||
const MAX_BEFORE = 60;
|
||||
|
||||
// Words that end in a code keyword but name something else: "Gutscheincode",
|
||||
// "Postcode", "Barcode", and the word before a bare "code" ("promo code",
|
||||
// "Claude Code", "VS Code").
|
||||
const NOT_A_CODE_PREFIXES = new Set([
|
||||
'en', 'de', 'uni', 'x', 'vs', 'op', 'byte', 'bar', 'post', 'zip', 'qr', 'geo', 'short', 'pseudo',
|
||||
'hash', 'leet', 'trans', 're', 'source', 'quell', 'programm', 'program', 'status', 'error',
|
||||
'fehler', 'dress', 'color', 'colour', 'farb', 'html', 'css', 'embed', 'tracking', 'sendungs',
|
||||
'referral', 'empfehlungs', 'sort', 'swift', 'tax', 'ean', 'produkt', 'product', 'artikel',
|
||||
'promo', 'coupon', 'discount', 'voucher', 'gift', 'gutschein', 'rabatt', 'aktions', 'geschenk',
|
||||
'bonus', 'vorteils', 'area', 'country', 'länder', 'laender', 'landes', 'claude', 'studio',
|
||||
]);
|
||||
|
||||
// Word endings that name a code, across the languages the app ships.
|
||||
// "bestätigungscode", "verificatiecode", "engangskode", "vahvistuskoodi",
|
||||
// "verifieringskod", "ellenőrzőkód", "код", ...
|
||||
const CODE_WORD_ENDINGS = [
|
||||
'code', 'codes', 'codice', 'codici', 'código', 'códigos', 'codigo', 'codigos', 'cod', 'kod',
|
||||
'kodu', 'kodem', 'kodunuz', 'kód', 'kódot', 'kódu', 'kode', 'koden', 'koodi', 'koodia', 'kood',
|
||||
'kods', 'kodas', 'код', 'кода', 'коду', 'кодом', 'κωδικός', 'κωδικό', 'קוד', 'رمز', 'کد',
|
||||
];
|
||||
|
||||
const CODE_WORDS_EXACT = new Set(['passcode', 'einmalkennwort', 'einmalpasswort']);
|
||||
|
||||
// Only ever written in capitals when they mean a code; "pin" and "tan" are
|
||||
// ordinary words too.
|
||||
const UPPERCASE_CODE_WORDS = /^(?:PIN|OTP|(?:m|sms|push|chip|photo|i|e)?TAN)$/;
|
||||
|
||||
// Scripts without spaces between words, matched as substrings.
|
||||
const CJK_CODE_WORDS = /コード|認証番号|確認番号|验证码|驗證碼|校验码|动态码|인증\s?번호|인증\s?코드|코드/g;
|
||||
|
||||
const ONE_TIME_PASSWORD = /\b(?:one[- ]time|single[- ]use|temporary) pass(?:word|code)\b/gi;
|
||||
|
||||
const WORD = /[\p{L}\p{M}]+(?:-[\p{L}\p{M}]+)*/gu;
|
||||
|
||||
// "with code 250 (2.1.5)" in a delivery report: a code word followed by a
|
||||
// number too short to be a one-time code names a status or error code.
|
||||
const LABELS_SHORT_NUMBER = /^\s*[:=]?\s*\d{1,3}(?![\p{L}\p{N}]|[.,]\d|[ -]\d)/u;
|
||||
|
||||
function isCodeWordPart(part: string, previous: string | undefined): boolean {
|
||||
if (UPPERCASE_CODE_WORDS.test(part)) return true;
|
||||
const lower = part.toLowerCase();
|
||||
if (CODE_WORDS_EXACT.has(lower)) return true;
|
||||
for (const ending of CODE_WORD_ENDINGS) {
|
||||
if (!lower.endsWith(ending)) continue;
|
||||
const prefix = lower.slice(0, lower.length - ending.length);
|
||||
// A bare "code" takes its meaning from the word before it.
|
||||
const qualifier = prefix || previous?.toLowerCase();
|
||||
return !qualifier || !NOT_A_CODE_PREFIXES.has(qualifier);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface Span { start: number; end: number }
|
||||
|
||||
function findCodeWords(text: string): Span[] {
|
||||
const spans: Span[] = [];
|
||||
let previousWord: string | undefined;
|
||||
for (const match of text.matchAll(WORD)) {
|
||||
const start = match.index!;
|
||||
const end = start + match[0].length;
|
||||
const parts = match[0].split('-');
|
||||
const hit = parts.some((part, i) => isCodeWordPart(part, i > 0 ? parts[i - 1] : previousWord));
|
||||
const next = text.slice(end, end + 12);
|
||||
// "Code of Conduct", "code of practice"
|
||||
if (hit && !/^\s+of\s/i.test(next) && !LABELS_SHORT_NUMBER.test(next)) spans.push({ start, end });
|
||||
previousWord = parts[parts.length - 1];
|
||||
}
|
||||
for (const re of [CJK_CODE_WORDS, ONE_TIME_PASSWORD]) {
|
||||
for (const match of text.matchAll(re)) {
|
||||
spans.push({ start: match.index!, end: match.index! + match[0].length });
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
// Tried in this order at each position, so "557 260" and "6ZA-BRE" are taken
|
||||
// whole before their pieces are.
|
||||
const CANDIDATE = new RegExp(
|
||||
[
|
||||
// 6ZA-BRE, NJF63-SW7S7, and 2025-12-12 (dropped later)
|
||||
String.raw`(?<![\p{L}\p{N}])[A-Za-z0-9]{2,8}(?:-[A-Za-z0-9]{2,8}){1,3}(?![\p{L}\p{N}])`,
|
||||
// 557 260, 123-456
|
||||
String.raw`(?<!\d)\d{3,4}[ -]\d{3,4}(?!\d)`,
|
||||
// 3 0 4 6 2 7
|
||||
String.raw`(?<![\p{L}\p{N}])\d(?: \d){3,9}(?![\p{L}\p{N}])`,
|
||||
// 37CCHC, 5aem5f5z, 177945
|
||||
String.raw`(?<![\p{L}\p{N}])[A-Za-z0-9]{4,10}(?![\p{L}\p{N}])`,
|
||||
].join('|'),
|
||||
'gu',
|
||||
);
|
||||
|
||||
// Digits run into the words around them, which the pass above takes as one
|
||||
// token: "ein:86771674Teile", "Claude.ai177945Copy", "732888Gib".
|
||||
const GLUED_DIGITS = /(?<=\p{L})\d{4,10}(?!\d)|(?<!\d)\d{4,10}(?=\p{L})/gu;
|
||||
|
||||
const ALNUM = /[\p{L}\p{N}]/u;
|
||||
|
||||
/** Whether the digits at start..end have only letters around them, as in
|
||||
* "ai177945Copy", and are not one group of an id like "4887a30a5a2si2098"
|
||||
* or "PAXPR03MB8065". */
|
||||
function gluedToWords(text: string, start: number, end: number): boolean {
|
||||
// A longer run is never word + code + word; walking it whole for every
|
||||
// digit group in it would make one long token cost quadratic time.
|
||||
const floor = Math.max(0, start - GLUED_CONTEXT);
|
||||
const ceiling = Math.min(text.length, end + GLUED_CONTEXT);
|
||||
while (start > floor && ALNUM.test(text[start - 1])) start--;
|
||||
while (end < ceiling && ALNUM.test(text[end])) end++;
|
||||
if ((start > 0 && ALNUM.test(text[start - 1])) || (end < text.length && ALNUM.test(text[end]))) return false;
|
||||
return /^\p{L}*\d+\p{L}*$/u.test(text.slice(start, end));
|
||||
}
|
||||
const GLUED_CONTEXT = 40;
|
||||
|
||||
const YEAR = /^(?:19|20)\d\d$/;
|
||||
// 10min, 600px, 100GB
|
||||
const NUMBER_WITH_UNIT = /^\d+[a-z]{1,3}$/i;
|
||||
// THANKS10, HERBST25, FALL2026
|
||||
const PROMO_CODE = /^[A-Z]{4,}\d{1,4}$/;
|
||||
const PROMO_WORDS = /%|(?<!\p{L})(?:off|rabatt|spar\p{L}*|save|discount|coupon|gutschein\p{L}*|voucher|sale|angebot\p{L}*)(?!\p{L})/iu;
|
||||
// A number labelled as something else: "Kundennummer: 5512345", "Meeting-ID:
|
||||
// 845 1234 5678", "at line 1042", "card ending in 4821". Nouns that also
|
||||
// label codes ("Code für dein Konto: 123456") count only without a colon.
|
||||
const LABELLED_AS_OTHER = new RegExp(
|
||||
String.raw`(?:(?:\p{L}*(?:nummer|number)|(?<!\p{L})(?:nr|no|id|line|zeile|version|tel|telefon|phone|fax|mobil|plz))\.?:?` +
|
||||
String.raw`|(?<!\p{L})(?:order|bestellung|account|konto|invoice|rechnung|ticket|case|page|seite|room|zimmer|commit|issue|pr|build|run|job|step|port)` +
|
||||
String.raw`|ending in|endet auf|endend auf|ends with)[ #]*$`,
|
||||
'iu',
|
||||
);
|
||||
const LOWERCASE_HEX = /^[0-9a-f-]+$/;
|
||||
// "The code is: 123456", "Ihr Code lautet 123456"
|
||||
const LINKED_BY = /(?::|=|(?<!\p{L})(?:is|lautet|ist|est|es|è|é|är|er|jest|je))\s*$/iu;
|
||||
|
||||
const LETTER = /\p{L}/u;
|
||||
|
||||
/** Text around a candidate, not starting or ending inside a word, so that
|
||||
* "off" cut from "offiziellen" does not read as a word of its own. */
|
||||
function contextWindow(text: string, from: number, to: number): string {
|
||||
let start = Math.max(0, from);
|
||||
let end = Math.min(text.length, to);
|
||||
while (start > 0 && start < end && LETTER.test(text[start - 1]) && LETTER.test(text[start])) start++;
|
||||
while (end < text.length && end > start && LETTER.test(text[end]) && LETTER.test(text[end - 1])) end--;
|
||||
return text.slice(start, end);
|
||||
}
|
||||
|
||||
interface Candidate extends Span { code: string; score: number }
|
||||
|
||||
function scoreCandidate(text: string, raw: string, start: number, keywords: Span[]): Candidate | null {
|
||||
const end = start + raw.length;
|
||||
const letters = raw.replace(/[^A-Za-z]/g, '');
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const before = contextWindow(text, start - 40, start);
|
||||
const after = text.slice(end, end + 8);
|
||||
|
||||
if (!digits) return null;
|
||||
if (letters && letters !== letters.toUpperCase() && letters !== letters.toLowerCase()) return null;
|
||||
|
||||
let code = raw;
|
||||
if (!letters) {
|
||||
// Grouped digits are typed without the gaps: "557 260" is 557260.
|
||||
code = digits;
|
||||
if (raw.includes('-') && !/^\d{3,4}-\d{3,4}$/.test(raw)) return null; // dates, phone numbers
|
||||
if (digits.length < 4 || digits.length > 10) return null;
|
||||
// Part of a date, time, decimal or longer number: 12.12.2025, 10:49:27, 1.299,00
|
||||
if (/\d[.,:/-]$/.test(before) || /^[.,:/-]\d/.test(after)) return null;
|
||||
// Issue and ticket numbers, phone numbers, the next group of an IBAN
|
||||
if (/[#+]$/.test(before) || /(?:\d|\))[ /-]?$/.test(before) || /^[ /-]?\d/.test(after)) return null;
|
||||
// Amounts
|
||||
if (/[€$£]\s?$/.test(before) || /^\s?(?:[€$£%]|eur\b|usd\b|chf\b)/i.test(after)) return null;
|
||||
} else if (NUMBER_WITH_UNIT.test(raw) || PROMO_CODE.test(raw)) {
|
||||
return null;
|
||||
} else if (/^\d{4}-\d\d-\d\dT\d/i.test(raw)) {
|
||||
// An ISO timestamp cut at its first colon: 2026-10-13T15:37:13
|
||||
return null;
|
||||
}
|
||||
if (LABELLED_AS_OTHER.test(before)) return null;
|
||||
if (PROMO_WORDS.test(contextWindow(text, start - 60, end + 40))) return null;
|
||||
|
||||
let best = Infinity;
|
||||
let bestAfter = Infinity;
|
||||
for (const kw of keywords) {
|
||||
if (kw.start < end && kw.end > start) return null; // the candidate is part of a keyword ("2FA-Code")
|
||||
if (kw.end <= start && start - kw.end <= MAX_AFTER) {
|
||||
bestAfter = Math.min(bestAfter, start - kw.end);
|
||||
best = Math.min(best, start - kw.end);
|
||||
}
|
||||
if (kw.start >= end && kw.start - end <= MAX_BEFORE) best = Math.min(best, (kw.start - end) * 1.5);
|
||||
}
|
||||
if (best === Infinity) return null;
|
||||
|
||||
// Lowercase letters also make up words, hashes and ids, so they have to
|
||||
// follow their keyword closely ("confirmation code is 5aem5f5z"), and a
|
||||
// lowercase hex string (a commit, a colour, a token) only counts when the
|
||||
// keyword labels it directly ("den Code e382e507-1593-47f8").
|
||||
if (letters && letters === letters.toLowerCase()) {
|
||||
if (bestAfter > 40) return null;
|
||||
if (LOWERCASE_HEX.test(raw) && bestAfter > 2) return null;
|
||||
}
|
||||
|
||||
const linked = LINKED_BY.test(before);
|
||||
const ownLine = /(?:^|\n)\s*$/.test(before) && /^\s*(?:\n|$)/.test(after);
|
||||
// "KI Palooza 2026": a year is only the code when it is presented as one.
|
||||
if (!letters && YEAR.test(digits) && !linked && !ownLine) return null;
|
||||
|
||||
let score = best;
|
||||
if (linked) score -= 15;
|
||||
if (ownLine) score -= 15;
|
||||
return { start, end, code, score };
|
||||
}
|
||||
|
||||
function normalize(text: string): string {
|
||||
return text
|
||||
// Invisible spacers senders put between characters
|
||||
.replace(/[\u00ad\u200b-\u200f\u2060\ufeff]|\u034f/g, '')
|
||||
// Links and addresses carry ids and tokens, never the code to type.
|
||||
// Anchored to the start of a token so a long run of letters is scanned
|
||||
// once, not once per character.
|
||||
.replace(/https?:\/\/\S+|www\.\S+/gi, ' ')
|
||||
.replace(/(?<!\S)\S*@\S+/g, ' ')
|
||||
// Keep line breaks (a code often stands on a line of its own) but not
|
||||
// the runs of blank lines and indentation between table cells.
|
||||
.replace(/[^\S\n]+/g, ' ')
|
||||
.replace(/ ?\n[\s]*/g, '\n')
|
||||
// A preview is cut off with "...", and the word it cut may be half a code.
|
||||
.replace(/(?<![\p{L}\p{N}-])[\p{L}\p{N}-]*(?:\.\.\.|…)\s*$/u, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// A subject is a line, not a document; the MTA's message size is the only
|
||||
// bound on its length, and the list runs this for every row.
|
||||
const MAX_SUBJECT = 1_000;
|
||||
|
||||
function bestCandidate(rawText: string): Candidate | null {
|
||||
const text = normalize(rawText);
|
||||
if (!text) return null;
|
||||
const keywords = findCodeWords(text);
|
||||
if (!keywords.length) return null;
|
||||
|
||||
let best: Candidate | null = null;
|
||||
for (const re of [CANDIDATE, GLUED_DIGITS]) {
|
||||
for (const match of text.matchAll(re)) {
|
||||
if (re === GLUED_DIGITS && !gluedToWords(text, match.index!, match.index! + match[0].length)) continue;
|
||||
const candidate = scoreCandidate(text, match[0], match.index!, keywords);
|
||||
if (candidate && (!best || candidate.score < best.score)) best = candidate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-time code in a mail, or null. `text` is the preview for a list
|
||||
* row and the body for the reader. A code in the subject wins, since
|
||||
* senders that put it there ("Your code is 177945") also tend to repeat
|
||||
* it next to unrelated numbers in the body.
|
||||
*/
|
||||
export function findVerificationCode(subject: string | null | undefined, text: string | null | undefined): string | null {
|
||||
return bestCandidate((subject ?? '').slice(0, MAX_SUBJECT))?.code
|
||||
?? bestCandidate((text ?? '').slice(0, MAX_BODY_TEXT))?.code
|
||||
?? null;
|
||||
}
|
||||
|
||||
/** Only fresh codes are worth a chip in the list; they expire within minutes to a day. */
|
||||
export const VERIFICATION_CODE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function isFreshForVerificationCode(receivedAt: string | undefined, now = Date.now()): boolean {
|
||||
if (!receivedAt) return false;
|
||||
const age = now - new Date(receivedAt).getTime();
|
||||
// A little slack for a server clock ahead of this one.
|
||||
return age >= -5 * 60 * 1000 && age <= VERIFICATION_CODE_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The code for a list row, from the subject and preview the list already
|
||||
* has; older mail is skipped without looking.
|
||||
*/
|
||||
export function listVerificationCode(
|
||||
email: Pick<Email, 'subject' | 'preview' | 'receivedAt'>,
|
||||
now = Date.now(),
|
||||
): string | null {
|
||||
if (!isFreshForVerificationCode(email.receivedAt, now)) return null;
|
||||
return findVerificationCode(email.subject, email.preview);
|
||||
}
|
||||
|
||||
// A code sits near the top; a newsletter of megabytes need not be read whole.
|
||||
const MAX_BODY_HTML = 200_000;
|
||||
const MAX_BODY_TEXT = 50_000;
|
||||
|
||||
// Every code word above contains one of these (any case), so a body without
|
||||
// any of them has no code to find, and the HTML need not be turned into text.
|
||||
const CODE_WORD_HINT = /cod|cód|kod|kód|kood|код|κωδικ|קוד|رمز|کد|kennwort|passwort|pass(?:word|code)|コード|認証番号|確認番号|验证码|驗證碼|校验码|动态码|인증|코드/iu;
|
||||
const UPPERCASE_HINT = /PIN|OTP|TAN/;
|
||||
// Entities that stand for no letter, decoded so they can't hide a code word.
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ',
|
||||
shy: '', zwnj: '', zwj: '', lrm: '', rlm: '', copy: '', reg: '', trade: '', hellip: '', mdash: '',
|
||||
ndash: '', lsquo: '', rsquo: '', ldquo: '', rdquo: '', laquo: '', raquo: '', bull: '', middot: '',
|
||||
euro: '', pound: '', cent: '', yen: '', deg: '', times: '', sect: '', para: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* False when the HTML cannot hold a code word, so verificationCodeBodyText
|
||||
* can skip parsing it (the costly part of opening a big HTML mail). Errs on
|
||||
* the side of true: tags are dropped so a word split by markup joins up
|
||||
* again, numeric entities are decoded, invisible spacers removed, and any
|
||||
* named entity that might be a letter counts as a possible code word.
|
||||
*/
|
||||
export function htmlMayNameACode(html: string): boolean {
|
||||
let unknownEntity = false;
|
||||
const text = html
|
||||
// `[^<>]` so a `<` without a `>` after it is given up at the next `<`,
|
||||
// not after scanning to the end once per `<`.
|
||||
.replace(/<[^<>]*>/g, '')
|
||||
.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (_m, ref: string) => {
|
||||
if (ref[0] === '#') {
|
||||
const cp = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
|
||||
return cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : '';
|
||||
}
|
||||
const known = NAMED_ENTITIES[ref.toLowerCase()];
|
||||
if (known === undefined) unknownEntity = true;
|
||||
return known ?? '';
|
||||
})
|
||||
.replace(/[\u00ad\u200b-\u200f\u2060\ufeff]|\u034f/g, '');
|
||||
return unknownEntity || CODE_WORD_HINT.test(text) || UPPERCASE_HINT.test(text);
|
||||
}
|
||||
|
||||
// HTML 4 entities that stand for a letter (Latin-1 and Greek): a code word
|
||||
// such as "código" has to read as one word.
|
||||
const LETTER_ENTITIES: Record<string, string> = {
|
||||
AElig: 'Æ', Aacute: 'Á', Acirc: 'Â', Agrave: 'À', Alpha: 'Α', Aring: 'Å', Atilde: 'Ã', Auml: 'Ä',
|
||||
Beta: 'Β', Ccedil: 'Ç', Chi: 'Χ', Delta: 'Δ', ETH: 'Ð', Eacute: 'É', Ecirc: 'Ê', Egrave: 'È',
|
||||
Epsilon: 'Ε', Eta: 'Η', Euml: 'Ë', Gamma: 'Γ', Iacute: 'Í', Icirc: 'Î', Igrave: 'Ì', Iota: 'Ι',
|
||||
Iuml: 'Ï', Kappa: 'Κ', Lambda: 'Λ', Mu: 'Μ', Ntilde: 'Ñ', Nu: 'Ν', OElig: 'Œ', Oacute: 'Ó',
|
||||
Ocirc: 'Ô', Ograve: 'Ò', Omega: 'Ω', Omicron: 'Ο', Oslash: 'Ø', Otilde: 'Õ', Ouml: 'Ö', Phi: 'Φ',
|
||||
Pi: 'Π', Psi: 'Ψ', Rho: 'Ρ', Scaron: 'Š', Sigma: 'Σ', THORN: 'Þ', Tau: 'Τ', Theta: 'Θ',
|
||||
Uacute: 'Ú', Ucirc: 'Û', Ugrave: 'Ù', Upsilon: 'Υ', Uuml: 'Ü', Xi: 'Ξ', Yacute: 'Ý', Yuml: 'Ÿ',
|
||||
Zeta: 'Ζ', aacute: 'á', acirc: 'â', aelig: 'æ', agrave: 'à', alefsym: 'ℵ', alpha: 'α', aring: 'å',
|
||||
atilde: 'ã', auml: 'ä', beta: 'β', ccedil: 'ç', chi: 'χ', circ: 'ˆ', delta: 'δ', eacute: 'é',
|
||||
ecirc: 'ê', egrave: 'è', epsilon: 'ε', eta: 'η', eth: 'ð', euml: 'ë', fnof: 'ƒ', gamma: 'γ',
|
||||
iacute: 'í', icirc: 'î', igrave: 'ì', image: 'ℑ', iota: 'ι', iuml: 'ï', kappa: 'κ', lambda: 'λ',
|
||||
micro: 'µ', mu: 'μ', ntilde: 'ñ', nu: 'ν', oacute: 'ó', ocirc: 'ô', oelig: 'œ', ograve: 'ò',
|
||||
omega: 'ω', omicron: 'ο', ordf: 'ª', ordm: 'º', oslash: 'ø', otilde: 'õ', ouml: 'ö', phi: 'φ',
|
||||
pi: 'π', piv: 'ϖ', psi: 'ψ', real: 'ℜ', rho: 'ρ', scaron: 'š', sigma: 'σ', sigmaf: 'ς',
|
||||
szlig: 'ß', tau: 'τ', theta: 'θ', thetasym: 'ϑ', thorn: 'þ', uacute: 'ú', ucirc: 'û', ugrave: 'ù',
|
||||
upsih: 'ϒ', upsilon: 'υ', uuml: 'ü', xi: 'ξ', yacute: 'ý', yuml: 'ÿ', zeta: 'ζ',
|
||||
};
|
||||
|
||||
function decodeEntities(text: string): string {
|
||||
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (m, ref: string) => {
|
||||
if (ref[0] === '#') {
|
||||
const cp = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
|
||||
return cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : '';
|
||||
}
|
||||
return LETTER_ENTITIES[ref] ?? NAMED_ENTITIES[ref.toLowerCase()] ?? m;
|
||||
});
|
||||
}
|
||||
|
||||
// Elements whose boundaries end a line of text.
|
||||
const BLOCK_TAG = /<\/?(?:p|div|tr|li|ul|ol|h[1-6]|table|tbody|thead|blockquote|pre|section|article|header|footer|center|hr)\b[^<>]*>/gi;
|
||||
|
||||
/**
|
||||
* HTML to text for the code search. The webmail parses it with the DOM;
|
||||
* here every pattern stops at the next `<`, so a body of `<` without `>`
|
||||
* costs one pass, not one pass per `<` (the composer's htmlToPlainText would).
|
||||
*/
|
||||
function markupToText(html: string): string {
|
||||
return decodeEntities(
|
||||
html
|
||||
.replace(/<br\b[^<>]*>/gi, '\n')
|
||||
.replace(BLOCK_TAG, '\n')
|
||||
.replace(/<\/?t[dh]\b[^<>]*>/gi, ' ')
|
||||
.replace(/<[^<>]*>/g, ''),
|
||||
)
|
||||
.replace(/[^\S\n]+/g, ' ')
|
||||
.replace(/ ?\n\s*/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** The text of a loaded mail as the reader shows it, for finding its code. */
|
||||
export function verificationCodeBodyText(email: Pick<Email, 'htmlBody' | 'textBody' | 'bodyValues'>): string {
|
||||
const picked = pickEmailBody(email);
|
||||
const html = selectRenderableHtml(picked);
|
||||
if (html) {
|
||||
// htmlToPlainText walks the body, and a <style> in it is text there.
|
||||
// Cut first, then strip: an unclosed element runs to the end of what is
|
||||
// left, once, rather than to the end of the whole body once per opener.
|
||||
const markup = html
|
||||
.slice(0, MAX_BODY_HTML)
|
||||
.replace(/<(style|script|title)\b[^<>]*>[\s\S]*?(?:<\/\1\s*>|$)/gi, ' ');
|
||||
if (!htmlMayNameACode(markup)) return '';
|
||||
return markupToText(markup).slice(0, MAX_BODY_TEXT);
|
||||
}
|
||||
return (picked.text || '').slice(0, MAX_BODY_TEXT);
|
||||
}
|
||||
@@ -23,14 +23,17 @@ import { OfflineBanner } from '../components/OfflineBanner';
|
||||
import {
|
||||
ListAttachmentChips, ListAttachmentOpener, useListRowAttachments,
|
||||
} from '../components/email/ListAttachmentChips';
|
||||
import { VerificationCodeChip, useListVerificationCode } from '../components/email/VerificationCodeChip';
|
||||
import type { LoadListAttachments } from '../lib/list-attachments';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import {
|
||||
useEmailStore, effectiveFolderScope, spansAccounts, accountIdOfRow, deleteDestroysAcrossAccounts,
|
||||
searchSkipsTrashAndJunk, isTrashOrJunkRole, SCOPE_EXCEPT_TRASH_JUNK, spannedAccounts,
|
||||
type EmailFilters,
|
||||
} from '../stores/email-store';
|
||||
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store';
|
||||
import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store';
|
||||
import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../stores/keywords-store';
|
||||
import { mergeSharedTags } from '../lib/shared-tags';
|
||||
import { useBackWhileFocused } from '../lib/use-back-while-focused';
|
||||
import { listBackStep } from '../lib/list-back';
|
||||
import { reportFailure } from '../lib/report-failure';
|
||||
@@ -172,6 +175,7 @@ const EmailRow = React.memo(function EmailRow({
|
||||
const key = rowKeyOf(item);
|
||||
const handlePress = React.useCallback(() => onPress(key), [onPress, key]);
|
||||
const handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]);
|
||||
const verificationCode = useListVerificationCode(item);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -270,6 +274,7 @@ const EmailRow = React.memo(function EmailRow({
|
||||
{singleLine(item.preview)}
|
||||
</Text>
|
||||
)}
|
||||
{verificationCode && <VerificationCodeChip code={verificationCode} disabled={selectionMode} inList />}
|
||||
{onOpenAttachment && (
|
||||
<ListAttachmentChips
|
||||
email={item}
|
||||
@@ -472,6 +477,24 @@ export default function EmailListScreen({
|
||||
const currentRole = currentMailbox?.role ?? null;
|
||||
// The JMAP account behind the open folder (undefined = the user's own).
|
||||
const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||||
|
||||
// A group account's messages carry its shared tags (#1310): read them for
|
||||
// every shared account in the sidebar, and name a row's tags by the
|
||||
// account it lives in.
|
||||
const sharedTags = useKeywordsStore((s) => s.sharedTags);
|
||||
const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags);
|
||||
React.useEffect(() => {
|
||||
for (const accountId of spannedAccounts(mailboxes)) if (accountId) void loadSharedTags(accountId);
|
||||
}, [mailboxes, loadSharedTags]);
|
||||
const keywordDefsByAccount = React.useMemo(() => {
|
||||
const login = currentTagsLogin();
|
||||
const out = new Map<string, KeywordDef[]>();
|
||||
for (const [accountId, entry] of Object.entries(sharedTags)) {
|
||||
if (entry.login === login) out.set(accountId, mergeSharedTags(entry.tags, keywordDefs));
|
||||
}
|
||||
return out;
|
||||
}, [sharedTags, keywordDefs]);
|
||||
const folderKeywordDefs = (currentOwnerAccountId && keywordDefsByAccount.get(currentOwnerAccountId)) || keywordDefs;
|
||||
const inJunk = currentRole === 'junk' || currentRole === 'spam';
|
||||
const showRecipient = currentRole === 'sent' || currentRole === 'drafts';
|
||||
|
||||
@@ -784,7 +807,7 @@ export default function EmailListScreen({
|
||||
showPreview={showPreview}
|
||||
showRecipient={showRecipient}
|
||||
tagIds={rowTagIds.get(key) ?? ''}
|
||||
keywordDefs={keywordDefs}
|
||||
keywordDefs={(item.jmapAccountId && keywordDefsByAccount.get(item.jmapAccountId)) || folderKeywordDefs}
|
||||
disableAvatarImages={inJunk && !showAvatarsInJunk}
|
||||
answered={flags?.answered ?? false}
|
||||
forwarded={flags?.forwarded ?? false}
|
||||
@@ -801,7 +824,7 @@ export default function EmailListScreen({
|
||||
[
|
||||
selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction,
|
||||
swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor,
|
||||
showPreview, showRecipient, keywordDefs, inJunk, showAvatarsInJunk,
|
||||
showPreview, showRecipient, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk,
|
||||
loadAttachments, openAttachment,
|
||||
],
|
||||
);
|
||||
@@ -1000,9 +1023,12 @@ export default function EmailListScreen({
|
||||
}
|
||||
}, [backStep, clearSelection, clearSearchAndFilters]);
|
||||
useBackWhileFocused(backStep !== 'leave', handleBack);
|
||||
const folderScope = effectiveFolderScope(storeSearchQuery, filters);
|
||||
const openRole = currentMailbox?.role;
|
||||
const folderScope = effectiveFolderScope(storeSearchQuery, filters, openRole);
|
||||
// The default search scope leaves Spam and Trash out; "All folders" doesn't.
|
||||
const skipsTrashAndJunk = searchSkipsTrashAndJunk(storeSearchQuery, filters, openRole);
|
||||
// Accounts an "All folders" list could not reach (#1082).
|
||||
const unreachedAccounts = spansAccounts({ searchQuery: storeSearchQuery, filters })
|
||||
const unreachedAccounts = spansAccounts({ searchQuery: storeSearchQuery, filters, mailboxes, currentMailboxId })
|
||||
? Object.values(accountErrors)
|
||||
: [];
|
||||
const scopeFolderName = React.useMemo(() => {
|
||||
@@ -1013,11 +1039,16 @@ export default function EmailListScreen({
|
||||
const keywordFilterLabel = React.useMemo(() => {
|
||||
if (!filters.keyword) return null;
|
||||
const id = filters.keyword.replace(/^\$label:/, '').replace(/^\$color:/, '');
|
||||
return keywordDefs.find((k) => k.id === id)?.label ?? id;
|
||||
}, [filters.keyword, keywordDefs]);
|
||||
return folderKeywordDefs.find((k) => k.id === id)?.label ?? id;
|
||||
}, [filters.keyword, folderKeywordDefs]);
|
||||
const setFolderScope = (scope: string) => {
|
||||
const updated: EmailFilters = { ...filters };
|
||||
if (scope === 'current') delete updated.folder;
|
||||
// The scope a search gets by default stays unset, so it keeps following
|
||||
// the open folder; any other pick is kept as it is.
|
||||
const isDefault = scope === SCOPE_EXCEPT_TRASH_JUNK
|
||||
? !isTrashOrJunkRole(openRole)
|
||||
: scope === 'current' && isTrashOrJunkRole(openRole);
|
||||
if (isDefault) delete updated.folder;
|
||||
else updated.folder = scope;
|
||||
setFilters(updated);
|
||||
};
|
||||
@@ -1399,7 +1430,9 @@ export default function EmailListScreen({
|
||||
<FilterChip
|
||||
icon={<Folder size={12} color={c.textSecondary} />}
|
||||
label={
|
||||
folderScope === 'all'
|
||||
skipsTrashAndJunk
|
||||
? t('advanced_search.all_folders_except_spam_trash', 'All folders except Spam and Trash')
|
||||
: folderScope === 'all'
|
||||
? t('email_list.scope_all_folders', 'All folders')
|
||||
: folderScope === 'current'
|
||||
? t('email_list.scope_this_folder', 'This folder')
|
||||
@@ -1716,9 +1749,14 @@ export default function EmailListScreen({
|
||||
<View>
|
||||
<Text style={styles.filterFieldLabel}>{t('advanced_search.folder', 'Folder')}</Text>
|
||||
<View style={styles.filterToggleGroup}>
|
||||
<ScopeChip
|
||||
label={t('advanced_search.all_folders_except_spam_trash', 'All folders except Spam and Trash')}
|
||||
active={skipsTrashAndJunk}
|
||||
onPress={() => setFolderScope(SCOPE_EXCEPT_TRASH_JUNK)}
|
||||
/>
|
||||
<ScopeChip
|
||||
label={t('email_list.scope_all_folders', 'All folders')}
|
||||
active={folderScope === 'all'}
|
||||
active={folderScope === 'all' && !skipsTrashAndJunk}
|
||||
onPress={() => setFolderScope('all')}
|
||||
/>
|
||||
<ScopeChip
|
||||
@@ -1890,7 +1928,7 @@ export default function EmailListScreen({
|
||||
<TagSheet
|
||||
visible={tagSheetOpen}
|
||||
onClose={() => setTagSheetOpen(false)}
|
||||
keywords={keywordDefs}
|
||||
keywords={folderKeywordDefs}
|
||||
selectedEmails={selectedEmails}
|
||||
onToggle={handleBatchTagToggle}
|
||||
/>
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
type FlagsHint,
|
||||
} from '../lib/email-detail-cache';
|
||||
import { shareEmailEml } from '../lib/email-export';
|
||||
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
import { useKeywordsStore, useAccountKeywordDefs, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
||||
import { useLayout } from '../lib/use-layout';
|
||||
@@ -339,7 +339,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
[bottomActions],
|
||||
);
|
||||
|
||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||
// A group account's messages are tagged with its shared tags (#1310).
|
||||
const keywordDefs = useAccountKeywordDefs(ownerAccountId);
|
||||
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
|
||||
|
||||
@@ -155,7 +155,15 @@ function seed() {
|
||||
|
||||
const ids = () => useEmailStore.getState().emails.map((e) => e.id);
|
||||
|
||||
// "All folders" picked explicitly, unless a test picked a folder: the default
|
||||
// scope also leaves Trash and Junk out (see "the default search scope").
|
||||
async function search(query: string) {
|
||||
const { filters } = useEmailStore.getState();
|
||||
if (!filters.folder) useEmailStore.setState({ filters: { ...filters, folder: 'all' } });
|
||||
await searchDefault(query);
|
||||
}
|
||||
|
||||
async function searchDefault(query: string) {
|
||||
useEmailStore.getState().setSearchQuery(query);
|
||||
await vi.waitFor(() => expect(useEmailStore.getState().loading).toBe(false));
|
||||
}
|
||||
@@ -182,6 +190,36 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Webmail 763732fdc / 3904af2cb: a search leaves Spam and Trash out unless
|
||||
// "All folders" is picked, and one started inside them searches them.
|
||||
describe('the default search scope', () => {
|
||||
it("leaves every account's Trash and Junk out", async () => {
|
||||
await searchDefault('zephyr');
|
||||
|
||||
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']);
|
||||
const queries = server.current!.callsOf('Email/query');
|
||||
expect(queries.map(([, args]) => [args.accountId, args.filter])).toEqual([
|
||||
['c', { operator: 'AND', conditions: [{ text: 'zephyr' }, { inMailboxOtherThan: ['trash', 'junk'] }] }],
|
||||
['team', { operator: 'AND', conditions: [{ text: 'zephyr' }, { inMailboxOtherThan: ['t-trash', 't-junk'] }] }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('searches Trash itself when the search starts there', async () => {
|
||||
useEmailStore.setState({ currentMailboxId: 'team:t-trash' });
|
||||
await searchDefault('zephyr');
|
||||
|
||||
expect(ids()).toEqual(['m5']);
|
||||
const queries = server.current!.callsOf('Email/query');
|
||||
expect(queries.map(([, args]) => args.accountId)).toEqual(['team']);
|
||||
});
|
||||
|
||||
it('keeps Trash and Junk in when "All folders" is picked', async () => {
|
||||
useEmailStore.setState({ filters: { folder: 'all' } });
|
||||
await searchDefault('zephyr');
|
||||
expect(ids()).toContain('m5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('"All folders" search across the own and the team account (#1082)', () => {
|
||||
it('finds the team inbox matches too, in one request', async () => {
|
||||
await search('zephyr');
|
||||
@@ -422,6 +460,36 @@ describe('opening and acting on "All folders" rows (#1082)', () => {
|
||||
expect(server.current!.accounts.team.map((e) => e.id)).not.toContain('m5');
|
||||
});
|
||||
|
||||
// The refused message keeps its row; the others go, in both accounts,
|
||||
// and only they can be undone. Before, one refusal kept every row.
|
||||
it('deletes the rest of a selection across accounts when the server refuses one message', async () => {
|
||||
await search('zephyr');
|
||||
server.current!.refusing.set('team', new Set(['m2']));
|
||||
|
||||
const err = await useEmailStore.getState()
|
||||
.deleteEmailsBatch(['team:m1', 'team:m2', 'c:o1'], 'trash', 'inbox')
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(err).toMatchObject({ failedIds: ['m2'] });
|
||||
expect(ids()).not.toContain('m1');
|
||||
expect(ids()).not.toContain('o1');
|
||||
expect(ids()).toContain('m2');
|
||||
expect(server.current!.accounts.c.find((e) => e.id === 'o1')!.mailboxIds).toEqual({ trash: true });
|
||||
expect(useEmailStore.getState().pendingUndo?.items.map((it) => it.email.id).sort()).toEqual(['m1', 'o1']);
|
||||
});
|
||||
|
||||
it('files the rest when one account refuses a whole move', async () => {
|
||||
await search('zephyr');
|
||||
server.current!.failing.set('team', 'forbidden');
|
||||
|
||||
const err = await useEmailStore.getState().archiveEmailsBatch(['team:m1', 'c:o1']).catch((e: unknown) => e);
|
||||
|
||||
expect(err).toMatchObject({ type: 'forbidden' });
|
||||
expect(ids()).toContain('m1');
|
||||
expect(ids()).not.toContain('o1');
|
||||
expect(server.current!.accounts.c.find((e) => e.id === 'o1')!.mailboxIds).toEqual({ archive: true });
|
||||
});
|
||||
|
||||
it('files spam in each row\'s own Junk', async () => {
|
||||
await search('zephyr');
|
||||
await useEmailStore.getState().markSpam(['team:m2']);
|
||||
|
||||
@@ -154,7 +154,7 @@ import { generateAccountId } from '../../lib/account-utils';
|
||||
const TEST_ACCOUNT_ID = generateAccountId('test@example.com', 'https://mail.example.com');
|
||||
|
||||
import * as emailApi from '../../api/email';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useEmailStore, pagingTotal } from '../email-store';
|
||||
import { useSettingsStore } from '../settings-store';
|
||||
|
||||
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
|
||||
@@ -402,6 +402,40 @@ describe('email-store', () => {
|
||||
expect(useEmailStore.getState().emails).toHaveLength(2);
|
||||
});
|
||||
|
||||
// #1364: a server that sends no `total` must not stop the list after
|
||||
// its first page; full pages keep it going, a short one ends it.
|
||||
it('keeps paging while full pages come back without a total', async () => {
|
||||
useSettingsStore.getState().updateSetting('emailsPerPage', 2);
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [{ id: 'e1' } as any, { id: 'e2' } as any],
|
||||
totalEmails: pagingTotal(0, 2, 2, 2),
|
||||
loading: false,
|
||||
});
|
||||
expect(useEmailStore.getState().totalEmails).toBe(3);
|
||||
|
||||
try {
|
||||
mockQueryEmailPage.mockResolvedValueOnce({
|
||||
ids: ['e3', 'e4'], total: 0, list: [{ id: 'e3' }, { id: 'e4' }], threads: [],
|
||||
});
|
||||
await useEmailStore.getState().loadMoreEmails();
|
||||
mockQueryEmailPage.mockResolvedValueOnce({
|
||||
ids: ['e5'], total: 0, list: [{ id: 'e5' }], threads: [],
|
||||
});
|
||||
await useEmailStore.getState().loadMoreEmails();
|
||||
} finally {
|
||||
useSettingsStore.getState().updateSetting('emailsPerPage', 25);
|
||||
}
|
||||
|
||||
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1', 'e2', 'e3', 'e4', 'e5']);
|
||||
expect(mockQueryEmailPage).toHaveBeenLastCalledWith('mb-1', expect.objectContaining({ position: 4 }));
|
||||
// The short page was the last one.
|
||||
expect(useEmailStore.getState().totalEmails).toBe(5);
|
||||
mockQueryEmailPage.mockClear();
|
||||
await useEmailStore.getState().loadMoreEmails();
|
||||
expect(mockQueryEmailPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not load if already at total', async () => {
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
@@ -1033,6 +1067,55 @@ describe('email-store', () => {
|
||||
useSettingsStore.getState().updateSetting('deleteAction', 'trash');
|
||||
});
|
||||
|
||||
// The messages are in Trash: a refused read flag must not report the
|
||||
// delete as failed or keep the rows.
|
||||
it('batch delete with trash-and-read reports no failure when only the read flag is refused', async () => {
|
||||
const { JMAPSetError } = await import('../../api/jmap-result');
|
||||
const refusal = new JMAPSetError('forbidden', 'Failed to update 1 email(s), first: e1 – forbidden');
|
||||
refusal.failedIds = ['e1'];
|
||||
mockPatchKeywords.mockRejectedValueOnce(refusal);
|
||||
useSettingsStore.getState().updateSetting('deleteAction', 'trash-and-read');
|
||||
useEmailStore.setState({
|
||||
mailboxes: [
|
||||
{ id: 'mb-1', name: 'Inbox', role: 'inbox', isShared: false } as any,
|
||||
{ id: 'mb-trash', name: 'Trash', role: 'trash', isShared: false } as any,
|
||||
],
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [
|
||||
{ id: 'e1', keywords: {}, mailboxIds: { 'mb-1': true } } as any,
|
||||
{ id: 'e2', keywords: {}, mailboxIds: { 'mb-1': true } } as any,
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(useEmailStore.getState().deleteEmailsBatch(['e1', 'e2'], 'mb-trash', 'mb-1')).resolves.toBeUndefined();
|
||||
} finally {
|
||||
useSettingsStore.getState().updateSetting('deleteAction', 'trash');
|
||||
}
|
||||
expect(useEmailStore.getState().emails).toEqual([]);
|
||||
expect(useEmailStore.getState().pendingUndo?.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('single delete with trash-and-read reports no failure when the read flag is refused', async () => {
|
||||
mockPatchKeywords.mockRejectedValueOnce(new Error('forbidden'));
|
||||
useSettingsStore.getState().updateSetting('deleteAction', 'trash-and-read');
|
||||
useEmailStore.setState({
|
||||
mailboxes: [
|
||||
{ id: 'mb-1', name: 'Inbox', role: 'inbox', isShared: false } as any,
|
||||
{ id: 'mb-trash', name: 'Trash', role: 'trash', isShared: false } as any,
|
||||
],
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [{ id: 'e1', keywords: {}, mailboxIds: { 'mb-1': true } } as any],
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(useEmailStore.getState().deleteEmail('e1', 'mb-trash', 'mb-1')).resolves.toBeUndefined();
|
||||
} finally {
|
||||
useSettingsStore.getState().updateSetting('deleteAction', 'trash');
|
||||
}
|
||||
expect(useEmailStore.getState().emails).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops the rows the server deleted when it refused others of the selection', async () => {
|
||||
const { JMAPSetError } = await import('../../api/jmap-result');
|
||||
const refusal = new JMAPSetError('forbidden', 'Failed to delete 1 email(s), first: e2 – forbidden');
|
||||
@@ -1589,3 +1672,17 @@ describe('email-store', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagingTotal', () => {
|
||||
it('trusts a total the server gave', () => {
|
||||
expect(pagingTotal(120, 25, 25, 25)).toBe(120);
|
||||
expect(pagingTotal(25, 25, 25, 25)).toBe(25);
|
||||
expect(pagingTotal(0, 0, 0, 25)).toBe(0);
|
||||
});
|
||||
|
||||
it('reads a missing or too-small total from whether the page came back full', () => {
|
||||
expect(pagingTotal(0, 25, 25, 25)).toBe(26);
|
||||
expect(pagingTotal(0, 30, 5, 25)).toBe(30);
|
||||
expect(pagingTotal(10, 50, 25, 25)).toBe(51);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ function matches(email: FakeEmail, filter: Filter | undefined, textModel: Stalwa
|
||||
if (!filter) return true;
|
||||
return textFilterMatches(textModel, filter, { subject: email.subject ?? '', body: email.preview ?? '' }, (key, value) => {
|
||||
if (key === 'inMailbox') return !!email.mailboxIds[value as string];
|
||||
if (key === 'inMailboxOtherThan') return !(value as string[]).some((id) => email.mailboxIds[id]);
|
||||
if (key === 'hasKeyword') return !!email.keywords[value as string];
|
||||
if (key === 'notKeyword') return !email.keywords[value as string];
|
||||
throw new Error(`fake server: unsupported filter ${key}`);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { client, metadata } = vi.hoisted(() => ({
|
||||
client: { username: 'me@example.org' as string | null, serverUrl: 'https://mail.example.org', connectedAccountId: 'own' },
|
||||
metadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({ jmapClient: client }));
|
||||
vi.mock('../../api/bulwark-metadata', () => ({ getBulwarkMetadata: metadata }));
|
||||
|
||||
import { useKeywordsStore, currentTagsLogin } from '../keywords-store';
|
||||
|
||||
const TEAM_TAGS = { tags: { tags: { urgent: { label: 'Urgent', color: 'red' } } } };
|
||||
|
||||
beforeEach(() => {
|
||||
metadata.mockReset();
|
||||
client.username = 'me@example.org';
|
||||
useKeywordsStore.setState({ sharedTags: {} });
|
||||
});
|
||||
|
||||
describe('loadSharedTags', () => {
|
||||
it("reads a group account's shared tags and stamps them with the login", async () => {
|
||||
metadata.mockResolvedValue(TEAM_TAGS);
|
||||
await useKeywordsStore.getState().loadSharedTags('team');
|
||||
expect(metadata).toHaveBeenCalledWith('team');
|
||||
const entry = useKeywordsStore.getState().sharedTags.team;
|
||||
expect(entry.tags).toEqual([{ id: 'urgent', label: 'Urgent', color: 'red' }]);
|
||||
expect(entry.login).toBe(currentTagsLogin());
|
||||
});
|
||||
|
||||
it("never asks for the user's own account, and doesn't ask again while fresh", async () => {
|
||||
metadata.mockResolvedValue(TEAM_TAGS);
|
||||
await useKeywordsStore.getState().loadSharedTags('own');
|
||||
expect(metadata).not.toHaveBeenCalled();
|
||||
await useKeywordsStore.getState().loadSharedTags('team');
|
||||
await useKeywordsStore.getState().loadSharedTags('team');
|
||||
expect(metadata).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the last good tags when a read fails', async () => {
|
||||
metadata.mockResolvedValueOnce(TEAM_TAGS).mockResolvedValueOnce(null);
|
||||
await useKeywordsStore.getState().loadSharedTags('team');
|
||||
await useKeywordsStore.getState().loadSharedTags('team', { force: true });
|
||||
expect(useKeywordsStore.getState().sharedTags.team.tags).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops an answer that arrives after the user switched to another login', async () => {
|
||||
let resolve!: (v: unknown) => void;
|
||||
metadata.mockReturnValue(new Promise((r) => { resolve = r; }));
|
||||
const loading = useKeywordsStore.getState().loadSharedTags('team');
|
||||
client.username = 'other@example.org';
|
||||
resolve(TEAM_TAGS);
|
||||
await loading;
|
||||
expect(useKeywordsStore.getState().sharedTags.team).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+234
-58
@@ -264,11 +264,55 @@ export interface EmailFilters {
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/** The folder scope a query runs in, resolving the unset default. */
|
||||
export function effectiveFolderScope(searchQuery: string, filters: EmailFilters): SearchFolderScope {
|
||||
/**
|
||||
* The `folder` value of the "All folders except Spam and Trash" scope when
|
||||
* it is picked explicitly (from inside Spam or Trash, where it is not the
|
||||
* default). It spans the folders like "All folders".
|
||||
*/
|
||||
export const SCOPE_EXCEPT_TRASH_JUNK = 'all-except-trash-junk';
|
||||
|
||||
/**
|
||||
* The folder scope a query runs in, resolving the unset default. A search
|
||||
* from inside Spam or Trash looks there; anywhere else it spans every
|
||||
* folder (and leaves Spam and Trash out, see `searchSkipsTrashAndJunk`).
|
||||
* Webmail 763732fdc, 3904af2cb.
|
||||
*/
|
||||
export function effectiveFolderScope(
|
||||
searchQuery: string,
|
||||
filters: EmailFilters,
|
||||
openRole?: string | null,
|
||||
): SearchFolderScope {
|
||||
if (filters.keyword) return 'all';
|
||||
if (filters.folder === SCOPE_EXCEPT_TRASH_JUNK) return 'all';
|
||||
if (filters.folder) return filters.folder;
|
||||
return searchQuery.trim() ? 'all' : 'current';
|
||||
if (!searchQuery.trim()) return 'current';
|
||||
return isTrashOrJunkRole(openRole) ? 'current' : 'all';
|
||||
}
|
||||
|
||||
/** Trash, or Junk under either role name servers use. */
|
||||
export function isTrashOrJunkRole(role: string | null | undefined): boolean {
|
||||
return role === 'trash' || role === 'junk' || role === 'spam';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a search across folders leaves every account's Trash and Junk
|
||||
* out: the default scope does, "All folders" picked explicitly doesn't.
|
||||
*/
|
||||
export function searchSkipsTrashAndJunk(
|
||||
searchQuery: string,
|
||||
filters: EmailFilters,
|
||||
openRole?: string | null,
|
||||
): boolean {
|
||||
if (filters.keyword) return false;
|
||||
if (filters.folder === SCOPE_EXCEPT_TRASH_JUNK) return true;
|
||||
if (filters.folder) return false;
|
||||
return effectiveFolderScope(searchQuery, filters, openRole) === 'all';
|
||||
}
|
||||
|
||||
// The role of the folder on screen.
|
||||
function openRoleOf(state: Partial<Pick<EmailState, 'mailboxes' | 'currentMailboxId'>>): string | undefined {
|
||||
if (!state.currentMailboxId || !state.mailboxes) return undefined;
|
||||
return state.mailboxes.find((m) => m.id === state.currentMailboxId)?.role ?? undefined;
|
||||
}
|
||||
|
||||
// Snapshot of an action that can still be reversed via the undo snackbar.
|
||||
@@ -487,7 +531,7 @@ function buildJmapFilter(
|
||||
// The raw mailbox id an Email/query is scoped to: the open folder, an
|
||||
// explicitly picked folder, or undefined for "all folders" (#788).
|
||||
function queryScope(state: EmailState, current: MailboxRef): { mailboxId: string | undefined; accountId?: string } {
|
||||
const scope = effectiveFolderScope(state.searchQuery, state.filters);
|
||||
const scope = effectiveFolderScope(state.searchQuery, state.filters, openRoleOf(state));
|
||||
if (scope === 'current') return { mailboxId: current.id, accountId: current.accountId };
|
||||
if (scope === 'all') return { mailboxId: undefined, accountId: current.accountId };
|
||||
const ref = refFor(state.mailboxes, scope);
|
||||
@@ -552,8 +596,29 @@ function isUnsupportedSort(err: unknown): boolean {
|
||||
// to one folder stays in its account.
|
||||
|
||||
/** Whether the list on screen spans the own and the shared accounts. */
|
||||
export function spansAccounts(state: Pick<EmailState, 'searchQuery' | 'filters'>): boolean {
|
||||
return effectiveFolderScope(state.searchQuery, state.filters) === 'all';
|
||||
export function spansAccounts(
|
||||
state: Pick<EmailState, 'searchQuery' | 'filters'> & Partial<Pick<EmailState, 'mailboxes' | 'currentMailboxId'>>,
|
||||
): boolean {
|
||||
return effectiveFolderScope(state.searchQuery, state.filters, openRoleOf(state)) === 'all';
|
||||
}
|
||||
|
||||
// The raw JMAP ids of one account's Trash and Junk (undefined = the user's own).
|
||||
function trashAndJunkIds(mailboxes: Mailbox[], accountId: string | undefined): string[] {
|
||||
return accountMailboxes(mailboxes, accountId)
|
||||
.filter((m) => isTrashOrJunkRole(m.role))
|
||||
.map((m) => m.originalId ?? m.id);
|
||||
}
|
||||
|
||||
// `filter` AND `extra`, flattened into an existing top-level AND.
|
||||
function andFilter(
|
||||
filter: Record<string, unknown> | undefined,
|
||||
extra: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!filter || Object.keys(filter).length === 0) return extra;
|
||||
if (filter.operator === 'AND' && Array.isArray(filter.conditions)) {
|
||||
return { operator: 'AND', conditions: [...(filter.conditions as unknown[]), extra] };
|
||||
}
|
||||
return { operator: 'AND', conditions: [filter, extra] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -635,6 +700,22 @@ function listComparator(state: EmailState): (a: Email, b: Email) => number {
|
||||
return compareEmails(levels, { pinnedFirst: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The total that drives load-more. RFC 8620 makes Email/query's `total`
|
||||
* optional and some servers leave it out (#1364); the API layer reads a
|
||||
* missing one as 0, which stopped the list after its first page. A total
|
||||
* below the rows already loaded is one the server didn't give: then a full
|
||||
* page says there may be more (one more than loaded keeps load-more asking)
|
||||
* and a short page that the end is reached. `rowsLoaded` counts this page.
|
||||
*/
|
||||
export function pagingTotal(serverTotal: number, rowsLoaded: number, pageLength: number, limit: number): number {
|
||||
if (serverTotal > 0 && serverTotal >= rowsLoaded) return serverTotal;
|
||||
// One Email/get caps a page below `emailsPerPage` on some servers.
|
||||
const maxGet = typeof jmapClient.getMaxObjectsInGet === 'function' ? jmapClient.getMaxObjectsInGet() : limit;
|
||||
const fullPage = pageLength > 0 && pageLength >= Math.min(limit, maxGet);
|
||||
return fullPage ? rowsLoaded + 1 : rowsLoaded;
|
||||
}
|
||||
|
||||
interface SpanningPage {
|
||||
/** Stamped rows of every account that answered, merged under the list order. */
|
||||
list: Email[];
|
||||
@@ -659,14 +740,23 @@ async function fetchSpanningPage(
|
||||
): Promise<SpanningPage> {
|
||||
const primary = jmapClient.accountId;
|
||||
const threads = !useSettingsStore.getState().disableThreading;
|
||||
const run = async (accounts: Array<string | undefined>) => queryEmailPagesAcrossAccounts(
|
||||
await Promise.all(accounts.map(async (accountId) => ({
|
||||
const skipTrashAndJunk = searchSkipsTrashAndJunk(state.searchQuery, state.filters, openRoleOf(state));
|
||||
const run = async (accounts: Array<string | undefined>) => {
|
||||
const targets = await Promise.all(accounts.map(async (accountId) => ({
|
||||
accountId,
|
||||
position: loaded[accountId ?? primary] ?? 0,
|
||||
sort: await resolveSort(state, accountId),
|
||||
}))),
|
||||
{ limit, filter, threads },
|
||||
);
|
||||
})));
|
||||
if (!skipTrashAndJunk) return queryEmailPagesAcrossAccounts(targets, { limit, filter, threads });
|
||||
// Each account leaves its own Trash and Junk out, so each gets its own
|
||||
// filter (and request).
|
||||
const pages = await Promise.all(targets.map((target) => {
|
||||
const ids = trashAndJunkIds(state.mailboxes, target.accountId);
|
||||
const scoped = ids.length > 0 ? andFilter(filter, { inMailboxOtherThan: ids }) : filter;
|
||||
return queryEmailPagesAcrossAccounts([target], { limit, filter: scoped, threads });
|
||||
}));
|
||||
return pages.map((p) => p[0]);
|
||||
};
|
||||
let pages = await run(spannedAccounts(state.mailboxes));
|
||||
const refused = pages.filter((p) => !p.ok && isUnsupportedSort(p.error));
|
||||
if (refused.length > 0) {
|
||||
@@ -684,7 +774,7 @@ async function fetchSpanningPage(
|
||||
if (!firstError) firstError = p.error;
|
||||
continue;
|
||||
}
|
||||
page.total += p.total;
|
||||
page.total += pagingTotal(p.total, (loaded[stamp] ?? 0) + p.list.length, p.list.length, limit);
|
||||
for (const e of p.list) page.list.push({ ...e, jmapAccountId: stamp });
|
||||
for (const th of p.threads) page.threads.push({ ...th, id: `${stamp}:${th.id}` });
|
||||
}
|
||||
@@ -1143,7 +1233,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { list, total, threads } = await queryEmailPage(scope.mailboxId, {
|
||||
const { list, total: serverTotal, ids, threads } = await queryEmailPage(scope.mailboxId, {
|
||||
position,
|
||||
limit,
|
||||
sort: await resolveSort(state, scope.accountId),
|
||||
@@ -1152,6 +1242,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
threads: !useSettingsStore.getState().disableThreading,
|
||||
});
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
const total = pagingTotal(serverTotal, position + ids.length, ids.length, limit);
|
||||
// A message that arrived between pages shifts positions and would come
|
||||
// back a second time — drop ids we already show (duplicate keys).
|
||||
const existingIds = new Set(get().emails.map((e) => e.id));
|
||||
@@ -1687,13 +1778,21 @@ export const useEmailStore = create<EmailState>()(
|
||||
// delete action, also clear unread state for messages moved to trash.
|
||||
if (settings.deleteAction === 'trash-and-read' && email && !email.keywords?.$seen) {
|
||||
const patch = { $seen: true };
|
||||
await applyOrQueue({
|
||||
kind: 'keywords',
|
||||
emailId,
|
||||
accountId: source.accountId,
|
||||
patch,
|
||||
});
|
||||
patchCache(emailId, { mailboxIds: target, keywords: patch }, source.accountId);
|
||||
// The message is in Trash already: a refused read flag leaves it
|
||||
// unread there, it doesn't make the delete fail.
|
||||
let markedRead = true;
|
||||
try {
|
||||
await applyOrQueue({
|
||||
kind: 'keywords',
|
||||
emailId,
|
||||
accountId: source.accountId,
|
||||
patch,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[email-store] delete: marking read failed', err);
|
||||
markedRead = false;
|
||||
}
|
||||
patchCache(emailId, { mailboxIds: target, ...(markedRead ? { keywords: patch } : {}) }, source.accountId);
|
||||
} else {
|
||||
patchCache(emailId, { mailboxIds: target }, source.accountId);
|
||||
}
|
||||
@@ -1891,26 +1990,36 @@ export const useEmailStore = create<EmailState>()(
|
||||
// message kept every row of the selection, the deleted ones included.
|
||||
let refusal: unknown = null;
|
||||
const kept = new Set<string>();
|
||||
const step = async (run: () => Promise<void>, keepRefused: boolean) => {
|
||||
const step = async (run: () => Promise<void>) => {
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
const failed = failedIdsOf(err);
|
||||
if (!failed) throw err;
|
||||
refusal ??= err;
|
||||
if (keepRefused) for (const id of failed) kept.add(id);
|
||||
for (const id of failed) kept.add(id);
|
||||
}
|
||||
};
|
||||
// Moved to Trash but left unread: the row has still left the folder, and
|
||||
// the delete did what was asked, so a refused read flag is no failure.
|
||||
const leftUnread = new Set<string>();
|
||||
await applyOrQueueBatch(ops, async () => {
|
||||
if (toDestroy.length > 0) {
|
||||
await step(() => apiDeleteEmails(toDestroy.map((e) => e.id), trash.id, trash.id, trash.accountId), true);
|
||||
await step(() => apiDeleteEmails(toDestroy.map((e) => e.id), trash.id, trash.id, trash.accountId));
|
||||
}
|
||||
if (toTrash.length > 0) {
|
||||
await step(() => apiMoveEmails(toTrash.map((e) => e.id), source.id, trash.id, source.accountId), true);
|
||||
await step(() => apiMoveEmails(toTrash.map((e) => e.id), source.id, trash.id, source.accountId));
|
||||
}
|
||||
if (toMarkRead.length > 0) {
|
||||
// Moved but left unread: the row has still left the folder.
|
||||
await step(() => patchKeywordsForEmails([...markReadIds], markReadPatch, source.accountId), false);
|
||||
const markRead = [...markReadIds].filter((id) => !kept.has(id));
|
||||
if (markRead.length > 0) {
|
||||
try {
|
||||
await patchKeywordsForEmails(markRead, markReadPatch, source.accountId);
|
||||
} catch (err) {
|
||||
// A lost connection queues the whole batch instead.
|
||||
if (isTransientNetworkError(err)) throw err;
|
||||
console.warn('[email-store] delete: marking read failed', err);
|
||||
for (const id of failedIdsOf(err) ?? markRead) leftUnread.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
const destroyed = toDestroy.filter((e) => !kept.has(e.id));
|
||||
@@ -1920,7 +2029,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
for (const e of trashed) {
|
||||
patchCache(e.id, {
|
||||
mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id),
|
||||
...(markReadIds.has(e.id) ? { keywords: markReadPatch } : {}),
|
||||
...(markReadIds.has(e.id) && !leftUnread.has(e.id) ? { keywords: markReadPatch } : {}),
|
||||
}, source.accountId);
|
||||
}
|
||||
|
||||
@@ -2283,10 +2392,15 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
const settings = useSettingsStore.getState();
|
||||
const to = toMailboxId ? refFor(state.mailboxes, toMailboxId) : undefined;
|
||||
const ops: OutboxOp[] = [];
|
||||
const runs: Array<() => Promise<unknown>> = [];
|
||||
const cacheUpdates: Array<() => void> = [];
|
||||
// Each server write with the rows it files and their account. `keeps`: a
|
||||
// row the server refuses stays where it is (a move); a refused keyword
|
||||
// patch (marking read on the way to Trash) still lets the row go.
|
||||
const runs: Array<{ run: () => Promise<unknown>; accountId?: string; ids: string[]; keeps: boolean }> = [];
|
||||
// `withPatch` is false for a row whose keyword patch the server refused.
|
||||
const cacheUpdates: Array<{ key: string; apply: (withPatch: boolean) => void }> = [];
|
||||
const items: UndoEntry['items'] = [];
|
||||
const gone: Email[] = [];
|
||||
const gone: Array<{ email: Email; key: string }> = [];
|
||||
const keyOf = (accountId: string | undefined, id: string) => `${accountId ?? ''}:${id}`;
|
||||
const copies: Array<{ accountId?: string; emails: Email[] }> = [];
|
||||
let missing: string | null = null;
|
||||
let refreshFolders = false;
|
||||
@@ -2307,8 +2421,11 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
...(keywordPatch ? { originalKeywords: { ...e.keywords } } : {}),
|
||||
accountId,
|
||||
});
|
||||
cacheUpdates.push(() => patchCache(e.id, { mailboxIds, ...(patch ? { keywords: patch } : {}) }, accountId));
|
||||
gone.push(e);
|
||||
cacheUpdates.push({
|
||||
key: keyOf(accountId, e.id),
|
||||
apply: (withPatch) => patchCache(e.id, { mailboxIds, ...(patch && withPatch ? { keywords: patch } : {}) }, accountId),
|
||||
});
|
||||
gone.push({ email: e, key: keyOf(accountId, e.id) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2327,17 +2444,22 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
for (const e of rows) {
|
||||
ops.push({ kind: 'archive', emailId: e.id, accountId, archiveMailboxId: archiveId, mode, receivedAt: e.receivedAt });
|
||||
items.push({ email: e, originalMailboxIds: { ...e.mailboxIds }, accountId });
|
||||
cacheUpdates.push(() => patchCache(e.id, { mailboxIds: { [archiveId]: true } }, accountId));
|
||||
gone.push(e);
|
||||
cacheUpdates.push({ key: keyOf(accountId, e.id), apply: () => patchCache(e.id, { mailboxIds: { [archiveId]: true } }, accountId) });
|
||||
gone.push({ email: e, key: keyOf(accountId, e.id) });
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
runs.push(() => apiArchiveEmails(
|
||||
rows.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
|
||||
archiveId,
|
||||
mode,
|
||||
toRawMailboxes(mailboxes),
|
||||
runs.push({
|
||||
run: () => apiArchiveEmails(
|
||||
rows.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
|
||||
archiveId,
|
||||
mode,
|
||||
toRawMailboxes(mailboxes),
|
||||
accountId,
|
||||
),
|
||||
accountId,
|
||||
));
|
||||
ids: ids(rows),
|
||||
keeps: true,
|
||||
});
|
||||
if (mode !== 'single') refreshFolders = true;
|
||||
}
|
||||
} else if (action === 'delete') {
|
||||
@@ -2345,10 +2467,12 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
const destroy = emails.filter((e) => destroysOnDelete(state, e));
|
||||
const toTrash = emails.filter((e) => !destroy.includes(e));
|
||||
if (destroy.length > 0) {
|
||||
for (const e of destroy) ops.push({ kind: 'destroy', emailId: e.id, accountId });
|
||||
runs.push(() => apiDestroyEmails(ids(destroy), accountId));
|
||||
cacheUpdates.push(() => dropFromCache(ids(destroy), accountId));
|
||||
gone.push(...destroy);
|
||||
for (const e of destroy) {
|
||||
ops.push({ kind: 'destroy', emailId: e.id, accountId });
|
||||
cacheUpdates.push({ key: keyOf(accountId, e.id), apply: () => dropFromCache([e.id], accountId) });
|
||||
gone.push({ email: e, key: keyOf(accountId, e.id) });
|
||||
}
|
||||
runs.push({ run: () => apiDestroyEmails(ids(destroy), accountId), accountId, ids: ids(destroy), keeps: true });
|
||||
}
|
||||
if (toTrash.length > 0 && !trash) {
|
||||
missing = t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.');
|
||||
@@ -2358,8 +2482,20 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
const unread = settings.deleteAction === 'trash-and-read' ? toTrash.filter((e) => !e.keywords?.$seen) : [];
|
||||
const read = new Set(unread);
|
||||
for (const e of toTrash) fileInto(accountId, [e], trashId, read.has(e) ? { $seen: true } : undefined);
|
||||
runs.push(() => restoreEmailMailboxes(toTrash.map((e) => ({ id: e.id, mailboxIds: { [trashId]: true } })), accountId));
|
||||
if (unread.length > 0) runs.push(() => patchKeywordsForEmails(ids(unread), { $seen: true }, accountId));
|
||||
runs.push({
|
||||
run: () => restoreEmailMailboxes(toTrash.map((e) => ({ id: e.id, mailboxIds: { [trashId]: true } })), accountId),
|
||||
accountId,
|
||||
ids: ids(toTrash),
|
||||
keeps: true,
|
||||
});
|
||||
if (unread.length > 0) {
|
||||
runs.push({
|
||||
run: () => patchKeywordsForEmails(ids(unread), { $seen: true }, accountId),
|
||||
accountId,
|
||||
ids: ids(unread),
|
||||
keeps: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (action === 'spam' || action === 'notSpam') {
|
||||
const dest = action === 'spam' ? findJunkMailbox(mailboxes) : mailboxes.find((m) => m.role === 'inbox');
|
||||
@@ -2369,9 +2505,14 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
}
|
||||
const destId = dest.originalId ?? dest.id;
|
||||
fileInto(accountId, emails, destId, keywordPatch);
|
||||
runs.push(() => (action === 'spam'
|
||||
? apiMarkAsSpam(ids(emails), destId, accountId, { markRead: settings.deleteAction === 'trash-and-read' })
|
||||
: apiUndoSpam(ids(emails), destId, accountId)));
|
||||
runs.push({
|
||||
run: () => (action === 'spam'
|
||||
? apiMarkAsSpam(ids(emails), destId, accountId, { markRead: settings.deleteAction === 'trash-and-read' })
|
||||
: apiUndoSpam(ids(emails), destId, accountId)),
|
||||
accountId,
|
||||
ids: ids(emails),
|
||||
keeps: true,
|
||||
});
|
||||
} else if (to) {
|
||||
if (to.accountId !== accountId) {
|
||||
copies.push({ accountId, emails });
|
||||
@@ -2383,13 +2524,39 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
});
|
||||
if (rows.length === 0) continue;
|
||||
fileInto(accountId, rows, to.id);
|
||||
runs.push(() => restoreEmailMailboxes(rows.map((e) => ({ id: e.id, mailboxIds: { [to.id]: true } })), accountId));
|
||||
runs.push({
|
||||
run: () => restoreEmailMailboxes(rows.map((e) => ({ id: e.id, mailboxIds: { [to.id]: true } })), accountId),
|
||||
accountId,
|
||||
ids: ids(rows),
|
||||
keeps: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The server refuses messages one by one and applies the rest, and one
|
||||
// account may refuse what another takes (a read-only share): the refused
|
||||
// rows stay, the others are filed, and the first refusal is reported once
|
||||
// the list shows the rest. Before, one refused message kept every row.
|
||||
const refused = new Set<string>();
|
||||
const unpatched = new Set<string>();
|
||||
let refusal: unknown = null;
|
||||
if (ops.length > 0) {
|
||||
const { queued } = await applyOrQueueBatch(ops, async () => {
|
||||
for (const run of runs) await run();
|
||||
for (const { run, accountId, ids, keeps } of runs) {
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
// A lost connection queues the whole batch instead.
|
||||
if (isTransientNetworkError(err)) throw err;
|
||||
const failed = (failedIdsOf(err) ?? ids).map((id) => keyOf(accountId, id));
|
||||
if (keeps) {
|
||||
refusal ??= err;
|
||||
for (const key of failed) refused.add(key);
|
||||
} else {
|
||||
for (const key of failed) unpatched.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Year/month archiving may have created folders.
|
||||
if (refreshFolders && !queued) void get().fetchMailboxes();
|
||||
@@ -2397,15 +2564,20 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
for (const copy of copies) {
|
||||
try {
|
||||
await crossAccountMove(copy.emails, { accountId: copy.accountId, id: '' }, to!);
|
||||
gone.push(...copy.emails);
|
||||
gone.push(...copy.emails.map((email) => ({ email, key: keyOf(copy.accountId, email.id) })));
|
||||
dropFromCache(copy.emails.map((e) => e.id), copy.accountId);
|
||||
} catch (err) {
|
||||
missing = err instanceof Error ? err.message : t('notifications.move_failed', 'Move failed');
|
||||
}
|
||||
}
|
||||
for (const update of cacheUpdates) update();
|
||||
for (const { key, apply } of cacheUpdates) if (!refused.has(key)) apply(!unpatched.has(key));
|
||||
if (refused.size > 0) {
|
||||
const filed = items.filter((it) => !refused.has(keyOf(it.accountId, it.email.id)));
|
||||
items.length = 0;
|
||||
items.push(...filed);
|
||||
}
|
||||
|
||||
const goneKeys = new Set(gone.map(rowKeyOf));
|
||||
const goneKeys = new Set(gone.filter(({ key }) => !refused.has(key)).map(({ email }) => rowKeyOf(email)));
|
||||
const count = items.length;
|
||||
const targetName = toMailboxId ? mailboxPath(state.mailboxes, toMailboxId) : undefined;
|
||||
const label = action === 'archive'
|
||||
@@ -2436,6 +2608,7 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
: {}),
|
||||
...(missing ? { error: missing } : {}),
|
||||
});
|
||||
if (refusal) throw refusal;
|
||||
}
|
||||
|
||||
// ── Refresh implementations (wrapped by coalesceRefresh above) ─────────
|
||||
@@ -2832,7 +3005,9 @@ async function refreshEmailsImpl(): Promise<void> {
|
||||
const trimmed = out.slice(0, Math.max(limit, baseEmails.length));
|
||||
|
||||
const nextQueryState = queryChanges.newQueryState;
|
||||
const nextTotal = queryChanges.total;
|
||||
// Without a total from the server, a window of at least a page
|
||||
// may go on past it.
|
||||
const nextTotal = pagingTotal(queryChanges.total, trimmed.length, trimmed.length, limit);
|
||||
|
||||
if (viewChanged()) return;
|
||||
|
||||
@@ -2896,11 +3071,12 @@ async function refreshEmailsImpl(): Promise<void> {
|
||||
|
||||
if (viewChanged()) return;
|
||||
|
||||
const total = pagingTotal(queryRes.total, queryRes.ids.length, queryRes.ids.length, limit);
|
||||
const updates: Partial<EmailState> = {
|
||||
// Rows the user just read/unstarred in this filtered view stay put
|
||||
// until the view is re-opened, instead of vanishing under them.
|
||||
emails: baseView ? queryRes.list : mergeRetainedRows(get().emails, queryRes.list, get().retainedIds),
|
||||
totalEmails: queryRes.total,
|
||||
totalEmails: total,
|
||||
threadCounts: withThreadCounts(get().threadCounts, queryRes.threads),
|
||||
loading: false,
|
||||
};
|
||||
@@ -2916,7 +3092,7 @@ async function refreshEmailsImpl(): Promise<void> {
|
||||
...get().mailboxSnapshots,
|
||||
[currentMailboxId]: {
|
||||
emails: queryRes.list,
|
||||
total: queryRes.total,
|
||||
total,
|
||||
queryState: queryRes.queryState,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { colors } from '../theme/tokens';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { getBulwarkMetadata } from '../api/bulwark-metadata';
|
||||
import { SHARED_TAGS_KEY, mergeSharedTags, parseSharedTags } from '../lib/shared-tags';
|
||||
|
||||
const STORAGE_KEY = 'webmail:keywords:v1';
|
||||
|
||||
@@ -37,9 +41,28 @@ export function keywordToken(id: string): string {
|
||||
return `$label:${id}`;
|
||||
}
|
||||
|
||||
/** A group account's shared tags (lib/shared-tags.ts), as last read. */
|
||||
export interface SharedTagsEntry {
|
||||
/** Server and user that read them: account ids repeat across servers. */
|
||||
login: string;
|
||||
tags: KeywordDef[];
|
||||
loadedAt: number;
|
||||
}
|
||||
|
||||
/** Shared tags older than this are read again when a view asks for them. */
|
||||
export const SHARED_TAGS_STALE_MS = 2 * 60_000;
|
||||
|
||||
interface KeywordsState {
|
||||
keywords: KeywordDef[];
|
||||
hydrated: boolean;
|
||||
/** Shared tag definitions of the group accounts, by JMAP account id. */
|
||||
sharedTags: Record<string, SharedTagsEntry>;
|
||||
/**
|
||||
* Read a group account's shared tags from its Inbox metadata, unless they
|
||||
* were read for this login in the last two minutes. Nothing goes out for
|
||||
* the user's own account or one that keeps no metadata (Stalwart 0.16).
|
||||
*/
|
||||
loadSharedTags: (accountId: string, options?: { force?: boolean }) => Promise<void>;
|
||||
hydrate: () => Promise<void>;
|
||||
add: (kw: KeywordDef) => void;
|
||||
update: (id: string, patch: Partial<Omit<KeywordDef, 'id'>>) => void;
|
||||
@@ -47,6 +70,15 @@ interface KeywordsState {
|
||||
resetDefaults: () => void;
|
||||
}
|
||||
|
||||
/** The login the JMAP client is connected as, '' before a session. */
|
||||
export function currentTagsLogin(): string {
|
||||
const user = jmapClient.username;
|
||||
const server = jmapClient.serverUrl;
|
||||
return user && server ? `${server}|${user}` : '';
|
||||
}
|
||||
|
||||
const sharedInflight = new Map<string, Promise<void>>();
|
||||
|
||||
function persist(keywords: KeywordDef[]): void {
|
||||
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keywords)).catch((err) => {
|
||||
console.warn('[keywords-store] persist failed', err);
|
||||
@@ -56,6 +88,34 @@ function persist(keywords: KeywordDef[]): void {
|
||||
export const useKeywordsStore = create<KeywordsState>((set, get) => ({
|
||||
keywords: DEFAULT_KEYWORDS,
|
||||
hydrated: false,
|
||||
sharedTags: {},
|
||||
|
||||
loadSharedTags: (accountId, options) => {
|
||||
const login = currentTagsLogin();
|
||||
if (!login || !accountId || accountId === jmapClient.connectedAccountId) return Promise.resolve();
|
||||
const current = get().sharedTags[accountId];
|
||||
if (!options?.force && current?.login === login && Date.now() - current.loadedAt < SHARED_TAGS_STALE_MS) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const key = `${login}|${accountId}`;
|
||||
const running = sharedInflight.get(key);
|
||||
if (running) return running;
|
||||
const run = (async () => {
|
||||
const entries = await getBulwarkMetadata(accountId);
|
||||
// The user switched accounts meanwhile: these tags are another login's.
|
||||
if (currentTagsLogin() !== login) return;
|
||||
// A failed read (or none to make) keeps the last good tags.
|
||||
if (!entries) return;
|
||||
set((s) => ({
|
||||
sharedTags: {
|
||||
...s.sharedTags,
|
||||
[accountId]: { login, tags: parseSharedTags(entries[SHARED_TAGS_KEY]), loadedAt: Date.now() },
|
||||
},
|
||||
}));
|
||||
})().finally(() => sharedInflight.delete(key));
|
||||
sharedInflight.set(key, run);
|
||||
return run;
|
||||
},
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
@@ -97,3 +157,22 @@ export const useKeywordsStore = create<KeywordsState>((set, get) => ({
|
||||
persist(DEFAULT_KEYWORDS);
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* The tag definitions for messages of a JMAP account: a group account's
|
||||
* shared tags first (read from its Inbox metadata, Stalwart 1.0), then the
|
||||
* user's own; just the user's own for their own account or before the
|
||||
* shared ones are known. `accountId` undefined means the user's own.
|
||||
*/
|
||||
export function useAccountKeywordDefs(accountId: string | undefined): KeywordDef[] {
|
||||
const personal = useKeywordsStore((s) => s.keywords);
|
||||
const entry = useKeywordsStore((s) => (accountId ? s.sharedTags[accountId] : undefined));
|
||||
const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags);
|
||||
React.useEffect(() => {
|
||||
if (accountId) void loadSharedTags(accountId);
|
||||
}, [accountId, loadSharedTags]);
|
||||
return React.useMemo(
|
||||
() => (entry && entry.login === currentTagsLogin() ? mergeSharedTags(entry.tags, personal) : personal),
|
||||
[entry, personal],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,6 +185,9 @@ interface PersistedSettings {
|
||||
deleteAction: DeleteAction;
|
||||
permanentlyDeleteJunk: boolean;
|
||||
showPreview: boolean;
|
||||
// Offer the one-time code of a sign-in mail as a copy chip in the list
|
||||
// (for a day) and in the reader.
|
||||
showVerificationCodes: boolean;
|
||||
emailsPerPage: number;
|
||||
// Mail list sort order: oldest-first when true. Applies to every mailbox
|
||||
// (the JMAP Email/query sorts by receivedAt).
|
||||
@@ -370,6 +373,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
deleteAction: 'trash',
|
||||
permanentlyDeleteJunk: false,
|
||||
showPreview: true,
|
||||
showVerificationCodes: true,
|
||||
emailsPerPage: 25,
|
||||
mailSortAscending: false,
|
||||
disableThreading: false,
|
||||
|
||||
Reference in New Issue
Block a user