Merge branch 'feat/do-uxs-wording' into feat/do-ux-settings-fixes

This commit is contained in:
Linus Rath
2026-11-04 08:35:00 +01:00
48 changed files with 2067 additions and 777 deletions
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import { fetchMailClientConfig, mailClientConfigUrl, parseMailClientConfig } from '../mail-client-config';
// The autoconfig document Stalwart 1.0 serves (VM 106, 2026-11-04), cut down.
const STALWART = `<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="example.org">
<domain>example.org</domain>
<incomingServer type="imap">
<hostname>mail.example.org</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>me@example.org</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<incomingServer type="pop3">
<hostname>mail.example.org</hostname>
<port>995</port>
<socketType>SSL</socketType>
</incomingServer>
<outgoingServer type="smtp">
<hostname>mail.example.org</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>me@example.org</username>
</outgoingServer>
</emailProvider>
<calendar type="caldav">
<username>me@example.org</username>
<serverURL>https://mail.example.org/dav/cal</serverURL>
</calendar>
<addressBook type="carddav">
<serverURL>https://mail.example.org/dav/card</serverURL>
</addressBook>
</clientConfig>`;
describe('parseMailClientConfig', () => {
it('reads IMAP, SMTP, CalDAV and CardDAV from a Stalwart document', () => {
expect(parseMailClientConfig(STALWART)).toEqual({
username: 'me@example.org',
imap: { hostname: 'mail.example.org', port: 993, security: 'ssl' },
smtp: { hostname: 'mail.example.org', port: 465, security: 'ssl' },
caldav: 'https://mail.example.org/dav/cal',
carddav: 'https://mail.example.org/dav/card',
});
});
it('tells STARTTLS and no encryption apart', () => {
const xml = STALWART.replace('<port>465</port>\n\t\t\t<socketType>SSL', '<port>587</port>\n\t\t\t<socketType>STARTTLS')
.replace('<port>993</port>\n\t\t\t<socketType>SSL', '<port>143</port>\n\t\t\t<socketType>plain');
const config = parseMailClientConfig(xml);
expect(config?.smtp).toEqual({ hostname: 'mail.example.org', port: 587, security: 'starttls' });
expect(config?.imap).toEqual({ hostname: 'mail.example.org', port: 143, security: 'none' });
});
it('returns null for anything that is not a client config with a server', () => {
expect(parseMailClientConfig('<html><body>Not found</body></html>')).toBeNull();
expect(parseMailClientConfig('not xml at all <')).toBeNull();
expect(parseMailClientConfig('<clientConfig version="1.1"><emailProvider/></clientConfig>')).toBeNull();
});
it('drops a DAV address that is not http(s)', () => {
const config = parseMailClientConfig(STALWART.replace('https://mail.example.org/dav/cal', 'javascript:alert(1)'));
expect(config?.caldav).toBeUndefined();
});
});
describe('fetchMailClientConfig', () => {
it('asks the signed-in server only, for the account address', async () => {
const fetchImpl = vi.fn().mockResolvedValue(new Response(STALWART, { status: 200 }));
const config = await fetchMailClientConfig('https://mail.example.org/', 'me@example.org', fetchImpl);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl.mock.calls[0][0]).toBe(mailClientConfigUrl('https://mail.example.org', 'me@example.org'));
expect(fetchImpl.mock.calls[0][0]).toBe(
'https://mail.example.org/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=me%40example.org',
);
expect(fetchImpl.mock.calls[0][1]).toMatchObject({ credentials: 'omit' });
expect(config?.imap?.port).toBe(993);
});
it('gives null on an error answer, a network failure or a name without @', async () => {
expect(await fetchMailClientConfig('https://x.example', 'me@x.example', vi.fn().mockResolvedValue(new Response('', { status: 404 })))).toBeNull();
expect(await fetchMailClientConfig('https://x.example', 'me@x.example', vi.fn().mockRejectedValue(new TypeError('offline')))).toBeNull();
const never = vi.fn();
expect(await fetchMailClientConfig('https://x.example', 'me', never)).toBeNull();
expect(never).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import en from '@/locales/en/common.json';
import enPlain from '@/locales/en/plain.json';
import { getByPath } from '../settings-search';
import { withPlainLanguage } from '@/i18n/plain-language';
// Usability pass 2026-11-04 (settings-11, -12, -36): settings rows say what
// they change, without product names or protocol jargon. The English text a
// user sees is common.json with the plain overlay on top.
const shown = withPlainLanguage(en as Record<string, unknown>, enPlain as Record<string, unknown>);
const text = (key: string) => getByPath(shown, key) as string;
describe('settings page wording', () => {
it('names the colour mode row "Light or dark" and the layouts by what they do', () => {
expect(text('settings.appearance.theme.label')).toBe('Light or dark');
expect(text('settings.appearance.interface_layout.mountain_view')).toBe('Search bar on top, apps on the left');
expect(text('settings.email_behavior.mail_layout.split_description')).toBe('List and message side by side');
expect(text('settings.email_behavior.mail_layout.focus_description')).toBe('List, then the message full width');
expect(text('settings.email_behavior.mail_layout.horizontal_description')).toBe('Message below the list');
});
it('uses no product names or folder names the sidebar does not show', () => {
const values = [
'settings.appearance.interface_layout.mountain_view',
'settings.email_behavior.mail_layout.description',
'settings.appearance.colorful_sidebar_icons.description',
].map(text).join(' ');
expect(values).not.toMatch(/Mountain View|Gmail|Zimbra|Junk/);
expect(text('settings.appearance.colorful_sidebar_icons.description')).toContain('red Spam');
});
it('describes setting up other mail apps without JMAP or TOTP', () => {
expect(text('settings.security.email_client.title')).toBe('Use your mail in other apps');
expect(text('settings.security.email_client.jmap_username_label')).toBe('User name');
expect(text('settings.security.totp.label')).toBe('Two-step sign-in');
const all = ['title', 'description', 'jmap_username_label', 'password_instructions']
.map((k) => text(`settings.security.email_client.${k}`)).join(' ');
expect(all).not.toMatch(/JMAP/);
});
it('words the contacts and calendar rows plainly', () => {
expect(text('settings.contacts.title')).toBe('Contact list');
expect(text('calendar.settings.invitation_parsing')).toBe('Show invitations from mail');
expect(text('calendar.subscription.title')).toBe('Subscribe to a calendar');
expect(text('calendar.settings.enable_tasks')).toBe('Tasks');
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* Stalwart names the calendar and address book it creates for a new account
* "Stalwart Calendar (<address>)" and "Stalwart Address Book (<address>)"
* (registry defaults, 0.16 and 1.0). Settings show those as plain "Calendar"
* and "Contacts" in the user's language, the way the sidebar shows "Sent"
* instead of the server's folder name. A name the user chose stays as it is.
*/
const SERVER_DEFAULT_NAMES = {
calendar: /^Stalwart Calendar(?: \([^()]*\))?$/,
addressBook: /^Stalwart Address Book(?: \([^()]*\))?$/,
} as const;
export type DefaultCollectionKind = keyof typeof SERVER_DEFAULT_NAMES;
export function isServerDefaultCollectionName(name: string | undefined, kind: DefaultCollectionKind): boolean {
return !!name && SERVER_DEFAULT_NAMES[kind].test(name.trim());
}
/** The name to show: `plainName` for the server's own default name, else the collection's name. */
export function collectionDisplayName(name: string, kind: DefaultCollectionKind, plainName: string): string {
return isServerDefaultCollectionName(name, kind) ? plainName : name;
}
+2 -2
View File
@@ -269,7 +269,7 @@ export function bundleExportFilename(
}
// Build a synthetic email for previewing templates in the settings UI.
export function buildSampleEmail(): Email {
export function buildSampleEmail(subject = "Benachrichtigung von Ihrem Gerät"): Email {
// Use a fixed date so the preview doesn't churn as the user types.
const iso = "2026-05-22T14:05:33Z";
return {
@@ -283,7 +283,7 @@ export function buildSampleEmail(): Email {
from: [{ name: "Alice Sender", email: "alice@example.com" }],
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
cc: [],
subject: "Benachrichtigung von Ihrem Gerät",
subject,
preview: "",
hasAttachment: true,
blobId: "sample-blob",
+117
View File
@@ -0,0 +1,117 @@
/**
* Server settings for other mail apps (Thunderbird, Apple Mail, Outlook,
* phone apps), read from the mail server's Mozilla autoconfig document.
*
* Stalwart 0.16 and 1.0 both serve
* `/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=<address>`
* without sign-in and with `Access-Control-Allow-Origin: *`, so the page reads
* it directly from the server it is signed in to. No other host is asked.
*/
import { desktopJmapBase } from '@/lib/desktop/jmap-route';
export type MailSocketSecurity = 'ssl' | 'starttls' | 'none';
export interface MailServerEndpoint {
hostname: string;
port: number;
security: MailSocketSecurity;
}
export interface MailClientConfig {
username?: string;
imap?: MailServerEndpoint;
smtp?: MailServerEndpoint;
caldav?: string;
carddav?: string;
}
function text(el: Element | null | undefined, tag: string): string | undefined {
const value = el?.getElementsByTagName(tag)[0]?.textContent?.trim();
return value ? value : undefined;
}
function security(raw: string | undefined): MailSocketSecurity {
const value = (raw ?? '').toUpperCase();
if (value === 'SSL' || value === 'TLS') return 'ssl';
if (value === 'STARTTLS') return 'starttls';
return 'none';
}
function endpoint(el: Element | undefined): MailServerEndpoint | undefined {
if (!el) return undefined;
const hostname = text(el, 'hostname');
const port = Number(text(el, 'port'));
if (!hostname || !Number.isInteger(port) || port <= 0 || port > 65535) return undefined;
return { hostname, port, security: security(text(el, 'socketType')) };
}
function davUrl(raw: string | undefined): string | undefined {
if (!raw) return undefined;
try {
const url = new URL(raw);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : undefined;
} catch {
return undefined;
}
}
/**
* The parts of an autoconfig document a person needs to set up another app.
* Returns null for anything that isn't a clientConfig with at least one server.
*/
export function parseMailClientConfig(xml: string): MailClientConfig | null {
let doc: Document;
try {
doc = new DOMParser().parseFromString(xml, 'application/xml');
} catch {
return null;
}
if (doc.getElementsByTagName('parsererror').length > 0) return null;
const root = doc.documentElement;
if (!root || root.nodeName !== 'clientConfig') return null;
const incoming = Array.from(root.getElementsByTagName('incomingServer'));
const outgoing = Array.from(root.getElementsByTagName('outgoingServer'));
const imapEl = incoming.find((el) => el.getAttribute('type') === 'imap');
const smtpEl = outgoing.find((el) => el.getAttribute('type') === 'smtp');
const calendarEl = Array.from(root.getElementsByTagName('calendar')).find((el) => el.getAttribute('type') === 'caldav');
const contactsEl = Array.from(root.getElementsByTagName('addressBook')).find((el) => el.getAttribute('type') === 'carddav');
const config: MailClientConfig = {
username: text(imapEl, 'username') ?? text(smtpEl, 'username'),
imap: endpoint(imapEl),
smtp: endpoint(smtpEl),
caldav: davUrl(text(calendarEl, 'serverURL')),
carddav: davUrl(text(contactsEl, 'serverURL')),
};
if (!config.imap && !config.smtp && !config.caldav && !config.carddav) return null;
return config;
}
/** The autoconfig address on the server the page is signed in to. */
export function mailClientConfigUrl(serverUrl: string, emailAddress: string): string {
const base = desktopJmapBase(serverUrl.replace(/\/+$/, ''));
return `${base}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=${encodeURIComponent(emailAddress)}`;
}
/** Reads and parses the server's autoconfig; null when it has none or can't be reached. */
export async function fetchMailClientConfig(
serverUrl: string,
emailAddress: string,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<MailClientConfig | null> {
if (!serverUrl || !emailAddress.includes('@')) return null;
try {
const response = await fetchImpl(mailClientConfigUrl(serverUrl, emailAddress), {
credentials: 'omit',
cache: 'no-store',
signal,
});
if (!response.ok) return null;
return parseMailClientConfig(await response.text());
} catch {
return null;
}
}