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 || "")
|
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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
120
server/index.js
120
server/index.js
@@ -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"
|
||||||
});
|
});
|
||||||
await sendEmailMessage(
|
try {
|
||||||
email,
|
await sendEmailMessage(
|
||||||
message.subject,
|
email,
|
||||||
message.text,
|
message.subject,
|
||||||
message.html
|
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, {
|
await appendAudit("auth.request_access", user, {
|
||||||
status: user.status,
|
status: user.status,
|
||||||
requestedMethod: requestedMethod || null,
|
requestedMethod: requestedMethod || null,
|
||||||
@@ -1089,14 +1104,29 @@ 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"
|
||||||
});
|
});
|
||||||
await dispatchAuthChallenge(req, user, selectedMethod, {
|
try {
|
||||||
type: "link",
|
await dispatchAuthChallenge(req, user, selectedMethod, {
|
||||||
subject: message.subject,
|
type: "link",
|
||||||
text: message.text,
|
subject: message.subject,
|
||||||
html: message.html,
|
text: message.text,
|
||||||
token,
|
html: message.html,
|
||||||
link
|
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") {
|
} 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,13 +1137,28 @@ async function handleRequestAccess(req, res, body) {
|
|||||||
text: `Dein Code lautet: ${otpCode}`,
|
text: `Dein Code lautet: ${otpCode}`,
|
||||||
code: otpCode
|
code: otpCode
|
||||||
});
|
});
|
||||||
await dispatchAuthChallenge(req, user, selectedMethod, {
|
try {
|
||||||
type: "otp",
|
await dispatchAuthChallenge(req, user, selectedMethod, {
|
||||||
subject: message.subject,
|
type: "otp",
|
||||||
text: message.text,
|
subject: message.subject,
|
||||||
html: message.html,
|
text: message.text,
|
||||||
code: otpCode
|
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") {
|
} 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,
|
||||||
|
|||||||
@@ -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-"));
|
||||||
|
|||||||
Reference in New Issue
Block a user