Files
margin-mail/tests/imap.spec.ts
T
pj eb59cb5f8d build the app, and ship it on Linux and Windows as well as macOS
The client itself: the sync engine and mirror, the Gmail and IMAP providers,
the screener, the three boxes and two piles, compose and send, the sanitiser
and tracker stripping, contacts, clips, backup, notifications and the guide.

The pipeline that ships it. Three runners now build in parallel and the
publish gate wants all four platform keys in latest.json before a release
leaves draft. Linux gets two more packages Tauri does not build: a flatpak
repackaged from the deb against GNOME 48, and a Nix package that relinks the
deb against nixpkgs so it runs as a native Wayland client rather than through
Xwayland. CI runs the Rust suite on all three desktops rather than on Linux
alone, and rebuilds the flatpak manifest on every push to main.

Two things that were only ever exercised on macOS and were wrong everywhere
else. The menu bar was built by adjusting the submenus macOS is given, so on
a platform whose default menu has no File or View the new File menu landed
after Edit, Check for Updates landed nowhere, and the three places and the
reading pane had no menu at all. The deb declared libwebkit2gtk-4.1-0 and
libgtk-3-0 twice, because tauri.conf.json named the dependencies Tauri
already emits.

The updater key exists now, so releases can sign their artifacts.
2026-09-06 12:15:30 +05:30

427 lines
21 KiB
TypeScript

// A mailbox that is not Google, reached with an address, then a name and a password.
//
// `openApp(page, { firstRun: true })` is an install with nothing connected, which is the only way
// to reach the connect screen at all. From there the fixture's directory of providers decides what
// each address does: one publishes its own settings, one is only in the public directory and wants
// an app password, one is worked out from the MX record, one presents a certificate nobody vouches
// for, one is Proton and points at a Bridge that is not running, and anything else is not published
// anywhere, which is the servers sheet.
//
// Nothing here reaches into the app. Every outcome is produced by typing a different address.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Locator, type Page } from "@playwright/test";
import { listReady, openApp, settle } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
/** The IMAP column of the sheet, and the SMTP one. They carry the same five field names. */
const incoming = (page: Page): Locator => page.locator(".imap-leg").first();
const outgoing = (page: Page): Locator => page.locator(".imap-leg").nth(1);
/** The first step: the address, submitted from the keyboard, and the sign-in panel it leads to. */
async function lookUp(page: Page, email: string): Promise<void> {
await page.getByLabel("Email address").fill(email);
await page.keyboard.press("Enter");
await expect(page.getByLabel("Your name")).toBeVisible();
}
/** The second step: who you are and the password, wherever the flow is being drawn. */
async function signIn(page: Page, password = "correct horse"): Promise<void> {
await page.getByLabel("Your name").fill("Priyanshu Jain");
await page.getByLabel("Password").fill(password);
await page.keyboard.press("Enter");
}
/**
* The whole of the flow up to the servers' answer, filled the way a person fills it and submitted
* from the keyboard, because Enter has to be the way out of forms this short.
*/
async function connectWith(page: Page, email: string, password = "correct horse"): Promise<void> {
await lookUp(page, email);
await signIn(page, password);
}
/**
* The same flow in its other host: a sheet over Settings rather than the stage of a screen that is
* not the app yet. Everything past the button is the one component, which is what the last two
* tests are for.
*/
async function lookUpFromSettings(page: Page, email: string): Promise<void> {
await page.keyboard.press("Meta+,");
await expect(page.locator(".settings")).toBeVisible();
await page.getByRole("button", { name: "Add account" }).click();
await expect(page.getByRole("dialog", { name: "Add an account" })).toBeVisible();
await lookUp(page, email);
// The panel's own head carries what the stage draws as a heading, and there is only one of them.
await expect(page.locator(".welcome-title")).toHaveCount(0);
}
test("the welcome screen asks for the address and nothing else", async ({ page }) => {
await openApp(page, { firstRun: true });
await expect(page.getByLabel("Email address")).toBeVisible();
expect(await page.locator(".imap-address .field-label").allTextContents()).toEqual([
"Email address",
]);
// Nothing about a provider, and no second way in: the address is the way in.
await expect(page.getByRole("button", { name: /Google/ })).toHaveCount(0);
await expect(page.locator(".welcome-soon")).toHaveCount(0);
await expect(page.getByLabel("Password")).toHaveCount(0);
});
test("the sign-in step is a name and a password, under where the servers came from", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await lookUp(page, "[email protected]");
await expect(page.locator(".welcome-title")).toHaveText("Sign in to Fastmail");
// Published by the provider is a different promise from guessed, and the sentence says which,
// before the password is typed rather than after it was sent.
await expect(page.locator(".welcome-line")).toContainText("Fastmail publishes its own settings");
const facts = await page.locator(".imap-fact").allTextContents();
expect(facts[0].replace(/\s+/g, " ")).toContain("imap.fastmail.example, port 993, TLS");
expect(facts[1].replace(/\s+/g, " ")).toContain("smtp.fastmail.example, port 465, TLS");
expect(await page.locator(".imap-form .field-label").allTextContents()).toEqual([
"Your name",
"Password",
]);
// The password is a password, on a screen whose whole subject is where that password goes.
await expect(page.locator(".imap-form input[type='password']")).toHaveCount(1);
await expect(page.getByLabel("Your name")).toBeFocused();
await expect(page.getByRole("button", { name: "Add account" })).toBeVisible();
await expect(page.getByRole("button", { name: "Change the servers" })).toBeVisible();
await page.getByLabel("Your name").fill("Priyanshu Jain");
mkdirSync(shots, { recursive: true });
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "connect-mail.png") });
// Escape is the way back to the address, which is still there to be corrected.
await page.keyboard.press("Escape");
await expect(page.locator(".welcome-title")).toHaveText("Margin Mail");
await expect(page.getByLabel("Email address")).toHaveValue("[email protected]");
});
test("a provider that wants an app password is told so before the password is typed", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await lookUp(page, "[email protected]");
// The single most common way an IMAP setup fails, said under the field it fails in.
await expect(page.locator(".imap-form .field-hint").nth(1)).toContainText("app password");
await expect(page.locator(".imap-form .field-hint").nth(1)).toContainText("Privacy & Security");
});
test("discovery that finds nothing says so, and the servers come from the sign-in step", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await lookUp(page, "[email protected]");
// Not a failure, and the panel does not read as one: it is the same panel with a different
// first sentence, no server lines, and a button that opens them.
await expect(page.locator(".welcome-title")).toHaveText("Sign in to harborlife.example");
await expect(page.locator(".welcome-line")).toContainText(
"Nobody publishes settings for harborlife.example",
);
await expect(page.locator(".imap-fact")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Enter the servers" })).toBeVisible();
await signIn(page);
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await expect(page.locator(".banner")).toContainText(
"Nobody publishes settings for harborlife.example",
);
await expect(incoming(page).getByLabel("Server")).toHaveValue("imap.harborlife.example");
await expect(outgoing(page).getByLabel("Server")).toHaveValue("smtp.harborlife.example");
// The address is carried across, and so is the password: nothing is typed twice.
await expect(incoming(page).getByLabel("Username")).toHaveValue("[email protected]");
await expect(incoming(page).getByLabel("Password")).toHaveValue("correct horse");
// Which is exactly what the outgoing half does not insist on.
await expect(outgoing(page).getByLabel("Username")).toHaveValue("");
await expect(outgoing(page).getByLabel("Password")).toHaveValue("");
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "connect-mail-servers.png") });
// Escape goes back a step rather than throwing the whole flow away.
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Servers" })).toHaveCount(0);
await expect(page.locator(".welcome-title")).toHaveText("Sign in to harborlife.example");
await expect(page.getByLabel("Your name")).toHaveValue("Priyanshu Jain");
});
test("the servers can be typed without waiting for a lookup", async ({ page }) => {
await openApp(page, { firstRun: true });
await page.getByLabel("Email address").fill("[email protected]");
await page.getByRole("button", { name: "Enter the servers myself" }).click();
// Straight to the sign-in step, with a sentence that does not pretend a lookup was made.
await expect(page.locator(".welcome-title")).toHaveText("Sign in to selfhosted.example");
await expect(page.locator(".welcome-line")).toContainText("The servers are yours to type");
await expect(page.getByRole("button", { name: "Enter the servers" })).toBeVisible();
await signIn(page);
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await expect(incoming(page).getByLabel("Server")).toHaveValue("imap.selfhosted.example");
// Nothing was looked up, so nothing is said about what the lookup found.
await expect(page.locator(".banner")).toHaveCount(0);
});
test("changing the security corrects the port, on both halves", async ({ page }) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await expect(incoming(page).getByLabel("Port")).toHaveValue("993");
await incoming(page).getByRole("tab", { name: "STARTTLS" }).click();
await expect(incoming(page).getByLabel("Port")).toHaveValue("143");
await incoming(page).getByRole("tab", { name: "TLS", exact: true }).click();
await expect(incoming(page).getByLabel("Port")).toHaveValue("993");
// The two halves agree on the shape and disagree on the numbers.
await expect(outgoing(page).getByLabel("Port")).toHaveValue("465");
await outgoing(page).getByRole("tab", { name: "STARTTLS" }).click();
await expect(outgoing(page).getByLabel("Port")).toHaveValue("587");
await outgoing(page).getByRole("tab", { name: "None" }).click();
await expect(outgoing(page).getByLabel("Port")).toHaveValue("25");
// And an unencrypted connection says what that means rather than letting it pass.
await expect(outgoing(page).locator(".field-hint[data-tone='error']")).toContainText(
"Nothing on this connection is encrypted",
);
});
test("a refused password says so on the panel it was typed on, and what to do about it", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
// A mistyped password is corrected where it was typed, not in a two-column sheet of servers
// that were never the problem.
await expect(page.locator(".welcome-trouble")).toContainText(
"Signing in to imap.oakridge-school.example did not work",
);
await expect(page.getByRole("dialog", { name: "Servers" })).toHaveCount(0);
// The server's own words, because they name the thing to go and fix.
await expect(page.locator(".imap-said")).toContainText("Application-specific password required");
await expect(page.locator(".imap-advice")).toHaveText(
"This account wants an app password rather than the one you sign in with.",
);
// And the way to change the servers is still there for whoever needs it.
await expect(page.getByRole("button", { name: "Change the servers" })).toBeEnabled();
});
test("a wrong password on the outgoing half says it was the outgoing half", async ({ page }) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await incoming(page).getByLabel("Server").fill("mail.harborlife.example");
await outgoing(page).getByLabel("Server").fill("mail.harborlife.example");
await outgoing(page).getByLabel("Password").fill("wrong");
await page.getByRole("button", { name: "Test and add" }).click();
await expect(page.locator(".banner")).toContainText(
"Your mail came through. Sending, through mail.harborlife.example, did not.",
);
await expect(page.locator(".imap-said")).toContainText("Invalid credentials");
});
test("a certificate nobody vouches for is a decision, with the fingerprint to check it by", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await lookUp(page, "[email protected]");
// The rung of the ladder that answered for this one was a guess, and the panel says so before
// anybody types a password into it.
await expect(page.locator(".welcome-line")).toContainText(
"Nobody publishes settings for sunnydaymusic.example",
);
await expect(page.locator(".welcome-line")).toContainText("a guess");
await signIn(page);
await expect(page.getByRole("dialog", { name: "The server's certificate" })).toBeVisible();
await expect(page.locator(".imap-lead")).toContainText(
"mail.sunnydaymusic.example presented a certificate",
);
await expect(page.locator(".imap-lead")).toContainText("it signed its own certificate");
// Scoped to the sheet: the sign-in panel under it still carries its two server lines.
const sheet = page.getByRole("dialog", { name: "The server's certificate" });
const facts = await sheet.locator(".imap-fact dt").allTextContents();
expect(facts).toEqual(["Fingerprint", "Subject", "Issuer", "Expires"]);
await expect(sheet.locator(".imap-fact dd").first()).toContainText("9F:2C:41:8E");
// Never asked about the loopback, so a person seeing this is being asked about the network.
await expect(page.locator(".imap-note")).toContainText("out on the network");
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "connect-mail-certificate.png") });
// Going back is the other answer, and it goes back to the panel whose test raised the question.
await page.getByRole("button", { name: "Go back" }).click();
await expect(page.getByRole("dialog", { name: "Servers" })).toHaveCount(0);
await expect(page.locator(".welcome-title")).toHaveText("Sign in to sunnydaymusic.example");
});
test("trusting a certificate is remembered, and the connection carries on", async ({ page }) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
// One question per socket, because that is what the trust store holds: a fingerprint accepted on
// a host and a port. This server answers on both ports, so it is asked about twice, and the
// second panel names the outgoing one.
await page.getByRole("button", { name: "Trust this certificate" }).click();
await expect(page.locator(".imap-note")).toContainText("port 465");
await page.getByRole("button", { name: "Trust this certificate" }).click();
// Both accepted, the servers answered, and there is nothing left to confirm.
await expect(page.locator(".welcome")).toHaveCount(0);
await expect(page.locator(".row").first()).toBeVisible();
});
test("a bridge that is not running says so, rather than printing a socket error", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await lookUp(page, "[email protected]");
// Proton's own published settings, which point at Bridge on this machine, and the hint that says
// which password Bridge wants before it is asked for.
await expect(page.locator(".welcome-title")).toHaveText("Sign in to Proton Mail Bridge");
await expect(page.locator(".imap-fact").first()).toContainText("127.0.0.1, port 1143");
await expect(page.locator(".imap-form .field-hint").nth(1)).toContainText("Mailbox details");
await signIn(page);
await expect(page.locator(".imap-advice")).toContainText("Proton Bridge is not running");
// The socket error is still there for whoever wants it. It is just not the headline.
await expect(page.locator(".imap-said")).toContainText("Connection refused");
});
test("a bridge that is running connects like anything else", async ({ page }) => {
await openApp(page, { firstRun: true, storage: { "marginmail-dev-bridge": "1" } });
await lookUp(page, "[email protected]");
// A port that was typed rather than defaulted survives the security changing under it.
await page.getByRole("button", { name: "Change the servers" }).click();
await incoming(page).getByRole("tab", { name: "TLS", exact: true }).click();
await expect(incoming(page).getByLabel("Port")).toHaveValue("1143");
await page.keyboard.press("Escape");
await signIn(page);
await expect(page.locator(".welcome")).toHaveCount(0);
await expect(page.locator(".row").first()).toBeVisible();
});
test("the account arrives and the welcome screen is over", async ({ page }) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
await expect(page.locator(".welcome")).toHaveCount(0);
await expect(page.locator(".row").first()).toBeVisible();
});
test("servers typed by hand connect the same account the same way", async ({ page }) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await incoming(page).getByLabel("Server").fill("mail.harborlife.example");
await outgoing(page).getByLabel("Server").fill("mail.harborlife.example");
// Enter, from inside the sheet, because the whole flow has to be finishable without a mouse.
await outgoing(page).getByLabel("Server").press("Enter");
await expect(page.locator(".welcome")).toHaveCount(0);
await expect(page.locator(".row").first()).toBeVisible();
});
test("Settings runs the same flow, and the account it connects is in the list", async ({ page }) => {
await openApp(page);
await listReady(page);
await lookUpFromSettings(page, "[email protected]");
// The same panel as the welcome screen's, with the same sentence about where the settings came
// from, in a sheet whose head is doing the heading's job.
await expect(page.getByRole("dialog", { name: "Sign in to Fastmail" })).toBeVisible();
await expect(page.locator(".welcome-line")).toContainText("Fastmail publishes its own settings");
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "settings-connect-mail.png") });
await signIn(page);
// The account is written and its first sync takes the window, the way a Google account's does
// from Settings: the sign-in sheet is gone, the arriving panel is up, and when the pass ends
// what is behind it is the new account's Inbox with the first-run panel over it.
await expect(page.getByRole("dialog", { name: "Sign in to Fastmail" })).toHaveCount(0);
const panel = page.getByRole("dialog", { name: "Bringing in [email protected]" });
await expect(panel).toBeVisible();
await panel.getByRole("button", { name: "Start" }).click();
await expect(panel).toHaveCount(0);
await expect(page.locator(".settings")).toHaveCount(0);
await expect(page.locator(".account-chip")).toContainText("[email protected]");
const setUp = page.getByRole("dialog", { name: "You are set up" });
await expect(setUp).toBeVisible();
await setUp.getByRole("button", { name: "Done" }).click();
// The tour follows that panel for every account added, a password account included. Escape is
// how somebody on their third mailbox refuses it, and the keys belong to the window again after.
await expect(page.getByRole("dialog", { name: "Getting started" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator(".overlay")).toHaveCount(0);
// And the account is on the list without anything being reloaded.
await page.keyboard.press("Meta+,");
await expect(page.locator(".acct")).toHaveCount(3);
await expect(page.locator(".acct").nth(2).locator(".acct-addr")).toHaveText("[email protected]");
// And its card is the IMAP card: the servers it was just added with, and nothing to grant.
await expect(page.locator(".acct").nth(2).locator(".acct-servers")).toContainText(
"imap.fastmail.example, port 993, TLS",
);
await expect(page.locator(".acct").nth(2).getByRole("button", { name: "Grant" })).toHaveCount(0);
});
test("a refusal in Settings is said in the sheet, and the servers open over it", async ({
page,
}) => {
await openApp(page);
await listReady(page);
await lookUpFromSettings(page, "[email protected]");
await signIn(page);
// A flow no wire protocol has ever run for real fails in Settings the way it fails on the welcome
// screen: out loud, with what the server said, in the panel the password was typed into.
const sheet = page.getByRole("dialog", { name: "Sign in to Oakridge School Mail" });
await expect(sheet).toBeVisible();
await expect(sheet.locator(".welcome-trouble")).toContainText(
"Signing in to imap.oakridge-school.example did not work",
);
await expect(sheet.locator(".imap-advice")).toHaveText(
"This account wants an app password rather than the one you sign in with.",
);
// The servers take the window rather than a place inside the section, so the panel that opened
// them gives it up: one dialog, not a sheet stacked on a sheet.
await page.getByRole("button", { name: "Change the servers" }).click();
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await expect(page.locator('[role="dialog"]')).toHaveCount(1);
// Escape goes back to the flow that opened it, still inside Settings, with the account list
// behind it untouched.
await page.keyboard.press("Escape");
await expect(sheet).toBeVisible();
await expect(page.locator(".acct")).toHaveCount(2);
});