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_PASS=${SMTP_PASS}
|
||||||
- SMTP_FROM=${SMTP_FROM:-EncryptID <noreply@rspace.online>}
|
- SMTP_FROM=${SMTP_FROM:-EncryptID <noreply@rspace.online>}
|
||||||
- RECOVERY_URL=${RECOVERY_URL:-https://auth.rspace.online/recover}
|
- 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:
|
labels:
|
||||||
# Traefik auto-discovery
|
# Traefik auto-discovery
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
|
@ -42,6 +44,7 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- traefik-public
|
- traefik-public
|
||||||
- encryptid-internal
|
- encryptid-internal
|
||||||
|
- rmail-mailcow
|
||||||
healthcheck:
|
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))"]
|
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
|
interval: 30s
|
||||||
|
|
@ -76,3 +79,6 @@ networks:
|
||||||
external: true
|
external: true
|
||||||
encryptid-internal:
|
encryptid-internal:
|
||||||
driver: bridge
|
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`,
|
`${claims.username}'s Space`,
|
||||||
username,
|
username,
|
||||||
claims.sub,
|
claims.sub,
|
||||||
"authenticated",
|
"members_only",
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const mod of getAllModules()) {
|
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);
|
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 ──
|
// ── Mount module routes under /:space/:moduleId ──
|
||||||
for (const mod of getAllModules()) {
|
for (const mod of getAllModules()) {
|
||||||
app.route(`/:space/${mod.id}`, mod.routes);
|
app.route(`/:space/${mod.id}`, mod.routes);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ export interface ShellOptions {
|
||||||
theme?: "dark" | "light";
|
theme?: "dark" | "light";
|
||||||
/** Extra <head> content (meta tags, preloads, etc.) */
|
/** Extra <head> content (meta tags, preloads, etc.) */
|
||||||
head?: string;
|
head?: string;
|
||||||
|
/** Space visibility level (for client-side access gate) */
|
||||||
|
spaceVisibility?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderShell(opts: ShellOptions): string {
|
export function renderShell(opts: ShellOptions): string {
|
||||||
|
|
@ -42,6 +44,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
modules,
|
modules,
|
||||||
theme = "dark",
|
theme = "dark",
|
||||||
head = "",
|
head = "",
|
||||||
|
spaceVisibility = "public_read",
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const moduleListJSON = JSON.stringify(modules);
|
const moduleListJSON = JSON.stringify(modules);
|
||||||
|
|
@ -68,8 +71,9 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
${styles}
|
${styles}
|
||||||
${head}
|
${head}
|
||||||
<style>${WELCOME_CSS}</style>
|
<style>${WELCOME_CSS}</style>
|
||||||
|
<style>${ACCESS_GATE_CSS}</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-theme="${theme}">
|
<body data-theme="${theme}" data-space-visibility="${escapeAttr(spaceVisibility)}">
|
||||||
<header class="rstack-header" data-theme="${theme}">
|
<header class="rstack-header" data-theme="${theme}">
|
||||||
<div class="rstack-header__left">
|
<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>
|
<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) ──
|
// ── Welcome overlay (first visit to demo) ──
|
||||||
(function() {
|
(function() {
|
||||||
var currentSpace = '${escapeAttr(spaceSlug)}';
|
var currentSpace = '${escapeAttr(spaceSlug)}';
|
||||||
|
|
@ -148,6 +127,45 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
if (el) el.style.display = 'none';
|
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 ──
|
// ── Tab bar / Layer system initialization ──
|
||||||
// Tabs persist in localStorage so they survive full-page navigations.
|
// Tabs persist in localStorage so they survive full-page navigations.
|
||||||
// When a user opens a new rApp (via the app switcher or tab-add),
|
// When a user opens a new rApp (via the app switcher or tab-add),
|
||||||
|
|
@ -494,6 +512,31 @@ function renderWelcomeOverlay(): string {
|
||||||
</div>`;
|
</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 = `
|
const WELCOME_CSS = `
|
||||||
.rspace-welcome {
|
.rspace-welcome {
|
||||||
position: fixed; bottom: 20px; right: 20px; z-index: 10000;
|
position: fixed; bottom: 20px; right: 20px; z-index: 10000;
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,34 @@ spaces.post("/", async (c) => {
|
||||||
}, 201);
|
}, 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 ──
|
// ── Get space info ──
|
||||||
|
|
||||||
spaces.get("/:slug", async (c) => {
|
spaces.get("/:slug", async (c) => {
|
||||||
|
|
@ -983,33 +1011,6 @@ spaces.post("/:slug/access-requests", async (c) => {
|
||||||
return c.json({ id: reqId, status: "pending" }, 201);
|
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 ──
|
// ── Approve or deny an access request ──
|
||||||
|
|
||||||
spaces.patch("/:slug/access-requests/:reqId", async (c) => {
|
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.
|
* 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";
|
import { rspaceNavUrl, getCurrentModule as getModule } from "../url-helpers";
|
||||||
|
|
||||||
interface SpaceInfo {
|
interface SpaceInfo {
|
||||||
|
|
@ -129,7 +129,9 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
cta = this.#yourSpaceCTAhtml("Sign in to create →");
|
cta = this.#yourSpaceCTAhtml("Sign in to create →");
|
||||||
} else {
|
} 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 = `
|
menu.innerHTML = `
|
||||||
${cta}
|
${cta}
|
||||||
|
|
@ -159,7 +161,9 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
||||||
html += this.#yourSpaceCTAhtml("Sign in to create →");
|
html += this.#yourSpaceCTAhtml("Sign in to create →");
|
||||||
html += `<div class="divider"></div>`;
|
html += `<div class="divider"></div>`;
|
||||||
} else if (!hasOwnedSpace) {
|
} 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>`;
|
html += `<div class="divider"></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,8 @@ export interface StoredUserProfile {
|
||||||
profileEmailIsRecovery: boolean;
|
profileEmailIsRecovery: boolean;
|
||||||
did: string | null;
|
did: string | null;
|
||||||
walletAddress: string | null;
|
walletAddress: string | null;
|
||||||
|
emailForwardEnabled: boolean;
|
||||||
|
emailForwardMailcowId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -604,6 +606,8 @@ function rowToProfile(row: any): StoredUserProfile {
|
||||||
profileEmailIsRecovery: row.profile_email_is_recovery || false,
|
profileEmailIsRecovery: row.profile_email_is_recovery || false,
|
||||||
did: row.did || null,
|
did: row.did || null,
|
||||||
walletAddress: row.wallet_address || 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(),
|
createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(),
|
||||||
updatedAt: row.updated_at?.toISOString?.() || row.created_at?.toISOString?.() || new Date().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;
|
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
|
// 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 wallet_address TEXT;
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();
|
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 (
|
CREATE TABLE IF NOT EXISTS credentials (
|
||||||
credential_id TEXT PRIMARY KEY,
|
credential_id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,16 @@ import {
|
||||||
getAddressById,
|
getAddressById,
|
||||||
saveUserAddress,
|
saveUserAddress,
|
||||||
deleteUserAddress,
|
deleteUserAddress,
|
||||||
|
getEmailForwardStatus,
|
||||||
|
setEmailForward,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
|
import {
|
||||||
|
isMailcowConfigured,
|
||||||
|
createAlias,
|
||||||
|
deleteAlias,
|
||||||
|
updateAlias,
|
||||||
|
aliasExists,
|
||||||
|
} from './mailcow.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CONFIGURATION
|
// CONFIGURATION
|
||||||
|
|
@ -686,6 +695,25 @@ app.put('/api/user/profile', async (c) => {
|
||||||
const profile = await updateUserProfile(claims.sub as string, updates);
|
const profile = await updateUserProfile(claims.sub as string, updates);
|
||||||
if (!profile) return c.json({ error: 'User not found' }, 404);
|
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 });
|
return c.json({ success: true, profile });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -903,6 +931,118 @@ app.post('/api/account/email/verify', async (c) => {
|
||||||
return c.json({ success: true, email });
|
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
|
* POST /api/account/device/start — get WebAuthn options for registering another passkey
|
||||||
* Auth required
|
* Auth required
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue