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:
Jeff Emmett 2026-03-24 18:40:29 -07:00
parent 348f86c179
commit 75ad3f8194
7 changed files with 429 additions and 57 deletions

View File

@ -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: [

View File

@ -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(", ")}`);

View File

@ -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 });
}); });

View File

@ -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 DIDprofileEmail 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 };

View File

@ -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)
);

View File

@ -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">&#128273; 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 = '&#128273; 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(() => {});

View File

@ -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}`);
}