Author SHA1 Message Date
Claude 2044d9e0bc Add screenshots to the quarantine self-service article
Four screenshots of the actual PMG quarantine portal (login with
Request Quarantine Link, the request dialog, the held-mail list, and
the message-selected action toolbar), captured via a new
capture-quarantine.ts following the existing capture-*.ts pattern
(1440x900, deviceScaleFactor 2, viewport-only).

Redacted: real server hostname, the test mailbox address, and the
synthetic test-message content are all swapped for believable generic
customer-facing examples before each shot.

Restructured the article's two near-duplicate step lists ("Check your
quarantine" / "Get in without waiting") into one flow now that the
screenshots show it's genuinely a single screen, not two paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXgBxaVABantBF8twz4LpU
2026-09-09 11:41:33 -07:00
Claude 9f30b6a2b3 Add customer article on releasing/allowing quarantined email
Covers PMG's self-service quarantine portal (ticket-based sign-in,
release, personal whitelist) and the on-demand link request for
customers who need something released immediately rather than
waiting for the daily digest. Cross-linked from the existing
spam-filtering-changes migration notice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXgBxaVABantBF8twz4LpU
2026-09-09 10:38:32 -07:00
jknapp 6700ce27b8 Merge pull request 'Document Node.js as a backend option in switching-site-backend' (#13) from docs/node-backend-option into main
Build and deploy / deploy (push) Successful in 24s
2026-09-04 16:52:46 +00:00
shadowdaoandClaude Sonnet 5 113c795c83 Document Node.js as a backend option when switching a site's container type
The "Switching your site's backend" page only covered PHP/PHP-FPM and the
LiteSpeed/OLS add-on tier. Node18/Node20/Node22 are also selectable in the
same Container Type dropdown, but weren't mentioned. Adds a Node.js section
covering the npm-start model, the auto-raised 512 MB memory floor, the
WebSocket/real-time checkbox, and that Node containers skip the shared
PHP-FPM/LiteSpeed placement choice — plus a capture script and screenshot
of the Edit Site modal with Node22 selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 09:50:08 -07:00
11 changed files with 367 additions and 2 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

@@ -0,0 +1,66 @@
---
title: Release or allow quarantined email
description: Get a message out of spam quarantine yourself, right away — no need to wait for the daily notice.
sidebar:
order: 3
---
import { Steps, Aside } from '@astrojs/starlight/components';
import Support from '~/content/partials/support-link.mdx';
Mail our filters flag as likely spam isn't delivered to your inbox — it's held in **quarantine** instead, where you can review it, release anything that's actually wanted, and always allow a sender in future. You don't have to wait for the daily notice to do this — see below.
## Check your quarantine
You'll normally get a **daily summary email** listing anything held for you — but the steps below work any time, not just after that email arrives. There's no separate password to remember; a link emailed to your own address is what signs you in.
<Steps>
1. Go to your mail filtering portal at `https://pmg01.cloud-hosting.io:8006/quarantine`. Choose **Request Quarantine Link**.
![The mail filtering portal's login screen, with Request Quarantine Link next to Login](~/assets/screenshots/email/email-quarantine-login.png)
2. Enter the email address you want to check.
![The Request Quarantine Link dialog with an email address entered](~/assets/screenshots/email/email-quarantine-request.png)
3. Open the email that arrives — usually within moments — and follow the link inside it.
4. Review what's listed for your address.
![The Spam Quarantine list showing a held message with its sender, subject, and spam score](~/assets/screenshots/email/email-quarantine-list.png)
5. Select a message to see its content and the Whitelist / Blacklist / Deliver / Delete options, then choose **Deliver** to send it to your inbox as normal.
![A held message selected, showing its content and the Whitelist, Blacklist, Deliver, and Delete buttons](~/assets/screenshots/email/email-quarantine-actions.png)
</Steps>
<Aside type="tip">
**Need something released right now?** This works any time of day, not just once daily — request a new link as often as you need one. No need to wait for the daily summary email.
</Aside>
## Always allow a sender
If mail from someone keeps getting caught and you know it's legitimate, allow their address (or their whole domain) from the same screen — choose **Whitelist** on a held message (shown in the screenshot above), or add the address directly from the quarantine portal. Future mail from that sender skips spam filtering for **your address specifically** — it doesn't change anything for the rest of your organization.
<Aside type="caution">
Only allow senders you actually recognize. Allowing an address skips spam checks for it entirely, so it's worth being deliberate rather than allowing everything that shows up.
</Aside>
## Common problems
**The sign-in link email hasn't arrived.** Check your spam folder — slightly ironic for a spam-filtering tool, but it happens. If it's still missing after a few minutes, contact us.
**I can't find a message I expected.** Quarantine holds mail for a limited time before it's cleared automatically, so check soon after being notified rather than weeks later. If it's genuinely missing, contact us and we can check on our end.
**A sender I allowed is still landing in quarantine.** Double check you allowed the exact address (or domain) it actually sends from — some services send from a different address than the one they display, especially automated notifications.
## Related
- [Changes to spam filtering](/email/spam-filtering-changes/)
- [Set up your email](/email/set-up-your-email-client/)
## Still stuck?
<Support />
@@ -45,6 +45,7 @@ We'll update this page as the migration progresses.
## Related
- [Release or allow quarantined email](/email/release-quarantined-email/)
- [Set up your email](/email/set-up-your-email-client/)
- [Create an email account](/whp/how-to/create-an-email-account/)
@@ -1,6 +1,6 @@
---
title: Switching your site's backend
description: Change the web engine (container type) running a site — standard PHP/FPM or the premium LiteSpeed/OpenLiteSpeed tier.
description: Change the container type running a site — standard PHP/FPM, the premium LiteSpeed/OpenLiteSpeed tier, or Node.js.
sidebar:
order: 6
---
@@ -9,7 +9,7 @@ import { Steps, Aside } from '@astrojs/starlight/components';
import SignIn from '~/content/partials/signing-in.mdx';
import Support from '~/content/partials/support-link.mdx';
Every WHP site runs inside a container. The **container type** determines the web engine that serves your site's files and runs your PHP code. Most sites use a standard PHP or PHP-FPM container, which handles the vast majority of WordPress and PHP workloads well. If you've enabled the **Optimized Webserver** add-on, you also have access to LiteSpeed/OpenLiteSpeed (OLS) container types — a premium engine known for its built-in full-page cache (LSCache) and lower memory usage under traffic.
Every WHP site runs inside a container. The **container type** determines the engine that serves your site: PHP, LiteSpeed, or Node.js. Most sites use a standard PHP or PHP-FPM container, which handles the vast majority of WordPress and PHP workloads well. If you've enabled the **Optimized Webserver** add-on, you also have access to LiteSpeed/OpenLiteSpeed (OLS) container types — a premium engine known for its built-in full-page cache (LSCache) and lower memory usage under traffic. If you're deploying your own application code instead of PHP, Node.js container types are also available — see [Node.js](#nodejs) below.
Switching backends is a one-step change in the Sites editor, but it does briefly restart your container, so plan for a few seconds of downtime.
@@ -36,6 +36,7 @@ Switching backends is a one-step change in the Sites editor, but it does briefly
4. Select the backend you want:
- **PHP** or **PHP-FPM** options — standard shared webserver tier, suitable for most WordPress and PHP sites.
- **LiteSpeed PHP** options (e.g. *LiteSpeed PHP 8.x*) — premium OLS tier. These only appear if the Optimized Webserver add-on is active on your account.
- **Node18**, **Node20**, or **Node22** — for sites running your own Node.js application code instead of PHP. See [Node.js](#nodejs) below before switching to one of these.
5. Click **Save**. WHP recreates the container with the new engine. Expect a brief moment of downtime (typically a few seconds) while the container restarts.
@@ -55,6 +56,24 @@ To enable it:
2. Enable it from your [client portal](https://secure.anhonesthost.com/clientarea.php).
3. Once active, the LiteSpeed PHP options will appear in the Container Type dropdown when editing any site.
## Node.js
Node.js container types (**Node18**, **Node20**, **Node22**) run your own application code instead of PHP — the container starts your app the same way you would locally with `npm start`. They're for React/Next.js servers, Express APIs, and other Node apps, not for WordPress or PHP sites.
<Aside type="caution">
Switching an **existing PHP or WordPress site** to a Node container type won't make your PHP files run — Node containers don't execute PHP at all. Node backends are for sites where you're deploying your own Node.js application code. If you're starting a brand-new Node app rather than switching an existing site, [Create a site](/whp/how-to/create-a-site/) and choose **Node.js app** under "What are you building?" is the more direct path.
</Aside>
A few things behave differently for Node compared to switching between PHP tiers:
- **No PHP-FPM / LiteSpeed choice.** Node containers bundle their own web server, so the shared-vs-standalone placement option that applies to PHP and LiteSpeed containers doesn't apply here — you won't see that toggle once a Node container type is selected.
- **Higher minimum memory.** Node containers need more headroom than PHP containers. Selecting a Node container type in the editor bumps **Memory per Container** up to at least 512 MB (versus 256 MB for standard PHP). If your account doesn't have that much unused memory in its resource allowance, WHP will tell you and block the change until you free up resources or [upgrade your plan](/whp/add-ons/resource-upgrades/).
- **Optional WebSocket / real-time support.** The Edit Site form has a **WebSocket / real-time support** checkbox alongside the Container Type field. Turn it on if your Node app holds connections open (Socket.IO, live chat, streaming) — it keeps connections alive for up to 6 hours instead of the standard 5-minute timeout. This is unrelated to the container type itself and works whether or not you just switched to Node.
![Edit Site modal with Container Type set to Node22, showing Memory per Container automatically raised to 512 MB](~/assets/screenshots/whp/whp-sites-edit-node.png)
Once you've switched and saved, upload your app to the `app/` folder for that domain via SFTP — see [Create a site](/whp/how-to/create-a-site/#where-your-files-go) for the file layout and how WHP starts your app.
## Before cancelling the Optimized Webserver add-on
<Aside type="caution">
@@ -89,6 +108,10 @@ Once all sites are on standard backends, you can cancel or disable the add-on fr
**LiteSpeed cache not serving cached pages after the switch.** This is expected — the cache starts empty after every container recreation. It warms up automatically as visitors load pages.
**"Your account does not have enough resources left" when selecting a Node type.** Node containers need at least 512 MB of memory. Free up resources by removing or resizing another site, or [upgrade your plan](/whp/add-ons/resource-upgrades/).
**Site shows a 502 after switching to Node.** Give the container 1530 seconds to start your app. If it's still down, confirm your app listens on the port WHP passes it via environment variable — see [Create a site](/whp/how-to/create-a-site/#troubleshooting).
## Related
- [Create a site](/whp/how-to/create-a-site/)
+1
View File
@@ -63,6 +63,7 @@ npx tsx tools/screenshots/capture-dns.ts
| `capture-site-builder.ts` | Site Builder editor states | `WHP_USER` |
| `capture-dns.ts` | Domains & DNS list, Add Domain modal, records editor, bulk toolbar | `WHP_USER` |
| `capture-email.ts` | Email page "Mail Client Setup" section (autodiscovery DNS records) | `WHP_USER` |
| `capture-quarantine.ts` | PMG spam quarantine portal (login, request-link dialog, held-mail list, message actions) — **not a WHP page**, targets the mail filtering gateway directly | `PMG_BASE` + `PMG_TICKET_URL` (a real quarantine ticket link, e.g. from a test mailbox's "Request Quarantine Link" email) instead of WHP_USER/PASS |
Each script carries its own `redact()` (text-node + input-value swaps) so fleet hostnames, IPs, and customer data become neutral placeholders while brand/demo domains stay visible. Copy the closest existing script when adding a new section — match its viewport (1440×900), `deviceScaleFactor: 2`, and **read-only** discipline (open modals and tick boxes for the shot, but never save/delete/submit).
+118
View File
@@ -0,0 +1,118 @@
/**
* PMG quarantine capture — the customer self-service spam quarantine portal.
*
* Captures:
* - email-quarantine-login.png Login screen showing the "Request Quarantine
* Link" button next to Login.
* - email-quarantine-request.png The "Request Quarantine Link" dialog with an
* email address entered.
* - email-quarantine-list.png The authenticated quarantine grid showing held
* mail, redacted to generic example content.
* - email-quarantine-actions.png A selected message with the Whitelist /
* Blacklist / Deliver / Delete toolbar visible.
*
* Viewport-only (1440x900, deviceScaleFactor 2), redacted: the real server
* hostname, the real test mailbox address, and the real (internal-test-looking)
* sender/subject/body text are all swapped for generic customer-facing examples.
*
* Read-only: never actually delivers/whitelists/blacklists/deletes anything.
*/
import { chromium, type Page } from 'playwright';
import { mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, '../../src/assets/screenshots/email');
const BASE = process.env.PMG_BASE || 'https://pmg01.cloud-hosting.io:8006';
const TICKET_URL = process.env.PMG_TICKET_URL; // full quarantine?ticket=... URL
/** Swap real fleet/test identifiers for believable generic customer-facing text. */
async function redact(page: Page) {
await page.evaluate(() => {
const swaps: [RegExp, string][] = [
[/pmg01\.cloud-hosting\.io/gi, 'mail.yourdomain.com'],
[/claude@darksideofperfection\.com/gi, 'you@yourdomain.com'],
[/pmg-pilot-tester@anthropic\.com/gi, 'prizes@totally-legit-sweepstakes.example'],
[/SPAM: PMG pilot - spam detection test \(GTUBE\) \(retry\)/g, "SPAM: You've WON a prize!!!"],
[/SPAM: PMG pilot - clean delivery test \(retry\)/g, 'SPAM: Limited time offer - act now'],
[/This is a clean end-to-end delivery test, retried after greylist window\./g,
'Congratulations! You have been selected to receive an exclusive reward. Click below to claim yours before this offer expires.'],
[/GTUBE retry test\./g, "You've been selected for an exclusive reward!"],
[/XJS\*C4JDBQADN1\.NSBN3\*2IDNEN\*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL\*C\.34X/g,
'Click here to claim your prize before this limited-time offer expires: http://totally-legit-sweepstakes.example/claim'],
[/\b1010\b/g, '8.4'],
];
const docs: Document[] = [document];
for (const f of Array.from(document.querySelectorAll('iframe'))) {
try {
const doc = (f as HTMLIFrameElement).contentDocument;
if (doc) docs.push(doc);
} catch { /* cross-origin, skip */ }
}
for (const root of docs) {
const walker = root.createTreeWalker(root.body, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let n: Node | null = walker.nextNode();
while (n) { nodes.push(n as Text); n = walker.nextNode(); }
for (const node of nodes) {
let v = node.nodeValue ?? '';
for (const [re, rep] of swaps) v = v.replace(re, rep);
if (v !== node.nodeValue) node.nodeValue = v;
}
}
});
}
async function main() {
if (!TICKET_URL) throw new Error('missing env: PMG_TICKET_URL');
await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
});
const page = await ctx.newPage();
try {
// --- Shot 1: login screen, showing the Request Quarantine Link button ---
await page.goto(`${BASE}/quarantine`, { waitUntil: 'networkidle' });
await page.waitForSelector('text=Request Quarantine Link', { state: 'visible' });
await page.waitForTimeout(300);
await redact(page);
let p = resolve(OUT_DIR, 'email-quarantine-login.png');
await page.locator('.x-window').first().screenshot({ path: p });
console.log(`captured email-quarantine-login -> ${p}`);
// --- Shot 2: the Request Quarantine Link dialog, filled in ---
await page.getByRole('button', { name: 'Request Quarantine Link' }).click();
await page.getByRole('textbox', { name: 'Your E-Mail:' }).fill('you@yourdomain.com');
await page.waitForTimeout(200);
p = resolve(OUT_DIR, 'email-quarantine-request.png');
await page.locator('.x-window').last().screenshot({ path: p });
console.log(`captured email-quarantine-request -> ${p}`);
// --- Shot 3 & 4: authenticated quarantine view, via the real ticket link ---
await page.goto(TICKET_URL, { waitUntil: 'networkidle' });
await page.waitForSelector('grid[aria-label="Spam Quarantine"], .x-grid', { state: 'visible' }).catch(() => {});
await page.waitForTimeout(500);
await redact(page);
p = resolve(OUT_DIR, 'email-quarantine-list.png');
await page.screenshot({ path: p });
console.log(`captured email-quarantine-list -> ${p}`);
// Select the first row to reveal the action toolbar + message preview.
const firstRow = page.locator('.x-grid-row').first();
await firstRow.click();
await page.waitForTimeout(500);
await redact(page);
p = resolve(OUT_DIR, 'email-quarantine-actions.png');
await page.screenshot({ path: p });
console.log(`captured email-quarantine-actions -> ${p}`);
} finally {
await browser.close();
}
}
main().catch((err) => { console.error(err); process.exit(1); });
+156
View File
@@ -0,0 +1,156 @@
/**
* Sites "backend" capture — the Edit Site modal's Container Type field,
* captured with a Node.js container type selected (for the "switching your
* site's backend" how-to's Node.js section).
*
* Captures, as the demo customer:
* - whp-sites-edit-node.png Edit Site modal, Container Type = Node22,
* showing the auto-adjusted Memory per
* Container / Total Resources readout.
*
* Viewport-only (1440x900), redacted for our multi-server fleet: server /
* mail / nameserver hostnames, IPs, and the navbar brand strip become
* neutral or hidden, while the brand demo domain (whp-demo.anhh.co) stays
* visible on purpose.
*
* Read-only: opens the Edit modal and changes the Container Type dropdown
* for the shot, but never clicks Save.
*/
import { chromium, type Page } from 'playwright';
import { mkdir, readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, '../../src/assets/screenshots/whp');
async function loadEnv(): Promise<void> {
const envPath = resolve(__dirname, '.env');
const content = await readFile(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
const val = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = val;
}
}
function need(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`missing env: ${name}`);
return v;
}
const HIDE_CSS = `.navbar-text, .brand-full { visibility: hidden !important; }`;
async function login(page: Page, base: string, user: string, pass: string) {
await page.goto(`${base}/login.php`, { waitUntil: 'domcontentloaded' });
await page.fill('input[name="user"]', user);
await page.fill('input[name="password"]', pass);
await page.click('button[type="submit"]');
await page.waitForLoadState('networkidle');
}
/**
* Neutralise fleet-identifying text before the screenshot. The brand demo
* domain (anhh.co) is intentionally preserved; everything that names a
* specific server, mail host, nameserver, or IP is swapped for a placeholder.
*/
async function redact(page: Page) {
await page.addStyleTag({ content: HIDE_CSS });
await page.evaluate(() => {
const swaps: [RegExp, string][] = [
[/ns[12]\.whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, 'ns<n>.<your-server>.cloud-hosting.io'],
[/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '<your-server>.cloud-hosting.io'],
[/mail\d+\.cloud-hosting\.io/gi, '<mail-server>.cloud-hosting.io'],
[/WHP\d+(-[A-Z0-9]+)?\b/g, '<YOUR-SERVER>'],
[/whp\d+(-[a-z0-9]+)?\b/gi, '<your-server>'],
// Public IPv4 (skip RFC1918 — those read fine as generic examples)
[/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '<server-IP>'],
[/demo-user/g, 'your-username'],
];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let n: Node | null = walker.nextNode();
while (n) { nodes.push(n as Text); n = walker.nextNode(); }
for (const node of nodes) {
let v = node.nodeValue ?? '';
for (const [re, rep] of swaps) v = v.replace(re, rep);
if (v !== node.nodeValue) node.nodeValue = v;
}
document.querySelectorAll<HTMLInputElement>('input').forEach((el) => {
if (el.type === 'password' || !el.value) return;
let v = el.value;
for (const [re, rep] of swaps) v = v.replace(re, rep);
if (v !== el.value) el.value = v;
});
});
}
async function shot(page: Page, id: string) {
await page.waitForTimeout(400);
await redact(page);
const path = resolve(OUT_DIR, `${id}.png`);
await page.screenshot({ path, fullPage: false });
console.log(`captured ${id} -> ${path}`);
}
async function main() {
await loadEnv();
const BASE = need('WHP_BASE');
const USER = need('WHP_USER');
const PASS = need('WHP_PASS');
const DOMAIN = process.env.WHP_DEMO_DOMAIN ?? 'whp-demo.anhh.co';
await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
});
const page = await ctx.newPage();
try {
await login(page, BASE, USER, PASS);
await page.goto(`${BASE}/index.php?page=sites`, { waitUntil: 'networkidle' });
// Select the demo site from the "Select a Site" widget (Choices.js).
const siteChoicesContainer = page.locator('div.choices:has(#siteDropdown)').first();
await siteChoicesContainer.click();
await page.waitForTimeout(300);
const item = page
.locator('.choices__list--dropdown .choices__item--choice', { hasText: DOMAIN.split('.')[0] })
.first();
await item.click();
// Wait for the async site-details fetch, then open Edit (Manage).
await page.locator('#siteActionsContainer').waitFor({ state: 'visible', timeout: 10000 });
await page.waitForTimeout(300);
await page.locator('#manageSiteBtn').click();
await page.waitForTimeout(800);
// Switch the Container Type to Node22 (the newest of the three Node
// options) to show the memory floor auto-adjust to 512 MB.
const editSelect = page.locator('#edit_container_type_id');
await editSelect.waitFor({ state: 'visible', timeout: 10000 });
await editSelect.scrollIntoViewIfNeeded();
const nodeOption = editSelect.locator('option', { hasText: 'Node22' });
const nodeValue = await nodeOption.getAttribute('value');
if (!nodeValue) throw new Error('Node22 option not found in edit_container_type_id');
await editSelect.selectOption(nodeValue);
await page.waitForTimeout(600);
await shot(page, 'whp-sites-edit-node');
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});