Skip to content
Open
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
46 changes: 26 additions & 20 deletions addons/smtp/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import {
import { CLI_BIN, PANEL_DB, STATE_DIR } from "../../cli/paths";
import { writeFileAtomic } from "../../lib/atomic-write";
import {
emptySmtpPolicy, parseRelay, parseRule, senderFor, senderGrants, smtpAddress, smtpDomain,
emptySmtpPolicy, parseRelay, parseRule, senderGrants, smtpAddress, smtpDomain,
type SmtpPolicy, type SmtpRelay, type SmtpSiteRule, type SmtpSubmissionPolicy,
} from "./config";
import { SUBMISSION_POLICY_PATH } from "./submit";
import { prepareSubmission, SUBMISSION_POLICY_PATH } from "./submit";

const MANAGED_POOL_LINE = `php_admin_value[sendmail_path] = ${CLI_BIN} smtp-submit -t -i`;
const MANAGED_POOL_MARKER = "; clp-addons smtp relay";
Expand Down Expand Up @@ -49,7 +49,7 @@ export interface SmtpPaths {
lockFile: string;
submissionFile: string;
postfixDir: string;
sendmail: string;
runuser: string;
rootUid: number;
}
export interface SmtpActionOptions {
Expand All @@ -69,7 +69,7 @@ export const DEFAULT_SMTP_PATHS: SmtpPaths = {
lockFile: "/run/lock/clp-addons/smtp.lock",
submissionFile: SUBMISSION_POLICY_PATH,
postfixDir: "/etc/postfix",
sendmail: "/usr/sbin/sendmail",
runuser: "/usr/sbin/runuser",
rootUid: 0,
};

Expand Down Expand Up @@ -176,7 +176,7 @@ function stateOf(policy: SmtpPolicy, sites: SiteRow[]): SmtpState {
return {
domain: site.domain, user: site.user, phpVersion: site.phpVersion, rule,
overridden: Boolean(policy.siteRules[site.domain]),
senderPreview: senderFor(rule.sender, site.domain),
senderPreview: rule.sender.replaceAll("{site}", site.domain),
};
}),
};
Expand Down Expand Up @@ -242,9 +242,7 @@ export function postfixMaps(policy: SmtpPolicy): { credentials: string; routes:
function localSenderMap(policy: SmtpPolicy, sites: SiteRow[]): string {
const entries = ["root *", "postfix *", "clp *"];
for (const site of sites) {
const rule = effectiveRule(policy, site.domain);
const grants = senderGrants({ domain: site.domain, rule });
entries.push(`${site.user} ${[...grants.addresses, ...grants.domains.map((domain) => `@${domain}`)].join(" ")}`);
entries.push(`${site.user} ${senderGrants({ domain: site.domain, rule: effectiveRule(policy, site.domain) }).join(" ")}`);
}
return entries.join("\n") + "\n";
}
Expand Down Expand Up @@ -479,24 +477,32 @@ async function inputBody(options: SmtpActionOptions): Promise<Record<string, unk
return value as Record<string, unknown>;
}

function testSubmission(policy: SmtpPolicy, sites: SiteRow[], body: Record<string, unknown>): { domain: string; sender: string; recipient: string } {
export interface SmtpTestResult { queued: true; requested: string; sender: string; replyTo: string | null; recipient: string }

function testSubmission(policy: SmtpPolicy, sites: SiteRow[], body: Record<string, unknown>): { site: SiteRow; message: Buffer; result: SmtpTestResult } {
if (!policy.relay) failAction("configure the global SMTP relay first");
const domain = smtpDomain(body.domain);
const site = sites.find((item) => item.domain === domain);
if (!site) failAction(`CloudPanel has no PHP site ${domain}`);
const recipient = smtpAddress(body.recipient);
const sender = senderFor(effectiveRule(policy, domain).sender, domain);
return { domain, sender, recipient };
}

function sendTest(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], body: Record<string, unknown>): { queued: true; sender: string; recipient: string } {
const { domain, sender, recipient } = testSubmission(policy, sites, body);
const message = `To: ${recipient}\nFrom: ${sender}\nSubject: CloudPanel SMTP relay test for ${domain}\n\nThis message was submitted through the CloudPanel Addons Postfix relay.\n`;
const result = Bun.spawnSync([paths.sendmail, "-t", "-i", "-f", sender], {
stdin: Buffer.from(message), stdout: "pipe", stderr: "pipe", maxBuffer: 64 * 1024,
const requested = body.from === undefined || body.from === "" ? `wordpress@${domain}` : smtpAddress(body.from);
const message = Buffer.from(`To: ${recipient}\nFrom: ${requested}\nSubject: CloudPanel SMTP relay test for ${domain}\n\nThis message was sent through PHP's mail path for ${domain} and the CloudPanel Addons Postfix relay.\n`);
const prepared = prepareSubmission(message, { domain, uid: site.uid, user: site.user, rule: effectiveRule(policy, domain) });
const replyTo = Buffer.from(prepared.message).toString("utf8").match(/^Reply-To: (.+)$/m)?.[1] ?? null;
return { site, message, result: { queued: true, requested, sender: prepared.sender, replyTo, recipient } };
}

/** Submits as the site's Unix account through the same command its PHP pool uses. */
function sendTest(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], body: Record<string, unknown>): SmtpTestResult {
const { site, message, result } = testSubmission(policy, sites, body);
const submit = Bun.spawnSync([paths.runuser, "-u", site.user, "--", CLI_BIN, "smtp-submit", "-t", "-i"], {
stdin: message, stdout: "pipe", stderr: "pipe", maxBuffer: 64 * 1024, timeout: 30_000,
});
if (!result.success) failAction(`Postfix did not queue the test: ${Buffer.from(result.stderr).toString("utf8").trim() || "sendmail failed"}`);
return { queued: true, sender, recipient };
if (!submit.success) {
const detail = Buffer.from(submit.stderr).toString("utf8").trim().replace(/^\[smtp\] /, "");
failAction(`${site.domain}'s PHP mail path did not queue the test: ${detail || "submission failed"}`);
}
return result;
}

export async function executeSmtpAction(argv: string[], options: SmtpActionOptions = {}): Promise<unknown> {
Expand Down
10 changes: 7 additions & 3 deletions addons/smtp/app/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { bodyErrorResponse, guardMutation, htmlResponse, jsonResponse, newCsrfToken, readJsonObject } from "../../../lib/app-http";
import { smtpService } from "./service";
import { dashboardView, layout } from "./views";
import { dashboardContent, 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 === "/") {
Expand All @@ -20,16 +20,20 @@ export async function handle(req: Request, path: string, notice?: { current: str
if (denied) return denied;
let body: Record<string, unknown>;
try { body = await readJsonObject(req); } catch (error) { return bodyErrorResponse(error); }
if (path === "/api/test") {
const result = await smtpService.test(body);
return jsonResponse(result, { status: result.ok ? 200 : 400 });
}
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 });
if (result?.ok && result.data) return jsonResponse({ ...result, html: dashboardContent(result.data) });
if (result) return jsonResponse(result, { status: 400 });
}
return jsonResponse({ ok: false, error: "not found" }, { status: 404 });
}
4 changes: 2 additions & 2 deletions addons/smtp/app/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { callGatewayAction, type ActionResult } from "../../../lib/gateway-client";
import type { SmtpState } from "../action";
import type { SmtpState, SmtpTestResult } from "../action";

const call = (verb: string, body?: unknown): Promise<ActionResult<SmtpState>> =>
callGatewayAction<SmtpState>("smtp", verb, [], body === undefined ? undefined : JSON.stringify(body));
Expand All @@ -13,6 +13,6 @@ export const smtpService = {
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 }>> =>
test: (body: unknown): Promise<ActionResult<SmtpTestResult>> =>
callGatewayAction("smtp", "test", [], JSON.stringify(body)),
};
51 changes: 24 additions & 27 deletions addons/smtp/app/views.client.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
const smtpState = JSON.parse(document.getElementById('smtp-state')?.textContent || '{}');
let smtpState = JSON.parse(document.getElementById('smtp-state')?.textContent || '{}');

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

async function smtpPost(path, body) {
async function smtpPost(path, body, done) {
busy(true);
try {
await call(path, {
const reply = await call(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
location.reload();
for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
document.getElementById('smtp-root').innerHTML = reply.html;
smtpState = reply.data;
notify(done, 'ok');
} catch (error) {
notify(error.message, 'error');
} finally {
Expand All @@ -29,9 +32,8 @@ function smtpSaveSetup(event) {
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 },
});
rule: { sender: fields.sender, domains: [] },
}, 'Saved the relay and default From.');
}

async function smtpSendTest(event) {
Expand All @@ -41,9 +43,11 @@ async function smtpSendTest(event) {
try {
const result = await call('/api/test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: fields.domain, recipient: fields.recipient }),
body: JSON.stringify({ domain: fields.domain, recipient: fields.recipient, from: fields.from }),
});
notify('Postfix queued a test from ' + result.data.sender + ' to ' + result.data.recipient + '.', 'ok');
const data = result.data;
notify('Postfix queued the test to ' + data.recipient + '. The app asked for ' + data.requested +
' and it was sent as ' + data.sender + (data.replyTo ? ', with Reply-To ' + data.replyTo : '') + '.', 'ok');
} catch (error) {
notify(error.message, 'error');
} finally {
Expand All @@ -60,39 +64,32 @@ function smtpEditSite(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;
smtpSyncSiteDomains();
document.getElementById('smtp-site-heading').textContent = 'From for ' + domain;
document.getElementById('smtp-clear-site').hidden = !site.overridden;
document.getElementById('smtp-site-dialog').showModal();
}

function smtpSyncSiteMode() {
function smtpSyncSiteDomains() {
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;
}
const keepsDomain = form.elements.namedItem('sender').value.includes('{from.domain}');
document.getElementById('smtp-site-domains').hidden = !keepsDomain;
form.elements.namedItem('domains').disabled = !keepsDomain;
}

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),
} });
sender: fields.sender, domains: smtpList(fields.domains),
} }, 'Saved the From for ' + fields.domain + '.');
}

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

function smtpEditDomain(domain) {
Expand All @@ -112,10 +109,10 @@ function smtpEditDomain(domain) {
function smtpSaveDomain(event) {
event.preventDefault();
const fields = smtpFields(event.currentTarget);
smtpPost('/api/domain-relay', { domain: fields.domain, relay: smtpRelayPayload(fields) });
smtpPost('/api/domain-relay', { domain: fields.domain, relay: smtpRelayPayload(fields) }, 'Saved the relay for ' + fields.domain + '.');
}

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 });
smtpPost('/api/domain-relay/clear', { domain: domain }, domain + ' uses the global relay again.');
}
Loading