diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index 8ff8221..f90ceee 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -32,6 +32,8 @@ import { let _syncServer: SyncServer | null = null; +const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; + const routes = new Hono(); // ── SMTP Transport (lazy singleton) ── @@ -1861,6 +1863,14 @@ export const inboxModule: RSpaceModule = { }, async onSpaceCreate(ctx) { 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", feeds: [ diff --git a/server/index.ts b/server/index.ts index cd012cb..f4a902f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -841,7 +841,7 @@ app.post("/:space/api/comment-pins/notify-mention", async (c) => { body: `Comment pin #${pinIndex || "?"} in ${space}`, spaceSlug: space, moduleId: "rspace", - actionUrl: `/${space}/rspace#pin-${pinId}`, + actionUrl: `/rspace#pin-${pinId}`, actorDid: authorDid, actorUsername: authorName, }); @@ -2432,7 +2432,8 @@ app.get("/:space/:moduleId/template", async (c) => { 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 ── @@ -2559,7 +2560,8 @@ for (const mod of getAllModules()) { if (spaceDoc?.meta?.enabledModules && !spaceDoc.meta.enabledModules.includes(mod.id)) { const accept = c.req.header("Accept") || ""; 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); } @@ -3209,10 +3211,18 @@ const server = Bun.serve({ } } - // If first segment already matches the subdomain (space slug), - // pass through directly — URL already has /{space}/... prefix - // e.g. demo.rspace.online/demo/rcart/pay/123 → /demo/rcart/pay/123 + // If first segment matches the subdomain (space slug), the URL has a + // redundant /{space}/... prefix. For HTML navigation, redirect to strip + // 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) { + 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}`); return app.fetch(new Request(rewrittenUrl, req)); } @@ -3514,7 +3524,7 @@ const server = Bun.serve({ spaceSlug: communitySlug, actorDid: ws.data.claims.sub, actorUsername: senderInfo.username, - actionUrl: `/${communitySlug}/rspace`, + actionUrl: `/rspace`, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h }).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(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`); diff --git a/server/spaces.ts b/server/spaces.ts index 31e280b..759f245 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -799,10 +799,17 @@ spaces.patch("/:slug/members/:did", async (c) => { spaceSlug: slug, actorDid: claims.sub, actorUsername: claims.username, - actionUrl: `/${slug}/rspace`, + actionUrl: `/rspace`, metadata: { newRole: body.role }, }).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 }); }); @@ -845,6 +852,11 @@ spaces.delete("/:slug/members/:did", async (c) => { actorUsername: claims.username, }).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 }); }); @@ -1888,7 +1900,7 @@ spaces.post("/:slug/access-requests", async (c) => { spaceSlug: slug, actorDid: claims.sub, actorUsername: request.requesterUsername, - actionUrl: `/${slug}/rspace`, + actionUrl: `/rspace`, metadata: { requestId: reqId }, }).catch(() => {}); @@ -1965,10 +1977,17 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => { spaceSlug: slug, actorDid: claims.sub, actorUsername: claims.username, - actionUrl: `/${slug}/rspace`, + actionUrl: `/rspace`, metadata: { role: body.role || "viewer" }, }).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" }); } @@ -2174,10 +2193,17 @@ spaces.post("/:slug/invite", async (c) => { spaceSlug: slug, actorDid: claims.sub, actorUsername: claims.username, - actionUrl: `/${slug}/rspace`, + actionUrl: `/rspace`, metadata: { role }, }).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 if (inviteTransport) { try { @@ -2282,10 +2308,17 @@ spaces.post("/:slug/members/add", async (c) => { spaceSlug: slug, actorDid: claims.sub, actorUsername: claims.username, - actionUrl: `/${slug}/rspace`, + actionUrl: `/rspace`, metadata: { role }, }).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) if (inviteTransport && user.id) { try { @@ -2348,6 +2381,13 @@ spaces.post("/:slug/invite/accept", async (c) => { await loadCommunity(slug); 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 }); }); diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 25dba74..273ad3c 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -2332,4 +2332,79 @@ export async function getUserByUPAddress(upAddress: string): Promise<{ userId: s 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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(); + 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 }; diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index 0be3adf..4046f61 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.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 -- timestamp is considered revoked on verify/refresh. 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) +); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 06a977e..e85def4 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -136,6 +136,9 @@ import { migrateSpaceMemberDid, setUserLoggedOutAt, getUserLoggedOutAt, + upsertSpaceEmailForwarding, + removeSpaceEmailForwarding, + getSpaceEmailForwarding, } from './db.js'; import { isMailcowConfigured, @@ -146,6 +149,7 @@ import { } from './mailcow.js'; import { notify } from '../../server/notification-service'; import { startTrustEngine } from './trust-engine.js'; +import { provisionSpaceAlias, syncSpaceAlias, deprovisionSpaceAlias } from './space-alias-service.js'; // ============================================================================ // CONFIGURATION @@ -7137,21 +7141,20 @@ app.get('/', (c) => {
- +
-
- - + + - - @@ -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 () => { const btn = document.getElementById('auth-btn'); btn.disabled = true; btn.textContent = 'Waiting for passkey...'; hideMessages(); - // Get email/username if provided to scope the passkey picker - const signinInput = document.getElementById('signin-email').value.trim(); - const isEmail = signinInput.includes('@'); - try { - // Server-initiated auth flow — pass email/username to scope credentials - const authBody = {}; - if (signinInput) { - if (isEmail) authBody.email = signinInput; - else authBody.username = signinInput; - } + // Unscoped auth — let the browser show all available passkeys const startRes = await fetch('/api/auth/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(authBody), + body: JSON.stringify({}), }); if (!startRes.ok) throw new Error('Failed to start authentication'); - const { options: serverOptions, prfSalt, userFound } = 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'; - } + const { options: serverOptions, prfSalt } = await startRes.json(); // Build PRF extension for sign-in const prfExtension = prfSalt ? { @@ -7793,14 +7784,15 @@ app.get('/', (c) => { showProfile(data.token, data.username, data.did); } catch (err) { - // If passkey auth was cancelled/failed but email was provided, suggest magic link - if (isEmail && err.name === 'NotAllowedError') { - showError('Passkey not found on this device. Use the email link below to sign in.'); - document.getElementById('magic-link-section').style.display = 'block'; + if (err.name === 'NotAllowedError') { + showError('No passkey found on this device. Use email to sign in.'); + // Auto-show email fallback + document.getElementById('email-fallback-section').style.display = 'block'; + document.getElementById('show-email-fallback').style.display = 'none'; } else { showError(err.message || 'Authentication failed'); } - btn.textContent = 'Sign In with Passkey'; + btn.innerHTML = '🔑 Sign in with Passkey'; btn.disabled = false; } }; @@ -8684,6 +8676,94 @@ app.get('/api/users/directory', async (c) => { 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 setInterval(() => { cleanExpiredChallenges().catch(() => {}); diff --git a/src/encryptid/space-alias-service.ts b/src/encryptid/space-alias-service.ts new file mode 100644 index 0000000..d3ebf80 --- /dev/null +++ b/src/encryptid/space-alias-service.ts @@ -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 { + 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 { + 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 { + 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 { + 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}`); +}