diff --git a/src/components/settings/AccountSettings.tsx b/src/components/settings/AccountSettings.tsx
index cd5acf67..e2c4a9ba 100644
--- a/src/components/settings/AccountSettings.tsx
+++ b/src/components/settings/AccountSettings.tsx
@@ -16,6 +16,7 @@ import { sharedAccountSettingsTabs } from '../../lib/capabilities';
import { jmapClient } from '../../api/jmap-client';
import { fetchMailQuota, type MailQuota } from '../../api/quota';
import { MAX_ACCOUNTS, accountRowText } from '../../lib/account-utils';
+import { toUnicodeEmail } from '../../lib/idn';
import type { RootStackParamList } from '../../navigation/types';
function formatFileSize(bytes: number): string {
@@ -184,10 +185,10 @@ export function AccountSettings() {
: t('settings.account.accounts.switch_to', 'Switch to this account')}
>
- {rowText.title}
+ {toUnicodeEmail(rowText.title)}
{isActive && }
- {rowText.subtitle ? {rowText.subtitle} : null}
+ {rowText.subtitle ? {toUnicodeEmail(rowText.subtitle)} : null}
{isDefault && (
{t('settings.account.accounts.default_badge', 'Default account')}
diff --git a/src/lib/__tests__/idn.test.ts b/src/lib/__tests__/idn.test.ts
new file mode 100644
index 00000000..1652378d
--- /dev/null
+++ b/src/lib/__tests__/idn.test.ts
@@ -0,0 +1,126 @@
+// Ported from the webmail's lib/__tests__/idn.test.ts (#1100).
+import { describe, it, expect } from 'vitest';
+import {
+ toAsciiDomain,
+ toAsciiEmail,
+ toUnicodeDomain,
+ toUnicodeEmail,
+ toUnicodeEmailOnOwnDomains,
+} from '../idn';
+
+// Pairs as Stalwart (idna crate, UTS #46) and browsers produce them.
+const PAIRS: Array<[string, string]> = [
+ ['bücher.de', 'xn--bcher-kva.de'],
+ ['münchen.de', 'xn--mnchen-3ya.de'],
+ ['ノード.com', 'xn--gdkj2l.com'],
+ ['ドメイン名例.jp', 'xn--eckwd4c7cu47r2wf.jp'],
+ ['пример.рф', 'xn--e1afmkfd.xn--p1ai'],
+ ['straße.de', 'xn--strae-oqa.de'],
+ ['𠀋.jp', 'xn--u50i.jp'],
+];
+
+describe('toAsciiDomain', () => {
+ it.each(PAIRS)('converts %s to %s', (unicode, ascii) => {
+ expect(toAsciiDomain(unicode)).toBe(ascii);
+ });
+
+ it('lowercases and maps like a browser does', () => {
+ expect(toAsciiDomain('Bücher.DE')).toBe('xn--bcher-kva.de');
+ expect(toAsciiDomain('ΑΒΓ.gr')).toBe('xn--mxacd.gr');
+ expect(toAsciiDomain(' example.com ')).toBe('example.com');
+ });
+
+ it('rejects input that is not a bare host name', () => {
+ expect(toAsciiDomain('')).toBeNull();
+ expect(toAsciiDomain('a b.de')).toBeNull();
+ expect(toAsciiDomain('evil.com/path')).toBeNull();
+ expect(toAsciiDomain('evil.com:25')).toBeNull();
+ expect(toAsciiDomain('user@evil.com')).toBeNull();
+ expect(toAsciiDomain('%65vil.com')).toBeNull();
+ expect(toAsciiDomain('xn--zz.de')).toBeNull();
+ });
+});
+
+describe('toAsciiEmail', () => {
+ it('converts only the domain of an IDN address', () => {
+ expect(toAsciiEmail('alice@ノード.com')).toBe('alice@xn--gdkj2l.com');
+ expect(toAsciiEmail('Jörg@Bücher.de')).toBe('Jörg@xn--bcher-kva.de');
+ });
+
+ it('leaves ASCII addresses and bare login names exactly as typed', () => {
+ expect(toAsciiEmail('Alice@Example.COM')).toBe('Alice@Example.COM');
+ expect(toAsciiEmail('alice@xn--bcher-kva.de')).toBe('alice@xn--bcher-kva.de');
+ expect(toAsciiEmail('alice')).toBe('alice');
+ expect(toAsciiEmail('jörg')).toBe('jörg');
+ expect(toAsciiEmail('')).toBe('');
+ });
+
+ it('splits at the last @ and keeps unconvertible input unchanged', () => {
+ expect(toAsciiEmail('"a@b"@bücher.de')).toBe('"a@b"@xn--bcher-kva.de');
+ expect(toAsciiEmail('alice@bü cher.de')).toBe('alice@bü cher.de');
+ });
+});
+
+describe('toUnicodeDomain', () => {
+ it.each(PAIRS)('shows %s for %s', (unicode, ascii) => {
+ expect(toUnicodeDomain(ascii)).toBe(unicode);
+ });
+
+ it('decodes uppercase ACE labels to a lowercase domain', () => {
+ expect(toUnicodeDomain('XN--BCHER-KVA.DE')).toBe('bücher.de');
+ expect(toUnicodeDomain('mail.xn--Bcher-kva.de')).toBe('mail.bücher.de');
+ });
+
+ it('leaves plain ASCII domains alone, case included', () => {
+ expect(toUnicodeDomain('Example.COM')).toBe('Example.COM');
+ expect(toUnicodeDomain('')).toBe('');
+ });
+
+ it('keeps labels that are not valid, canonical punycode', () => {
+ expect(toUnicodeDomain('xn--zz.de')).toBe('xn--zz.de');
+ expect(toUnicodeDomain('xn--.de')).toBe('xn--.de');
+ // Decodes to plain "abc", whose ASCII form is not "xn--abc-".
+ expect(toUnicodeDomain('xn--abc-.de')).toBe('xn--abc-.de');
+ // Decode to "À" and fullwidth "abcü", which UTS #46 maps to other
+ // labels; a registrable domain can never contain them.
+ expect(toUnicodeDomain('xn--3ba.de')).toBe('xn--3ba.de');
+ expect(toUnicodeDomain('xn--tda9921kdae.de')).toBe('xn--tda9921kdae.de');
+ expect(toUnicodeDomain('xn--0ca.de')).toBe('à.de');
+ });
+});
+
+describe('toUnicodeEmail', () => {
+ it('converts only the domain', () => {
+ expect(toUnicodeEmail('alice@xn--gdkj2l.com')).toBe('alice@ノード.com');
+ expect(toUnicodeEmail('Alice+Tag@xn--bcher-kva.de')).toBe('Alice+Tag@bücher.de');
+ });
+
+ it('returns other input unchanged', () => {
+ expect(toUnicodeEmail('alice@example.com')).toBe('alice@example.com');
+ expect(toUnicodeEmail('Alice Example')).toBe('Alice Example');
+ expect(toUnicodeEmail('alice')).toBe('alice');
+ });
+});
+
+describe('toUnicodeEmailOnOwnDomains', () => {
+ const own = ['alice@xn--gdkj2l.com', null, undefined, ''];
+
+ it('shows addresses on the user\'s own IDN domains in Unicode', () => {
+ expect(toUnicodeEmailOnOwnDomains('alice@xn--gdkj2l.com', own)).toBe('alice@ノード.com');
+ expect(toUnicodeEmailOnOwnDomains('bob@XN--GDKJ2L.COM', own)).toBe('bob@ノード.com');
+ });
+
+ it('recognizes the domain when the user\'s own address is in Unicode', () => {
+ expect(toUnicodeEmailOnOwnDomains('bob@xn--bcher-kva.de', ['alice@bücher.de'])).toBe('bob@bücher.de');
+ });
+
+ it('keeps everyone else\'s IDN domain in ASCII form', () => {
+ expect(toUnicodeEmailOnOwnDomains('eve@xn--bcher-kva.de', own)).toBe('eve@xn--bcher-kva.de');
+ expect(toUnicodeEmailOnOwnDomains('eve@sub.xn--gdkj2l.com', own)).toBe('eve@sub.xn--gdkj2l.com');
+ expect(toUnicodeEmailOnOwnDomains('eve@xn--bcher-kva.de', [])).toBe('eve@xn--bcher-kva.de');
+ });
+
+ it('leaves ASCII addresses untouched', () => {
+ expect(toUnicodeEmailOnOwnDomains('Bob@Example.com', ['alice@example.com'])).toBe('Bob@Example.com');
+ });
+});
diff --git a/src/lib/__tests__/server-discovery.test.ts b/src/lib/__tests__/server-discovery.test.ts
index c2b99c59..871ea32e 100644
--- a/src/lib/__tests__/server-discovery.test.ts
+++ b/src/lib/__tests__/server-discovery.test.ts
@@ -197,6 +197,14 @@ describe('discoverServerForEmail', () => {
expect(mockSecureFetch).not.toHaveBeenCalled();
});
+ it('probes an internationalized domain in its ASCII form (#1100)', async () => {
+ respondFor({ 'https://xn--bcher-kva.de': 401 });
+ await expect(discoverServerForEmail('jörg@Bücher.de')).resolves.toBe('https://xn--bcher-kva.de');
+ expect(emailDomain('jörg@bücher.de')).toBe('xn--bcher-kva.de');
+ expect(normalizeServerUrl('mail.bücher.de/jmap/session')).toBe('https://mail.xn--bcher-kva.de');
+ expect(normalizeServerUrl('https://Mail.Bücher.de:8443')).toBe('https://mail.xn--bcher-kva.de:8443');
+ });
+
it('stops searching as soon as the sign-in is cancelled (U-133)', async () => {
// Every host hangs; only the cancel can end the search before the deadline.
mockSecureFetch.mockImplementation(() => new Promise(() => undefined));
diff --git a/src/lib/idn.ts b/src/lib/idn.ts
new file mode 100644
index 00000000..6cf97440
--- /dev/null
+++ b/src/lib/idn.ts
@@ -0,0 +1,250 @@
+/**
+ * Internationalized domain names (IDN) in email addresses (webmail
+ * lib/idn.ts, #1100).
+ *
+ * Stalwart keeps domains in their ASCII (punycode, `xn--`) form and maps a
+ * Unicode domain to it on lookup, so the app does the same: whatever the
+ * user types is converted to ASCII before it is sent anywhere, and the ASCII
+ * form is turned back into Unicode only for display.
+ *
+ * The webmail converts through the WHATWG URL parser, which React Native's
+ * URL doesn't implement for hosts, so ToASCII is done here: NFKC and
+ * lowercasing (the UTS #46 mapping for everything a mail domain contains in
+ * practice), then RFC 3492 for each non-ASCII label. A decoded domain is
+ * only shown when it converts back to exactly the ASCII form it came from.
+ */
+
+// Characters that make the input something other than a bare host (path,
+// query, port, userinfo, percent-escapes, IPv6) and the URL parser's other
+// forbidden host code points.
+const NON_HOST_CHARS = /[\s/\\?#@:%[\]<>^|]/;
+const ACE_LABEL = /(^|\.)xn--/i;
+const NON_ASCII = /[^\x00-\x7f]/;
+// UTS #46 label separators: full stop, ideographic, fullwidth, halfwidth.
+const SEPARATORS = /[.。.。]/;
+
+function mapLabel(label: string): string {
+ let mapped = label;
+ try {
+ mapped = mapped.normalize('NFKC');
+ } catch {
+ // An engine without normalize(): the label as typed.
+ }
+ return mapped.toLowerCase();
+}
+
+/**
+ * The ASCII form of a domain (`bücher.de` -> `xn--bcher-kva.de`), lowercased
+ * and mapped. Null when the input is not a valid host name.
+ */
+export function toAsciiDomain(domain: string): string | null {
+ const trimmed = domain.trim();
+ if (!trimmed || NON_HOST_CHARS.test(trimmed)) return null;
+ const labels = trimmed.split(SEPARATORS);
+ const out: string[] = [];
+ for (let i = 0; i < labels.length; i++) {
+ const label = mapLabel(labels[i]);
+ if (!label) {
+ // Only a trailing dot may leave an empty label.
+ if (i === labels.length - 1 && i > 0) {
+ out.push('');
+ continue;
+ }
+ return null;
+ }
+ if (NON_HOST_CHARS.test(label)) return null;
+ if (!NON_ASCII.test(label)) {
+ // An ACE label must be valid, canonical punycode of a non-ASCII label.
+ if (label.startsWith('xn--')) {
+ const decoded = decodePunycode(label.slice(4));
+ if (decoded === null || !NON_ASCII.test(decoded) || encodePunycode(decoded) !== label.slice(4)) return null;
+ }
+ out.push(label);
+ continue;
+ }
+ const encoded = encodePunycode(label);
+ if (encoded === null) return null;
+ out.push(`xn--${encoded}`);
+ }
+ return out.join('.');
+}
+
+/**
+ * The address with its domain in ASCII form. Only a non-ASCII domain is
+ * touched, so ASCII addresses and bare login names come back unchanged; the
+ * local part is never changed (it is case-sensitive to the server).
+ */
+export function toAsciiEmail(address: string): string {
+ const at = address.lastIndexOf('@');
+ if (at <= 0) return address;
+ const domain = address.slice(at + 1);
+ if (!NON_ASCII.test(domain)) return address;
+ const ascii = toAsciiDomain(domain);
+ return ascii ? `${address.slice(0, at)}@${ascii}` : address;
+}
+
+/**
+ * The Unicode form of a domain for display (`xn--bcher-kva.de` -> `bücher.de`).
+ * Returns the input unchanged when it has no `xn--` label or does not decode
+ * to a valid, canonical domain.
+ */
+export function toUnicodeDomain(domain: string): string {
+ if (!ACE_LABEL.test(domain)) return domain;
+ // Punycode keeps the case of its ASCII letters, so decode the lowercased
+ // domain or `XN--BCHER-KVA.DE` would come out as `BüCHER.DE`.
+ const lower = domain.toLowerCase();
+ const decoded: string[] = [];
+ for (const label of lower.split('.')) {
+ if (!label.startsWith('xn--')) {
+ decoded.push(label);
+ continue;
+ }
+ const unicode = decodePunycode(label.slice(4));
+ if (unicode === null) return domain;
+ decoded.push(unicode);
+ }
+ const unicode = decoded.join('.');
+ return toAsciiDomain(unicode) === lower ? unicode : domain;
+}
+
+/** The address with its domain in Unicode form, for display. */
+export function toUnicodeEmail(address: string): string {
+ const at = address.lastIndexOf('@');
+ if (at <= 0) return address;
+ const domain = address.slice(at + 1);
+ const unicode = toUnicodeDomain(domain);
+ return unicode === domain ? address : `${address.slice(0, at)}@${unicode}`;
+}
+
+/**
+ * `toUnicodeEmail` for an address in a message, applied only when it is on
+ * one of the user's own domains (taken from `ownAddresses`). Anyone else's
+ * IDN domain keeps its xn-- form: a Unicode domain can be drawn to look like
+ * a familiar one, and the ASCII form gives such a look-alike away.
+ */
+export function toUnicodeEmailOnOwnDomains(
+ address: string,
+ ownAddresses: readonly (string | null | undefined)[],
+): string {
+ const domain = address.slice(address.lastIndexOf('@') + 1).toLowerCase();
+ if (!ACE_LABEL.test(domain)) return address;
+ const own = ownAddresses.some((ownAddress) => {
+ if (!ownAddress) return false;
+ const ascii = toAsciiEmail(ownAddress);
+ return ascii.slice(ascii.lastIndexOf('@') + 1).toLowerCase() === domain;
+ });
+ return own ? toUnicodeEmail(address) : address;
+}
+
+// RFC 3492 section 5 parameters.
+const BASE = 36;
+const T_MIN = 1;
+const T_MAX = 26;
+const SKEW = 38;
+const DAMP = 700;
+const INITIAL_BIAS = 72;
+const INITIAL_N = 0x80;
+const MAX_INT = 0x7fffffff;
+
+function adapt(delta: number, numPoints: number, firstTime: boolean): number {
+ let d = firstTime ? Math.floor(delta / DAMP) : Math.floor(delta / 2);
+ d += Math.floor(d / numPoints);
+ let k = 0;
+ while (d > ((BASE - T_MIN) * T_MAX) / 2) {
+ d = Math.floor(d / (BASE - T_MIN));
+ k += BASE;
+ }
+ return k + Math.floor(((BASE - T_MIN + 1) * d) / (d + SKEW));
+}
+
+function digitValue(code: number): number {
+ if (code >= 0x30 && code <= 0x39) return code - 0x16; // 0-9 -> 26-35
+ if (code >= 0x41 && code <= 0x5a) return code - 0x41; // A-Z -> 0-25
+ if (code >= 0x61 && code <= 0x7a) return code - 0x61; // a-z -> 0-25
+ return BASE;
+}
+
+function digitChar(d: number): string {
+ // 0-25 -> a-z, 26-35 -> 0-9
+ return String.fromCharCode(d < 26 ? 0x61 + d : 0x16 + d);
+}
+
+/** RFC 3492 section 6.3 encoding of one label (without `xn--`). */
+function encodePunycode(input: string): string | null {
+ const codePoints = Array.from(input, (ch) => ch.codePointAt(0)!);
+ let output = '';
+ for (const cp of codePoints) if (cp < 0x80) output += String.fromCharCode(cp);
+ const basic = output.length;
+ let handled = basic;
+ if (basic > 0) output += '-';
+
+ let n = INITIAL_N;
+ let delta = 0;
+ let bias = INITIAL_BIAS;
+ while (handled < codePoints.length) {
+ let m = MAX_INT;
+ for (const cp of codePoints) if (cp >= n && cp < m) m = cp;
+ if (m - n > Math.floor((MAX_INT - delta) / (handled + 1))) return null;
+ delta += (m - n) * (handled + 1);
+ n = m;
+ for (const cp of codePoints) {
+ if (cp < n) {
+ delta += 1;
+ if (delta > MAX_INT) return null;
+ }
+ if (cp === n) {
+ let q = delta;
+ for (let k = BASE; ; k += BASE) {
+ const t = k <= bias ? T_MIN : k >= bias + T_MAX ? T_MAX : k - bias;
+ if (q < t) break;
+ output += digitChar(t + ((q - t) % (BASE - t)));
+ q = Math.floor((q - t) / (BASE - t));
+ }
+ output += digitChar(q);
+ bias = adapt(delta, handled + 1, handled === basic);
+ delta = 0;
+ handled += 1;
+ }
+ }
+ delta += 1;
+ n += 1;
+ }
+ return output;
+}
+
+/** RFC 3492 section 6.2 decoding of one label (without `xn--`). */
+function decodePunycode(input: string): string | null {
+ const output: number[] = [];
+ const basic = Math.max(input.lastIndexOf('-'), 0);
+ for (let j = 0; j < basic; j++) {
+ const code = input.charCodeAt(j);
+ if (code >= 0x80) return null;
+ output.push(code);
+ }
+
+ let n = INITIAL_N;
+ let bias = INITIAL_BIAS;
+ let i = 0;
+ for (let index = basic > 0 ? basic + 1 : 0; index < input.length;) {
+ const oldI = i;
+ for (let w = 1, k = BASE; ; k += BASE) {
+ if (index >= input.length) return null;
+ const digit = digitValue(input.charCodeAt(index++));
+ if (digit >= BASE || digit > Math.floor((MAX_INT - i) / w)) return null;
+ i += digit * w;
+ const t = k <= bias ? T_MIN : k >= bias + T_MAX ? T_MAX : k - bias;
+ if (digit < t) break;
+ if (w > Math.floor(MAX_INT / (BASE - t))) return null;
+ w *= BASE - t;
+ }
+ const length = output.length + 1;
+ bias = adapt(i - oldI, length, oldI === 0);
+ if (Math.floor(i / length) > MAX_INT - n) return null;
+ n += Math.floor(i / length);
+ i %= length;
+ if (n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff)) return null;
+ output.splice(i++, 0, n);
+ }
+ if (output.length === 0) return null;
+ return String.fromCodePoint(...output);
+}
diff --git a/src/lib/server-discovery.ts b/src/lib/server-discovery.ts
index c38f31bf..677de27a 100644
--- a/src/lib/server-discovery.ts
+++ b/src/lib/server-discovery.ts
@@ -9,6 +9,7 @@
import { secureFetch } from './client-cert';
import { signalWithTimeout } from './sign-in-abort';
+import { toAsciiDomain } from './idn';
export const DISCOVERY_TIMEOUT_MS = 2500;
@@ -69,16 +70,30 @@ export function normalizeServerUrl(input: string): string | null {
const host = value.slice(value.indexOf('://') + 3).split('/')[0];
if (!host || /[\s@]/.test(host)) return null;
- const hostname = host.split(':')[0].toLowerCase();
+ let hostname = host.split(':')[0].toLowerCase();
if (!hostname) return null;
+ // An internationalized host goes on the wire in its ASCII form (#1100).
+ if (/[^\x00-\x7f]/.test(hostname)) {
+ const ascii = toAsciiDomain(hostname);
+ if (!ascii) return null;
+ const schemeEnd = value.indexOf('://') + 3;
+ value = value.slice(0, schemeEnd) + ascii + value.slice(schemeEnd + hostname.length);
+ hostname = ascii;
+ }
if (!hostname.includes('.') && hostname !== 'localhost') return null;
return value;
}
+/**
+ * The domain of an address in its ASCII form (`jörg@bücher.de` ->
+ * `xn--bcher-kva.de`), which is what discovery probes and the server knows.
+ * Show it with toUnicodeDomain.
+ */
export function emailDomain(email: string): string | null {
const match = EMAIL_RE.exec(email.trim());
- return match ? match[1].toLowerCase() : null;
+ if (!match) return null;
+ return toAsciiDomain(match[1]) ?? match[1].toLowerCase();
}
export function isEmailAddress(value: string): boolean {
diff --git a/src/screens/login/ServerStep.tsx b/src/screens/login/ServerStep.tsx
index f59f527c..010c1f5a 100644
--- a/src/screens/login/ServerStep.tsx
+++ b/src/screens/login/ServerStep.tsx
@@ -6,6 +6,7 @@ import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice';
import { useLocaleStore } from '../../stores/locale-store';
+import { toUnicodeDomain } from '../../lib/idn';
interface ServerStepProps {
value: string;
@@ -53,7 +54,7 @@ export default function ServerStep({
{t('login.mobile.server_title', "What's your server address?")}
{failedDomain
- ? t('login.mobile.server_not_found', "We couldn't find a server for {domain}. If you self-host, the address is the one you use for webmail.", { domain: failedDomain })
+ ? t('login.mobile.server_not_found', "We couldn't find a server for {domain}. If you self-host, the address is the one you use for webmail.", { domain: toUnicodeDomain(failedDomain) })
: t('login.mobile.server_subtitle', 'Enter the address you use to reach your webmail.')}
diff --git a/src/stores/__tests__/auth-store-cancel.test.ts b/src/stores/__tests__/auth-store-cancel.test.ts
index c391a872..293ae503 100644
--- a/src/stores/__tests__/auth-store-cancel.test.ts
+++ b/src/stores/__tests__/auth-store-cancel.test.ts
@@ -99,3 +99,13 @@ describe('cancelling a sign-in', () => {
expect(useAuthStore.getState().error).toBeNull();
});
});
+
+describe('internationalized domains (#1100)', () => {
+ it('signs in with the ASCII form of the domain, keeping the local part', async () => {
+ const pending = useAuthStore.getState().login(SERVER, 'Jörg@Bücher.de', 'pw').catch(() => undefined);
+ await connectStarted();
+ expect(jmapClient.connect).toHaveBeenCalledWith(SERVER, 'Jörg@xn--bcher-kva.de', 'pw', undefined, undefined);
+ finishConnect!({ apiUrl: `${SERVER}/jmap/` });
+ await pending;
+ });
+});
diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts
index 31717643..b7eac632 100644
--- a/src/stores/auth-store.ts
+++ b/src/stores/auth-store.ts
@@ -42,6 +42,7 @@ import { reconcileServerFingerprint } from '../lib/server-change';
import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle';
import { perfMark } from '../lib/perf-marks';
import { abortable, isSignInCancelled, throwIfCancelled } from '../lib/sign-in-abort';
+import { toAsciiEmail } from '../lib/idn';
// Persist middleware hydrates asynchronously on cold start. Without this
// guard, restoreSession() can read the account-store before AsyncStorage has
@@ -553,8 +554,11 @@ export const useAuthStore = create((set, get) => ({
client: null,
pendingTotpLogin: null,
- login: async (serverUrl, username, password, opts) => {
+ login: async (serverUrl, typedUsername, password, opts) => {
set({ isLoading: true, error: null });
+ // An address on an internationalized domain signs in with the domain's
+ // ASCII form, the one Stalwart stores (#1100); the local part stays.
+ const username = toAsciiEmail(typedUsername);
try {
// A new account with no room left fails here, before connect swaps
// the live client over or stores credentials for it.