diff --git a/plugins/rms.auth.smtp_relay/index.js b/plugins/rms.auth.smtp_relay/index.js
index 8f0108a..a44614d 100644
--- a/plugins/rms.auth.smtp_relay/index.js
+++ b/plugins/rms.auth.smtp_relay/index.js
@@ -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();
diff --git a/public/app.js b/public/app.js
index 737bce1..50368b0 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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;
diff --git a/public/index.html b/public/index.html
index d35247a..e2c8bb8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -204,7 +204,7 @@
-
+
0
90
180
diff --git a/public/styles.css b/public/styles.css
index 80c5fbf..9ec387d 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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 {
diff --git a/server/index.js b/server/index.js
index 31f4f23..82f92c3 100644
--- a/server/index.js
+++ b/server/index.js
@@ -1047,12 +1047,27 @@ async function handleRequestAccess(req, res, body) {
actionLink: link,
actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen"
});
- await sendEmailMessage(
- email,
- message.subject,
- message.text,
- message.html
- );
+ try {
+ await sendEmailMessage(
+ email,
+ message.subject,
+ message.text,
+ 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,14 +1104,29 @@ async function handleRequestAccess(req, res, body) {
actionLink: link,
actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen"
});
- await dispatchAuthChallenge(req, user, selectedMethod, {
- type: "link",
- subject: message.subject,
- text: message.text,
- html: message.html,
- token,
- link
- });
+ try {
+ await dispatchAuthChallenge(req, user, selectedMethod, {
+ type: "link",
+ subject: message.subject,
+ text: message.text,
+ html: message.html,
+ 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,13 +1137,28 @@ async function handleRequestAccess(req, res, body) {
text: `Dein Code lautet: ${otpCode}`,
code: otpCode
});
- await dispatchAuthChallenge(req, user, selectedMethod, {
- type: "otp",
- subject: message.subject,
- text: message.text,
- html: message.html,
- code: otpCode
- });
+ try {
+ await dispatchAuthChallenge(req, user, selectedMethod, {
+ type: "otp",
+ subject: message.subject,
+ text: message.text,
+ 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,
diff --git a/test/auth-methods.integration.test.js b/test/auth-methods.integration.test.js
index 7def689..7fbddb6 100644
--- a/test/auth-methods.integration.test.js
+++ b/test/auth-methods.integration.test.js
@@ -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-"));