mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
Merge branch 'feat/do-uxs-wording' into feat/do-ux-settings-fixes
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user