From ddf71d657c124aa345bc939d2f27bb110042759b Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:26:52 +0100
Subject: [PATCH] test: cover organisation signatures and disclaimers against
the test servers
---
integration/tests/19-org-signatures.spec.ts | 311 ++++++++++++++++++++
1 file changed, 311 insertions(+)
create mode 100644 integration/tests/19-org-signatures.spec.ts
diff --git a/integration/tests/19-org-signatures.spec.ts b/integration/tests/19-org-signatures.spec.ts
new file mode 100644
index 000000000..23ae5ac1e
--- /dev/null
+++ b/integration/tests/19-org-signatures.spec.ts
@@ -0,0 +1,311 @@
+import { test, expect, type Page } from '@playwright/test';
+import { JmapClient } from './helpers/jmap';
+import { addRecipient, emailItem, setSubject } from './helpers/app';
+import {
+ ORG,
+ ORG_PASSWORD,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+
+/**
+ * Organisation signatures and the disclaimer (organisation feature 1),
+ * through the real UI on a VM 106 test server. The admin sets a locked
+ * signature and a disclaimer for the example.org domain on Organisation >
+ * Signatures; org-alice's mail carries both once, org-dave's (org-two.test)
+ * neither; org-carol reads what arrives over JMAP. The admin's policy is
+ * put back afterwards.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 240000 });
+
+const STAMP = Date.now().toString(36);
+const SIGNATURE_MARK = `Org signature ${STAMP}`;
+const SIGNATURE_HTML = `
{name}
Phone: {phone}
Mobile: {mobile}
${SIGNATURE_MARK}
`;
+const DISCLAIMER = `Confidential ${STAMP}: meant for the addressee only.`;
+const PHONE = '+49 30 5550100';
+
+let original: Record;
+
+const countOf = (haystack: string, needle: string) => haystack.split(needle).length - 1;
+
+interface Received {
+ html: string;
+ text: string;
+}
+
+/** The message as org-carol receives it, read over JMAP. */
+async function receivedByCarol(subject: string): Promise {
+ const carol = await JmapClient.connect(ORG.carol, ORG_PASSWORD);
+ // Matched on the exact subject: the subject filter is a word search, and
+ // "Re: x" also finds "x".
+ const inbox = await carol.mailboxByRole('inbox');
+ let id: string | undefined;
+ const deadline = Date.now() + 90000;
+ while (!id) {
+ const q = await carol.request([
+ ['Email/query', { accountId: carol.accountId, filter: { inMailbox: inbox!.id, subject: subject.replace(/^Re: /, '') } }, '0'],
+ ['Email/get', { accountId: carol.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'],
+ ]);
+ id = (q.methodResponses[1][1].list as Array<{ id: string; subject: string }>).find((e) => e.subject === subject)?.id;
+ if (!id) {
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for "${subject}" for org-carol`);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+ }
+ const r = await carol.request([
+ ['Email/get', {
+ accountId: carol.accountId,
+ ids: [id],
+ properties: ['htmlBody', 'textBody', 'bodyValues'],
+ fetchHTMLBodyValues: true,
+ fetchTextBodyValues: true,
+ }, '0'],
+ ]);
+ const email = r.methodResponses[0][1].list[0];
+ const values = email.bodyValues as Record;
+ const part = (parts: Array<{ partId: string; type: string }>, type: string) =>
+ parts.filter((p) => p.type === type).map((p) => values[p.partId]?.value ?? '').join('\n');
+ return { html: part(email.htmlBody, 'text/html'), text: part(email.textBody, 'text/plain') };
+}
+
+/** Sends a plain mail over JMAP, for the reply test (SMTP on the test servers doesn't work). */
+async function sendOverJmap(fromEmail: string, to: string, subject: string, text: string): Promise {
+ const from = await JmapClient.connect(fromEmail, ORG_PASSWORD);
+ const identity = (await from.identities()).find((i) => i.email === fromEmail);
+ const drafts = await from.mailboxByRole('drafts');
+ if (!identity || !drafts) throw new Error(`No identity or Drafts for ${fromEmail}`);
+ const r = await from.request([
+ ['Email/set', {
+ accountId: from.accountId,
+ create: {
+ m: {
+ mailboxIds: { [drafts.id]: true },
+ keywords: { $draft: true, $seen: true },
+ from: [{ name: identity.name, email: identity.email }],
+ to: [{ email: to }],
+ subject,
+ bodyValues: { t: { value: text } },
+ textBody: [{ partId: 't', type: 'text/plain' }],
+ },
+ },
+ }, '0'],
+ ['EmailSubmission/set', {
+ accountId: from.accountId,
+ create: { s: { identityId: identity.id, emailId: '#m' } },
+ onSuccessDestroyEmail: ['#s'],
+ }, '1'],
+ ]);
+ const created = r.methodResponses[1][1].created;
+ if (!created?.s) throw new Error(`Submission failed: ${JSON.stringify(r.methodResponses)}`);
+}
+
+/** Opens a new message with the Compose button (the "c" shortcut can land before the list has focus). */
+async function startCompose(page: Page): Promise {
+ await page.getByRole('button', { name: 'Compose', exact: true }).first().click();
+ await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
+}
+
+async function writeAndSend(page: Page, to: string, subject: string, text: string): Promise {
+ await addRecipient(page, to);
+ await setSubject(page, subject);
+ await page.locator('.ProseMirror').first().click();
+ await page.keyboard.type(text);
+ await page.locator('[data-testid="composer-send"]:visible').first().click();
+ await page.locator('[data-testid="email-composer"]').waitFor({ state: 'hidden', timeout: 30000 });
+}
+
+/** org-alice fills in the phone the signature asks for, under Settings > Account. */
+async function fillDetails(page: Page): Promise {
+ await page.goto('/settings/account');
+ const phone = page.getByTestId('signature-detail-phone');
+ await expect(phone).toBeVisible();
+ // Let settings sync load the server's copy first: it would replace what is
+ // typed. (The page never goes network-idle: push keeps a request open.)
+ await page.waitForTimeout(3000);
+ if ((await phone.inputValue()) !== PHONE) {
+ // The next page load takes the server's copy again, so wait until the
+ // details went up (the sync waits two seconds after a change).
+ const synced = page.waitForResponse((res) => res.url().endsWith('/api/settings') && res.request().method() === 'POST'
+ && (res.request().postData() ?? '').includes(PHONE), { timeout: 30000 });
+ await phone.fill(PHONE);
+ await synced;
+ }
+ await expect(page.getByTestId('signature-detail-title')).toHaveCount(0);
+ await expect(page.getByTestId('signature-details-preview')).toContainText(PHONE);
+}
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await putPolicy(admin, {
+ ...original,
+ restrictions: {},
+ organisation: {},
+ scopes: [{ id: 'example-org', label: 'Example org', match: { domains: ['example.org'] }, policy: {} }],
+ });
+ await admin.close();
+});
+
+test.afterAll(async ({ browser }) => {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+});
+
+test('the admin sets a locked signature and a disclaimer for example.org', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-signatures');
+ await expect(page.getByRole('heading', { name: 'Signatures' }).first()).toBeVisible();
+ await page.getByRole('button', { name: 'Example org' }).click();
+
+ // The dev server loads the policy twice (strict mode); a fill before the
+ // second load lands would be replaced, so fill until it sticks.
+ await page.waitForLoadState('networkidle');
+ await expect(async () => {
+ await page.getByTestId('org-signature-html').fill(SIGNATURE_HTML);
+ await expect(page.getByTestId('org-signature-preview')).toContainText(SIGNATURE_MARK, { timeout: 2000 });
+ }).toPass({ timeout: 20000 });
+ await expect(page.getByTestId('org-signature-fields').locator('[data-used]')).toHaveCount(2);
+ await page.getByTestId('org-disclaimer-text').fill(DISCLAIMER);
+ await page.getByLabel('Lock').check();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await page.getByTestId('org-signature-html').scrollIntoViewIfNeeded();
+ await shot(page, 'signatures-admin-page');
+ await page.getByTestId('org-disclaimer-text').scrollIntoViewIfNeeded();
+ await shot(page, 'signatures-admin-disclaimer');
+
+ const policy = await getPolicy(admin);
+ const scope = (policy.scopes as Array<{ id: string; policy: Record }>).find((s) => s.id === 'example-org')!;
+ expect(scope.policy.organisation.signature).toEqual({ html: SIGNATURE_HTML, fields: ['phone', 'mobile'] });
+ expect(scope.policy.organisation.disclaimer).toEqual({ text: DISCLAIMER });
+ expect(scope.policy.restrictions.signature).toEqual({ locked: true });
+ // Everyone has no signature.
+ expect((policy.organisation as Record).signature.html).toBe('');
+ await admin.close();
+});
+
+test("org-alice's new mail carries the signature and the disclaimer once", async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await fillDetails(alice);
+ await shot(alice, 'signatures-user-details');
+
+ await alice.goto('/');
+ await startCompose(alice);
+ await expect(alice.getByTestId('org-disclaimer-preview')).toContainText('Added when you send');
+ await expect(alice.getByTestId('org-disclaimer-preview')).toContainText(DISCLAIMER);
+ // The signature is an island in a shadow root; getByText looks inside it.
+ const island = alice.locator('.ProseMirror .signature-block-island').first();
+ await expect(island.getByText(SIGNATURE_MARK)).toBeVisible();
+ await expect(island.getByText(PHONE, { exact: false })).toBeVisible();
+ await shot(alice, 'signatures-composer');
+ const subject = `Org signature new ${STAMP}`;
+ await writeAndSend(alice, ORG.carol, subject, 'Hello Carol');
+
+ const received = await receivedByCarol(subject);
+ expect(countOf(received.html, SIGNATURE_MARK)).toBe(1);
+ expect(countOf(received.text, SIGNATURE_MARK)).toBe(1);
+ expect(received.html).toContain(PHONE);
+ expect(received.html).not.toContain('Mobile:');
+ expect(countOf(received.html, DISCLAIMER)).toBe(1);
+ expect(countOf(received.text, DISCLAIMER)).toBe(1);
+ // Below everything.
+ expect(received.text.trimEnd().endsWith(DISCLAIMER)).toBe(true);
+ await alice.context().close();
+});
+
+test("org-alice's reply carries them once too", async ({ browser }) => {
+ const subject = `Org signature question ${STAMP}`;
+ await sendOverJmap(ORG.carol, ORG.alice, subject, 'A question from Carol');
+
+ const alice = await userPage(browser, ORG.alice);
+ await fillDetails(alice);
+ await alice.goto('/');
+ await expect(emailItem(alice, subject).first()).toBeVisible({ timeout: 60000 });
+ await emailItem(alice, subject).first().click();
+ await alice.keyboard.press('r');
+ await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
+ await alice.locator('.ProseMirror').first().click();
+ await alice.keyboard.type('The answer');
+ await alice.locator('[data-testid="composer-send"]:visible').first().click();
+ await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'hidden', timeout: 30000 });
+
+ const received = await receivedByCarol(`Re: ${subject}`);
+ expect(received.text).toContain('A question from Carol');
+ expect(countOf(received.html, SIGNATURE_MARK)).toBe(1);
+ expect(countOf(received.text, SIGNATURE_MARK)).toBe(1);
+ expect(countOf(received.html, DISCLAIMER)).toBe(1);
+ expect(countOf(received.text, DISCLAIMER)).toBe(1);
+ // The disclaimer comes after the quote.
+ expect(received.text.indexOf('A question from Carol')).toBeLessThan(received.text.indexOf(DISCLAIMER));
+ await alice.context().close();
+});
+
+test("the locked editor says it's set by the organisation", async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await fillDetails(alice);
+ await alice.getByRole('button', { name: 'Manage' }).first().click();
+ await alice.getByRole('button', { name: 'Edit Identity' }).first().click();
+ const locked = alice.getByTestId('identity-locked-signature');
+ await expect(locked).toContainText(SIGNATURE_MARK);
+ await expect(locked).toContainText(PHONE);
+ await expect(locked).toContainText('Set by your organisation');
+ await expect(alice.locator('#identity-html-sig')).toHaveCount(0);
+ await shot(alice, 'signatures-identity-locked');
+ await alice.context().close();
+});
+
+test("org-dave's mail from another domain gets neither", async ({ browser }) => {
+ const dave = await userPage(browser, ORG.dave);
+ await startCompose(dave);
+ await expect(dave.getByTestId('org-disclaimer-preview')).toHaveCount(0);
+ const subject = `Org signature dave ${STAMP}`;
+ await writeAndSend(dave, ORG.carol, subject, 'Hello from org-two');
+
+ const received = await receivedByCarol(subject);
+ expect(received.text).toContain('Hello from org-two');
+ expect(received.html + received.text).not.toContain(SIGNATURE_MARK);
+ expect(received.html + received.text).not.toContain(DISCLAIMER);
+ await dave.context().close();
+});
+
+test('the composer, the details and the admin page work at phone width', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice, true);
+ await alice.goto('/settings/account');
+ await alice.getByRole('button', { name: 'Account' }).or(alice.getByRole('link', { name: 'Account' })).first().click();
+ const phone = alice.getByTestId('signature-detail-phone');
+ await phone.scrollIntoViewIfNeeded();
+ await phone.fill(PHONE);
+ const box = await phone.boundingBox();
+ // One column: the input spans the width.
+ expect(box!.width).toBeGreaterThan(300);
+ await shot(alice, 'signatures-user-details-phone');
+
+ await alice.goto('/');
+ await startCompose(alice);
+ const preview = alice.getByTestId('org-disclaimer-preview');
+ await preview.scrollIntoViewIfNeeded();
+ await expect(preview).toContainText(DISCLAIMER);
+ const overflow = await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+ const previewBox = await preview.boundingBox();
+ expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(390);
+ await shot(alice, 'signatures-composer-phone');
+ await alice.context().close();
+
+ const admin = await adminContext(browser, true);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-signatures');
+ await expect(page.getByTestId('org-signature-html')).toBeVisible();
+ const adminOverflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(adminOverflow).toBeLessThanOrEqual(0);
+ await shot(page, 'signatures-admin-page-phone');
+ await admin.close();
+});