surface SMTP delivery failures and add interactive rotor compass target
This commit is contained in:
@@ -76,15 +76,22 @@ async function createPlugin(ctx) {
|
||||
html: String(payload.html || "")
|
||||
};
|
||||
let delivered = false;
|
||||
let smtpError = "";
|
||||
try {
|
||||
delivered = await deliverViaSmtp(entry);
|
||||
} 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.transport = delivered ? "smtp" : "outbox-fallback";
|
||||
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() {
|
||||
const config = readTransportConfig();
|
||||
|
||||
@@ -11,6 +11,7 @@ let activationWatchInFlight = false;
|
||||
let activationPending = false;
|
||||
let remainingUsageTimer = null;
|
||||
let resumeRefreshInFlight = false;
|
||||
let rotorCompassDragPointerId = null;
|
||||
|
||||
const state = {
|
||||
user: null,
|
||||
@@ -716,6 +717,51 @@ function bindEvents() {
|
||||
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) {
|
||||
els.openwebrxBandSetBtn.addEventListener("click", async () => {
|
||||
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() {
|
||||
if (!els.rotorCurrent) {
|
||||
return;
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
<button type="button" class="ghost-btn" data-azimuth="315">NW</button>
|
||||
</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-e">90</span>
|
||||
<span class="rotor-mark rotor-mark-s">180</span>
|
||||
|
||||
@@ -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%);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05);
|
||||
flex: 0 0 auto;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.rotor-compass.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.rotor-mark {
|
||||
|
||||
@@ -1047,12 +1047,27 @@ async function handleRequestAccess(req, res, body) {
|
||||
actionLink: link,
|
||||
actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen"
|
||||
});
|
||||
try {
|
||||
await sendEmailMessage(
|
||||
email,
|
||||
message.subject,
|
||||
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, {
|
||||
status: user.status,
|
||||
requestedMethod: requestedMethod || null,
|
||||
@@ -1089,6 +1104,7 @@ async function handleRequestAccess(req, res, body) {
|
||||
actionLink: link,
|
||||
actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen"
|
||||
});
|
||||
try {
|
||||
await dispatchAuthChallenge(req, user, selectedMethod, {
|
||||
type: "link",
|
||||
subject: message.subject,
|
||||
@@ -1097,6 +1113,20 @@ async function handleRequestAccess(req, res, body) {
|
||||
token,
|
||||
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") {
|
||||
const otpCode = await issueOtpChallenge(user.id, user.status === "active" ? "login" : "verify");
|
||||
challengeHint = `Code wurde ueber ${selectedMethod.label} gesendet.`;
|
||||
@@ -1107,6 +1137,7 @@ async function handleRequestAccess(req, res, body) {
|
||||
text: `Dein Code lautet: ${otpCode}`,
|
||||
code: otpCode
|
||||
});
|
||||
try {
|
||||
await dispatchAuthChallenge(req, user, selectedMethod, {
|
||||
type: "otp",
|
||||
subject: message.subject,
|
||||
@@ -1114,6 +1145,20 @@ async function handleRequestAccess(req, res, body) {
|
||||
html: message.html,
|
||||
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") {
|
||||
const purpose = user.status === "active" ? "login" : "verify";
|
||||
const stateToken = await issueOauthChallenge(user.id, purpose, selectedMethod.id);
|
||||
@@ -2285,7 +2330,7 @@ async function dispatchAuthChallenge(req, user, method, payload) {
|
||||
source: "server-prelog"
|
||||
});
|
||||
|
||||
await plugin.instance.execute("send_challenge", {
|
||||
const result = await plugin.instance.execute("send_challenge", {
|
||||
methodId: method.id,
|
||||
methodType: method.type,
|
||||
user: sanitizeUser(user),
|
||||
@@ -2293,6 +2338,10 @@ async function dispatchAuthChallenge(req, user, method, payload) {
|
||||
origin: publicBaseUrlFor(req),
|
||||
payload
|
||||
}, { user });
|
||||
|
||||
if (result && result.delivered === false && result.smtpError) {
|
||||
throw buildAuthChallengeDeliveryError(result.smtpError, method, user);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
if (smtpMethod) {
|
||||
const plugin = runtime.plugins.get(smtpMethod.pluginId);
|
||||
if (plugin && typeof plugin.instance.execute === "function") {
|
||||
await plugin.instance.execute("send_challenge", {
|
||||
const result = await plugin.instance.execute("send_challenge", {
|
||||
methodId: smtpMethod.id,
|
||||
methodType: smtpMethod.type,
|
||||
user: null,
|
||||
@@ -2490,9 +2556,15 @@ async function sendEmailMessage(to, subject, text, html = "") {
|
||||
html: String(html || "")
|
||||
}
|
||||
}, { user: null });
|
||||
if (strictDelivery && result && result.delivered === false && result.smtpError) {
|
||||
throw buildAuthChallengeDeliveryError(result.smtpError, smtpMethod, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (strictDelivery) {
|
||||
throw buildAuthChallengeDeliveryError("smtp-method-unavailable", { id: "smtp-link" }, null);
|
||||
}
|
||||
const entry = {
|
||||
at: new Date().toISOString(),
|
||||
to,
|
||||
|
||||
@@ -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) => {
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-public-methods-test-"));
|
||||
|
||||
Reference in New Issue
Block a user