Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/(main)/admin/_tabs/policy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
{ key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'] },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
Expand Down
90 changes: 90 additions & 0 deletions app/api/auth/verify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';

/**
* Server-side Basic-auth pre-check for the login form (#969).
*
* When the browser itself probes the JMAP session URL with wrong credentials,
* the server answers 401 + `WWW-Authenticate: Basic`, and on a same-origin
* deployment (JMAP reverse-proxied under the webmail's own host) the browser
* pops its native "This site requires authentication" dialog before the
* login form can show its own error. Probing from here first means a wrong
* password never reaches the browser as a 401 with a Basic challenge: the
* answer comes back as JSON from our own origin.
*
* The result is only *authoritative* for a definitive upstream 401. Anything
* else - the JMAP server unreachable from this container, a timeout, 5xx, a
* TOTP challenge (402), an unconfigured or disallowed URL - is reported as
* `inconclusive` so the browser-side connect keeps handling it exactly as
* before. Some deployments can't resolve the JMAP host from inside the
* container at all; those must keep logging in.
*/
export type VerifyResult = 'ok' | 'unauthorized' | 'inconclusive';

function respond(result: VerifyResult) {
return NextResponse.json({ result }, { headers: { 'Cache-Control': 'no-store' } });
}

export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => null);
const serverUrl = body?.serverUrl;
const username = body?.username;
const password = body?.password;
if (typeof serverUrl !== 'string' || typeof username !== 'string' || typeof password !== 'string'
|| !serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}

await configManager.ensureLoaded();
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
if (oauthEnabled && oauthOnly) {
return respond('inconclusive');
}

// Same upstream pinning as /api/auth/session: an unauthenticated caller
// must not be able to point this route at arbitrary internal hosts.
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);

let upstreamUrl: string;
let upstreamTrusted: boolean;
if (trustedUrl) {
upstreamUrl = trustedUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint && (await isPublicHttpUrl(serverUrl))) {
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return respond('inconclusive');
}

const authHeader = 'Basic ' + Buffer.from(username + ':' + password).toString('base64');
try {
await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
return respond('ok');
} catch (error) {
if (error instanceof JmapAuthVerificationError && error.upstreamStatus === 401) {
return respond('unauthorized');
}
logger.debug('Login pre-check inconclusive', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return respond('inconclusive');
}
} catch (error) {
logger.error('Login pre-check error', { error: error instanceof Error ? error.message : 'Unknown error' });
return respond('inconclusive');
}
}
16 changes: 16 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,22 @@ body {
"calt" 1;
}

/* Native <select> option lists are painted by the browser, not by the
Tailwind classes on the control, so they only honour color-scheme and an
explicit option colour. Themes whose --color-muted is translucent (Aurora
Glass dark: rgba(255,255,255,0.05)) composited to a light popup with
near-white text (#999). Pin options to the popover tokens, which every
theme defines as an opaque surface, and let dark themes ask the browser
for a dark popup where option colours are ignored (Safari). */
option {
background-color: var(--color-popover);
color: var(--color-popover-foreground);
}

.dark select {
color-scheme: dark;
}

/* Minimalist scrollbar */
::-webkit-scrollbar {
width: 8px;
Expand Down
41 changes: 40 additions & 1 deletion components/contacts/__tests__/contact-list.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ContactList } from '../contact-list';
import { useSettingsStore } from '@/stores/settings-store';
import type { ContactCard } from '@/lib/jmap/types';

function makeContact(overrides: Partial<ContactCard> & { id: string }): ContactCard {
Expand Down Expand Up @@ -78,4 +79,42 @@ describe('ContactList', () => {
expect(screen.getByText('bulk.export')).toBeInTheDocument();
});

describe('sort order (#963)', () => {
const carol = makeContact({
id: '4',
name: { components: [{ kind: 'given', value: 'Carol' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
});
const family = [alice, bob, carol];
const NAME = /^(Alice Smith|Bob Jones|Carol Smith)$/;
const renderedNames = () => screen.getAllByText(NAME).map((el) => el.textContent);

afterEach(() => {
useSettingsStore.setState({ sortContactsByLastName: false, groupContactsByLetter: true });
});

it('sorts by display name by default', () => {
render(<ContactList {...defaultProps} contacts={family} />);
expect(renderedNames()).toEqual(['Alice Smith', 'Bob Jones', 'Carol Smith']);
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});

it('groups family members together when sorting by last name', () => {
useSettingsStore.setState({ sortContactsByLastName: true });
render(<ContactList {...defaultProps} contacts={family} />);
expect(renderedNames()).toEqual(['Bob Jones', 'Alice Smith', 'Carol Smith']);
// Letter headers follow the surname, not the given name.
expect(screen.getByText('J')).toBeInTheDocument();
expect(screen.getByText('S')).toBeInTheDocument();
expect(screen.queryByText('A')).not.toBeInTheDocument();
});

it('still matches the search query against the display name', () => {
useSettingsStore.setState({ sortContactsByLastName: true });
render(<ContactList {...defaultProps} contacts={family} searchQuery="alice" />);
expect(renderedNames()).toEqual(['Alice Smith']);
});
});

});
41 changes: 32 additions & 9 deletions components/contacts/contact-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

import { useMemo, useState } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
import { getContactDisplayName, getContactPhotoUri, getContactSortName } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-store";

type TriState = boolean | null;
Expand Down Expand Up @@ -141,6 +141,8 @@ export function ContactList({
const locale = useLocale();
const density = useSettingsStore((state) => state.density);
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
const sortByLastName = useSettingsStore((state) => state.sortContactsByLastName);
const updateSetting = useSettingsStore((state) => state.updateSetting);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
Expand Down Expand Up @@ -229,21 +231,22 @@ export function ContactList({

const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
const nameA = getContactDisplayName(a).toLowerCase();
const nameB = getContactDisplayName(b).toLowerCase();
const nameA = getContactSortName(a, sortByLastName).toLowerCase();
const nameB = getContactSortName(b, sortByLastName).toLowerCase();
return nameA.localeCompare(nameB);
});
}, [filtered]);
}, [filtered, sortByLastName]);

const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);

// Group sorted contacts by first letter of display name. Non-letter
// starters (digits, symbols, empty) collect under "#" which sorts last.
// Group sorted contacts by first letter of the sort name (display name, or
// surname when sorting by last name). Non-letter starters (digits, symbols,
// empty) collect under "#" which sorts last.
const groupedSections = useMemo(() => {
const collator = new Intl.Collator(locale, { sensitivity: "base" });
const groups = new Map<string, ContactCard[]>();
for (const contact of sorted) {
const name = getContactDisplayName(contact).trim();
const name = getContactSortName(contact, sortByLastName).trim();
const first = name.charAt(0);
const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u)
? first.toLocaleUpperCase(locale)
Expand All @@ -259,7 +262,7 @@ export function ContactList({
return collator.compare(a, b);
})
.map(([letter, items]) => ({ letter, items }));
}, [sorted, locale]);
}, [sorted, locale, sortByLastName]);

const hasSelection = selectedContactIds.size > 0;
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
Expand Down Expand Up @@ -451,6 +454,26 @@ export function ContactList({
onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))}
/>
</div>

{/* Sort order (persisted setting, also on /settings/contacts) */}
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-border/50">
<span className="text-xs text-muted-foreground inline-flex items-center gap-1">
<ArrowDownAZ className="w-3.5 h-3.5" />
{t("filters.sort_by")}
</span>
<ToggleChip
icon={null}
label={t("filters.sort_first_name")}
value={sortByLastName ? null : true}
onClick={() => updateSetting("sortContactsByLastName", false)}
/>
<ToggleChip
icon={null}
label={t("filters.sort_last_name")}
value={sortByLastName ? true : null}
onClick={() => updateSetting("sortContactsByLastName", true)}
/>
</div>
</div>
</div>
)}
Expand Down
16 changes: 16 additions & 0 deletions components/email/__tests__/reply-addressing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ vi.mock('@/stores/settings-store', () => {
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: false,
replyIdentityMatch: 'domain',
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
sendDelaySeconds: 0,
Expand Down Expand Up @@ -323,6 +324,21 @@ describe('composer reply addressing', () => {
}
});

// #1000: on a domain whose other addresses are distribution lists rather
// than catch-all aliases, the setting can stay on but limited to configured
// identities, so a reply to list@ gets no From override.
it('does not rewrite From when matching is limited to exact addresses', () => {
const settings = useSettingsStore as unknown as { setState: (p: Record<string, unknown>) => void };
settings.setState({ autoSelectReplyIdentity: true, replyIdentityMatch: 'exact' });
try {
render(<EmailComposer mode="reply" replyTo={RECEIVED_CATCH_ALL} />);
expect(screen.queryByDisplayValue('colleague@example.com')).toBeNull();
expect(identitySelect().value).toBe('id-me');
} finally {
settings.setState({ autoSelectReplyIdentity: false, replyIdentityMatch: 'domain' });
}
});

it('never rewrites From on a forward, even with the setting on', () => {
setAutoSelect(true);
try {
Expand Down
8 changes: 6 additions & 2 deletions components/email/email-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ export function EmailComposer({
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const replyIdentityMatch = useSettingsStore((state) => state.replyIdentityMatch);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const emptySubjectWarningEnabled = useSettingsStore((state) => state.emptySubjectWarningEnabled);
Expand Down Expand Up @@ -878,9 +879,11 @@ export function EmailComposer({
// Catch-all From rewrite: opt-in, and never on a forward. A reply continues
// a thread whose participants already know the addressing; a forward
// introduces the rewritten From to a recipient the user just typed, who has
// no way to tell it is not really from that person.
// no way to tell it is not really from that person. `replyIdentityMatch`
// lets a user keep the setting on but limit it to configured identities,
// for domains where the other addresses are distribution lists (#1000).
if (autoSelectReplyIdentity && mode !== 'forward') {
const resolved = resolveReplyFrom(identities, recipients);
const resolved = resolveReplyFrom(identities, recipients, replyIdentityMatch);
if (resolved) {
setSelectedIdentityId(resolved.identityId);
if (resolved.overrideEmail && !fromOverrideEnabled) {
Expand All @@ -907,6 +910,7 @@ export function EmailComposer({
}
}, [
autoSelectReplyIdentity,
replyIdentityMatch,
composeFromAccountEmail,
fromOverrideEnabled,
identities,
Expand Down
16 changes: 15 additions & 1 deletion components/settings/composing-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import type { SendDelaySeconds } from '@/stores/settings-store';
import type { ReplyIdentityMatch, SendDelaySeconds } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { X } from 'lucide-react';
Expand All @@ -22,6 +22,7 @@ export function ComposingSettings() {

const {
autoSelectReplyIdentity,
replyIdentityMatch,
plainTextMode,
rtlEditingSupport,
attachmentReminderEnabled,
Expand All @@ -47,6 +48,19 @@ export function ComposingSettings() {
/>
</SettingItem>

{autoSelectReplyIdentity && (
<SettingItem label={t('reply_identity_match.label')} description={t('reply_identity_match.description')}>
<Select
value={replyIdentityMatch}
onChange={(value) => updateSetting('replyIdentityMatch', value as ReplyIdentityMatch)}
options={[
{ value: 'exact', label: t('reply_identity_match.exact') },
{ value: 'domain', label: t('reply_identity_match.domain') },
]}
/>
</SettingItem>
)}

<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch
checked={plainTextMode}
Expand Down
11 changes: 11 additions & 0 deletions components/settings/contacts-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function ContactsSettings() {
importContacts,
} = useContactStore();
const groupContactsByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const sortContactsByLastName = useSettingsStore((s) => s.sortContactsByLastName);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [showImport, setShowImport] = useState(false);

Expand Down Expand Up @@ -68,6 +69,16 @@ export function ContactsSettings() {
/>
</SettingItem>

<SettingItem
label={tSettings("sort_by_last_name_label")}
description={tSettings("sort_by_last_name_description")}
>
<ToggleSwitch
checked={sortContactsByLastName}
onChange={(checked) => updateSetting("sortContactsByLastName", checked)}
/>
</SettingItem>

<SettingItem
label={tSettings("import_label")}
description={tSettings("import_description")}
Expand Down
Loading
Loading