mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
feat: let admins keep sender and subject out of push notifications, enforced by the server
The push preview route now reads the device policy of the login it serves (its scopes worked out from the login's own JMAP session, every non-personal account counted as a group) and under `pushWithoutContent` asks the mail server for the message's ids only, so sender, subject and text never reach the notification. The service worker shows "New mail" for such an answer. A policy that can't be read counts as on. The three existing push preview route tests now mock the config manager with the default policy: without it the route can't read a policy in the test environment and holds the text back, which those tests didn't mean to cover. No assertion changed.
This commit is contained in:
@@ -13,6 +13,7 @@ import { rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { DisallowedUrlError } from '@/lib/security/url-guard';
|
||||
import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readJsonCapped } from '@/lib/security/capped-body';
|
||||
import { serverDevicePolicy } from '@/lib/admin/server-device-policy';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -23,6 +24,10 @@ interface ResolvedTarget {
|
||||
accountId: string;
|
||||
/** See StalwartCredentials.trusted - false routes through the guarded fetch. */
|
||||
trusted: boolean;
|
||||
/** The login, for its device policy. */
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
accountName?: string;
|
||||
/**
|
||||
* Cookie slot of the login that owns the account. Handed back so a click
|
||||
* on the notification opens the message in that login: the worker knows
|
||||
@@ -75,7 +80,7 @@ async function resolveTargetForAccount(accountId: string, forwarded: Record<stri
|
||||
Object.prototype.hasOwnProperty.call(session.accounts ?? {}, accountId);
|
||||
if (mailAccountId !== accountId && !isSharedAccount) return null;
|
||||
return {
|
||||
target: { authHeader: ctx.authHeader, apiUrl, accountId, trusted, slot },
|
||||
target: { authHeader: ctx.authHeader, apiUrl, accountId, trusted, slot, serverUrl, username: ctx.username, accountName: ctx.accountName },
|
||||
own: mailAccountId === accountId,
|
||||
};
|
||||
} catch {
|
||||
@@ -103,7 +108,7 @@ async function resolveDefaultTarget(creds: StalwartCredentials, forwarded: Recor
|
||||
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
|
||||
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||
if (!apiUrl || !accountId) return null;
|
||||
return { authHeader: creds.authHeader, apiUrl, accountId, trusted: creds.trusted };
|
||||
return { authHeader: creds.authHeader, apiUrl, accountId, trusted: creds.trusted, serverUrl: creds.serverUrl, username: creds.username, accountName: creds.accountName };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,7 +203,20 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const inboxId = inboxBody.ids[0];
|
||||
const emailProperties = ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'];
|
||||
// Organisation > Devices > Push without sender and subject, for this
|
||||
// login's scopes (worked out from its own session): the message's
|
||||
// sender, subject and text are never read, so they can't reach the
|
||||
// notification, the push service or the lock screen. Unknown counts as on.
|
||||
const redacted = await serverDevicePolicy({
|
||||
serverUrl: target.serverUrl,
|
||||
authHeader,
|
||||
username: target.username,
|
||||
accountName: target.accountName,
|
||||
trusted,
|
||||
}).then((devices) => devices.pushWithoutContent, () => true);
|
||||
const emailProperties = redacted
|
||||
? ['id', 'threadId', 'receivedAt']
|
||||
: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'];
|
||||
|
||||
if (!inboxId && !requestedEmailId) {
|
||||
return NextResponse.json({
|
||||
@@ -333,10 +351,13 @@ export async function GET(request: NextRequest) {
|
||||
// Some servers build the preview from HTML without skipping <style>; a
|
||||
// notification that opens on a style sheet says nothing.
|
||||
if (email?.preview) email = { ...email, preview: cleanPreview(email.preview) };
|
||||
// Whatever the server sent anyway, only what a click needs goes back.
|
||||
if (redacted && email) email = { id: email.id, threadId: email.threadId, receivedAt: email.receivedAt ?? null };
|
||||
|
||||
return NextResponse.json({
|
||||
email,
|
||||
unreadTotal,
|
||||
...(redacted ? { redacted: true } : {}),
|
||||
...(target.slot !== undefined ? { slot: target.slot } : {}),
|
||||
}, {
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Organisation > Devices > Push without sender and subject, held by the
|
||||
// server: the preview route never reads the sender, subject or text of a
|
||||
// message for a login the policy covers, so they can't reach the
|
||||
// notification. Who is covered comes from the login's own JMAP session,
|
||||
// with every non-personal account counted as a group.
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (data: unknown, init?: { status?: number }) => ({ json: async () => data, status: init?.status ?? 200 }),
|
||||
},
|
||||
}));
|
||||
vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) }));
|
||||
vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
|
||||
vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 1 }));
|
||||
|
||||
const login = vi.hoisted(() => ({ username: 'alice@example.org' }));
|
||||
vi.mock('@/lib/stalwart/auth-context', () => ({
|
||||
readStalwartAuthContextFromStore: (_store: unknown, slot: number) =>
|
||||
slot === 0 ? { serverUrl: 'https://mail.example.org', authHeader: `Bearer ${login.username}`, username: login.username } : null,
|
||||
}));
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
|
||||
|
||||
const policy = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, getPolicy: () => policy.current },
|
||||
}));
|
||||
|
||||
const fetchJmapServer = vi.fn();
|
||||
vi.mock('@/lib/stalwart/server-fetch', () => ({
|
||||
fetchJmapServer: (...args: unknown[]) => fetchJmapServer(...args),
|
||||
isTrustedJmapServerUrl: async () => true,
|
||||
}));
|
||||
vi.mock('@/lib/security/url-guard', () => ({ DisallowedUrlError: class DisallowedUrlError extends Error {} }));
|
||||
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { clearServerDeviceSubjects } from '@/lib/admin/server-device-policy';
|
||||
|
||||
const SESSION = {
|
||||
apiUrl: 'https://mail.example.org/jmap',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' },
|
||||
accounts: { a: { name: 'alice@example.org', isPersonal: true }, g: { name: 'team@example.org', isPersonal: false } },
|
||||
};
|
||||
|
||||
const json = (data: unknown) => ({ ok: true, json: async () => data, headers: new Headers() });
|
||||
|
||||
let emailGets: Array<Record<string, unknown>> = [];
|
||||
function mockJmap() {
|
||||
emailGets = [];
|
||||
fetchJmapServer.mockImplementation(async (url: string, init?: { body?: string }) => {
|
||||
if (url.endsWith('/.well-known/jmap') || url.endsWith('/jmap/session')) return json(SESSION);
|
||||
const body = JSON.parse(init?.body ?? '{}') as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
if (body.methodCalls[0][0] === 'Mailbox/query') return json({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] });
|
||||
for (const call of body.methodCalls) if (call[0] === 'Email/get') emailGets.push(call[1]);
|
||||
// The server answers with everything, whatever was asked.
|
||||
return json({
|
||||
methodResponses: [
|
||||
['Email/query', { ids: ['e1'], total: 1 }, 'eq'],
|
||||
['Email/get', { list: [{ id: 'e1', threadId: 't1', subject: 'Salary list', preview: 'All salaries', from: [{ name: 'Boss', email: 'boss@example.org' }], receivedAt: '2026-11-01T10:00:00Z' }] }, 'eg'],
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function callRoute() {
|
||||
const { GET } = await import('@/app/api/push/preview/route');
|
||||
const request = { headers: new Headers(), nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? 'a' : null) } } };
|
||||
const res = (await GET(request as unknown as Parameters<typeof GET>[0])) as unknown as { status: number; json: () => Promise<Record<string, unknown>> };
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
const withDevices = (devices: Record<string, boolean>, scope?: { domains?: string[]; groups?: string[] }) => {
|
||||
policy.current = scope
|
||||
? { ...DEFAULT_POLICY, scopes: [{ id: 's', label: 'S', match: scope, policy: { organisation: { devices } } }] }
|
||||
: { ...DEFAULT_POLICY, organisation: { devices } };
|
||||
};
|
||||
|
||||
describe('push preview under "Push without sender and subject"', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearServerDeviceSubjects();
|
||||
login.username = 'alice@example.org';
|
||||
mockJmap();
|
||||
});
|
||||
|
||||
it('shows sender and subject when the policy is off', async () => {
|
||||
withDevices({ pushWithoutContent: false });
|
||||
const { body } = await callRoute();
|
||||
expect(body.redacted).toBeUndefined();
|
||||
expect(body.email).toMatchObject({ subject: 'Salary list', from: [{ email: 'boss@example.org' }] });
|
||||
});
|
||||
|
||||
it('neither asks for nor returns sender, subject or text when it is on', async () => {
|
||||
withDevices({ pushWithoutContent: true });
|
||||
const { status, body } = await callRoute();
|
||||
expect(status).toBe(200);
|
||||
expect(body.redacted).toBe(true);
|
||||
expect(body.email).toEqual({ id: 'e1', threadId: 't1', receivedAt: '2026-11-01T10:00:00Z' });
|
||||
expect(emailGets.length).toBeGreaterThan(0);
|
||||
for (const get of emailGets) {
|
||||
expect(get.properties).not.toContain('from');
|
||||
expect(get.properties).not.toContain('subject');
|
||||
expect(get.properties).not.toContain('preview');
|
||||
}
|
||||
});
|
||||
|
||||
it('applies a group scope from the login’s own session, counting every non-personal account', async () => {
|
||||
withDevices({ pushWithoutContent: true }, { groups: ['team@example.org'] });
|
||||
const { body } = await callRoute();
|
||||
expect(body.redacted).toBe(true);
|
||||
expect(JSON.stringify(body)).not.toContain('Salary');
|
||||
});
|
||||
|
||||
it('leaves logins outside the scope alone', async () => {
|
||||
withDevices({ pushWithoutContent: true }, { domains: ['other.example'] });
|
||||
const { body } = await callRoute();
|
||||
expect(body.redacted).toBeUndefined();
|
||||
expect(body.email).toMatchObject({ subject: 'Salary list' });
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,12 @@ vi.mock('@/lib/security/url-guard', () => ({
|
||||
DisallowedUrlError: class DisallowedUrlError extends Error {},
|
||||
}));
|
||||
|
||||
// The route reads the device policy (Organisation > Devices); none is set here.
|
||||
vi.mock('@/lib/admin/config-manager', async () => {
|
||||
const { DEFAULT_POLICY } = await import('@/lib/admin/types');
|
||||
return { configManager: { ensureLoaded: async () => {}, getPolicy: () => DEFAULT_POLICY } };
|
||||
});
|
||||
|
||||
async function callRoute(accountId: string) {
|
||||
const { GET } = await import('@/app/api/push/preview/route');
|
||||
const request = {
|
||||
|
||||
@@ -72,6 +72,12 @@ vi.mock('@/lib/security/url-guard', () => ({
|
||||
DisallowedUrlError: class DisallowedUrlError extends Error {},
|
||||
}));
|
||||
|
||||
// The route reads the device policy (Organisation > Devices); none is set here.
|
||||
vi.mock('@/lib/admin/config-manager', async () => {
|
||||
const { DEFAULT_POLICY } = await import('@/lib/admin/types');
|
||||
return { configManager: { ensureLoaded: async () => {}, getPolicy: () => DEFAULT_POLICY } };
|
||||
});
|
||||
|
||||
async function callRoute(accountId: string, emailId: string | null) {
|
||||
const { GET } = await import('@/app/api/push/preview/route');
|
||||
const request = {
|
||||
|
||||
@@ -39,6 +39,12 @@ vi.mock('@/lib/security/url-guard', () => ({
|
||||
DisallowedUrlError: class DisallowedUrlError extends Error {},
|
||||
}));
|
||||
|
||||
// The route reads the device policy (Organisation > Devices); none is set here.
|
||||
vi.mock('@/lib/admin/config-manager', async () => {
|
||||
const { DEFAULT_POLICY } = await import('@/lib/admin/types');
|
||||
return { configManager: { ensureLoaded: async () => {}, getPolicy: () => DEFAULT_POLICY } };
|
||||
});
|
||||
|
||||
function jsonResponse(data: unknown) {
|
||||
return { ok: true, json: async () => data };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Organisation > Devices > Push without sender and subject: the preview
|
||||
// route answers `redacted` with only the message's ids. The worker then
|
||||
// says "New mail", never "(no subject)", and still opens the message.
|
||||
const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
|
||||
|
||||
function loadWorker(preview: Record<string, unknown>) {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const calls: Array<{ title: string; options: Record<string, unknown> }> = [];
|
||||
const self = {
|
||||
location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' },
|
||||
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
|
||||
skipWaiting: () => {},
|
||||
clients: { claim: () => {}, matchAll: async () => [] },
|
||||
registration: {
|
||||
showNotification: async (title: string, options: Record<string, unknown>) => { calls.push({ title, options }); },
|
||||
getNotifications: async () => [],
|
||||
},
|
||||
};
|
||||
const caches = { open: async () => ({ match: async () => undefined, put: async () => {} }) };
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => preview }));
|
||||
const context = vm.createContext({ self, caches, fetch: fetchMock, URL, URLSearchParams, Response, Date, console, AbortController, setTimeout, clearTimeout });
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
async function push(payload: unknown) {
|
||||
const waits: Promise<unknown>[] = [];
|
||||
listeners.get('push')?.({ data: { json: () => payload }, waitUntil: (p: Promise<unknown>) => waits.push(p) });
|
||||
await Promise.all(waits);
|
||||
}
|
||||
return { push, calls };
|
||||
}
|
||||
|
||||
const delivery = { kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] };
|
||||
|
||||
describe('service worker with a redacted preview', () => {
|
||||
it('shows "New mail" and opens the one message', async () => {
|
||||
const { push, calls } = loadWorker({ unreadTotal: 1, redacted: true, email: { id: 'e1', threadId: 't1', receivedAt: null } });
|
||||
await push(delivery);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].title).toBe('New mail');
|
||||
expect(calls[0].options.body).toBe('You have new mail');
|
||||
expect(calls[0].options.data).toMatchObject({ kind: 'email', emailId: 'e1' });
|
||||
});
|
||||
|
||||
it('counts several unread messages without naming any', async () => {
|
||||
const { push, calls } = loadWorker({ unreadTotal: 3, redacted: true, email: { id: 'e1', threadId: 't1' } });
|
||||
await push(delivery);
|
||||
expect(calls[0].title).toBe('New mail');
|
||||
expect(calls[0].options.body).toBe('3 unread messages');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EMAIL_PUSH_PROPERTIES } from '@/lib/web-push';
|
||||
|
||||
// The emailPush subscription goes through the push service: it may carry
|
||||
// ids only. With from, subject or preview here, they would reach the push
|
||||
// service around the device policy "Push without sender and subject",
|
||||
// whose text the server holds back in app/api/push/preview.
|
||||
describe('EMAIL_PUSH_PROPERTIES', () => {
|
||||
it('stays id and threadId', () => {
|
||||
expect([...EMAIL_PUSH_PROPERTIES]).toEqual(['id', 'threadId']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The device policies (policy.json `organisation.devices`) as the server
|
||||
* applies them itself, where it can: the push notification text
|
||||
* (app/api/push/preview) and the office editor (app/api/wopi). The browser
|
||||
* talks to the mail server directly for everything else, so those gates
|
||||
* are browser-only (lib/device-policy.ts).
|
||||
*
|
||||
* Who the caller is comes from their own JMAP session, never from the
|
||||
* browser. For these gates every non-personal account in the session
|
||||
* counts as a group (a mailbox another person shared counts too): more
|
||||
* scopes matched means more held back, the safe direction. A session that
|
||||
* can't be read counts the login address alone.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { configManager } from './config-manager';
|
||||
import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from './organisation-policy';
|
||||
import { resolvePolicy, type PolicySubject } from './policy-scopes';
|
||||
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
|
||||
|
||||
export type ServerDevicePolicy = OrganisationPolicy['devices'];
|
||||
|
||||
export interface DeviceCreds {
|
||||
serverUrl: string;
|
||||
authHeader: string;
|
||||
username: string;
|
||||
accountName?: string;
|
||||
trusted: boolean;
|
||||
}
|
||||
|
||||
const TTL_MS = 5 * 60 * 1000;
|
||||
const CACHE_KEY = Symbol.for('bulwark.serverDeviceSubjects');
|
||||
|
||||
// One cache per process, not per bundle copy.
|
||||
function cache(): Map<string, { at: number; subject: Promise<PolicySubject> }> {
|
||||
const g = globalThis as typeof globalThis & { [CACHE_KEY]?: Map<string, { at: number; subject: Promise<PolicySubject> }> };
|
||||
g[CACHE_KEY] ??= new Map();
|
||||
return g[CACHE_KEY];
|
||||
}
|
||||
|
||||
async function readSubject(creds: DeviceCreds): Promise<PolicySubject> {
|
||||
const email = (creds.accountName || creds.username || '').trim().toLowerCase();
|
||||
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader, { trusted: creds.trusted }).catch(() => null);
|
||||
const accounts = (session?.accounts ?? {}) as Record<string, { name?: string; isPersonal?: boolean }>;
|
||||
const groups = Object.values(accounts)
|
||||
.filter((account) => account?.isPersonal === false && typeof account.name === 'string' && account.name.includes('@'))
|
||||
.map((account) => account.name!.trim().toLowerCase())
|
||||
.filter((name) => name !== email)
|
||||
.sort();
|
||||
return { email, groups };
|
||||
}
|
||||
|
||||
/** The caller as the device gates see them, kept for a few minutes per credential. Never rejects. */
|
||||
export function strictDeviceSubject(creds: DeviceCreds): Promise<PolicySubject> {
|
||||
const key = createHash('sha256').update(`${creds.serverUrl}\n${creds.authHeader}`).digest('hex');
|
||||
const entries = cache();
|
||||
const now = Date.now();
|
||||
const hit = entries.get(key);
|
||||
if (hit && now - hit.at < TTL_MS) return hit.subject;
|
||||
const subject = readSubject(creds);
|
||||
entries.set(key, { at: now, subject });
|
||||
if (entries.size > 1000) {
|
||||
for (const [k, v] of entries) if (now - v.at >= TTL_MS) entries.delete(k);
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
|
||||
/** The device policy that applies to this login. */
|
||||
export async function serverDevicePolicy(creds: DeviceCreds): Promise<ServerDevicePolicy> {
|
||||
await configManager.ensureLoaded();
|
||||
const subject = await strictDeviceSubject(creds);
|
||||
const devices = resolvePolicy(configManager.getPolicy(), subject).policy.organisation?.devices;
|
||||
return { ...DEFAULT_ORGANISATION_POLICY.devices, ...(devices ?? {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The WOPI CheckFileInfo switches for the office editor (Collabora and
|
||||
* ONLYOFFICE read these): no printing, no export or "save a copy" when
|
||||
* downloads or export are off, and the watermark.
|
||||
*/
|
||||
export function wopiDeviceInfo(devices: ServerDevicePolicy, username: string, now: Date = new Date()): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (devices.noPrint) {
|
||||
out.DisablePrint = true;
|
||||
out.HidePrintOption = true;
|
||||
}
|
||||
if (devices.noDownloads || devices.noExport) {
|
||||
out.DisableExport = true;
|
||||
out.HideExportOption = true;
|
||||
out.HideSaveOption = true;
|
||||
out.DisableCopy = true;
|
||||
}
|
||||
if (devices.watermark) out.WatermarkText = `${username} · ${now.toISOString().slice(0, 10)}`;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function clearServerDeviceSubjects(): void {
|
||||
cache().clear();
|
||||
}
|
||||
+5
-2
@@ -81,8 +81,11 @@ const PUSH_TYPES = ['EmailDelivery'] as const;
|
||||
// capability and get the plain EmailDelivery subscription as before.
|
||||
export const EMAIL_PUSH_CAPABILITY = 'urn:ietf:params:jmap:emailpush';
|
||||
|
||||
// Only ids: the relay stays content-blind and the SW dedupes on them.
|
||||
const EMAIL_PUSH_PROPERTIES = ['id', 'threadId'];
|
||||
// Only ids: the relay stays content-blind and the SW dedupes on them. Never
|
||||
// add from, subject or preview: they would reach the push service around
|
||||
// the device policy "Push without sender and subject" (its text is held
|
||||
// back in app/api/push/preview). A test keeps this list as it is.
|
||||
export const EMAIL_PUSH_PROPERTIES: readonly string[] = ['id', 'threadId'];
|
||||
|
||||
function sameTypes(a: readonly string[] | null | undefined, b: readonly string[]): boolean {
|
||||
if (!a || a.length !== b.length) return false;
|
||||
|
||||
+7
-1
@@ -204,7 +204,13 @@ async function handlePush(event) {
|
||||
let body;
|
||||
let data = { kind: "mail-list", accountId, slot };
|
||||
|
||||
if (email) {
|
||||
if (email && preview && preview.redacted) {
|
||||
// The organisation keeps sender and subject off notifications
|
||||
// (Organisation > Devices): the route sent neither.
|
||||
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
|
||||
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
|
||||
if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, slot };
|
||||
} else if (email) {
|
||||
const sender = email.from && email.from[0];
|
||||
const senderName = (sender && sender.name) || (sender && sender.email) || "New mail";
|
||||
title = senderName + (accountLabel ? ` (${accountLabel})` : "");
|
||||
|
||||
Reference in New Issue
Block a user