@structured-id/ui-core 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Shared UI package for StructuredID applications: Vue 3 composables, Pinia stores
6
6
 
7
7
  - **Composables** -- `createAuth` (password ceremonies over an injected ZKPP client), `useAuthApi`, `useAccountApi`, `useIdentityApi`, `useSecurityLevel`, transport setup (`initGrpc`, `initAccountApi`)
8
8
  - **Components** -- `SidLoginForm`, `SidRegistrationForm`, `SidMfaChallenge`, `SidPrincipalInput`, profile management pages
9
- - **Utilities** -- `normalizePrincipal`, `detectLoginInputType`, `rpcRefusal`, validators, formatters
9
+ - **Utilities** -- `normalizePrincipal`, `detectLoginInputType`, `rpcRefusal`, email checks (`initEmail`, `checkEmail`, `isValidEmail`), validators, formatters
10
10
 
11
11
  The generated messages and clients, the `google.rpc` error model and the gRPC-web / WebTransport transport live in `@structured-id/proto`; import them from there (`@structured-id/proto/sid/v1/identity/identity.client`, `@structured-id/proto/transport`).
12
12
 
@@ -34,6 +34,34 @@ const identity = new IdentityServiceClient(getTransport());
34
34
  const { login } = createAuth(() => loadZkppClient());
35
35
  ```
36
36
 
37
+ ### Email addresses
38
+
39
+ Email fields are checked with [`@structured-world/structured-email-address-ts`](https://www.npmjs.com/package/@structured-world/structured-email-address-ts),
40
+ the TypeScript port of the parser the server uses, checked byte-identical
41
+ against it. Its Public Suffix List trie is the application's own asset (the
42
+ bundler emits `psl.bin` from `@structured-world/structured-public-domains`),
43
+ served by the application's host and cached in IndexedDB; where it is served
44
+ from is a setting (`psl.url`), never a third-party CDN. Each namespace's admission mirrors the
45
+ server's email policy: `LOCAL` (an installation's own accounts: internal and
46
+ internationalized domains), `CORPORATE_BASELINE`, `GLOBAL_PERSONAL` (ASCII, no
47
+ IDN, a public mail domain). The server still decides; the field refuses only
48
+ what that namespace never admits.
49
+
50
+ Load the parser once at start and name the application's namespace (`LOCAL`
51
+ when omitted). The parser is loaded lazily as its own chunk; until it is,
52
+ `checkEmail` answers `pending` and `SidPrincipalInput` keeps the form's submit
53
+ disabled. A field accepting email starts the load itself if the application
54
+ did not.
55
+
56
+ ```ts
57
+ import { GLOBAL_PERSONAL, initEmail } from "@structured-id/ui-core";
58
+
59
+ // The trie from the application's own copy...
60
+ await initEmail({ policy: GLOBAL_PERSONAL });
61
+ // ...or from where the deployment's configuration says it is served.
62
+ await initEmail({ policy: GLOBAL_PERSONAL, psl: { url: config.pslUrl } });
63
+ ```
64
+
37
65
  ## Development
38
66
 
39
67
  The package ships its TypeScript and Vue sources; the consuming application's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-id/ui-core",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -43,9 +43,12 @@
43
43
  "@protobuf-ts/runtime": "^2.11.1",
44
44
  "@protobuf-ts/runtime-rpc": "^2.11.1",
45
45
  "@structured-id/proto": "^0.3.0",
46
+ "@structured-world/structured-email-address-ts": "^0.0.22",
47
+ "@structured-world/structured-public-domains": "0.0.x",
46
48
  "libphonenumber-js": "^1.13.15"
47
49
  },
48
50
  "devDependencies": {
51
+ "@types/node": "^26",
49
52
  "@vitejs/plugin-vue": "^6.0.9",
50
53
  "@vue/compiler-dom": "^3.5.43",
51
54
  "@vue/devtools-api": "^8.2.1",
@@ -0,0 +1,5 @@
1
+ // A Vite asset imported for its served URL (`import url from "./x?url"`).
2
+ declare module "*?url" {
3
+ const url: string;
4
+ export default url;
5
+ }
package/src/index.ts CHANGED
@@ -36,7 +36,16 @@ export { useAuthStore } from "./stores/auth";
36
36
 
37
37
  // Utils
38
38
  export {
39
+ CORPORATE_BASELINE,
40
+ GLOBAL_PERSONAL,
41
+ LOCAL,
42
+ checkEmail,
43
+ emailPolicy,
44
+ emailReady,
45
+ initEmail,
39
46
  isValidEmail,
47
+ type EmailCheck,
48
+ type EmailPolicy,
40
49
  isValidPhone,
41
50
  isValidUuid,
42
51
  isValidUsername,
@@ -1,8 +1,13 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { beforeAll, describe, it, expect, vi } from "vitest";
2
2
  import { mount } from "@vue/test-utils";
3
3
  import { defineComponent } from "vue";
4
4
  import SidLoginForm from "./SidLoginForm.vue";
5
5
  import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
6
+ import { initEmail } from "../../utils/email";
7
+ import { TEST_PSL } from "../../utils/email.testing";
8
+
9
+ // As an application does at start: the email parser loaded, LOCAL namespace.
10
+ beforeAll(() => initEmail({ psl: TEST_PSL }));
6
11
 
7
12
  // Stub all Quasar components as divs
8
13
  const quasarStubs = {
@@ -1,5 +1,7 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { beforeAll, describe, it, expect, vi } from "vitest";
2
2
  import { flushPromises, mount } from "@vue/test-utils";
3
+ import { initEmail } from "../../utils/email";
4
+ import { TEST_PSL } from "../../utils/email.testing";
3
5
  import { RpcError } from "@protobuf-ts/runtime-rpc";
4
6
  import { base64encode } from "@protobuf-ts/runtime";
5
7
  import { Any } from "@structured-id/proto/google/protobuf/any";
@@ -10,6 +12,9 @@ import {
10
12
  } from "@structured-id/proto/google/rpc/error_details";
11
13
  import SidRegistrationForm from "./SidRegistrationForm.vue";
12
14
 
15
+ // As an application does at start: the email parser loaded, LOCAL namespace.
16
+ beforeAll(() => initEmail({ psl: TEST_PSL }));
17
+
13
18
  const quasarStubs = {
14
19
  "q-card": { template: "<div><slot /></div>" },
15
20
  "q-card-section": { template: "<div><slot /></div>" },
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { flushPromises, mount } from "@vue/test-utils";
3
+
4
+ // A module graph of its own: here no application started the email parser,
5
+ // so the field is what starts it.
6
+ vi.mock("../utils/email", async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import("../utils/email")>();
8
+ const { TEST_PSL } = await import("../utils/email.testing");
9
+ return {
10
+ ...actual,
11
+ // The field calls it without options; the test serves the trie itself.
12
+ initEmail: vi.fn(() => actual.initEmail({ psl: TEST_PSL })),
13
+ };
14
+ });
15
+
16
+ const stubs = {
17
+ "q-input": { template: "<div />" },
18
+ "q-icon": { template: "<span />" },
19
+ "q-chip": { template: "<span />" },
20
+ };
21
+
22
+ describe("SidPrincipalInput starting the parser", () => {
23
+ // Regression: the parser was started only on mount, so a field whose
24
+ // accepted types came to include email later kept every email pending
25
+ // and its form's submit disabled for good.
26
+ it("starts it when the accepted types come to include email", async () => {
27
+ const email = await import("../utils/email");
28
+ const { default: SidPrincipalInput } =
29
+ await import("./SidPrincipalInput.vue");
30
+ const w = mount(SidPrincipalInput, {
31
+ props: { modelValue: "alice@example.com", types: ["username"] },
32
+ global: { stubs },
33
+ });
34
+ expect(email.initEmail).not.toHaveBeenCalled();
35
+ await w.setProps({ types: ["email", "username"] });
36
+ await vi.waitFor(() => expect(email.emailReady.value).toBe(true));
37
+ await flushPromises();
38
+ expect(email.initEmail).toHaveBeenCalled();
39
+ expect(w.emitted("update:valid")?.at(-1)?.[0]).toBe(true);
40
+ });
41
+ });
@@ -1,8 +1,13 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { beforeAll, describe, it, expect } from "vitest";
2
2
  import { mount } from "@vue/test-utils";
3
- import { defineComponent } from "vue";
3
+ import { defineComponent, nextTick } from "vue";
4
4
  import SidPrincipalInput from "./SidPrincipalInput.vue";
5
5
  import { principalRecognizable } from "./principalInputTexts";
6
+ import { GLOBAL_PERSONAL, LOCAL, initEmail } from "../utils/email";
7
+ import { TEST_PSL } from "../utils/email.testing";
8
+
9
+ // As an application does at start: the email parser loaded, LOCAL namespace.
10
+ beforeAll(() => initEmail({ psl: TEST_PSL }));
6
11
 
7
12
  const stubs = {
8
13
  "q-input": defineComponent({
@@ -236,6 +241,20 @@ describe("SidPrincipalInput", () => {
236
241
  // The form disables its submit on this, before any error is shown: only a
237
242
  // recognized, accepted, well-formed value, not refused by the server, is
238
243
  // valid.
244
+ // Regression: the application's namespace was not reactive, so a field
245
+ // mounted under one kept the verdict of it after initEmail named another.
246
+ it("re-judges an email when the application's namespace changes", async () => {
247
+ await initEmail({ policy: GLOBAL_PERSONAL, psl: TEST_PSL });
248
+ const w = mount(SidPrincipalInput, {
249
+ props: { modelValue: "admin@printer", types: ["email"] },
250
+ global: { stubs },
251
+ });
252
+ expect(w.emitted("update:valid")?.at(-1)?.[0]).toBe(false);
253
+ await initEmail({ policy: LOCAL });
254
+ await nextTick();
255
+ expect(w.emitted("update:valid")?.at(-1)?.[0]).toBe(true);
256
+ });
257
+
239
258
  it("tells the form whether the value can be submitted", () => {
240
259
  const valid = (props: { modelValue: string; fieldError?: string }) => {
241
260
  const w = mount(SidPrincipalInput, {
@@ -35,7 +35,7 @@
35
35
  <script setup lang="ts">
36
36
  import { ref, computed, watch } from "vue";
37
37
  import { isValidPhoneNumber } from "libphonenumber-js";
38
- import { isValidEmail } from "../utils/validators";
38
+ import { checkEmail, emailReady, initEmail } from "../utils/email";
39
39
 
40
40
  import {
41
41
  ALL_PRINCIPAL_TYPES,
@@ -167,8 +167,13 @@ const typeRule = (val: string): string | true => {
167
167
  }
168
168
 
169
169
  switch (type) {
170
- case "email":
171
- return isValidEmail(val.trim()) || text.value.invalidEmail;
170
+ case "email": {
171
+ // Read so the rule re-runs once the parser has loaded.
172
+ void emailReady.value;
173
+ const check = checkEmail(val.trim());
174
+ // Nothing is wrong yet while the parser loads; `valid` stays false.
175
+ return check !== "invalid" || text.value.invalidEmail;
176
+ }
172
177
  case "phone":
173
178
  return isValidPhoneNumber(val) || text.value.invalidPhone;
174
179
  case "username": {
@@ -199,9 +204,26 @@ const valid = computed(
199
204
  () =>
200
205
  detectedType.value !== "unknown" &&
201
206
  typeRule(model.value) === true &&
207
+ // An email is not submittable before the parser has judged it.
208
+ (detectedType.value !== "email" || emailReady.value) &&
202
209
  !props.fieldError,
203
210
  );
204
211
 
212
+ // The application starts the parser at boot (initEmail with its namespace);
213
+ // a field that accepts email starts it when nobody did, also once its
214
+ // accepted types come to include email.
215
+ watch(
216
+ () => accepted.value.includes("email"),
217
+ (takesEmail) => {
218
+ if (takesEmail && !emailReady.value) {
219
+ initEmail().catch((error: unknown) => {
220
+ console.error("email address parser failed to load", error);
221
+ });
222
+ }
223
+ },
224
+ { immediate: true },
225
+ );
226
+
205
227
  watch(valid, (v) => emit("update:valid", v), { immediate: true });
206
228
 
207
229
  // --- Dynamic icon & type ---
@@ -0,0 +1,107 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import {
3
+ CORPORATE_BASELINE,
4
+ GLOBAL_PERSONAL,
5
+ LOCAL,
6
+ checkEmail,
7
+ emailPolicy,
8
+ initEmail,
9
+ isValidEmail,
10
+ } from "./email";
11
+ import { TEST_PSL } from "./email.testing";
12
+
13
+ // The admission the server applies (sid_authn::email): the same parser, the
14
+ // same configuration per namespace, so the field refuses what the server
15
+ // would refuse and accepts what it would accept.
16
+
17
+ describe("before the parser is loaded", () => {
18
+ // Nothing is judged without the parser: a hand-written guess would accept
19
+ // what the server refuses.
20
+ it("says pending and never valid", () => {
21
+ expect(checkEmail("user@example.com", LOCAL)).toBe("pending");
22
+ expect(isValidEmail("user@example.com", LOCAL)).toBe(false);
23
+ });
24
+ });
25
+
26
+ describe("with the parser", () => {
27
+ beforeAll(async () => {
28
+ await initEmail({ psl: TEST_PSL });
29
+ });
30
+
31
+ it("accepts an ordinary mailbox in every namespace", () => {
32
+ for (const policy of [GLOBAL_PERSONAL, CORPORATE_BASELINE, LOCAL]) {
33
+ expect(checkEmail("a.b+c@example.com", policy)).toBe("valid");
34
+ }
35
+ });
36
+
37
+ it("refuses what is never an address", () => {
38
+ for (const input of [
39
+ "",
40
+ "noat",
41
+ "@no-local.com",
42
+ "user@",
43
+ "spaces in@email.com",
44
+ "user@exa mple.com",
45
+ ]) {
46
+ expect(checkEmail(input, LOCAL), input).toBe("invalid");
47
+ }
48
+ });
49
+
50
+ // RFC 5321 §4.1.2: a quoted local part is a mailbox; comments and display
51
+ // names are header syntax and are not.
52
+ it("accepts a quoted local part, refuses header syntax", () => {
53
+ expect(checkEmail('"a b"@example.com', LOCAL)).toBe("valid");
54
+ expect(checkEmail('"a@b"@example.com', LOCAL)).toBe("valid");
55
+ expect(checkEmail("a(comment)@example.com", LOCAL)).toBe("invalid");
56
+ expect(checkEmail("Name <a@example.com>", LOCAL)).toBe("invalid");
57
+ });
58
+
59
+ it("refuses a local part left empty by the equality rules", () => {
60
+ expect(checkEmail("+tag@example.com", LOCAL)).toBe("invalid");
61
+ });
62
+
63
+ describe("the global Personal namespace", () => {
64
+ it("refuses non-ASCII in either part", () => {
65
+ expect(checkEmail("алиса@example.com", GLOBAL_PERSONAL)).toBe("invalid");
66
+ expect(checkEmail("user@münchen.de", GLOBAL_PERSONAL)).toBe("invalid");
67
+ });
68
+
69
+ it("refuses an A-label domain", () => {
70
+ expect(checkEmail("user@xn--mnchen-3ya.de", GLOBAL_PERSONAL)).toBe(
71
+ "invalid",
72
+ );
73
+ });
74
+
75
+ it("requires a public suffix", () => {
76
+ expect(checkEmail("admin@printer", GLOBAL_PERSONAL)).toBe("invalid");
77
+ expect(checkEmail("user@example.notatld", GLOBAL_PERSONAL)).toBe(
78
+ "invalid",
79
+ );
80
+ });
81
+ });
82
+
83
+ describe("an installation's own accounts", () => {
84
+ it("admits internal and internationalized mail domains", () => {
85
+ expect(checkEmail("admin@printer", LOCAL)).toBe("valid");
86
+ expect(checkEmail("user@münchen.de", LOCAL)).toBe("valid");
87
+ expect(checkEmail("алиса@example.com", LOCAL)).toBe("valid");
88
+ });
89
+ });
90
+
91
+ it("corporate baseline admits an internal domain, ASCII only", () => {
92
+ expect(checkEmail("admin@printer", CORPORATE_BASELINE)).toBe("valid");
93
+ expect(checkEmail("user@münchen.de", CORPORATE_BASELINE)).toBe("invalid");
94
+ });
95
+ });
96
+
97
+ describe("the application's namespace", () => {
98
+ it("is LOCAL unless the application names its own", async () => {
99
+ await initEmail();
100
+ expect(emailPolicy()).toBe(LOCAL);
101
+ await initEmail({ policy: GLOBAL_PERSONAL });
102
+ expect(emailPolicy()).toBe(GLOBAL_PERSONAL);
103
+ expect(isValidEmail("admin@printer")).toBe(false);
104
+ await initEmail({ policy: LOCAL });
105
+ expect(isValidEmail("admin@printer")).toBe(true);
106
+ });
107
+ });
@@ -0,0 +1,19 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import type { PslSource } from "./email";
4
+
5
+ /**
6
+ * The trie the application would serve, read from the installed package, for
7
+ * tests running without the application's host. Not cached, so a test never
8
+ * reads another run's copy.
9
+ */
10
+ export const TEST_PSL: PslSource = {
11
+ url: "psl.bin",
12
+ cache: false,
13
+ fetch: (async () => {
14
+ const path = createRequire(import.meta.url).resolve(
15
+ "@structured-world/structured-public-domains/psl.bin",
16
+ );
17
+ return new Response(await readFile(path));
18
+ }) as typeof fetch,
19
+ };
@@ -0,0 +1,165 @@
1
+ // An ambient declaration cannot be imported, and a consumer compiling this
2
+ // source sees it only through this reference.
3
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference
4
+ /// <reference path="../assets.d.ts" />
5
+ import { readonly, ref, shallowRef, type Ref } from "vue";
6
+
7
+ /**
8
+ * Which mailboxes an email namespace admits, as the server's policy for it
9
+ * (sid_authn::email) does. Equality and the resolution key stay the
10
+ * server's: the field only refuses what the namespace never admits.
11
+ */
12
+ export interface EmailPolicy {
13
+ /** Accept non-ASCII characters in the local part (RFC 6531). */
14
+ readonly unicodeLocal: boolean;
15
+ /** Accept internationalized domains, in Unicode or A-label spelling. */
16
+ readonly idnDomain: boolean;
17
+ /** Accept a domain without a dot (an installation-local mail domain). */
18
+ readonly singleLabelDomain: boolean;
19
+ /** Require the domain to end in a Public Suffix List suffix. */
20
+ readonly publicSuffix: boolean;
21
+ }
22
+
23
+ /** The global SaaS/federated Personal namespace: ASCII, no IDN, a public mail domain. */
24
+ export const GLOBAL_PERSONAL: EmailPolicy = Object.freeze({
25
+ unicodeLocal: false,
26
+ idnDomain: false,
27
+ singleLabelDomain: false,
28
+ publicSuffix: true,
29
+ });
30
+
31
+ /** An organization's Corporate namespace before it sets its own rules. */
32
+ export const CORPORATE_BASELINE: EmailPolicy = Object.freeze({
33
+ unicodeLocal: false,
34
+ idnDomain: false,
35
+ singleLabelDomain: true,
36
+ publicSuffix: false,
37
+ });
38
+
39
+ /** An installation's own managed accounts (standalone). */
40
+ export const LOCAL: EmailPolicy = Object.freeze({
41
+ unicodeLocal: true,
42
+ idnDomain: true,
43
+ singleLabelDomain: true,
44
+ publicSuffix: false,
45
+ });
46
+
47
+ /** A verdict on one input; `pending` until the parser is loaded. */
48
+ export type EmailCheck = "valid" | "invalid" | "pending";
49
+
50
+ type Parser = typeof import("@structured-world/structured-email-address-ts");
51
+
52
+ /** How the Public Suffix List trie is fetched and cached. */
53
+ export type PslSource = NonNullable<
54
+ NonNullable<Parameters<Parser["init"]>[0]>["psl"]
55
+ >;
56
+
57
+ let parser: Parser | undefined;
58
+ let loading: Promise<Parser> | undefined;
59
+ // Reactive, so a verdict computed under it is recomputed when it changes.
60
+ const appPolicy = shallowRef<EmailPolicy>(LOCAL);
61
+ const loaded = ref(false);
62
+
63
+ /** Whether the parser is loaded; a rule depending on it re-runs when it is. */
64
+ export const emailReady: Readonly<Ref<boolean>> = readonly(loaded);
65
+
66
+ /**
67
+ * The trie this package ships, as the application's own asset: the bundler
68
+ * emits it beside the bundle, so the application's host serves it.
69
+ */
70
+ async function bundledPsl(): Promise<string> {
71
+ const asset =
72
+ await import("@structured-world/structured-public-domains/psl.bin?url");
73
+ return asset.default;
74
+ }
75
+
76
+ /**
77
+ * Load the parser (once) and set the application's email namespace, LOCAL
78
+ * unless named. The parser is the TypeScript port of the server's, checked
79
+ * byte-identical against it; its Public Suffix List trie comes from `psl`
80
+ * (`url`: the setting of where it is served, the application's own copy of
81
+ * it when unset; never a third-party CDN), cached in IndexedDB. An
82
+ * application calls this at start; a field starts it when nobody did.
83
+ */
84
+ export async function initEmail(options?: {
85
+ policy?: EmailPolicy;
86
+ psl?: PslSource;
87
+ }): Promise<void> {
88
+ if (options?.policy) appPolicy.value = options.policy;
89
+ loading ??= import("@structured-world/structured-email-address-ts").then(
90
+ async (module) => {
91
+ const url = options?.psl?.url ?? (await bundledPsl());
92
+ await module.init({ psl: { ...options?.psl, url } });
93
+ return module;
94
+ },
95
+ );
96
+ try {
97
+ parser = await loading;
98
+ } catch (error) {
99
+ // A failed load is retried by the next call, never cached as loaded.
100
+ loading = undefined;
101
+ throw error;
102
+ }
103
+ loaded.value = true;
104
+ }
105
+
106
+ /** The application's email namespace. */
107
+ export function emailPolicy(): EmailPolicy {
108
+ return appPolicy.value;
109
+ }
110
+
111
+ /** Whether a domain label is an IDNA A-label (`xn--`). */
112
+ function isALabel(label: string): boolean {
113
+ return label.slice(0, 4).toLowerCase() === "xn--";
114
+ }
115
+
116
+ /**
117
+ * Whether `value` is one mailbox the namespace admits: an RFC 5321 mailbox
118
+ * (dot-atom or quoted local part; no comments, display name or address
119
+ * literal) under the namespace's character, domain and suffix rules, with a
120
+ * local part that its equality rules (case, dots, `+tag`) do not empty.
121
+ */
122
+ export function checkEmail(
123
+ value: string,
124
+ policy: EmailPolicy = appPolicy.value,
125
+ ): EmailCheck {
126
+ if (!parser) return "pending";
127
+ if (
128
+ !policy.unicodeLocal &&
129
+ !policy.idnDomain &&
130
+ !/^[\x00-\x7f]*$/.test(value)
131
+ ) {
132
+ return "invalid";
133
+ }
134
+ let local: string;
135
+ let domain: string;
136
+ try {
137
+ const mailbox = parser.parse(value, {
138
+ strictness: "strict",
139
+ allowQuotedLocalPart: true,
140
+ casePolicy: "preserve",
141
+ dotPolicy: "preserve",
142
+ subaddress: "preserve",
143
+ allowSingleLabelDomain: policy.singleLabelDomain,
144
+ domainCheck: policy.publicSuffix ? "psl" : "syntax",
145
+ });
146
+ local = mailbox.localPart;
147
+ domain = mailbox.domain;
148
+ } catch {
149
+ return "invalid";
150
+ }
151
+ if (!policy.unicodeLocal && !/^[\x00-\x7f]*$/.test(local)) return "invalid";
152
+ // A Unicode domain arrives IDNA-encoded, so A-labels cover both spellings.
153
+ if (!policy.idnDomain && domain.split(".").some(isALabel)) return "invalid";
154
+ const base = local.toLowerCase().split("+", 1)[0] ?? "";
155
+ if (base.replaceAll(".", "") === "") return "invalid";
156
+ return "valid";
157
+ }
158
+
159
+ /** {@link checkEmail} as a yes/no; `false` while the parser is loading. */
160
+ export function isValidEmail(
161
+ value: string,
162
+ policy: EmailPolicy = appPolicy.value,
163
+ ): boolean {
164
+ return checkEmail(value, policy) === "valid";
165
+ }
@@ -1,5 +1,4 @@
1
1
  export {
2
- isValidEmail,
3
2
  isValidPhone,
4
3
  isValidUuid,
5
4
  isValidUsername,
@@ -8,6 +7,19 @@ export {
8
7
  isRequired,
9
8
  } from "./validators";
10
9
 
10
+ export {
11
+ CORPORATE_BASELINE,
12
+ GLOBAL_PERSONAL,
13
+ LOCAL,
14
+ checkEmail,
15
+ emailPolicy,
16
+ emailReady,
17
+ initEmail,
18
+ isValidEmail,
19
+ type EmailCheck,
20
+ type EmailPolicy,
21
+ } from "./email";
22
+
11
23
  export {
12
24
  formatDate,
13
25
  formatRelativeTime,
@@ -1,5 +1,10 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { beforeAll, describe, it, expect } from "vitest";
2
2
  import { detectLoginInputType, normalizePrincipal } from "./normalizePrincipal";
3
+ import { initEmail } from "./email";
4
+ import { TEST_PSL } from "./email.testing";
5
+
6
+ // As an application does at start: the email parser loaded, LOCAL namespace.
7
+ beforeAll(() => initEmail({ psl: TEST_PSL }));
3
8
 
4
9
  describe("detectLoginInputType", () => {
5
10
  it("detects email", () => {
@@ -1,7 +1,7 @@
1
1
  import parsePhoneNumberFromString, {
2
2
  isValidPhoneNumber,
3
3
  } from "libphonenumber-js";
4
- import { isValidEmail } from "./validators";
4
+ import { checkEmail } from "./email";
5
5
 
6
6
  export type LoginInputType = "email" | "phone" | "username" | "unknown";
7
7
 
@@ -100,8 +100,13 @@ export function normalizePrincipal(
100
100
  switch (type) {
101
101
  case "email": {
102
102
  // Sent as typed: the server validates it, derives the lookup key and
103
- // keeps this spelling as the address to mail.
104
- if (!isValidEmail(trimmed)) {
103
+ // keeps this spelling as the address to mail. Checked here under the
104
+ // application's namespace (initEmail), as the server would.
105
+ const check = checkEmail(trimmed);
106
+ if (check === "pending") {
107
+ throw new Error("Email check not loaded: call initEmail() first");
108
+ }
109
+ if (check === "invalid") {
105
110
  throw new Error("Invalid email address");
106
111
  }
107
112
  return { value: trimmed, display: trimmed, type: "email" };
@@ -1,6 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
- isValidEmail,
4
3
  isValidPhone,
5
4
  isValidUuid,
6
5
  isValidUsername,
@@ -9,28 +8,6 @@ import {
9
8
  isRequired,
10
9
  } from "./validators";
11
10
 
12
- describe("isValidEmail", () => {
13
- it("accepts valid emails", () => {
14
- expect(isValidEmail("user@example.com")).toBe(true);
15
- expect(isValidEmail("a.b+c@d.co")).toBe(true);
16
- });
17
- it("rejects invalid emails", () => {
18
- expect(isValidEmail("")).toBe(false);
19
- expect(isValidEmail("noat")).toBe(false);
20
- expect(isValidEmail("@no-local.com")).toBe(false);
21
- expect(isValidEmail("spaces in@email.com")).toBe(false);
22
- expect(isValidEmail("user@")).toBe(false);
23
- expect(isValidEmail("user@exa mple.com")).toBe(false);
24
- });
25
- // Regression: a dot was required in the domain and quoted local parts were
26
- // refused, so the client turned away addresses the server's policy admits.
27
- it("leaves admission to the server's email policy", () => {
28
- expect(isValidEmail("admin@printer")).toBe(true);
29
- expect(isValidEmail('"a b"@example.com')).toBe(true);
30
- expect(isValidEmail('"a@b"@example.com')).toBe(true);
31
- });
32
- });
33
-
34
11
  describe("isValidPhone", () => {
35
12
  it("accepts E.164 format", () => {
36
13
  expect(isValidPhone("+380501234567")).toBe(true);
@@ -1,20 +1,3 @@
1
- /**
2
- * Whether `value` has the shape of one mailbox: a local part and a domain
3
- * around the last `@`, whitespace only inside a quoted local part. Advisory:
4
- * the server's email policy decides admission (internal single-label domains
5
- * and quoted local parts may be admitted), so this refuses only what can
6
- * never be an address.
7
- */
8
- export function isValidEmail(value: string): boolean {
9
- const at = value.lastIndexOf("@");
10
- if (at <= 0 || at === value.length - 1) return false;
11
- const local = value.slice(0, at);
12
- const domain = value.slice(at + 1);
13
- const quoted =
14
- local.length > 1 && local.startsWith('"') && local.endsWith('"');
15
- return !/\s/.test(domain) && (quoted || !/\s/.test(local));
16
- }
17
-
18
1
  /** Phone number validation (E.164 format). */
19
2
  export function isValidPhone(value: string): boolean {
20
3
  return /^\+[1-9]\d{6,14}$/.test(value);