surface SMTP delivery failures and add interactive rotor compass target

This commit is contained in:
Deploy
2026-04-08 03:08:53 +02:00
parent e95519a4ea
commit 2bcf6ab1e5
6 changed files with 257 additions and 27 deletions

View File

@@ -76,15 +76,22 @@ async function createPlugin(ctx) {
html: String(payload.html || "") html: String(payload.html || "")
}; };
let delivered = false; let delivered = false;
let smtpError = "";
try { try {
delivered = await deliverViaSmtp(entry); delivered = await deliverViaSmtp(entry);
} catch (error) { } catch (error) {
entry.smtpError = String(error && error.message ? error.message : error); smtpError = String(error && error.message ? error.message : error);
entry.smtpError = smtpError;
} }
entry.delivered = delivered; entry.delivered = delivered;
entry.transport = delivered ? "smtp" : "outbox-fallback"; entry.transport = delivered ? "smtp" : "outbox-fallback";
await ctx.appendMailOutbox(entry); await ctx.appendMailOutbox(entry);
return { ok: true, delivered, transport: delivered ? "smtp" : "outbox-fallback" }; return {
ok: true,
delivered,
transport: delivered ? "smtp" : "outbox-fallback",
smtpError: smtpError || null
};
}, },
async health() { async health() {
const config = readTransportConfig(); const config = readTransportConfig();

View File

@@ -11,6 +11,7 @@ let activationWatchInFlight = false;
let activationPending = false; let activationPending = false;
let remainingUsageTimer = null; let remainingUsageTimer = null;
let resumeRefreshInFlight = false; let resumeRefreshInFlight = false;
let rotorCompassDragPointerId = null;
const state = { const state = {
user: null, user: null,
@@ -716,6 +717,51 @@ function bindEvents() {
await setOpenWebRxRotor(); await setOpenWebRxRotor();
}); });
} }
if (els.rotorCompass) {
els.rotorCompass.addEventListener("pointerdown", (event) => {
if (!els.rotorTarget || els.rotorTarget.disabled) {
return;
}
if (event.pointerType === "mouse" && event.button !== 0) {
return;
}
event.preventDefault();
rotorCompassDragPointerId = event.pointerId;
if (typeof els.rotorCompass.setPointerCapture === "function") {
try {
els.rotorCompass.setPointerCapture(event.pointerId);
} catch {
// ignore pointer capture errors
}
}
els.rotorCompass.classList.add("is-dragging");
updateRotorTargetFromPointerEvent(event);
});
els.rotorCompass.addEventListener("pointermove", (event) => {
if (rotorCompassDragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
updateRotorTargetFromPointerEvent(event);
});
const stopRotorCompassDrag = (event) => {
if (rotorCompassDragPointerId !== event.pointerId) {
return;
}
if (typeof els.rotorCompass.releasePointerCapture === "function") {
try {
els.rotorCompass.releasePointerCapture(event.pointerId);
} catch {
// ignore pointer capture errors
}
}
rotorCompassDragPointerId = null;
els.rotorCompass.classList.remove("is-dragging");
};
els.rotorCompass.addEventListener("pointerup", stopRotorCompassDrag);
els.rotorCompass.addEventListener("pointercancel", stopRotorCompassDrag);
els.rotorCompass.addEventListener("lostpointercapture", stopRotorCompassDrag);
}
if (els.openwebrxBandSetBtn) { if (els.openwebrxBandSetBtn) {
els.openwebrxBandSetBtn.addEventListener("click", async () => { els.openwebrxBandSetBtn.addEventListener("click", async () => {
await setOpenWebRxBand(); await setOpenWebRxBand();
@@ -2031,6 +2077,43 @@ async function setOpenWebRxRotor() {
} }
} }
function normalizeCompassAzimuth(value) {
return ((Number(value) % 360) + 360) % 360;
}
function rotorTargetBounds() {
const rotor = state.openWebRx && state.openWebRx.rotor ? state.openWebRx.rotor : null;
const min = rotor && Number.isFinite(Number(rotor.min)) ? Number(rotor.min) : 0;
const max = rotor && Number.isFinite(Number(rotor.max)) ? Number(rotor.max) : 360;
return {
min: Math.min(min, max),
max: Math.max(min, max)
};
}
function updateRotorTargetFromPointerEvent(event) {
if (!els.rotorCompass || !els.rotorTarget) {
return;
}
const rect = els.rotorCompass.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const pointerX = Number(event.clientX);
const pointerY = Number(event.clientY);
if (!Number.isFinite(pointerX) || !Number.isFinite(pointerY)) {
return;
}
const dx = pointerX - centerX;
const dy = pointerY - centerY;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
return;
}
const azimuth = normalizeCompassAzimuth((Math.atan2(dx, -dy) * 180) / Math.PI);
const bounds = rotorTargetBounds();
const clamped = Math.min(bounds.max, Math.max(bounds.min, Math.round(azimuth)));
els.rotorTarget.value = String(clamped);
}
function renderRotorState() { function renderRotorState() {
if (!els.rotorCurrent) { if (!els.rotorCurrent) {
return; return;

View File

@@ -204,7 +204,7 @@
<button type="button" class="ghost-btn" data-azimuth="315">NW</button> <button type="button" class="ghost-btn" data-azimuth="315">NW</button>
</div> </div>
</div> </div>
<div id="rotorCompass" class="rotor-compass" aria-label="Rotor Kompass"> <div id="rotorCompass" class="rotor-compass" aria-label="Rotor Kompass" title="Mit Maus oder Touch den Ziel-Azimut waehlen">
<span class="rotor-mark rotor-mark-n">0</span> <span class="rotor-mark rotor-mark-n">0</span>
<span class="rotor-mark rotor-mark-e">90</span> <span class="rotor-mark rotor-mark-e">90</span>
<span class="rotor-mark rotor-mark-s">180</span> <span class="rotor-mark rotor-mark-s">180</span>

View File

@@ -510,6 +510,13 @@ button:disabled {
background: radial-gradient(circle at 35% 30%, rgba(255,255,255,0.1), rgba(15,23,42,0.88) 65%); background: radial-gradient(circle at 35% 30%, rgba(255,255,255,0.1), rgba(15,23,42,0.88) 65%);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05);
flex: 0 0 auto; flex: 0 0 auto;
cursor: crosshair;
touch-action: none;
user-select: none;
}
.rotor-compass.is-dragging {
cursor: grabbing;
} }
.rotor-mark { .rotor-mark {

View File

@@ -1047,12 +1047,27 @@ async function handleRequestAccess(req, res, body) {
actionLink: link, actionLink: link,
actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen" actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen"
}); });
try {
await sendEmailMessage( await sendEmailMessage(
email, email,
message.subject, message.subject,
message.text, message.text,
message.html message.html,
{ strictDelivery: true }
); );
} catch (error) {
if (isChallengeDeliveryError(error)) {
await appendAudit("auth.request_access_failed", user, {
status: user.status,
requestedMethod: requestedMethod || null,
method: "fallback-mail",
code: error.code,
reason: error && error.details ? error.details.reason : null
});
return sendError(res, 502, error.code, error.publicMessage || "E-Mail konnte nicht zugestellt werden. Bitte spaeter erneut versuchen.");
}
throw error;
}
await appendAudit("auth.request_access", user, { await appendAudit("auth.request_access", user, {
status: user.status, status: user.status,
requestedMethod: requestedMethod || null, requestedMethod: requestedMethod || null,
@@ -1089,6 +1104,7 @@ async function handleRequestAccess(req, res, body) {
actionLink: link, actionLink: link,
actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen" actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen"
}); });
try {
await dispatchAuthChallenge(req, user, selectedMethod, { await dispatchAuthChallenge(req, user, selectedMethod, {
type: "link", type: "link",
subject: message.subject, subject: message.subject,
@@ -1097,6 +1113,20 @@ async function handleRequestAccess(req, res, body) {
token, token,
link link
}); });
} catch (error) {
if (isChallengeDeliveryError(error)) {
await appendAudit("auth.request_access_failed", user, {
status: user.status,
requestedMethod: requestedMethod || null,
method: selectedMethod.id,
challengeType,
code: error.code,
reason: error && error.details ? error.details.reason : null
});
return sendError(res, 502, error.code, error.publicMessage || "E-Mail konnte nicht zugestellt werden. Bitte spaeter erneut versuchen.");
}
throw error;
}
} else if (selectedMethod.type === "otp") { } else if (selectedMethod.type === "otp") {
const otpCode = await issueOtpChallenge(user.id, user.status === "active" ? "login" : "verify"); const otpCode = await issueOtpChallenge(user.id, user.status === "active" ? "login" : "verify");
challengeHint = `Code wurde ueber ${selectedMethod.label} gesendet.`; challengeHint = `Code wurde ueber ${selectedMethod.label} gesendet.`;
@@ -1107,6 +1137,7 @@ async function handleRequestAccess(req, res, body) {
text: `Dein Code lautet: ${otpCode}`, text: `Dein Code lautet: ${otpCode}`,
code: otpCode code: otpCode
}); });
try {
await dispatchAuthChallenge(req, user, selectedMethod, { await dispatchAuthChallenge(req, user, selectedMethod, {
type: "otp", type: "otp",
subject: message.subject, subject: message.subject,
@@ -1114,6 +1145,20 @@ async function handleRequestAccess(req, res, body) {
html: message.html, html: message.html,
code: otpCode code: otpCode
}); });
} catch (error) {
if (isChallengeDeliveryError(error)) {
await appendAudit("auth.request_access_failed", user, {
status: user.status,
requestedMethod: requestedMethod || null,
method: selectedMethod.id,
challengeType,
code: error.code,
reason: error && error.details ? error.details.reason : null
});
return sendError(res, 502, error.code, error.publicMessage || "E-Mail konnte nicht zugestellt werden. Bitte spaeter erneut versuchen.");
}
throw error;
}
} else if (selectedMethod.type === "oauth") { } else if (selectedMethod.type === "oauth") {
const purpose = user.status === "active" ? "login" : "verify"; const purpose = user.status === "active" ? "login" : "verify";
const stateToken = await issueOauthChallenge(user.id, purpose, selectedMethod.id); const stateToken = await issueOauthChallenge(user.id, purpose, selectedMethod.id);
@@ -2285,7 +2330,7 @@ async function dispatchAuthChallenge(req, user, method, payload) {
source: "server-prelog" source: "server-prelog"
}); });
await plugin.instance.execute("send_challenge", { const result = await plugin.instance.execute("send_challenge", {
methodId: method.id, methodId: method.id,
methodType: method.type, methodType: method.type,
user: sanitizeUser(user), user: sanitizeUser(user),
@@ -2293,6 +2338,10 @@ async function dispatchAuthChallenge(req, user, method, payload) {
origin: publicBaseUrlFor(req), origin: publicBaseUrlFor(req),
payload payload
}, { user }); }, { user });
if (result && result.delivered === false && result.smtpError) {
throw buildAuthChallengeDeliveryError(result.smtpError, method, user);
}
} }
async function issueOtpChallenge(userId, purpose) { async function issueOtpChallenge(userId, purpose) {
@@ -2472,12 +2521,29 @@ async function ensureMailOutboxInitialized() {
} }
} }
async function sendEmailMessage(to, subject, text, html = "") { function buildAuthChallengeDeliveryError(reason, method, user) {
const error = new Error("E-Mail konnte nicht zugestellt werden");
error.code = "auth.challenge_delivery_failed";
error.publicMessage = "E-Mail konnte nicht zugestellt werden. Bitte spaeter erneut versuchen.";
error.details = {
reason: String(reason || "delivery-failed"),
method: method && method.id ? String(method.id) : null,
userId: user && user.id ? String(user.id) : null
};
return error;
}
function isChallengeDeliveryError(error) {
return Boolean(error && error.code === "auth.challenge_delivery_failed");
}
async function sendEmailMessage(to, subject, text, html = "", options = {}) {
const strictDelivery = Boolean(options && options.strictDelivery);
const smtpMethod = listPublicAuthMethods().find((entry) => entry.id === "smtp-link"); const smtpMethod = listPublicAuthMethods().find((entry) => entry.id === "smtp-link");
if (smtpMethod) { if (smtpMethod) {
const plugin = runtime.plugins.get(smtpMethod.pluginId); const plugin = runtime.plugins.get(smtpMethod.pluginId);
if (plugin && typeof plugin.instance.execute === "function") { if (plugin && typeof plugin.instance.execute === "function") {
await plugin.instance.execute("send_challenge", { const result = await plugin.instance.execute("send_challenge", {
methodId: smtpMethod.id, methodId: smtpMethod.id,
methodType: smtpMethod.type, methodType: smtpMethod.type,
user: null, user: null,
@@ -2490,9 +2556,15 @@ async function sendEmailMessage(to, subject, text, html = "") {
html: String(html || "") html: String(html || "")
} }
}, { user: null }); }, { user: null });
if (strictDelivery && result && result.delivered === false && result.smtpError) {
throw buildAuthChallengeDeliveryError(result.smtpError, smtpMethod, null);
}
return; return;
} }
} }
if (strictDelivery) {
throw buildAuthChallengeDeliveryError("smtp-method-unavailable", { id: "smtp-link" }, null);
}
const entry = { const entry = {
at: new Date().toISOString(), at: new Date().toISOString(),
to, to,

View File

@@ -1020,6 +1020,67 @@ test("new users default to smtp-link and outbox uses smtp relay plugin", async (
} }
}); });
test("request-access returns error when SMTP delivery fails", async (t) => {
const rootDir = path.resolve(__dirname, "..");
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-smtp-fail-test-"));
const port = randomPort();
const baseUrl = `http://127.0.0.1:${port}`;
const server = spawn(process.execPath, ["server/index.js"], {
cwd: rootDir,
env: {
...process.env,
PORT: String(port),
DATA_DIR: dataDir,
ADMIN_EMAILS: "admin@arcg.at",
PRIMARY_EMAIL_DOMAIN: "arcg.at",
PUBLIC_BASE_URL: baseUrl,
SMTP_HOST: "127.0.0.1",
SMTP_PORT: "1",
SMTP_SECURE: "false"
},
stdio: ["ignore", "pipe", "pipe"]
});
let serverStdErr = "";
server.stderr.on("data", (chunk) => {
serverStdErr += String(chunk);
});
t.after(async () => {
if (!server.killed) {
server.kill("SIGTERM");
}
await fs.rm(dataDir, { recursive: true, force: true });
});
await waitForServer(baseUrl);
let deliveryError = null;
try {
await requestJson(baseUrl, "/v1/auth/request-access", {
method: "POST",
body: { email: "deliveryfail@arcg.at" }
});
} catch (error) {
deliveryError = error;
}
assert.ok(deliveryError, "request-access should fail on smtp delivery error");
assert.equal(deliveryError.status, 502);
assert.equal(deliveryError.payload.error.code, "auth.challenge_delivery_failed");
const outbox = await readOutbox(dataDir);
const failed = [...outbox].reverse().find((entry) => entry.to === "deliveryfail@arcg.at" && entry.via === "rms.auth.smtp_relay" && entry.delivered === false);
assert.ok(failed, "failed smtp outbox entry expected");
assert.equal(failed.transport, "outbox-fallback");
assert.ok(String(failed.smtpError || "").length > 0, "smtp error must be recorded");
if (server.exitCode !== null && server.exitCode !== 0) {
throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`);
}
});
test("public auth methods expose smtp-link first with 'per Mail' label", async (t) => { test("public auth methods expose smtp-link first with 'per Mail' label", async (t) => {
const rootDir = path.resolve(__dirname, ".."); const rootDir = path.resolve(__dirname, "..");
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-public-methods-test-")); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-public-methods-test-"));