feat: email forwarding via Mailcow aliases + private space access gate

Email forwarding (EncryptID):
- New mailcow.ts API client for alias CRUD via Mailcow REST API
- Schema: email_forward_enabled + email_forward_mailcow_id columns
- API endpoints: GET/POST email-forward status, enable, disable
- Profile email change hook updates/disables alias automatically
- Docker: rmail-mailcow network + MAILCOW_API_URL/KEY env vars

Private spaces:
- Access gate overlay blocks members_only spaces for unauthenticated users
- Space visibility injected into HTML via middleware
- Auto-provision creates spaces as members_only by default
- Personalized "Create {username}'s Space" CTA in space switcher
- Removed unused /notifications endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-28 21:54:07 -08:00
parent 75b148e772
commit 1db8341fb2
9 changed files with 425 additions and 57 deletions

View File

@ -22,6 +22,8 @@ services:
- SMTP_PASS=${SMTP_PASS}
- SMTP_FROM=${SMTP_FROM:-EncryptID <noreply@rspace.online>}
- RECOVERY_URL=${RECOVERY_URL:-https://auth.rspace.online/recover}
- MAILCOW_API_URL=${MAILCOW_API_URL:-http://nginx-mailcow:8080}
- MAILCOW_API_KEY=${MAILCOW_API_KEY:-}
labels:
# Traefik auto-discovery
- "traefik.enable=true"
@ -42,6 +44,7 @@ services:
networks:
- traefik-public
- encryptid-internal
- rmail-mailcow
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/health').then(r => r.json()).then(d => process.exit(d.database ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
@ -76,3 +79,6 @@ networks:
external: true
encryptid-internal:
driver: bridge
rmail-mailcow:
external: true
name: mailcowdockerized_mailcow-network

View File

@ -789,7 +789,7 @@ app.post("/api/spaces/auto-provision", async (c) => {
`${claims.username}'s Space`,
username,
claims.sub,
"authenticated",
"members_only",
);
for (const mod of getAllModules()) {
@ -806,6 +806,29 @@ app.post("/api/spaces/auto-provision", async (c) => {
return c.json({ status: "created", slug: username }, 201);
});
// ── Inject space visibility into HTML responses ──
// Replaces the default data-space-visibility="public_read" rendered by renderShell
// with the actual visibility from the space config, so the client-side access gate
// can block content for members_only spaces when no session exists.
app.use("/:space/*", async (c, next) => {
await next();
const ct = c.res.headers.get("content-type");
if (!ct?.includes("text/html")) return;
const space = c.req.param("space");
if (!space || space === "api" || space.includes(".")) return;
const config = await getSpaceConfig(space);
const vis = config?.visibility || "public_read";
if (vis === "public_read" || vis === "public") return;
const html = await c.res.text();
c.res = new Response(
html.replace(
'data-space-visibility="public_read"',
`data-space-visibility="${vis}"`,
),
{ status: c.res.status, headers: c.res.headers },
);
});
// ── Mount module routes under /:space/:moduleId ──
for (const mod of getAllModules()) {
app.route(`/:space/${mod.id}`, mod.routes);

View File

@ -28,6 +28,8 @@ export interface ShellOptions {
theme?: "dark" | "light";
/** Extra <head> content (meta tags, preloads, etc.) */
head?: string;
/** Space visibility level (for client-side access gate) */
spaceVisibility?: string;
}
export function renderShell(opts: ShellOptions): string {
@ -42,6 +44,7 @@ export function renderShell(opts: ShellOptions): string {
modules,
theme = "dark",
head = "",
spaceVisibility = "public_read",
} = opts;
const moduleListJSON = JSON.stringify(modules);
@ -68,8 +71,9 @@ export function renderShell(opts: ShellOptions): string {
${styles}
${head}
<style>${WELCOME_CSS}</style>
<style>${ACCESS_GATE_CSS}</style>
</head>
<body data-theme="${theme}">
<body data-theme="${theme}" data-space-visibility="${escapeAttr(spaceVisibility)}">
<header class="rstack-header" data-theme="${theme}">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
@ -109,31 +113,6 @@ export function renderShell(opts: ShellOptions): string {
}
})();
// ── Auto-space resolution ──
// Logged-in users on demo space → redirect to personal space
(function() {
try {
var raw = localStorage.getItem('encryptid_session');
if (!raw) return;
var session = JSON.parse(raw);
if (!session || !session.claims || !session.claims.username) return;
var currentSpace = '${escapeAttr(spaceSlug)}';
if (currentSpace !== 'demo') return;
fetch('/api/spaces/auto-provision', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + session.accessToken,
'Content-Type': 'application/json'
}
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.slug) {
window.location.replace(window.__rspaceNavUrl(data.slug, '${escapeAttr(moduleId)}'));
}
}).catch(function() {});
} catch(e) {}
})();
// ── Welcome overlay (first visit to demo) ──
(function() {
var currentSpace = '${escapeAttr(spaceSlug)}';
@ -148,6 +127,45 @@ export function renderShell(opts: ShellOptions): string {
if (el) el.style.display = 'none';
};
// ── Private space access gate ──
// If the space is members_only and no session exists, show a sign-in gate
(function() {
var vis = document.body.getAttribute('data-space-visibility');
if (vis !== 'members_only') return;
try {
var raw = localStorage.getItem('encryptid_session');
if (raw) {
var session = JSON.parse(raw);
if (session && session.accessToken) return;
}
} catch(e) {}
// No valid session — gate the content
var main = document.getElementById('app');
if (main) main.style.display = 'none';
var gate = document.createElement('div');
gate.id = 'rspace-access-gate';
gate.innerHTML =
'<div class="access-gate__card">' +
'<div class="access-gate__icon">&#x1F512;</div>' +
'<h2 class="access-gate__title">Private Space</h2>' +
'<p class="access-gate__desc">This space is private. Sign in to continue.</p>' +
'<button class="access-gate__btn" id="gate-signin">Sign In</button>' +
'</div>';
document.body.appendChild(gate);
var btn = document.getElementById('gate-signin');
if (btn) btn.addEventListener('click', function() {
var identity = document.querySelector('rstack-identity');
if (identity && identity.showAuthModal) {
identity.showAuthModal({
onSuccess: function() {
gate.remove();
if (main) main.style.display = '';
}
});
}
});
})();
// ── Tab bar / Layer system initialization ──
// Tabs persist in localStorage so they survive full-page navigations.
// When a user opens a new rApp (via the app switcher or tab-add),
@ -494,6 +512,31 @@ function renderWelcomeOverlay(): string {
</div>`;
}
const ACCESS_GATE_CSS = `
#rspace-access-gate {
position: fixed; inset: 0; z-index: 9999;
display: flex; align-items: center; justify-content: center;
background: rgba(15, 23, 42, 0.95); backdrop-filter: blur(8px);
}
.access-gate__card {
text-align: center; color: white; max-width: 400px; padding: 2rem;
}
.access-gate__icon { font-size: 3rem; margin-bottom: 1rem; }
.access-gate__title {
font-size: 1.5rem; margin: 0 0 0.5rem;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.access-gate__desc { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin: 0 0 1.5rem; }
.access-gate__btn {
padding: 12px 32px; border-radius: 8px; border: none;
font-size: 1rem; font-weight: 600; cursor: pointer;
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
transition: opacity 0.15s, transform 0.15s;
}
.access-gate__btn:hover { opacity: 0.9; transform: translateY(-1px); }
`;
const WELCOME_CSS = `
.rspace-welcome {
position: fixed; bottom: 20px; right: 20px; z-index: 10000;

View File

@ -201,6 +201,34 @@ spaces.post("/", async (c) => {
}, 201);
});
// ── Get pending access requests for spaces the user owns ──
// NOTE: Must be defined BEFORE /:slug to avoid "notifications" matching as a slug
spaces.get("/notifications", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const slugs = await listCommunities();
const ownedSlugs = new Set<string>();
for (const slug of slugs) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (data?.meta?.ownerDID === claims.sub) {
ownedSlugs.add(slug);
}
}
const requests = Array.from(accessRequests.values()).filter(
(r) => ownedSlugs.has(r.spaceSlug) && r.status === "pending"
);
return c.json({ requests });
});
// ── Get space info ──
spaces.get("/:slug", async (c) => {
@ -983,33 +1011,6 @@ spaces.post("/:slug/access-requests", async (c) => {
return c.json({ id: reqId, status: "pending" }, 201);
});
// ── Get pending access requests for spaces the user owns ──
spaces.get("/notifications", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const slugs = await listCommunities();
const ownedSlugs = new Set<string>();
for (const slug of slugs) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (data?.meta?.ownerDID === claims.sub) {
ownedSlugs.add(slug);
}
}
const requests = Array.from(accessRequests.values()).filter(
(r) => ownedSlugs.has(r.spaceSlug) && r.status === "pending"
);
return c.json({ requests });
});
// ── Approve or deny an access request ──
spaces.patch("/:slug/access-requests/:reqId", async (c) => {

View File

@ -9,7 +9,7 @@
* Passes auth token so the API returns private spaces the user can access.
*/
import { isAuthenticated, getAccessToken } from "./rstack-identity";
import { isAuthenticated, getAccessToken, getUsername } from "./rstack-identity";
import { rspaceNavUrl, getCurrentModule as getModule } from "../url-helpers";
interface SpaceInfo {
@ -129,7 +129,9 @@ export class RStackSpaceSwitcher extends HTMLElement {
if (!auth) {
cta = this.#yourSpaceCTAhtml("Sign in to create →");
} else {
cta = this.#yourSpaceCTAhtml("Create (you)rSpace →");
const username = getUsername();
const label = username ? `Create ${username}'s Space →` : "Create (you)rSpace →";
cta = this.#yourSpaceCTAhtml(label);
}
menu.innerHTML = `
${cta}
@ -159,7 +161,9 @@ export class RStackSpaceSwitcher extends HTMLElement {
html += this.#yourSpaceCTAhtml("Sign in to create →");
html += `<div class="divider"></div>`;
} else if (!hasOwnedSpace) {
html += this.#yourSpaceCTAhtml("Create (you)rSpace →");
const username = getUsername();
const label = username ? `Create ${username}'s Space →` : "Create (you)rSpace →";
html += this.#yourSpaceCTAhtml(label);
html += `<div class="divider"></div>`;
}

View File

@ -589,6 +589,8 @@ export interface StoredUserProfile {
profileEmailIsRecovery: boolean;
did: string | null;
walletAddress: string | null;
emailForwardEnabled: boolean;
emailForwardMailcowId: string | null;
createdAt: string;
updatedAt: string;
}
@ -604,6 +606,8 @@ function rowToProfile(row: any): StoredUserProfile {
profileEmailIsRecovery: row.profile_email_is_recovery || false,
did: row.did || null,
walletAddress: row.wallet_address || null,
emailForwardEnabled: row.email_forward_enabled || false,
emailForwardMailcowId: row.email_forward_mailcow_id || null,
createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(),
updatedAt: row.updated_at?.toISOString?.() || row.created_at?.toISOString?.() || new Date().toISOString(),
};
@ -731,6 +735,37 @@ export async function deleteUserAddress(id: string, userId: string): Promise<boo
return result.count > 0;
}
// ============================================================================
// EMAIL FORWARDING OPERATIONS
// ============================================================================
export async function getEmailForwardStatus(userId: string): Promise<{
enabled: boolean;
mailcowId: string | null;
username: string;
profileEmail: string | null;
} | null> {
const [row] = await sql`
SELECT username, profile_email, email_forward_enabled, email_forward_mailcow_id
FROM users WHERE id = ${userId}
`;
if (!row) return null;
return {
enabled: row.email_forward_enabled || false,
mailcowId: row.email_forward_mailcow_id || null,
username: row.username,
profileEmail: row.profile_email || null,
};
}
export async function setEmailForward(userId: string, enabled: boolean, mailcowId: string | null): Promise<void> {
await sql`
UPDATE users
SET email_forward_enabled = ${enabled}, email_forward_mailcow_id = ${mailcowId}, updated_at = NOW()
WHERE id = ${userId}
`;
}
// ============================================================================
// HEALTH CHECK
// ============================================================================

112
src/encryptid/mailcow.ts Normal file
View File

@ -0,0 +1,112 @@
/**
* Mailcow API Client Email Forwarding Alias Management
*
* Thin wrapper around Mailcow's REST API for creating/managing
* forwarding aliases (username@rspace.online personal email).
*/
const MAILCOW_API_URL = process.env.MAILCOW_API_URL || 'http://nginx-mailcow:8080';
const MAILCOW_API_KEY = process.env.MAILCOW_API_KEY || '';
/** Check whether Mailcow integration is configured */
export function isMailcowConfigured(): boolean {
return MAILCOW_API_KEY.length > 0;
}
async function mailcowFetch(path: string, options: RequestInit = {}): Promise<Response> {
return fetch(`${MAILCOW_API_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-API-Key': MAILCOW_API_KEY,
...options.headers,
},
});
}
/**
* Create a forwarding alias.
* Returns the Mailcow alias ID on success, or throws on failure.
*/
export async function createAlias(username: string, targetEmail: string): Promise<string> {
const address = `${username}@rspace.online`;
const res = await mailcowFetch('/api/v1/add/alias', {
method: 'POST',
body: JSON.stringify({
address,
goto: targetEmail,
active: '1',
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Mailcow createAlias failed (${res.status}): ${text}`);
}
// Mailcow returns an array with status objects; look up the alias ID
const id = await findAliasId(address);
if (!id) throw new Error('Alias created but could not retrieve ID');
return id;
}
/**
* Delete a forwarding alias by its Mailcow ID.
*/
export async function deleteAlias(aliasId: string): Promise<void> {
const res = await mailcowFetch('/api/v1/delete/alias', {
method: 'POST',
body: JSON.stringify([aliasId]),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Mailcow deleteAlias failed (${res.status}): ${text}`);
}
}
/**
* Update the forwarding destination of an existing alias.
*/
export async function updateAlias(aliasId: string, newTargetEmail: string): Promise<void> {
const res = await mailcowFetch(`/api/v1/edit/alias`, {
method: 'POST',
body: JSON.stringify({
items: [aliasId],
attr: {
goto: newTargetEmail,
active: '1',
},
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Mailcow updateAlias failed (${res.status}): ${text}`);
}
}
/**
* Find a Mailcow alias ID by its address (e.g. "alice@rspace.online").
* Returns the ID string or null if not found.
*/
export async function findAliasId(address: string): Promise<string | null> {
const res = await mailcowFetch('/api/v1/get/alias/all');
if (!res.ok) {
const text = await res.text();
throw new Error(`Mailcow findAliasId failed (${res.status}): ${text}`);
}
const aliases: Array<{ id: number; address: string }> = await res.json();
const match = aliases.find((a) => a.address === address);
return match ? String(match.id) : null;
}
/**
* Check whether an alias already exists for the given address.
*/
export async function aliasExists(address: string): Promise<boolean> {
const id = await findAliasId(address);
return id !== null;
}

View File

@ -20,6 +20,10 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_email_is_recovery BOOLEAN DEF
ALTER TABLE users ADD COLUMN IF NOT EXISTS wallet_address TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();
-- Email forwarding (Mailcow alias)
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_forward_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_forward_mailcow_id TEXT;
CREATE TABLE IF NOT EXISTS credentials (
credential_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,

View File

@ -62,7 +62,16 @@ import {
getAddressById,
saveUserAddress,
deleteUserAddress,
getEmailForwardStatus,
setEmailForward,
} from './db.js';
import {
isMailcowConfigured,
createAlias,
deleteAlias,
updateAlias,
aliasExists,
} from './mailcow.js';
// ============================================================================
// CONFIGURATION
@ -686,6 +695,25 @@ app.put('/api/user/profile', async (c) => {
const profile = await updateUserProfile(claims.sub as string, updates);
if (!profile) return c.json({ error: 'User not found' }, 404);
// If profile email changed and forwarding is active, update/disable the alias
if (updates.profileEmail !== undefined && isMailcowConfigured()) {
try {
const fwdStatus = await getEmailForwardStatus(claims.sub as string);
if (fwdStatus?.enabled && fwdStatus.mailcowId) {
if (updates.profileEmail) {
// Email changed — update alias destination
await updateAlias(fwdStatus.mailcowId, updates.profileEmail);
} else {
// Email cleared — disable forwarding
await deleteAlias(fwdStatus.mailcowId);
await setEmailForward(claims.sub as string, false, null);
}
}
} catch (err) {
console.error('EncryptID: Failed to update Mailcow alias after profile email change:', err);
}
}
return c.json({ success: true, profile });
});
@ -903,6 +931,118 @@ app.post('/api/account/email/verify', async (c) => {
return c.json({ success: true, email });
});
// ============================================================================
// EMAIL FORWARDING (Mailcow alias management)
// ============================================================================
/**
* GET /api/account/email-forward check forwarding status
* Auth required
*/
app.get('/api/account/email-forward', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const status = await getEmailForwardStatus(claims.sub as string);
if (!status) return c.json({ error: 'User not found' }, 404);
const available = isMailcowConfigured();
const address = `${status.username}@rspace.online`;
return c.json({
enabled: status.enabled,
address: status.enabled ? address : null,
forwardsTo: status.enabled ? status.profileEmail : null,
available,
});
});
/**
* POST /api/account/email-forward/enable create forwarding alias
* Auth required. Requires a verified profile_email.
*/
app.post('/api/account/email-forward/enable', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
if (!isMailcowConfigured()) {
return c.json({ error: 'Email forwarding is not configured' }, 503);
}
const userId = claims.sub as string;
const status = await getEmailForwardStatus(userId);
if (!status) return c.json({ error: 'User not found' }, 404);
if (status.enabled) {
return c.json({ error: 'Email forwarding is already enabled' }, 409);
}
if (!status.profileEmail) {
return c.json({ error: 'A verified profile email is required to enable forwarding' }, 400);
}
const address = `${status.username}@rspace.online`;
// Check for existing alias conflict
try {
if (await aliasExists(address)) {
return c.json({ error: 'An alias already exists for this address' }, 409);
}
} catch (err) {
console.error('EncryptID: Mailcow aliasExists check failed:', err);
return c.json({ error: 'Email forwarding service unavailable' }, 503);
}
try {
const mailcowId = await createAlias(status.username, status.profileEmail);
await setEmailForward(userId, true, mailcowId);
return c.json({
success: true,
enabled: true,
address,
forwardsTo: status.profileEmail,
});
} catch (err) {
console.error('EncryptID: Failed to create Mailcow alias:', err);
return c.json({ error: 'Email forwarding service unavailable' }, 503);
}
});
/**
* POST /api/account/email-forward/disable remove forwarding alias
* Auth required.
*/
app.post('/api/account/email-forward/disable', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
if (!isMailcowConfigured()) {
return c.json({ error: 'Email forwarding is not configured' }, 503);
}
const userId = claims.sub as string;
const status = await getEmailForwardStatus(userId);
if (!status) return c.json({ error: 'User not found' }, 404);
if (!status.enabled) {
return c.json({ error: 'Email forwarding is not enabled' }, 400);
}
// Best-effort: clear local state even if Mailcow delete fails
if (status.mailcowId) {
try {
await deleteAlias(status.mailcowId);
} catch (err) {
console.error('EncryptID: Failed to delete Mailcow alias (clearing local state anyway):', err);
}
}
await setEmailForward(userId, false, null);
return c.json({ success: true, enabled: false });
});
/**
* POST /api/account/device/start get WebAuthn options for registering another passkey
* Auth required