diff --git a/docker-compose.encryptid.yml b/docker-compose.encryptid.yml index 022123f..3c66333 100644 --- a/docker-compose.encryptid.yml +++ b/docker-compose.encryptid.yml @@ -22,6 +22,8 @@ services: - SMTP_PASS=${SMTP_PASS} - SMTP_FROM=${SMTP_FROM:-EncryptID } - 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 diff --git a/server/index.ts b/server/index.ts index b711d09..c33928c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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); diff --git a/server/shell.ts b/server/shell.ts index fa1e88f..14bb991 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -28,6 +28,8 @@ export interface ShellOptions { theme?: "dark" | "light"; /** Extra 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} + - +
@@ -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 = + '
' + + '
🔒
' + + '

Private Space

' + + '

This space is private. Sign in to continue.

' + + '' + + '
'; + 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 {
`; } +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; diff --git a/server/spaces.ts b/server/spaces.ts index 4a54703..aa67f94 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -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(); + + 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(); - - 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) => { diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 72e2151..80fd25c 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -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 += `
`; } 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 += `
`; } diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index bd960b0..750bcb5 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -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 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 { + await sql` + UPDATE users + SET email_forward_enabled = ${enabled}, email_forward_mailcow_id = ${mailcowId}, updated_at = NOW() + WHERE id = ${userId} + `; +} + // ============================================================================ // HEALTH CHECK // ============================================================================ diff --git a/src/encryptid/mailcow.ts b/src/encryptid/mailcow.ts new file mode 100644 index 0000000..990d59c --- /dev/null +++ b/src/encryptid/mailcow.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const id = await findAliasId(address); + return id !== null; +} diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index 1730b77..d64a898 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -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, diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index ccb8015..69c0e1c 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -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