Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ curl -fsSL https://github.com/7heMech/cloudpanel-addons/releases/latest/download
```

The installer asks which addons you want, checks that the download is genuine,
and installs Docker if Instatic needs it. Requires an x86-64 CloudPanel host.
and installs Docker or Postfix when a selected addon needs it. Requires an
x86-64 CloudPanel host.

When it finishes, open the new **Addons** tab in CloudPanel.

Expand All @@ -28,6 +29,7 @@ When it finishes, open the new **Addons** tab in CloudPanel.
| Stager | Staging copies of WordPress, PHP, static and Instatic sites, and promotion back to live. Based on [clp-stager](https://github.com/7heMech/clp-stager). |
| Maintenance Mode | A customizable 503 page per site, with instant toggles and IP bypasses. |
| PHP Resources | Categories of PHP-FPM worker limits, assigned in bulk and to new sites. |
| [SMTP Relay](docs/smtp-relay.md) | Send PHP and WordPress mail through a shared or per-domain SMTP relay. |
| Git Deploy | Deploys from a Git remote, from the panel or from a push. |
| Panel Tweaks | Site search and columns, mobile layouts and theme improvements. |
| WordPress Sign-In | One-click sign-in to any WordPress on the server, no password needed. |
Expand All @@ -48,7 +50,7 @@ Sites page for their own sites, and nothing else.
| `clp-addons uninstall <addon> --yes` | Remove an addon and keep its data. |

Addon names are `cloudflare-ips`, `instatic`, `stager`, `maintenance`,
`php-resources`, `git`, `panel-tweaks`, and `wp-login`. Run
`php-resources`, `git`, `panel-tweaks`, `wp-login`, and `smtp`. Run
`clp-addons --help` for version selection and data removal options.

See [Instatic backup and restore](docs/instatic-backups.md) for CloudPanel Remote
Expand Down
588 changes: 588 additions & 0 deletions addons/smtp/action.ts

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions addons/smtp/addon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { AddonDefinition } from "../../cli/addon-catalog";
import { deactivateSmtp, executeSmtpAction, runSmtpAction, type SmtpActionOptions } from "./action";
import { handle } from "./app/index";

export const SMTP_ADDON: AddonDefinition = {
name: "smtp",
title: "SMTP Relay",
description: "Relay PHP mail through Postfix with sender rules for each site.",
requiresUnits: ["postfix"],
targets: [],
handler: handle,
action: runSmtpAction,
deactivate: deactivateSmtp,
maintenance: {
label: "SMTP relay",
run: async (options) => {
const result = await executeSmtpAction(["reconcile"], (options ?? {}) as SmtpActionOptions) as { repaired: number };
return result.repaired > 0 ? `${result.repaired} PHP mail pool${result.repaired === 1 ? "" : "s"} restored` : null;
},
},
};
35 changes: 35 additions & 0 deletions addons/smtp/app/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { bodyErrorResponse, guardMutation, htmlResponse, jsonResponse, newCsrfToken, readJsonObject } from "../../../lib/app-http";
import { smtpService } from "./service";
import { dashboardView, layout } from "./views";

export async function handle(req: Request, path: string, notice?: { current: string; latest: string } | null): Promise<Response> {
if (req.method === "GET" && path === "/") {
const csrf = newCsrfToken();
const result = await smtpService.state();
if (!result.ok || !result.data) {
return htmlResponse(layout("SMTP Relay", `<div class="alert" role="alert">${Bun.escapeHTML(result.error ?? "SMTP state is unavailable")}</div>`, notice), { status: 500, csrf });
}
return htmlResponse(layout("SMTP Relay", dashboardView(result.data), notice), { csrf });
}
if (req.method === "GET" && path === "/api/state") {
const result = await smtpService.state();
return jsonResponse(result, { status: result.ok ? 200 : 500 });
}
if (req.method === "POST") {
const denied = guardMutation(req);
if (denied) return denied;
let body: Record<string, unknown>;
try { body = await readJsonObject(req); } catch (error) { return bodyErrorResponse(error); }
const result = path === "/api/setup" ? await smtpService.saveSetup(body)
: path === "/api/relay" ? await smtpService.saveRelay(body)
: path === "/api/default" ? await smtpService.saveDefault(body)
: path === "/api/site" ? await smtpService.saveSite(body)
: path === "/api/site/clear" ? await smtpService.clearSite(body)
: path === "/api/domain-relay" ? await smtpService.saveDomainRelay(body)
: path === "/api/domain-relay/clear" ? await smtpService.clearDomainRelay(body)
: path === "/api/test" ? await smtpService.test(body)
: null;
if (result) return jsonResponse(result, { status: result.ok ? 200 : 400 });
}
return jsonResponse({ ok: false, error: "not found" }, { status: 404 });
}
18 changes: 18 additions & 0 deletions addons/smtp/app/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { callGatewayAction, type ActionResult } from "../../../lib/gateway-client";
import type { SmtpState } from "../action";

const call = (verb: string, body?: unknown): Promise<ActionResult<SmtpState>> =>
callGatewayAction<SmtpState>("smtp", verb, [], body === undefined ? undefined : JSON.stringify(body));

export const smtpService = {
state: () => call("list"),
saveSetup: (body: unknown) => call("save-setup", body),
saveRelay: (body: unknown) => call("save-relay", body),
saveDefault: (body: unknown) => call("save-default", body),
saveSite: (body: unknown) => call("save-site", body),
clearSite: (body: unknown) => call("clear-site", body),
saveDomainRelay: (body: unknown) => call("save-domain-relay", body),
clearDomainRelay: (body: unknown) => call("clear-domain-relay", body),
test: (body: unknown): Promise<ActionResult<{ queued: boolean; sender: string; recipient: string }>> =>
callGatewayAction("smtp", "test", [], JSON.stringify(body)),
};
121 changes: 121 additions & 0 deletions addons/smtp/app/views.client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
const smtpState = JSON.parse(document.getElementById('smtp-state')?.textContent || '{}');

function smtpFields(form) {
return Object.fromEntries(new FormData(form).entries());
}

async function smtpPost(path, body) {
busy(true);
try {
await call(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
location.reload();
} catch (error) {
notify(error.message, 'error');
} finally {
busy(false);
}
}

function smtpRelayPayload(fields) {
return { host: fields.host, port: Number(fields.port), username: fields.username, password: fields.password };
}

function smtpSaveSetup(event) {
event.preventDefault();
const fields = smtpFields(event.currentTarget);
smtpPost('/api/setup', {
relay: smtpRelayPayload(fields),
rule: { mode: fields.mode, sender: fields.sender,
domains: smtpState.defaultRule.domains, addresses: smtpState.defaultRule.addresses },
});
}

async function smtpSendTest(event) {
event.preventDefault();
const fields = smtpFields(event.currentTarget);
busy(true);
try {
const result = await call('/api/test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: fields.domain, recipient: fields.recipient }),
});
notify('Postfix queued a test from ' + result.data.sender + ' to ' + result.data.recipient + '.', 'ok');
} catch (error) {
notify(error.message, 'error');
} finally {
busy(false);
}
}

function smtpList(value) {
return String(value || '').split(/[\s,]+/).map(function (part) { return part.trim(); }).filter(Boolean);
}

function smtpEditSite(domain) {
const site = smtpState.sites.find(function (item) { return item.domain === domain; });
if (!site) return;
const form = document.getElementById('smtp-site-form');
form.elements.namedItem('domain').value = domain;
form.elements.namedItem('mode').value = site.rule.mode;
form.elements.namedItem('sender').value = site.rule.sender;
form.elements.namedItem('domains').value = site.rule.domains.join('\n');
form.elements.namedItem('addresses').value = site.rule.addresses.join('\n');
smtpSyncSiteMode();
document.getElementById('smtp-site-heading').textContent = 'Sender policy for ' + domain;
document.getElementById('smtp-clear-site').hidden = !site.overridden;
document.getElementById('smtp-site-dialog').showModal();
}

function smtpSyncSiteMode() {
const form = document.getElementById('smtp-site-form');
const allow = form.elements.namedItem('mode').value === 'allow';
document.getElementById('smtp-site-allow-fields').hidden = !allow;
for (const name of ['domains', 'addresses']) {
const field = form.elements.namedItem(name);
if (!allow) field.value = '';
field.disabled = !allow;
}
}

function smtpSaveSite(event) {
event.preventDefault();
const fields = smtpFields(event.currentTarget);
smtpPost('/api/site', { domain: fields.domain, rule: {
mode: fields.mode, sender: fields.sender,
domains: smtpList(fields.domains), addresses: smtpList(fields.addresses),
} });
}

function smtpClearSite() {
const domain = document.getElementById('smtp-site-form').elements.namedItem('domain').value;
smtpPost('/api/site/clear', { domain: domain });
}

function smtpEditDomain(domain) {
const old = domain && smtpState.relayOverrides[domain];
const form = document.getElementById('smtp-domain-form');
form.elements.namedItem('domain').value = domain || '';
form.elements.namedItem('domain').readOnly = Boolean(old);
form.elements.namedItem('host').value = old ? old.host : '';
form.elements.namedItem('port').value = old ? old.port : 587;
form.elements.namedItem('username').value = old ? old.username : '';
form.elements.namedItem('password').value = '';
form.elements.namedItem('password').required = !old;
form.elements.namedItem('password').placeholder = old ? 'Leave blank to keep saved password' : 'New SMTP password';
document.getElementById('smtp-domain-dialog').showModal();
}

function smtpSaveDomain(event) {
event.preventDefault();
const fields = smtpFields(event.currentTarget);
smtpPost('/api/domain-relay', { domain: fields.domain, relay: smtpRelayPayload(fields) });
}

async function smtpClearDomain(domain) {
if (!await confirmAction({ title: 'Remove SMTP override?', text: domain + ' will use the global relay credential again.', confirmLabel: 'Remove' })) return;
smtpPost('/api/domain-relay/clear', { domain: domain });
}
17 changes: 17 additions & 0 deletions addons/smtp/app/views.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.smtp-status { display:flex; align-items:center; justify-content:space-between; gap:16px; }
.smtp-status p { margin:0; }
.smtp-form h2 { margin-bottom:6px; }
.smtp-form h3 { margin:18px 0 4px; font-size:16px; }
.smtp-form > .hint { margin-top:0; }
.smtp-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
.smtp-form label,.smtp-dialog label { display:flex; flex-direction:column; gap:6px; font-weight:600; margin:12px 0; }
.smtp-form input,.smtp-form select,.smtp-dialog input,.smtp-dialog select,.smtp-dialog textarea { width:100%; box-sizing:border-box; padding:9px 10px; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text); font:inherit; font-weight:400; }
.smtp-form .actions { margin-top:12px; }
.smtp-site-table .hint { display:block; }
.smtp-site-table code { overflow-wrap:anywhere; }
.smtp-site-table .wide-cell { overflow-wrap:anywhere; }
.smtp-domain-table .actions { white-space:nowrap; }
.smtp-dialog { width:min(650px,calc(100% - 24px)); max-height:calc(100dvh - 24px); overflow:auto; padding:24px; border:1px solid var(--border); border-radius:12px; background:var(--surface); color:var(--text); }
.smtp-dialog::backdrop { background:rgb(10 20 30 / 55%); }
.smtp-dialog h2 { margin-top:0; }
@media (max-width:760px) { .smtp-grid { grid-template-columns:1fr; gap:0; } .smtp-status { align-items:flex-start; } }
73 changes: 73 additions & 0 deletions addons/smtp/app/views.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import CLIENT from "./views.client.js" with { type: "text" };
import CSS from "./views.css" with { type: "text" };
import { esc } from "../../../lib/app-http";
import { renderLayout } from "../../../lib/app-ui";
import { mountPath } from "../../../lib/mount";
import type { SmtpState } from "../action";

const BASE = mountPath("smtp");

export function layout(title: string, content: string, notice?: { current: string; latest: string } | null): string {
return renderLayout(title, content, { brand: "SMTP Relay", base: BASE, nav: [], css: CSS, script: CLIENT, updateNotice: notice });
}

function modeOptions(mode: string): string {
return `<option value="force" ${mode === "force" ? "selected" : ""}>Force one address</option><option value="allow" ${mode === "allow" ? "selected" : ""}>Allow site domains</option>`;
}

export function dashboardView(state: SmtpState): string {
const data = JSON.stringify(state).replaceAll("<", "\\u003c");
const relay = state.relay;
const sites = state.sites.map((site) => `<tr>
<td class="site-cell"><strong>${esc(site.domain)}</strong><span class="hint">${esc(site.user)}</span></td>
<td data-label="Mode">${site.rule.mode === "force" ? "Force" : "Allow listed"}${site.overridden ? ' <span class="badge">Override</span>' : ""}</td>
<td data-label="${site.rule.mode === "force" ? "Forced From" : "Fallback From"}" class="wide-cell"><code>${esc(site.senderPreview)}</code>${site.rule.mode === "allow" ? `<span class="hint">Also allowed: ${[site.domain, ...site.rule.domains].map((domain) => `@${esc(domain)}`).concat(site.rule.addresses.map(esc)).join(", ")}</span>` : ""}</td>
<td data-label="Actions" class="action-cell"><button class="btn" type="button" onclick="smtpEditSite('${site.domain}')">Edit</button></td>
</tr>`).join("");
const overrides = Object.entries(state.relayOverrides).map(([domain, item]) => `<tr>
<td class="site-cell"><strong>${esc(domain)}</strong></td><td data-label="SMTP host">${esc(item.host)}:${item.port}</td><td data-label="Username">${esc(item.username)}</td>
<td data-label="Actions" class="actions action-cell"><button class="btn" type="button" onclick="smtpEditDomain('${domain}')">Edit</button><button class="btn" type="button" onclick="smtpClearDomain('${domain}')">Remove</button></td>
</tr>`).join("");
return `<script type="application/json" id="smtp-state">${data}</script>
<div class="page-heading"><div><h1>SMTP relay</h1><p>Send WordPress and other PHP mail through Postfix, with a sender policy for each site.</p></div></div>
<form class="card smtp-form" id="smtp-setup-form" onsubmit="smtpSaveSetup(event)">
<div class="smtp-status"><div><h2>Relay and default sender</h2><p class="hint">Set the fallback SMTP account and sender rule for every PHP site in one save.</p></div><span class="badge">${relay ? "Configured" : "Setup needed"}</span></div>
<h3>Global SMTP relay</h3><p class="hint">Used for sending domains without a relay override. Postfix queues messages and retries temporary failures.</p>
<div class="smtp-grid">
<label>SMTP hostname<input name="host" required autocomplete="off" placeholder="mail.example.com" value="${esc(relay?.host ?? "")}"></label>
<label>Port<input name="port" type="number" min="1" max="65535" required value="${relay?.port ?? 587}"></label>
<label>Username<input name="username" required autocomplete="off" value="${esc(relay?.username ?? "")}"></label>
<label>Password<input name="password" type="password" ${relay ? "" : "required"} autocomplete="new-password" placeholder="${relay ? "Leave blank to keep saved password" : "SMTP password"}"></label>
</div><h3>Default sender policy</h3><p class="hint">{domain} is each CloudPanel site's domain. Force replaces the requested From address; allow listed preserves addresses on that site's approved domains.</p>
<div class="smtp-grid"><label>Mode<select name="mode">${modeOptions(state.defaultRule.mode)}</select></label>
<label>Sender address or template<input name="sender" required value="${esc(state.defaultRule.sender)}" placeholder="noreply@{domain}"></label></div>
<div class="actions"><button class="btn btn-primary" type="submit">Save relay and default</button></div>
</form>
<form class="card smtp-form" id="smtp-test-form" onsubmit="smtpSendTest(event)">
<h2>Send a test</h2><p class="hint">Submits directly to Postfix as root using the selected site's configured sender. It does not test PHP mail or that site's sender permissions. A successful result means Postfix queued the message; check the inbox for delivery.</p>
<div class="smtp-grid"><label>Sender site<select name="domain" required>${state.sites.map((site) => `<option value="${esc(site.domain)}">${esc(site.domain)}</option>`).join("")}</select></label>
<label>Recipient<input name="recipient" type="email" required placeholder="you@example.com"></label></div>
<div class="actions"><button class="btn" type="submit" ${!state.configured || state.sites.length === 0 ? "disabled" : ""}>Queue test email</button></div>
</form>
<div class="card card-table"><div class="card-header"><div><h2>PHP sites</h2><p class="hint">Edit a site to add sending domains or use a different address.</p></div></div>
${sites ? `<table class="fleet-table smtp-site-table"><thead><tr><th>Site</th><th>Mode</th><th>From policy</th><th>Actions</th></tr></thead><tbody>${sites}</tbody></table>` : '<div class="empty">No PHP sites found.</div>'}
</div>
<div class="card card-table"><div class="card-header"><div><h2>Sending domain relays</h2><p class="hint">Use a different SMTP account for a domain whose provider does not allow the global credential to send as it.</p></div><button class="btn" type="button" onclick="smtpEditDomain('')">Add domain relay</button></div>
${overrides ? `<table class="fleet-table smtp-domain-table"><thead><tr><th>Sending domain</th><th>SMTP host</th><th>Username</th><th>Actions</th></tr></thead><tbody>${overrides}</tbody></table>` : '<div class="empty">All sending domains use the global relay.</div>'}
</div>
<dialog id="smtp-site-dialog" class="smtp-dialog"><form method="dialog" id="smtp-site-form" onsubmit="smtpSaveSite(event)">
<h2 id="smtp-site-heading">Site sender</h2><input type="hidden" name="domain">
<label>Mode<select name="mode" onchange="smtpSyncSiteMode()">${modeOptions("force")}</select></label>
<label>Sender address or template<input name="sender" required></label>
<div id="smtp-site-allow-fields" hidden><label>Additional allowed domains<textarea name="domains" rows="3" placeholder="news.example.com"></textarea></label>
<label>Additional exact addresses<textarea name="addresses" rows="3" placeholder="billing@example.com"></textarea></label>
<p class="hint">The site's own domain is always allowed. Switching to Force clears these additional grants.</p></div>
<div class="actions"><button class="btn" type="button" onclick="smtpClearSite()" id="smtp-clear-site">Use default</button><button class="btn" type="button" onclick="this.closest('dialog').close()">Cancel</button><button class="btn btn-primary" type="submit">Save site</button></div>
</form></dialog>
<dialog id="smtp-domain-dialog" class="smtp-dialog"><form method="dialog" id="smtp-domain-form" onsubmit="smtpSaveDomain(event)">
<h2>Sending domain relay</h2><label>Sending domain<input name="domain" required placeholder="example.com"></label>
<div class="smtp-grid"><label>SMTP hostname<input name="host" required placeholder="mail.example.com"></label><label>Port<input name="port" type="number" min="1" max="65535" required value="587"></label></div>
<label>Username<input name="username" required></label><label>Password<input name="password" type="password" autocomplete="new-password" required placeholder="New SMTP password"></label>
<div class="actions"><button class="btn" type="button" onclick="this.closest('dialog').close()">Cancel</button><button class="btn btn-primary" type="submit">Save relay</button></div>
</form></dialog>`;
}
Loading