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:
parent
75b148e772
commit
1db8341fb2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">🔒</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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue