test(integration): dockerized webmail⇆Stalwart Playwright sync suite
Add an end-to-end integration harness that runs the webmail against a real Stalwart mail server in Docker and drives it with Playwright, focused on the mail/folder synchronisation behaviour (unread/total counters, folder-list sync, account-scoped Unified Mailbox) that the unified-mailbox work touches. - integration/ stack: Stalwart (declarative bootstrap: alice/bob/carol, submission + IMAP listeners, permissive CORS) + webmail (dev mode, so the browser's plaintext cross-origin JMAP calls aren't blocked by the prod CSP). - Helpers: dependency-free SMTP submit client, JMAP client for seeding / inspecting server state, and page helpers (login, add/switch account, locale-independent folder-counter reads). - Specs: login, single-account sync (receive/read/move/delete/folder-create/ burst) and multi-account (per-account isolation + cross-account Unified Inbox aggregation + background-account delivery). 12 tests, all green. - Add focused data-testid hooks to the mail UI (folder rows + counters, email list items, account switcher, composer) for stable selectors. - Exclude examples/ and integration/ from tsconfig/eslint/.dockerignore.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Page-level helpers for driving the Bulwark webmail in integration tests.
|
||||
*
|
||||
* Selectors rely on the data-testid hooks added to the mail UI (sidebar folder
|
||||
* rows + counters, account switcher, composer). Folder counters are read from
|
||||
* the `data-unread` / `data-total` attributes on `[data-testid=folder-counts]`
|
||||
* rather than parsing rendered text, so assertions are locale-independent.
|
||||
*/
|
||||
import { expect, type Page, type Locator } from '@playwright/test';
|
||||
import type { TestAccount } from './config';
|
||||
|
||||
/**
|
||||
* The account switcher renders twice (collapsed nav rail + expanded sidebar);
|
||||
* both carry the same data-testid and state, so always target the first.
|
||||
*/
|
||||
export function accountSwitcher(page: Page): Locator {
|
||||
return page.locator('[data-testid="account-switcher"]').first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The Next.js dev-mode overlay (`<nextjs-portal>`) sits in the bottom-left
|
||||
* corner and intercepts pointer events over the account switcher. Disable
|
||||
* pointer events on the portal host (light DOM) so it can't swallow clicks.
|
||||
* Registered as an init script so it survives navigations within the test.
|
||||
*/
|
||||
export async function neutralizeDevOverlay(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const inject = () => {
|
||||
const s = document.createElement('style');
|
||||
s.textContent = 'nextjs-portal{pointer-events:none!important}';
|
||||
document.documentElement.appendChild(s);
|
||||
};
|
||||
if (document.documentElement) inject();
|
||||
else document.addEventListener('DOMContentLoaded', inject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the cross-account Unified Mailbox before the app boots by seeding the
|
||||
* persisted settings store. Requires the `unifiedCrossAccountEnabled` admin
|
||||
* feature gate (provided by integration/webmail-config/policy.json). Must be
|
||||
* called before {@link login} so the init script is registered before the
|
||||
* first navigation.
|
||||
*/
|
||||
export async function seedUnifiedSettings(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
'settings-storage',
|
||||
JSON.stringify({
|
||||
state: { enableUnifiedMailbox: true, unifiedCrossAccount: true, includeGroupInUnified: true },
|
||||
version: 7,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill and submit the login form (works for first login and add-account). */
|
||||
async function submitCredentials(page: Page, account: TestAccount): Promise<void> {
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 30000 });
|
||||
await page.fill('#username', account.email);
|
||||
await page.fill('#password', account.password);
|
||||
await page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
/** Log in as `account` from a clean context and wait for the mailbox to load. */
|
||||
export async function login(page: Page, account: TestAccount): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await submitCredentials(page, account);
|
||||
// Landed in the app once the account switcher (sidebar chrome) is present.
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 30000 });
|
||||
}
|
||||
|
||||
/** Add a second (or later) account via the account switcher + login form. */
|
||||
export async function addAccount(page: Page, account: TestAccount): Promise<void> {
|
||||
await accountSwitcher(page).click();
|
||||
await page.locator('[data-testid="add-account"]').click();
|
||||
await submitCredentials(page, account);
|
||||
// Wait until the switcher reports the newly added account as active.
|
||||
await expect
|
||||
.poll(async () => activeAccountEmail(page), { timeout: 30000 })
|
||||
.toBe(account.email);
|
||||
}
|
||||
|
||||
/** Email of the currently active account, read from the switcher option list. */
|
||||
export async function activeAccountEmail(page: Page): Promise<string | null> {
|
||||
const switcher = accountSwitcher(page);
|
||||
const id = await switcher.getAttribute('data-active-account-id');
|
||||
if (!id) return null;
|
||||
await switcher.click();
|
||||
const email = await page
|
||||
.locator(`[data-testid="account-option"][data-account-id="${id}"]`)
|
||||
.first()
|
||||
.getAttribute('data-account-email');
|
||||
// Close the popover again.
|
||||
await page.keyboard.press('Escape');
|
||||
return email;
|
||||
}
|
||||
|
||||
/** Switch the active account to the one matching `email`. */
|
||||
export async function switchAccount(page: Page, email: string): Promise<void> {
|
||||
await accountSwitcher(page).click();
|
||||
await page.locator(`[data-testid="account-option"][data-account-email="${email}"]`).first().click();
|
||||
await expect.poll(async () => activeAccountEmail(page), { timeout: 30000 }).toBe(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the app to reconcile mailbox state immediately.
|
||||
*
|
||||
* The JMAP client refetches on `visibilitychange` (tab focus) via
|
||||
* checkForStateChanges(). Dispatching it makes reconciliation deterministic
|
||||
* after an *external* mutation, sidestepping the small window right after
|
||||
* login where a change can land before the SSE push channel has settled.
|
||||
* Mirrors what happens when a real user tabs back to the mailbox.
|
||||
*/
|
||||
export async function forceSync(page: Page): Promise<void> {
|
||||
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
|
||||
}
|
||||
|
||||
export interface FolderSelector {
|
||||
role?: string;
|
||||
name?: string;
|
||||
mailboxId?: string;
|
||||
}
|
||||
|
||||
/** Locator for a sidebar folder row. */
|
||||
export function folderRow(page: Page, sel: FolderSelector): Locator {
|
||||
let s = '[data-testid="folder-row"]';
|
||||
if (sel.role) s += `[data-folder-role="${sel.role}"]`;
|
||||
if (sel.name) s += `[data-folder-name="${sel.name}"]`;
|
||||
if (sel.mailboxId) s += `[data-mailbox-id="${sel.mailboxId}"]`;
|
||||
return page.locator(s);
|
||||
}
|
||||
|
||||
export interface FolderCounts {
|
||||
unread: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a folder's unread/total counts. When both are zero the counts element
|
||||
* is not rendered, so a missing element is reported as {0,0}.
|
||||
*/
|
||||
export async function folderCounts(page: Page, sel: FolderSelector): Promise<FolderCounts> {
|
||||
const row = folderRow(page, sel).first();
|
||||
const counts = row.locator('[data-testid="folder-counts"]');
|
||||
if ((await counts.count()) === 0) return { unread: 0, total: 0 };
|
||||
const unread = await counts.getAttribute('data-unread');
|
||||
const total = await counts.getAttribute('data-total');
|
||||
return { unread: Number(unread ?? 0), total: Number(total ?? 0) };
|
||||
}
|
||||
|
||||
/** Poll until a folder's unread count reaches `expected`. */
|
||||
export async function expectFolderUnread(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await folderCounts(page, sel)).unread, { timeout })
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
/** Poll until a folder's total count reaches `expected`. */
|
||||
export async function expectFolderTotal(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await folderCounts(page, sel)).total, { timeout })
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
/** Click a folder row to select it. */
|
||||
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
|
||||
await folderRow(page, sel).first().click();
|
||||
}
|
||||
|
||||
/** Locator for an email row by (exact) subject. */
|
||||
export function emailItem(page: Page, subject: string): Locator {
|
||||
return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`);
|
||||
}
|
||||
|
||||
/** Poll until an email with `subject` is present in the list. */
|
||||
export async function expectEmailVisible(page: Page, subject: string, timeout = 20000): Promise<void> {
|
||||
await expect(emailItem(page, subject).first()).toBeVisible({ timeout });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Shared configuration for the integration tests. Values mirror the Stalwart
|
||||
* bootstrap (integration/stalwart/*) and the docker-compose port mappings.
|
||||
* Everything is overridable via env so the suite can run against a differently
|
||||
* mapped stack (e.g. remote CI) without code changes.
|
||||
*/
|
||||
|
||||
export const DOMAIN = process.env.IT_DOMAIN ?? 'example.org';
|
||||
|
||||
/** Shared password for every test mailbox (TEST_ACCOUNT_PASSWORD in .env). */
|
||||
export const ACCOUNT_PASSWORD = process.env.IT_ACCOUNT_PASSWORD ?? 'test-pass-123';
|
||||
|
||||
/** Webmail app origin (containerised, published on the host). */
|
||||
export const WEBMAIL_URL = process.env.IT_WEBMAIL_URL ?? 'http://localhost:3000';
|
||||
|
||||
/** Stalwart JMAP + admin base URL (host-published). */
|
||||
export const JMAP_URL = process.env.IT_JMAP_URL ?? 'http://localhost:8025';
|
||||
|
||||
/** Stalwart SMTP submission listener (host-published, maps to container 587). */
|
||||
export const SMTP_HOST = process.env.IT_SMTP_HOST ?? 'localhost';
|
||||
export const SMTP_PORT = Number(process.env.IT_SMTP_PORT ?? 1025);
|
||||
|
||||
/** Recovery admin — `user:password`, used for stalwart-cli style admin JMAP. */
|
||||
export const ADMIN_CREDENTIALS = process.env.IT_ADMIN ?? 'admin:bootstrap-secret';
|
||||
|
||||
export interface TestAccount {
|
||||
/** Local part, e.g. "alice". */
|
||||
user: string;
|
||||
/** Full address, e.g. "alice@example.org". */
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
function acct(user: string): TestAccount {
|
||||
return { user, email: `${user}@${DOMAIN}`, password: ACCOUNT_PASSWORD };
|
||||
}
|
||||
|
||||
/** The mailboxes provisioned by the Stalwart bootstrap plan. */
|
||||
export const ACCOUNTS = {
|
||||
alice: acct('alice'),
|
||||
bob: acct('bob'),
|
||||
carol: acct('carol'),
|
||||
} as const;
|
||||
|
||||
export type AccountKey = keyof typeof ACCOUNTS;
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Minimal JMAP client for test setup/inspection against Stalwart.
|
||||
*
|
||||
* Uses global fetch (Node 18+). Not a full JMAP implementation — just the
|
||||
* pieces the integration tests need: authenticate, read/reset mailboxes,
|
||||
* create folders, and poll for delivery. Assertions on *server* state (via
|
||||
* this client) are kept separate from assertions on *UI* state (via the page),
|
||||
* so a failing test can tell whether the bug is in delivery or in the webmail's
|
||||
* sync.
|
||||
*/
|
||||
import { JMAP_URL } from './config';
|
||||
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||
|
||||
interface JmapMailbox {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
parentId: string | null;
|
||||
totalEmails: number;
|
||||
unreadEmails: number;
|
||||
}
|
||||
|
||||
type MethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
export class JmapClient {
|
||||
private authHeader: string;
|
||||
private apiUrl: string;
|
||||
accountId = '';
|
||||
|
||||
private constructor(private email: string, password: string) {
|
||||
this.authHeader = 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
|
||||
// Stalwart advertises apiUrl on its configured hostname (mail.example.org);
|
||||
// rewrite onto the reachable origin, exactly as the app client does.
|
||||
this.apiUrl = `${JMAP_URL}/jmap/`;
|
||||
}
|
||||
|
||||
static async connect(email: string, password: string): Promise<JmapClient> {
|
||||
const c = new JmapClient(email, password);
|
||||
const res = await fetch(`${JMAP_URL}/jmap/session`, {
|
||||
headers: { Authorization: c.authHeader },
|
||||
});
|
||||
if (!res.ok) throw new Error(`JMAP session failed for ${email}: ${res.status}`);
|
||||
const session = await res.json();
|
||||
const primary = session.primaryAccounts?.[MAIL];
|
||||
if (!primary) throw new Error(`No mail account for ${email} in JMAP session`);
|
||||
c.accountId = primary;
|
||||
return c;
|
||||
}
|
||||
|
||||
async request(methodCalls: MethodCall[]): Promise<any> {
|
||||
const res = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: this.authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ using: [CORE, MAIL], methodCalls }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async mailboxes(): Promise<JmapMailbox[]> {
|
||||
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
|
||||
return r.methodResponses[0][1].list as JmapMailbox[];
|
||||
}
|
||||
|
||||
async mailboxByRole(role: string): Promise<JmapMailbox | undefined> {
|
||||
return (await this.mailboxes()).find((m) => m.role === role);
|
||||
}
|
||||
|
||||
async mailboxByName(name: string): Promise<JmapMailbox | undefined> {
|
||||
return (await this.mailboxes()).find((m) => m.name === name);
|
||||
}
|
||||
|
||||
/** Create a folder (top-level) and return its id. Idempotent by name. */
|
||||
async createMailbox(name: string, parentId: string | null = null): Promise<string> {
|
||||
const existing = await this.mailboxByName(name);
|
||||
if (existing) return existing.id;
|
||||
const r = await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, create: { new: { name, parentId } } }, '0'],
|
||||
]);
|
||||
const created = r.methodResponses[0][1].created?.new;
|
||||
if (!created) throw new Error(`Mailbox/set create failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async deleteMailboxByName(name: string): Promise<void> {
|
||||
const mb = await this.mailboxByName(name);
|
||||
if (!mb) return;
|
||||
await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: [mb.id] }, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
private async allEmailIds(): Promise<string[]> {
|
||||
const r = await this.request([['Email/query', { accountId: this.accountId, limit: 5000 }, '0']]);
|
||||
return r.methodResponses[0][1].ids as string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a mailbox to a clean slate: destroy every message and delete any
|
||||
* non-system (custom) folder. System folders (Inbox/Sent/Trash/…) are kept.
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
const ids = await this.allEmailIds();
|
||||
if (ids.length) {
|
||||
await this.request([['Email/set', { accountId: this.accountId, destroy: ids }, '0']]);
|
||||
}
|
||||
const custom = (await this.mailboxes()).filter((m) => !m.role);
|
||||
if (custom.length) {
|
||||
await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: custom.map((m) => m.id) }, '0'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up an email id by subject within an optional mailbox. */
|
||||
async findEmailBySubject(subject: string, mailboxId?: string): Promise<any | undefined> {
|
||||
const filter: Record<string, unknown> = { subject };
|
||||
if (mailboxId) filter.inMailbox = mailboxId;
|
||||
const r = await this.request([
|
||||
['Email/query', { accountId: this.accountId, filter }, '0'],
|
||||
['Email/get', {
|
||||
accountId: this.accountId,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'preview'],
|
||||
}, '1'],
|
||||
]);
|
||||
return r.methodResponses[1][1].list[0];
|
||||
}
|
||||
|
||||
/** Poll until a message with `subject` is delivered (or throw on timeout). */
|
||||
async waitForEmail(subject: string, opts: { mailboxId?: string; timeoutMs?: number } = {}): Promise<any> {
|
||||
const deadline = Date.now() + (opts.timeoutMs ?? 15000);
|
||||
for (;;) {
|
||||
const found = await this.findEmailBySubject(subject, opts.mailboxId);
|
||||
if (found) return found;
|
||||
if (Date.now() > deadline) throw new Error(`Timed out waiting for email "${subject}" (${this.email})`);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Dependency-free SMTP submission client.
|
||||
*
|
||||
* Speaks just enough SMTP to authenticate against Stalwart's plaintext
|
||||
* submission listener (AUTH LOGIN, no STARTTLS) and inject a message. Used to
|
||||
* simulate real inbound mail so the webmail's sync behaviour can be observed.
|
||||
* A raw socket keeps the test harness free of a nodemailer dependency.
|
||||
*/
|
||||
import net from 'node:net';
|
||||
import { SMTP_HOST, SMTP_PORT } from './config';
|
||||
|
||||
interface SendOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
/** Envelope + auth sender, e.g. "alice@example.org". */
|
||||
from: string;
|
||||
/** Auth username; defaults to `from`. */
|
||||
authUser?: string;
|
||||
authPass: string;
|
||||
/** One or more envelope recipients. */
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
/** Plain-text body. */
|
||||
body: string;
|
||||
/** Extra headers (e.g. custom Message-ID / In-Reply-To for threading). */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
class SmtpError extends Error {}
|
||||
|
||||
function crlf(s: string): string {
|
||||
return s.replace(/\r?\n/g, '\r\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a single message. Resolves once the server has accepted it (250 after
|
||||
* end-of-DATA). Rejects on any non-2xx/3xx reply or socket error.
|
||||
*/
|
||||
export async function sendMail(opts: SendOptions): Promise<void> {
|
||||
const host = opts.host ?? SMTP_HOST;
|
||||
const port = opts.port ?? SMTP_PORT;
|
||||
const recipients = Array.isArray(opts.to) ? opts.to : [opts.to];
|
||||
const authUser = opts.authUser ?? opts.from;
|
||||
|
||||
const socket = net.createConnection({ host, port });
|
||||
socket.setEncoding('utf8');
|
||||
socket.setTimeout(15000);
|
||||
|
||||
let buffer = '';
|
||||
let resolveLine: ((line: string) => void) | null = null;
|
||||
let pendingError: Error | null = null;
|
||||
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk;
|
||||
// A complete reply ends with "<code> ...\r\n" (space, not hyphen, after code).
|
||||
const lines = buffer.split('\r\n');
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const line = lines[i];
|
||||
if (/^\d{3} /.test(line) && resolveLine) {
|
||||
const r = resolveLine;
|
||||
resolveLine = null;
|
||||
buffer = lines.slice(i + 1).join('\r\n');
|
||||
r(line);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('timeout', () => { pendingError = new SmtpError('SMTP timeout'); socket.destroy(); });
|
||||
socket.on('error', (e) => { pendingError = e; });
|
||||
|
||||
const waitReply = (expect: string): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (pendingError) return reject(pendingError);
|
||||
resolveLine = (line) => {
|
||||
if (!line.startsWith(expect)) {
|
||||
reject(new SmtpError(`Expected ${expect}, got: ${line}`));
|
||||
} else {
|
||||
resolve(line);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const send = (line: string): void => { socket.write(line + '\r\n'); };
|
||||
const b64 = (s: string) => Buffer.from(s).toString('base64');
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
await waitReply('220');
|
||||
send('EHLO integration-tests');
|
||||
await waitReply('250');
|
||||
send('AUTH LOGIN');
|
||||
await waitReply('334');
|
||||
send(b64(authUser));
|
||||
await waitReply('334');
|
||||
send(b64(opts.authPass));
|
||||
await waitReply('235');
|
||||
send(`MAIL FROM:<${opts.from}>`);
|
||||
await waitReply('250');
|
||||
for (const rcpt of recipients) {
|
||||
send(`RCPT TO:<${rcpt}>`);
|
||||
await waitReply('250');
|
||||
}
|
||||
send('DATA');
|
||||
await waitReply('354');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
From: opts.from,
|
||||
To: recipients.join(', '),
|
||||
Subject: opts.subject,
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
...opts.headers,
|
||||
};
|
||||
const headerBlock = Object.entries(headers)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\r\n');
|
||||
// Dot-stuff any line that begins with '.'
|
||||
const safeBody = crlf(opts.body).replace(/\r\n\./g, '\r\n..');
|
||||
send(`${headerBlock}\r\n\r\n${safeBody}\r\n.`);
|
||||
await waitReply('250');
|
||||
send('QUIT');
|
||||
await waitReply('221').catch(() => { /* some servers drop before 221 */ });
|
||||
} finally {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user