Skip to content

Commit b0e80cf

Browse files
committed
feat(onboarding): implement account wizard with provider presets
Completes the accounts-dialog OpenSpec change (33/33 tasks). - mailbrus-core: per-account TOML config (SMTP fields, signature, email-as-id), atomic write, keyring/plain credential write, connection-test (IMAP + SMTP AUTH), unit tests for all paths - mailbrus-server: reloadable AppState via arc_swap, GET/POST /api/accounts endpoints, rustls ring CryptoProvider init at startup - OnboardingWizard: provider preset dropdown (Gmail, Outlook/Hotmail, Yahoo Mail, iCloud, Fastmail) auto-fills IMAP/SMTP; Advanced toggle for manual override; app-password notes for providers that require them - StatusBar hidden during onboarding; renders only after mailbox loads - E2E: onboarding happy path + 422/409 negative paths
1 parent ed2ef44 commit b0e80cf

28 files changed

Lines changed: 2394 additions & 257 deletions

File tree

Cargo.lock

Lines changed: 56 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/harness/config.ts

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
/**
2-
* Generate a mailbrus config.toml that mirrors the cloned corpus's account
3-
* layout. The server uses this to populate `/api/maildirs`; existing tests
4-
* that previously relied on filesystem-based account discovery now drive the
5-
* same data through the typed config.
6-
*
7-
* The IMAP fields are placeholders — they are only used if a sync is
8-
* triggered, which existing tests do not do.
2+
* Generate per-account TOML files under `accounts/` that mirror the cloned
3+
* corpus. The new config format uses one flat `<email>.toml` per account
4+
* (no `[accounts.X]` wrapper). The `--config` flag takes the base directory;
5+
* accounts are discovered by scanning `<dir>/accounts/*.toml`.
96
*/
10-
import { readdir, writeFile } from 'node:fs/promises';
7+
import { mkdir, readdir, writeFile } from 'node:fs/promises';
118
import { join } from 'node:path';
129
import type { Clone } from './clone.ts';
1310

@@ -18,7 +15,10 @@ export interface ConfigEntry {
1815
}
1916

2017
export interface ConfigHandle {
18+
/** Base config directory (passed to --config). Contains accounts/ inside. */
2119
path: string;
20+
/** Accounts subdirectory: write extra *.toml files here to add accounts. */
21+
accountsDir: string;
2222
entries: ConfigEntry[];
2323
}
2424

@@ -39,15 +39,9 @@ function escape(s: string): string {
3939
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
4040
}
4141

42-
/**
43-
* Build an account section. `id` is used as the TOML table key and as the
44-
* `email` placeholder — most tests assert `id == address`, which the existing
45-
* filesystem path produced naturally.
46-
*/
47-
function renderAccount(entry: ConfigEntry): string {
48-
const idKey = /^[A-Za-z0-9_-]+$/.test(entry.id) ? entry.id : `"${escape(entry.id)}"`;
42+
/** Flat per-account TOML (no [accounts.X] wrapper). Filename stem = id = email. */
43+
export function renderAccountToml(entry: ConfigEntry): string {
4944
return [
50-
`[accounts.${idKey}]`,
5145
`protocol = "imap"`,
5246
`email = "${escape(entry.id)}"`,
5347
`imap_host = "imap.invalid"`,
@@ -60,11 +54,35 @@ function renderAccount(entry: ConfigEntry): string {
6054
].join('\n');
6155
}
6256

63-
/** Write a fresh config.toml at `<clone.root>/mailbrus-config.toml`. */
57+
/**
58+
* Write a per-account TOML file into `accountsDir/<entry.id>.toml`.
59+
* Call after `writeFixtureConfig` to inject extra accounts (e.g. a
60+
* Stalwart-backed test account) without rebuilding the whole config.
61+
*/
62+
export async function addAccountToml(
63+
accountsDir: string,
64+
entry: ConfigEntry & { toml?: string }
65+
): Promise<void> {
66+
const body = entry.toml ?? renderAccountToml(entry);
67+
await writeFile(join(accountsDir, `${entry.id}.toml`), body);
68+
}
69+
70+
/** Write fixture config with all accounts from the cloned corpus. */
6471
export async function writeFixtureConfig(clone: Clone): Promise<ConfigHandle> {
6572
const entries = await scanAccounts(clone);
66-
const body = entries.map(renderAccount).join('\n');
67-
const path = join(clone.root, 'mailbrus-config.toml');
68-
await writeFile(path, body);
69-
return { path, entries };
73+
const configDir = join(clone.root, 'mailbrus-config');
74+
const accountsDir = join(configDir, 'accounts');
75+
await mkdir(accountsDir, { recursive: true });
76+
for (const entry of entries) {
77+
await addAccountToml(accountsDir, entry);
78+
}
79+
return { path: configDir, accountsDir, entries };
80+
}
81+
82+
/** Write a config directory with an empty accounts/ (zero-account onboarding state). */
83+
export async function writeEmptyFixtureConfig(clone: Clone): Promise<ConfigHandle> {
84+
const configDir = join(clone.root, 'mailbrus-config');
85+
const accountsDir = join(configDir, 'accounts');
86+
await mkdir(accountsDir, { recursive: true });
87+
return { path: configDir, accountsDir, entries: [] };
7088
}

e2e/pages/OnboardingPage.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// openspec/changes/accounts-dialog/specs/onboarding-wizard/spec.md
2+
import { expect, type Page } from '@playwright/test';
3+
4+
export interface AccountFormFields {
5+
email: string;
6+
imapHost: string;
7+
imapPort?: number;
8+
imapTls?: boolean;
9+
secret: string;
10+
displayName?: string;
11+
smtpHost?: string;
12+
}
13+
14+
export class OnboardingPage {
15+
constructor(private readonly page: Page) {}
16+
17+
/** Navigate to '/' and assert the wizard is shown (not the mailbox). */
18+
async open(): Promise<void> {
19+
await this.page.goto('/');
20+
await expect(this.page.getByTestId('onboarding-wizard')).toBeVisible();
21+
}
22+
23+
/** Assert wizard is visible without navigating. */
24+
async expectVisible(): Promise<void> {
25+
await expect(this.page.getByTestId('onboarding-wizard')).toBeVisible();
26+
}
27+
28+
/** Assert wizard is gone (mailbox has taken over). */
29+
async expectDismissed(): Promise<void> {
30+
await expect(this.page.getByTestId('onboarding-wizard')).not.toBeVisible({ timeout: 10_000 });
31+
}
32+
33+
/** Fill the required form fields. */
34+
async fillForm(fields: AccountFormFields): Promise<void> {
35+
await this.page.getByTestId('onboarding-wizard.email').fill(fields.email);
36+
await this.page.getByTestId('onboarding-wizard.imap-host').fill(fields.imapHost);
37+
if (fields.imapPort !== undefined) {
38+
await this.page.getByTestId('onboarding-wizard.imap-port').fill(String(fields.imapPort));
39+
}
40+
if (fields.imapTls !== undefined) {
41+
const checkbox = this.page.getByTestId('onboarding-wizard.imap-tls');
42+
const checked = await checkbox.isChecked();
43+
if (checked !== fields.imapTls) await checkbox.click();
44+
}
45+
await this.page.getByTestId('onboarding-wizard.secret').fill(fields.secret);
46+
if (fields.displayName) {
47+
await this.page.getByTestId('onboarding-wizard.display-name').fill(fields.displayName);
48+
}
49+
if (fields.smtpHost) {
50+
await this.page.getByTestId('onboarding-wizard.smtp-host').fill(fields.smtpHost);
51+
}
52+
}
53+
54+
/** Click the submit button. */
55+
async submit(): Promise<void> {
56+
await this.page.getByTestId('onboarding-wizard.submit').click();
57+
}
58+
59+
/** Wait for "Account added" success phase. */
60+
async expectCreated(): Promise<void> {
61+
await expect(this.page.getByTestId('onboarding-wizard.created')).toBeVisible({ timeout: 20_000 });
62+
}
63+
64+
/** Click "Sync now" and wait until it transitions to "Go to inbox". */
65+
async syncNow(): Promise<void> {
66+
await this.page.getByTestId('onboarding-wizard.sync-now').click();
67+
await expect(this.page.getByTestId('onboarding-wizard.go-to-inbox')).toBeVisible({ timeout: 30_000 });
68+
}
69+
70+
/** Click "Go to inbox" — triggers onAccountReady and dismisses the wizard. */
71+
async goToInbox(): Promise<void> {
72+
await this.page.getByTestId('onboarding-wizard.go-to-inbox').click();
73+
}
74+
75+
/** Field-level validation error for a given field id (e.g. 'email', 'imap-host'). */
76+
async fieldError(field: string): Promise<string | null> {
77+
const locator = this.page.getByTestId(`onboarding-wizard.${field}-error`);
78+
const visible = await locator.isVisible();
79+
return visible ? (await locator.textContent()) ?? null : null;
80+
}
81+
82+
/** Form-level (non-field) error. */
83+
async formError(): Promise<string | null> {
84+
const locator = this.page.getByTestId('onboarding-wizard.form-error');
85+
const visible = await locator.isVisible();
86+
return visible ? (await locator.textContent()) ?? null : null;
87+
}
88+
}

0 commit comments

Comments
 (0)