feat(rinbox): per-space email forwarding via Mailcow aliases
Provision {space}@rspace.online forwarding aliases that route to opted-in members' personal emails. Admins/mods opted in by default; regular members can opt in via PUT /api/spaces/:slug/email-forwarding/me. New: space-alias-service.ts, schema tables, 8 DB functions, 6 API routes. Hooks: rinbox onSpaceCreate/Delete, spaces.ts member lifecycle, startup migration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
348f86c179
commit
75ad3f8194
|
|
@ -32,6 +32,8 @@ import {
|
||||||
|
|
||||||
let _syncServer: SyncServer | null = null;
|
let _syncServer: SyncServer | null = null;
|
||||||
|
|
||||||
|
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
|
||||||
|
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
// ── SMTP Transport (lazy singleton) ──
|
// ── SMTP Transport (lazy singleton) ──
|
||||||
|
|
@ -1861,6 +1863,14 @@ export const inboxModule: RSpaceModule = {
|
||||||
},
|
},
|
||||||
async onSpaceCreate(ctx) {
|
async onSpaceCreate(ctx) {
|
||||||
initSpaceInbox(ctx.spaceSlug, ctx.ownerDID || 'did:unknown');
|
initSpaceInbox(ctx.spaceSlug, ctx.ownerDID || 'did:unknown');
|
||||||
|
// Provision Mailcow forwarding alias for {space}@rspace.online
|
||||||
|
fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${ctx.spaceSlug}/alias`, { method: 'POST' })
|
||||||
|
.catch((e) => console.error(`[Inbox] Failed to provision space alias for ${ctx.spaceSlug}:`, e));
|
||||||
|
},
|
||||||
|
async onSpaceDelete(ctx) {
|
||||||
|
// Deprovision Mailcow forwarding alias
|
||||||
|
fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${ctx.spaceSlug}/alias`, { method: 'DELETE' })
|
||||||
|
.catch((e) => console.error(`[Inbox] Failed to deprovision space alias for ${ctx.spaceSlug}:`, e));
|
||||||
},
|
},
|
||||||
standaloneDomain: "rinbox.online",
|
standaloneDomain: "rinbox.online",
|
||||||
feeds: [
|
feeds: [
|
||||||
|
|
|
||||||
|
|
@ -841,7 +841,7 @@ app.post("/:space/api/comment-pins/notify-mention", async (c) => {
|
||||||
body: `Comment pin #${pinIndex || "?"} in ${space}`,
|
body: `Comment pin #${pinIndex || "?"} in ${space}`,
|
||||||
spaceSlug: space,
|
spaceSlug: space,
|
||||||
moduleId: "rspace",
|
moduleId: "rspace",
|
||||||
actionUrl: `/${space}/rspace#pin-${pinId}`,
|
actionUrl: `/rspace#pin-${pinId}`,
|
||||||
actorDid: authorDid,
|
actorDid: authorDid,
|
||||||
actorUsername: authorName,
|
actorUsername: authorName,
|
||||||
});
|
});
|
||||||
|
|
@ -2432,7 +2432,8 @@ app.get("/:space/:moduleId/template", async (c) => {
|
||||||
console.error(`[Template] On-demand seed failed for "${space}":`, e);
|
console.error(`[Template] On-demand seed failed for "${space}":`, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.redirect(`/${space}/${moduleId}`, 302);
|
const redirectPath = c.get("isSubdomain") ? `/${moduleId}` : `/${space}/${moduleId}`;
|
||||||
|
return c.redirect(redirectPath, 302);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Empty-state detection for onboarding ──
|
// ── Empty-state detection for onboarding ──
|
||||||
|
|
@ -2559,7 +2560,8 @@ for (const mod of getAllModules()) {
|
||||||
if (spaceDoc?.meta?.enabledModules && !spaceDoc.meta.enabledModules.includes(mod.id)) {
|
if (spaceDoc?.meta?.enabledModules && !spaceDoc.meta.enabledModules.includes(mod.id)) {
|
||||||
const accept = c.req.header("Accept") || "";
|
const accept = c.req.header("Accept") || "";
|
||||||
if (accept.includes("text/html")) {
|
if (accept.includes("text/html")) {
|
||||||
return c.redirect(`/${space}/rspace`);
|
const redir = c.get("isSubdomain") ? "/rspace" : `/${space}/rspace`;
|
||||||
|
return c.redirect(redir);
|
||||||
}
|
}
|
||||||
return c.json({ error: "Module not enabled for this space" }, 404);
|
return c.json({ error: "Module not enabled for this space" }, 404);
|
||||||
}
|
}
|
||||||
|
|
@ -3209,10 +3211,18 @@ const server = Bun.serve<WSData>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If first segment already matches the subdomain (space slug),
|
// If first segment matches the subdomain (space slug), the URL has a
|
||||||
// pass through directly — URL already has /{space}/... prefix
|
// redundant /{space}/... prefix. For HTML navigation, redirect to strip
|
||||||
// e.g. demo.rspace.online/demo/rcart/pay/123 → /demo/rcart/pay/123
|
// it so the address bar never shows the space slug. For API/fetch
|
||||||
|
// requests, silently rewrite to avoid breaking client-side fetches.
|
||||||
|
// e.g. demo.rspace.online/demo/rspace → 302 → demo.rspace.online/rspace
|
||||||
if (pathSegments[0].toLowerCase() === subdomain) {
|
if (pathSegments[0].toLowerCase() === subdomain) {
|
||||||
|
const accept = req.headers.get("Accept") || "";
|
||||||
|
if (accept.includes("text/html") && !url.pathname.includes("/api/")) {
|
||||||
|
const cleanPath = "/" + pathSegments.slice(1).join("/") + url.search;
|
||||||
|
return Response.redirect(`https://${host}${cleanPath}`, 302);
|
||||||
|
}
|
||||||
|
// API/fetch — rewrite internally
|
||||||
const rewrittenUrl = new URL(url.pathname + url.search, `http://localhost:${PORT}`);
|
const rewrittenUrl = new URL(url.pathname + url.search, `http://localhost:${PORT}`);
|
||||||
return app.fetch(new Request(rewrittenUrl, req));
|
return app.fetch(new Request(rewrittenUrl, req));
|
||||||
}
|
}
|
||||||
|
|
@ -3514,7 +3524,7 @@ const server = Bun.serve<WSData>({
|
||||||
spaceSlug: communitySlug,
|
spaceSlug: communitySlug,
|
||||||
actorDid: ws.data.claims.sub,
|
actorDid: ws.data.claims.sub,
|
||||||
actorUsername: senderInfo.username,
|
actorUsername: senderInfo.username,
|
||||||
actionUrl: `/${communitySlug}/rspace`,
|
actionUrl: `/rspace`,
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -3682,5 +3692,24 @@ loadAllDocs(syncServer)
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Provision Mailcow forwarding aliases for all existing spaces
|
||||||
|
const ENCRYPTID_INTERNAL_FOR_ALIAS = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const slugs = await listCommunities();
|
||||||
|
let count = 0;
|
||||||
|
for (const slug of slugs) {
|
||||||
|
if (slug === "demo") continue;
|
||||||
|
try {
|
||||||
|
await fetch(`${ENCRYPTID_INTERNAL_FOR_ALIAS}/api/internal/spaces/${slug}/alias`, { method: "POST" });
|
||||||
|
count++;
|
||||||
|
} catch { /* encryptid not ready yet, will provision on next restart */ }
|
||||||
|
}
|
||||||
|
if (count > 0) console.log(`[SpaceAlias] Provisioned ${count} space aliases`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[SpaceAlias] Startup provisioning failed:", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
console.log(`rSpace unified server running on http://localhost:${PORT}`);
|
console.log(`rSpace unified server running on http://localhost:${PORT}`);
|
||||||
console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);
|
console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);
|
||||||
|
|
|
||||||
|
|
@ -799,10 +799,17 @@ spaces.patch("/:slug/members/:did", async (c) => {
|
||||||
spaceSlug: slug,
|
spaceSlug: slug,
|
||||||
actorDid: claims.sub,
|
actorDid: claims.sub,
|
||||||
actorUsername: claims.username,
|
actorUsername: claims.username,
|
||||||
actionUrl: `/${slug}/rspace`,
|
actionUrl: `/rspace`,
|
||||||
metadata: { newRole: body.role },
|
metadata: { newRole: body.role },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Sync space email alias after role change
|
||||||
|
fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userDid: did, role: body.role }),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return c.json({ ok: true, did, role: body.role });
|
return c.json({ ok: true, did, role: body.role });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -845,6 +852,11 @@ spaces.delete("/:slug/members/:did", async (c) => {
|
||||||
actorUsername: claims.username,
|
actorUsername: claims.username,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Remove member's email forwarding preference + resync alias
|
||||||
|
fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/email-forwarding/${encodeURIComponent(did)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1888,7 +1900,7 @@ spaces.post("/:slug/access-requests", async (c) => {
|
||||||
spaceSlug: slug,
|
spaceSlug: slug,
|
||||||
actorDid: claims.sub,
|
actorDid: claims.sub,
|
||||||
actorUsername: request.requesterUsername,
|
actorUsername: request.requesterUsername,
|
||||||
actionUrl: `/${slug}/rspace`,
|
actionUrl: `/rspace`,
|
||||||
metadata: { requestId: reqId },
|
metadata: { requestId: reqId },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
|
@ -1965,10 +1977,17 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => {
|
||||||
spaceSlug: slug,
|
spaceSlug: slug,
|
||||||
actorDid: claims.sub,
|
actorDid: claims.sub,
|
||||||
actorUsername: claims.username,
|
actorUsername: claims.username,
|
||||||
actionUrl: `/${slug}/rspace`,
|
actionUrl: `/rspace`,
|
||||||
metadata: { role: body.role || "viewer" },
|
metadata: { role: body.role || "viewer" },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Sync space email alias with approved member
|
||||||
|
fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userDid: request.requesterDID, role: body.role || "viewer" }),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return c.json({ ok: true, status: "approved" });
|
return c.json({ ok: true, status: "approved" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2174,10 +2193,17 @@ spaces.post("/:slug/invite", async (c) => {
|
||||||
spaceSlug: slug,
|
spaceSlug: slug,
|
||||||
actorDid: claims.sub,
|
actorDid: claims.sub,
|
||||||
actorUsername: claims.username,
|
actorUsername: claims.username,
|
||||||
actionUrl: `/${slug}/rspace`,
|
actionUrl: `/rspace`,
|
||||||
metadata: { role },
|
metadata: { role },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Sync space email alias with new member
|
||||||
|
fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userDid: existingUser.did, role }),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
// Send "you've been added" email
|
// Send "you've been added" email
|
||||||
if (inviteTransport) {
|
if (inviteTransport) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -2282,10 +2308,17 @@ spaces.post("/:slug/members/add", async (c) => {
|
||||||
spaceSlug: slug,
|
spaceSlug: slug,
|
||||||
actorDid: claims.sub,
|
actorDid: claims.sub,
|
||||||
actorUsername: claims.username,
|
actorUsername: claims.username,
|
||||||
actionUrl: `/${slug}/rspace`,
|
actionUrl: `/rspace`,
|
||||||
metadata: { role },
|
metadata: { role },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Sync space email alias with new member
|
||||||
|
fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userDid: user.did, role }),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
// Send email notification (non-fatal)
|
// Send email notification (non-fatal)
|
||||||
if (inviteTransport && user.id) {
|
if (inviteTransport && user.id) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -2348,6 +2381,13 @@ spaces.post("/:slug/invite/accept", async (c) => {
|
||||||
await loadCommunity(slug);
|
await loadCommunity(slug);
|
||||||
setMember(slug, claims.sub, result.role as any, (claims as any).username);
|
setMember(slug, claims.sub, result.role as any, (claims as any).username);
|
||||||
|
|
||||||
|
// Sync space email alias with accepted member
|
||||||
|
fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userDid: claims.sub, role: result.role }),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return c.json({ ok: true, spaceSlug: result.spaceSlug, role: result.role });
|
return c.json({ ok: true, spaceSlug: result.spaceSlug, role: result.role });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2332,4 +2332,79 @@ export async function getUserByUPAddress(upAddress: string): Promise<{ userId: s
|
||||||
return { userId: legacy.id, username: legacy.username };
|
return { userId: legacy.id, username: legacy.username };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SPACE EMAIL ALIAS OPERATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getSpaceEmailAlias(spaceSlug: string): Promise<{ spaceSlug: string; mailcowId: string } | null> {
|
||||||
|
const [row] = await sql`SELECT * FROM space_email_aliases WHERE space_slug = ${spaceSlug}`;
|
||||||
|
if (!row) return null;
|
||||||
|
return { spaceSlug: row.space_slug, mailcowId: row.mailcow_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSpaceEmailAlias(spaceSlug: string, mailcowId: string): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO space_email_aliases (space_slug, mailcow_id)
|
||||||
|
VALUES (${spaceSlug}, ${mailcowId})
|
||||||
|
ON CONFLICT (space_slug)
|
||||||
|
DO UPDATE SET mailcow_id = ${mailcowId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSpaceEmailAlias(spaceSlug: string): Promise<boolean> {
|
||||||
|
const result = await sql`DELETE FROM space_email_aliases WHERE space_slug = ${spaceSlug}`;
|
||||||
|
return result.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertSpaceEmailForwarding(spaceSlug: string, userDid: string, optIn: boolean): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO space_email_forwarding (space_slug, user_did, opt_in, updated_at)
|
||||||
|
VALUES (${spaceSlug}, ${userDid}, ${optIn}, NOW())
|
||||||
|
ON CONFLICT (space_slug, user_did)
|
||||||
|
DO UPDATE SET opt_in = ${optIn}, updated_at = NOW()
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeSpaceEmailForwarding(spaceSlug: string, userDid: string): Promise<boolean> {
|
||||||
|
const result = await sql`DELETE FROM space_email_forwarding WHERE space_slug = ${spaceSlug} AND user_did = ${userDid}`;
|
||||||
|
return result.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOptedInDids(spaceSlug: string): Promise<string[]> {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT user_did FROM space_email_forwarding
|
||||||
|
WHERE space_slug = ${spaceSlug} AND opt_in = TRUE
|
||||||
|
`;
|
||||||
|
return rows.map((r) => r.user_did);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSpaceEmailForwarding(spaceSlug: string, userDid: string): Promise<{ optIn: boolean } | null> {
|
||||||
|
const [row] = await sql`
|
||||||
|
SELECT opt_in FROM space_email_forwarding
|
||||||
|
WHERE space_slug = ${spaceSlug} AND user_did = ${userDid}
|
||||||
|
`;
|
||||||
|
if (!row) return null;
|
||||||
|
return { optIn: row.opt_in };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch DID→profileEmail lookup. Joins users table on did column,
|
||||||
|
* decrypts profile_email_enc for each match.
|
||||||
|
*/
|
||||||
|
export async function getProfileEmailsByDids(dids: string[]): Promise<Map<string, string>> {
|
||||||
|
if (dids.length === 0) return new Map();
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT did, profile_email, profile_email_enc FROM users
|
||||||
|
WHERE did = ANY(${dids})
|
||||||
|
`;
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const email = row.profile_email_enc
|
||||||
|
? await decryptField(row.profile_email_enc)
|
||||||
|
: row.profile_email;
|
||||||
|
if (email) result.set(row.did, email);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export { sql };
|
export { sql };
|
||||||
|
|
|
||||||
|
|
@ -556,3 +556,21 @@ CREATE INDEX IF NOT EXISTS idx_fund_claims_email_hmac ON fund_claims(email_hmac)
|
||||||
-- When a user logs out, this timestamp is set. Any JWT issued before this
|
-- When a user logs out, this timestamp is set. Any JWT issued before this
|
||||||
-- timestamp is considered revoked on verify/refresh.
|
-- timestamp is considered revoked on verify/refresh.
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS logged_out_at TIMESTAMPTZ;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS logged_out_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SPACE EMAIL ALIASES (per-space Mailcow forwarding)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS space_email_aliases (
|
||||||
|
space_slug TEXT PRIMARY KEY,
|
||||||
|
mailcow_id TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS space_email_forwarding (
|
||||||
|
space_slug TEXT NOT NULL,
|
||||||
|
user_did TEXT NOT NULL,
|
||||||
|
opt_in BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (space_slug, user_did)
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,9 @@ import {
|
||||||
migrateSpaceMemberDid,
|
migrateSpaceMemberDid,
|
||||||
setUserLoggedOutAt,
|
setUserLoggedOutAt,
|
||||||
getUserLoggedOutAt,
|
getUserLoggedOutAt,
|
||||||
|
upsertSpaceEmailForwarding,
|
||||||
|
removeSpaceEmailForwarding,
|
||||||
|
getSpaceEmailForwarding,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
isMailcowConfigured,
|
isMailcowConfigured,
|
||||||
|
|
@ -146,6 +149,7 @@ import {
|
||||||
} from './mailcow.js';
|
} from './mailcow.js';
|
||||||
import { notify } from '../../server/notification-service';
|
import { notify } from '../../server/notification-service';
|
||||||
import { startTrustEngine } from './trust-engine.js';
|
import { startTrustEngine } from './trust-engine.js';
|
||||||
|
import { provisionSpaceAlias, syncSpaceAlias, deprovisionSpaceAlias } from './space-alias-service.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CONFIGURATION
|
// CONFIGURATION
|
||||||
|
|
@ -7137,21 +7141,20 @@ app.get('/', (c) => {
|
||||||
<div id="error-msg" class="error"></div>
|
<div id="error-msg" class="error"></div>
|
||||||
<div id="success-msg" class="success"></div>
|
<div id="success-msg" class="success"></div>
|
||||||
|
|
||||||
<!-- Sign-in mode (default) -->
|
<!-- Sign-in mode (default) — passkey-first -->
|
||||||
<div id="signin-fields">
|
<div id="signin-fields">
|
||||||
|
<button class="btn-primary" id="auth-btn" onclick="handleAuth()" style="font-size:1.1rem;padding:0.9rem">🔑 Sign in with Passkey</button>
|
||||||
|
<div style="text-align:center;margin-top:0.75rem">
|
||||||
|
<a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">No passkey on this device? Sign in with email</a>
|
||||||
|
</div>
|
||||||
|
<div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="signin-email">Email or Username</label>
|
<label for="signin-email">Email address</label>
|
||||||
<input id="signin-email" type="text" placeholder="you@example.com or username" autocomplete="email" />
|
<input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-primary" id="auth-btn" onclick="handleAuth()">Sign In with Passkey</button>
|
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
|
||||||
<div id="magic-link-section" style="display:none;margin-top:0.75rem">
|
|
||||||
<div style="text-align:center;color:#64748b;font-size:0.8rem;margin-bottom:0.5rem">or</div>
|
|
||||||
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Email me a login link</button>
|
|
||||||
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
|
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;margin-top:0.75rem;font-size:0.8rem;color:#64748b" id="signin-hint">
|
|
||||||
Enter your email to find your account, or leave blank to use any passkey on this device
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Registration stepper (hidden until register tab) -->
|
<!-- Registration stepper (hidden until register tab) -->
|
||||||
|
|
@ -7676,47 +7679,35 @@ app.get('/', (c) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// handleAuth is now sign-in only (registration uses stepper)
|
// Toggle email fallback visibility
|
||||||
|
window.toggleEmailFallback = () => {
|
||||||
|
const section = document.getElementById('email-fallback-section');
|
||||||
|
const link = document.getElementById('show-email-fallback');
|
||||||
|
if (section.style.display === 'none') {
|
||||||
|
section.style.display = 'block';
|
||||||
|
link.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
section.style.display = 'none';
|
||||||
|
link.style.display = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// handleAuth — always unscoped passkey picker (browser shows all stored passkeys)
|
||||||
window.handleAuth = async () => {
|
window.handleAuth = async () => {
|
||||||
const btn = document.getElementById('auth-btn');
|
const btn = document.getElementById('auth-btn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Waiting for passkey...';
|
btn.textContent = 'Waiting for passkey...';
|
||||||
hideMessages();
|
hideMessages();
|
||||||
|
|
||||||
// Get email/username if provided to scope the passkey picker
|
|
||||||
const signinInput = document.getElementById('signin-email').value.trim();
|
|
||||||
const isEmail = signinInput.includes('@');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Server-initiated auth flow — pass email/username to scope credentials
|
// Unscoped auth — let the browser show all available passkeys
|
||||||
const authBody = {};
|
|
||||||
if (signinInput) {
|
|
||||||
if (isEmail) authBody.email = signinInput;
|
|
||||||
else authBody.username = signinInput;
|
|
||||||
}
|
|
||||||
const startRes = await fetch('/api/auth/start', {
|
const startRes = await fetch('/api/auth/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(authBody),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
if (!startRes.ok) throw new Error('Failed to start authentication');
|
if (!startRes.ok) throw new Error('Failed to start authentication');
|
||||||
const { options: serverOptions, prfSalt, userFound } = await startRes.json();
|
const { options: serverOptions, prfSalt } = await startRes.json();
|
||||||
|
|
||||||
// If user provided email/username but no account found, show magic link option
|
|
||||||
if (signinInput && !userFound) {
|
|
||||||
showError('No account found. Check your email/username or register a new account.');
|
|
||||||
if (isEmail) {
|
|
||||||
document.getElementById('magic-link-section').style.display = 'block';
|
|
||||||
}
|
|
||||||
btn.textContent = 'Sign In with Passkey';
|
|
||||||
btn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show magic link option when email is provided (in case passkey isn't on this device)
|
|
||||||
if (isEmail) {
|
|
||||||
document.getElementById('magic-link-section').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build PRF extension for sign-in
|
// Build PRF extension for sign-in
|
||||||
const prfExtension = prfSalt ? {
|
const prfExtension = prfSalt ? {
|
||||||
|
|
@ -7793,14 +7784,15 @@ app.get('/', (c) => {
|
||||||
|
|
||||||
showProfile(data.token, data.username, data.did);
|
showProfile(data.token, data.username, data.did);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If passkey auth was cancelled/failed but email was provided, suggest magic link
|
if (err.name === 'NotAllowedError') {
|
||||||
if (isEmail && err.name === 'NotAllowedError') {
|
showError('No passkey found on this device. Use email to sign in.');
|
||||||
showError('Passkey not found on this device. Use the email link below to sign in.');
|
// Auto-show email fallback
|
||||||
document.getElementById('magic-link-section').style.display = 'block';
|
document.getElementById('email-fallback-section').style.display = 'block';
|
||||||
|
document.getElementById('show-email-fallback').style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
showError(err.message || 'Authentication failed');
|
showError(err.message || 'Authentication failed');
|
||||||
}
|
}
|
||||||
btn.textContent = 'Sign In with Passkey';
|
btn.innerHTML = '🔑 Sign in with Passkey';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -8684,6 +8676,94 @@ app.get('/api/users/directory', async (c) => {
|
||||||
startTrustEngine();
|
startTrustEngine();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SPACE EMAIL ALIAS ROUTES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Internal: provision alias for a space (called on space creation + startup)
|
||||||
|
app.post('/api/internal/spaces/:slug/alias', async (c) => {
|
||||||
|
const slug = c.req.param('slug');
|
||||||
|
try {
|
||||||
|
await provisionSpaceAlias(slug);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[SpaceAlias] Provision failed for ${slug}:`, e);
|
||||||
|
return c.json({ error: (e as Error).message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal: sync alias recipients (called on member add/role change)
|
||||||
|
app.post('/api/internal/spaces/:slug/alias/sync', async (c) => {
|
||||||
|
const slug = c.req.param('slug');
|
||||||
|
try {
|
||||||
|
const body = await c.req.json<{ userDid?: string; role?: string }>().catch(() => ({}));
|
||||||
|
// If a member is specified, seed their opt-in preference first
|
||||||
|
if (body.userDid && body.role) {
|
||||||
|
const optIn = body.role === 'admin' || body.role === 'moderator';
|
||||||
|
await upsertSpaceEmailForwarding(slug, body.userDid, optIn);
|
||||||
|
}
|
||||||
|
await syncSpaceAlias(slug);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[SpaceAlias] Sync failed for ${slug}:`, e);
|
||||||
|
return c.json({ error: (e as Error).message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal: deprovision alias (called on space deletion)
|
||||||
|
app.delete('/api/internal/spaces/:slug/alias', async (c) => {
|
||||||
|
const slug = c.req.param('slug');
|
||||||
|
try {
|
||||||
|
await deprovisionSpaceAlias(slug);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[SpaceAlias] Deprovision failed for ${slug}:`, e);
|
||||||
|
return c.json({ error: (e as Error).message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal: remove member's forwarding preference + resync
|
||||||
|
app.delete('/api/internal/spaces/:slug/email-forwarding/:did', async (c) => {
|
||||||
|
const slug = c.req.param('slug');
|
||||||
|
const did = decodeURIComponent(c.req.param('did'));
|
||||||
|
try {
|
||||||
|
await removeSpaceEmailForwarding(slug, did);
|
||||||
|
await syncSpaceAlias(slug);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[SpaceAlias] Remove forwarding failed for ${slug}/${did}:`, e);
|
||||||
|
return c.json({ error: (e as Error).message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authenticated: get my opt-in status for a space
|
||||||
|
app.get('/api/spaces/:slug/email-forwarding/me', async (c) => {
|
||||||
|
const slug = c.req.param('slug');
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
const pref = await getSpaceEmailForwarding(slug, claims.sub);
|
||||||
|
return c.json({ optIn: pref?.optIn ?? false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authenticated: toggle my opt-in for a space
|
||||||
|
app.put('/api/spaces/:slug/email-forwarding/me', async (c) => {
|
||||||
|
const slug = c.req.param('slug');
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
const body = await c.req.json<{ optIn: boolean }>();
|
||||||
|
if (typeof body.optIn !== 'boolean') {
|
||||||
|
return c.json({ error: 'optIn (boolean) is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertSpaceEmailForwarding(slug, claims.sub, body.optIn);
|
||||||
|
await syncSpaceAlias(slug).catch((e) => {
|
||||||
|
console.error(`[SpaceAlias] Sync after opt-in toggle failed for ${slug}:`, e);
|
||||||
|
});
|
||||||
|
return c.json({ ok: true, optIn: body.optIn });
|
||||||
|
});
|
||||||
|
|
||||||
// Clean expired challenges, recovery tokens, fund claims, and OIDC codes every 10 minutes
|
// Clean expired challenges, recovery tokens, fund claims, and OIDC codes every 10 minutes
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
cleanExpiredChallenges().catch(() => {});
|
cleanExpiredChallenges().catch(() => {});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* Space Email Alias Service — Mailcow forwarding alias management per space.
|
||||||
|
*
|
||||||
|
* Provisions {space}@rspace.online aliases that forward to opted-in members'
|
||||||
|
* personal emails. Admins/moderators are opted in by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
isMailcowConfigured,
|
||||||
|
createAlias,
|
||||||
|
deleteAlias,
|
||||||
|
updateAlias,
|
||||||
|
findAliasId,
|
||||||
|
} from './mailcow.js';
|
||||||
|
import {
|
||||||
|
listSpaceMembers,
|
||||||
|
getSpaceEmailAlias,
|
||||||
|
setSpaceEmailAlias,
|
||||||
|
deleteSpaceEmailAlias,
|
||||||
|
upsertSpaceEmailForwarding,
|
||||||
|
getOptedInDids,
|
||||||
|
getProfileEmailsByDids,
|
||||||
|
} from './db.js';
|
||||||
|
|
||||||
|
const TAG = '[SpaceAlias]';
|
||||||
|
|
||||||
|
/** Compute comma-separated goto from opted-in members with emails */
|
||||||
|
async function computeGoto(spaceSlug: string): Promise<string> {
|
||||||
|
const dids = await getOptedInDids(spaceSlug);
|
||||||
|
if (dids.length === 0) return `noreply+${spaceSlug}@rspace.online`;
|
||||||
|
|
||||||
|
const emailMap = await getProfileEmailsByDids(dids);
|
||||||
|
const emails = [...emailMap.values()].filter(Boolean);
|
||||||
|
if (emails.length === 0) return `noreply+${spaceSlug}@rspace.online`;
|
||||||
|
|
||||||
|
return emails.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision a Mailcow forwarding alias for a space.
|
||||||
|
* Seeds opt-in rows for all current members (admin/mod = opted in).
|
||||||
|
* Idempotent — skips if alias already exists.
|
||||||
|
*/
|
||||||
|
export async function provisionSpaceAlias(spaceSlug: string): Promise<void> {
|
||||||
|
if (!isMailcowConfigured()) {
|
||||||
|
console.warn(`${TAG} Mailcow not configured, skipping alias for ${spaceSlug}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we already have it in DB
|
||||||
|
const existing = await getSpaceEmailAlias(spaceSlug);
|
||||||
|
if (existing) {
|
||||||
|
console.log(`${TAG} Alias already exists for ${spaceSlug} (id: ${existing.mailcowId})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Mailcow already has it (e.g. manually created)
|
||||||
|
const address = `${spaceSlug}@rspace.online`;
|
||||||
|
const existingMailcowId = await findAliasId(address);
|
||||||
|
if (existingMailcowId) {
|
||||||
|
await setSpaceEmailAlias(spaceSlug, existingMailcowId);
|
||||||
|
console.log(`${TAG} Adopted existing Mailcow alias for ${spaceSlug} (id: ${existingMailcowId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed opt-in rows for all members
|
||||||
|
const members = await listSpaceMembers(spaceSlug);
|
||||||
|
for (const m of members) {
|
||||||
|
const optIn = m.role === 'admin' || m.role === 'moderator';
|
||||||
|
await upsertSpaceEmailForwarding(spaceSlug, m.userDID, optIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const goto = await computeGoto(spaceSlug);
|
||||||
|
|
||||||
|
if (existingMailcowId) {
|
||||||
|
// Update the existing alias with current goto
|
||||||
|
await updateAlias(existingMailcowId, goto);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new alias
|
||||||
|
const mailcowId = await createAlias(spaceSlug, goto);
|
||||||
|
await setSpaceEmailAlias(spaceSlug, mailcowId);
|
||||||
|
console.log(`${TAG} Provisioned alias for ${spaceSlug} → ${goto} (id: ${mailcowId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute and update the forwarding destinations for a space alias.
|
||||||
|
* If the alias doesn't exist yet, delegates to provisionSpaceAlias.
|
||||||
|
*/
|
||||||
|
export async function syncSpaceAlias(spaceSlug: string): Promise<void> {
|
||||||
|
if (!isMailcowConfigured()) return;
|
||||||
|
|
||||||
|
const alias = await getSpaceEmailAlias(spaceSlug);
|
||||||
|
if (!alias) {
|
||||||
|
await provisionSpaceAlias(spaceSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const goto = await computeGoto(spaceSlug);
|
||||||
|
await updateAlias(alias.mailcowId, goto);
|
||||||
|
console.log(`${TAG} Synced alias for ${spaceSlug} → ${goto}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the Mailcow alias and DB records for a space.
|
||||||
|
*/
|
||||||
|
export async function deprovisionSpaceAlias(spaceSlug: string): Promise<void> {
|
||||||
|
if (!isMailcowConfigured()) return;
|
||||||
|
|
||||||
|
const alias = await getSpaceEmailAlias(spaceSlug);
|
||||||
|
if (alias) {
|
||||||
|
try {
|
||||||
|
await deleteAlias(alias.mailcowId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${TAG} Failed to delete Mailcow alias for ${spaceSlug}:`, e);
|
||||||
|
}
|
||||||
|
await deleteSpaceEmailAlias(spaceSlug);
|
||||||
|
}
|
||||||
|
console.log(`${TAG} Deprovisioned alias for ${spaceSlug}`);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue