mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Users with several accounts can save the list of accounts in their browser (address, server, label, colour) on the webmail server and bring it back on another device. The list is encrypted in the browser with a password the user chooses (PBKDF2-SHA-256, 600,000 iterations, AES-256-GCM, owner bound as additional data); the server stores only the envelope, one file per owner under SETTINGS_DATA_DIR/account-archives. No mailbox passwords are included. Off by default behind the admin feature gate accountArchiveEnabled, pinned off in Lite. Every method of /api/account-archive needs a session cookie of the owner, matched without regard to case, whose credential is checked against the mail server on each call; listing included. Restored accounts this browser lacks are listed as awaiting sign-in: they survive a reload, and opening one leads to the sign-in form with the address filled in. Accounts already present get their archived label and colour.
321 lines
16 KiB
TypeScript
321 lines
16 KiB
TypeScript
// Admin dashboard types
|
||
|
||
/**
|
||
* Operator-authored admin record. Lives in admin.json under the config dir
|
||
* and can be mounted read-only after setup. Only the password hash itself
|
||
* is config; mutable timestamps live in AdminStateData.
|
||
*/
|
||
export interface AdminConfigData {
|
||
passwordHash: string | undefined;
|
||
passwordHashFile: string | undefined;
|
||
}
|
||
|
||
/**
|
||
* Runtime-mutable admin record. Lives in admin-state.json under the state
|
||
* dir. Updated on every login and password change, so it must stay writable.
|
||
*/
|
||
export interface AdminStateData {
|
||
createdAt: string;
|
||
lastLogin: string | null;
|
||
passwordChangedAt: string;
|
||
}
|
||
|
||
/**
|
||
* Combined view used by getAdminMeta() and tests. Constructed by merging
|
||
* admin.json + admin-state.json at read time.
|
||
*/
|
||
export interface AdminData extends AdminConfigData, AdminStateData {}
|
||
|
||
export interface AdminSessionPayload {
|
||
role: 'admin';
|
||
iat: number;
|
||
exp: number;
|
||
}
|
||
|
||
export interface SettingRestriction {
|
||
locked?: boolean;
|
||
value?: unknown;
|
||
hidden?: boolean;
|
||
allowedValues?: unknown[];
|
||
min?: number;
|
||
max?: number;
|
||
}
|
||
|
||
export interface FeatureGates {
|
||
pluginsEnabled: boolean;
|
||
pluginsUploadEnabled: boolean;
|
||
requirePluginApproval: boolean;
|
||
themesEnabled: boolean;
|
||
sidebarAppsEnabled: boolean;
|
||
userThemesEnabled: boolean;
|
||
settingsExportEnabled: boolean;
|
||
customKeywordsEnabled: boolean;
|
||
templatesEnabled: boolean;
|
||
calendarEnabled: boolean;
|
||
calendarTasksEnabled: boolean;
|
||
smimeEnabled: boolean;
|
||
externalContentEnabled: boolean;
|
||
debugModeEnabled: boolean;
|
||
folderIconsEnabled: boolean;
|
||
hoverActionsConfigEnabled: boolean;
|
||
tabTitleSubjectEnabled: boolean;
|
||
filesEnabled: boolean;
|
||
contactsEnabled: boolean;
|
||
/** @deprecated Folded into `crossAllViewEnabled`; normalized forward on policy load. */
|
||
allMailViewEnabled: boolean;
|
||
crossUnreadViewEnabled: boolean;
|
||
crossStarredViewEnabled: boolean;
|
||
crossAllViewEnabled: boolean;
|
||
unifiedCrossAccountEnabled: boolean;
|
||
/** Encrypted account list kept on the server, restorable on another device. */
|
||
accountArchiveEnabled: boolean;
|
||
}
|
||
|
||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||
pluginsEnabled: false,
|
||
pluginsUploadEnabled: true,
|
||
requirePluginApproval: true,
|
||
themesEnabled: true,
|
||
sidebarAppsEnabled: true,
|
||
userThemesEnabled: true,
|
||
settingsExportEnabled: true,
|
||
customKeywordsEnabled: true,
|
||
templatesEnabled: true,
|
||
calendarEnabled: true,
|
||
calendarTasksEnabled: true,
|
||
smimeEnabled: true,
|
||
externalContentEnabled: true,
|
||
debugModeEnabled: true,
|
||
folderIconsEnabled: true,
|
||
hoverActionsConfigEnabled: true,
|
||
tabTitleSubjectEnabled: true,
|
||
filesEnabled: true,
|
||
contactsEnabled: true,
|
||
allMailViewEnabled: false,
|
||
crossUnreadViewEnabled: false,
|
||
crossStarredViewEnabled: false,
|
||
crossAllViewEnabled: false,
|
||
unifiedCrossAccountEnabled: false,
|
||
accountArchiveEnabled: false,
|
||
};
|
||
|
||
export interface ThemePolicy {
|
||
/** Built-in theme IDs that are disabled (hidden from users) */
|
||
disabledBuiltinThemes: string[];
|
||
/** Admin-deployed theme IDs that are disabled (hidden from users) */
|
||
disabledThemes: string[];
|
||
/** Default theme ID for new users (null = system default) */
|
||
defaultThemeId: string | null;
|
||
}
|
||
|
||
export const DEFAULT_THEME_POLICY: ThemePolicy = {
|
||
disabledBuiltinThemes: [],
|
||
disabledThemes: [],
|
||
defaultThemeId: null,
|
||
};
|
||
|
||
/** One entry of the Web Push relay list users can pick from. */
|
||
export interface PushRelayOption {
|
||
/** Name shown in the relay picker. Empty falls back to the URL host. */
|
||
label: string;
|
||
/** Relay base URL, without a trailing slash. */
|
||
url: string;
|
||
}
|
||
|
||
/**
|
||
* A sidebar app the operator ships to every user (#931). Same shape as the
|
||
* user's own `SidebarApp`, but the id is issued by the admin UI and the entry
|
||
* is read-only in the client - users see it in the rail without configuring
|
||
* anything, and cannot edit or delete it.
|
||
*/
|
||
export interface AdminSidebarApp {
|
||
id: string;
|
||
name: string;
|
||
url: string;
|
||
/** Lucide icon name (e.g. 'Globe'). */
|
||
icon: string;
|
||
/** Open in a new browser tab, or embedded in an iframe. */
|
||
openMode: 'tab' | 'inline';
|
||
showOnMobile: boolean;
|
||
}
|
||
|
||
export interface SettingsPolicy {
|
||
restrictions: Record<string, SettingRestriction>;
|
||
features: FeatureGates;
|
||
defaults: Record<string, unknown>;
|
||
themePolicy: ThemePolicy;
|
||
/** Plugin IDs that are force-enabled (users cannot disable) */
|
||
forceEnabledPlugins: string[];
|
||
/** Plugin IDs that have been approved by admin (users can enable) */
|
||
approvedPlugins: string[];
|
||
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
||
forceEnabledThemes: string[];
|
||
/**
|
||
* Extra Web Push relays offered alongside the built-in default. Users pick
|
||
* from this list; only admins can introduce a relay URL.
|
||
*/
|
||
pushRelays?: PushRelayOption[];
|
||
/** Relay preselected for users. Empty means the built-in default. */
|
||
pushRelayUrl?: string;
|
||
/** When true, users are pinned to pushRelayUrl and cannot pick another relay. */
|
||
pushRelayUrlLocked?: boolean;
|
||
/**
|
||
* Sidebar apps shown to every user, ahead of their own. Read-only in the
|
||
* client; sanitized on policy load and save. Independent of the
|
||
* `sidebarAppsEnabled` gate, which only governs *user-added* apps - an
|
||
* operator can ship a fixed set while forbidding custom ones.
|
||
*/
|
||
defaultSidebarApps?: AdminSidebarApp[];
|
||
}
|
||
|
||
/** Set to `public` on a policy answered with the pre-login subset. */
|
||
export const POLICY_SCOPE_HEADER = 'X-Bulwark-Policy-Scope';
|
||
|
||
export const DEFAULT_POLICY: SettingsPolicy = {
|
||
restrictions: {},
|
||
features: { ...DEFAULT_FEATURE_GATES },
|
||
defaults: {},
|
||
themePolicy: { ...DEFAULT_THEME_POLICY },
|
||
forceEnabledPlugins: [],
|
||
approvedPlugins: [],
|
||
forceEnabledThemes: [],
|
||
pushRelays: [],
|
||
pushRelayUrl: '',
|
||
pushRelayUrlLocked: false,
|
||
defaultSidebarApps: [],
|
||
};
|
||
|
||
export interface AuditEntry {
|
||
ts: string;
|
||
action: string;
|
||
detail: Record<string, unknown>;
|
||
ip: string;
|
||
}
|
||
|
||
/** Config keys that map to environment variables */
|
||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; fileKey?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
|
||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||
appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' },
|
||
appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' },
|
||
searchEngineIndexing: { envVar: 'SEARCH_ENGINE_INDEXING', type: 'boolean', defaultValue: false },
|
||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||
// Server-side switch for /api/account/stalwart/jmap. Independent of the UI
|
||
// flag above so operators can keep the client features but block the
|
||
// credential-bearing passthrough entirely (#904).
|
||
stalwartJmapPassthroughEnabled: { envVar: 'STALWART_JMAP_PASSTHROUGH_ENABLED', type: 'boolean', defaultValue: true },
|
||
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
|
||
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
||
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
||
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
|
||
pwaScreenshotMobileUrl: { envVar: 'PWA_SCREENSHOT_MOBILE_URL', type: 'url', defaultValue: '' },
|
||
pwaScreenshotDesktopUrl: { envVar: 'PWA_SCREENSHOT_DESKTOP_URL', type: 'url', defaultValue: '' },
|
||
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
|
||
appLogoDarkUrl: { envVar: 'APP_LOGO_DARK_URL', type: 'url', defaultValue: '' },
|
||
loginLogoLightUrl: { envVar: 'LOGIN_LOGO_LIGHT_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_Color.svg' },
|
||
loginLogoDarkUrl: { envVar: 'LOGIN_LOGO_DARK_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_White.svg' },
|
||
loginCompanyName: { envVar: 'LOGIN_COMPANY_NAME', type: 'string', defaultValue: '' },
|
||
loginImprintUrl: { envVar: 'LOGIN_IMPRINT_URL', type: 'url', defaultValue: '' },
|
||
loginPrivacyPolicyUrl: { envVar: 'LOGIN_PRIVACY_POLICY_URL', type: 'url', defaultValue: '' },
|
||
loginWebsiteUrl: { envVar: 'LOGIN_WEBSITE_URL', type: 'url', defaultValue: '' },
|
||
// Where users get the source of the build they are running (AGPL-3.0 §13).
|
||
// Operators of a modified build point it at their fork; the About card then
|
||
// links there instead of the upstream repository (#1161). Empty = upstream.
|
||
sourceCodeUrl: { envVar: 'SOURCE_CODE_URL', type: 'url', defaultValue: '' },
|
||
// Login header customization. The logo box is otherwise a fixed 64×64
|
||
// (w-16/h-16), which fits a wide wordmark to ~13px tall; set a max height
|
||
// and/or width (any CSS length, e.g. "230px" or "3rem") to size it. The
|
||
// heading ({appName}) and subtitle can be hidden when the logo already
|
||
// reads as the brand (e.g. a wordmark) and they'd be redundant.
|
||
loginLogoMaxHeight: { envVar: 'LOGIN_LOGO_MAX_HEIGHT', type: 'string', defaultValue: '' },
|
||
loginLogoMaxWidth: { envVar: 'LOGIN_LOGO_MAX_WIDTH', type: 'string', defaultValue: '' },
|
||
loginShowHeading: { envVar: 'LOGIN_SHOW_HEADING', type: 'boolean', defaultValue: true },
|
||
loginShowSubtitle: { envVar: 'LOGIN_SHOW_SUBTITLE', type: 'boolean', defaultValue: true },
|
||
// Hide the manual "I have a 2FA code" toggle on the login form. Deployments
|
||
// that delegate auth to an external directory (LDAP/OIDC) where 2FA lives in
|
||
// the IdP have no server-side TOTP, so the toggle only leads to a failed
|
||
// login. Server-required TOTP (totp_required) still shows regardless.
|
||
loginShowTotp: { envVar: 'LOGIN_SHOW_TOTP', type: 'boolean', defaultValue: true },
|
||
// Offer signing in with an access token (Bearer auth) instead of a
|
||
// password. For JMAP servers that hand out API tokens, such as Fastmail,
|
||
// whose API accepts no passwords at all.
|
||
loginShowTokenLogin: { envVar: 'LOGIN_SHOW_TOKEN_LOGIN', type: 'boolean', defaultValue: false },
|
||
// Show the build version in the login footer. Off keeps the exact version
|
||
// from being disclosed to unauthenticated visitors.
|
||
loginShowVersion: { envVar: 'LOGIN_SHOW_VERSION', type: 'boolean', defaultValue: true },
|
||
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
|
||
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
|
||
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', fileKey: "oauthClientSecretFile", type: 'string', defaultValue: '' },
|
||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||
// Overrides only the user-facing authorize endpoint. Discovery, token exchange
|
||
// and refresh continue to use the canonical OAUTH_ISSUER_URL. Lets a per-brand
|
||
// authorize host front a single canonical issuer. Empty = use the discovered
|
||
// authorization_endpoint.
|
||
oauthAuthorizeUrl: { envVar: 'OAUTH_AUTHORIZE_URL', type: 'url', defaultValue: '' },
|
||
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
|
||
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
|
||
oauthAllowPrivateEndpoints: { envVar: 'OAUTH_ALLOW_PRIVATE_ENDPOINTS', type: 'boolean', defaultValue: false },
|
||
// Signing out of an SSO account also ends the identity provider's session
|
||
// through its end_session_endpoint (#905). Off keeps the provider signed in,
|
||
// for providers shared with other apps that should stay signed in.
|
||
oauthEndSession: { envVar: 'OAUTH_END_SESSION', type: 'boolean', defaultValue: true },
|
||
// Where the provider sends the browser after ending its session. Must be
|
||
// registered with the provider as a post-logout redirect URI. Empty = none
|
||
// is sent and the provider shows its own signed-out page.
|
||
oauthPostLogoutRedirectUri: { envVar: 'OAUTH_POST_LOGOUT_REDIRECT_URI', type: 'url', defaultValue: '' },
|
||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||
// What being a Stalwart admin grants inside the Bulwark admin dashboard (#870).
|
||
// auto - Stalwart admins see the shield and are signed into /admin
|
||
// without the Bulwark admin password (legacy behaviour).
|
||
// password - Stalwart admins see the shield, but must enter the Bulwark
|
||
// admin password like everyone else.
|
||
// off - Stalwart admin status is ignored; /admin is reachable only
|
||
// via /admin/login with the Bulwark admin password.
|
||
// "password" and "off" require an admin password to be configured, or the
|
||
// dashboard would become unreachable.
|
||
stalwartAdminAccess: { envVar: 'STALWART_ADMIN_ACCESS', type: 'enum', defaultValue: 'auto', enumValues: ['auto', 'password', 'off'] },
|
||
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
||
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
||
domainBranding: { envVar: 'DOMAIN_BRANDING', type: 'json', defaultValue: [] },
|
||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||
parentOrigin: { envVar: 'NEXT_PUBLIC_PARENT_ORIGIN', type: 'string', defaultValue: '' },
|
||
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
|
||
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
|
||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', fileKey: "sessionSecretFile", type: 'string', defaultValue: '' },
|
||
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
|
||
// Connector links (connector.bulwarkmail.org): docs and the extension
|
||
// directory link to a destination, and the reader's own browser resolves it
|
||
// to their instance. `connectorEnabled` covers both halves - the
|
||
// /api/connector/capabilities endpoint a connector probes, and the "add this
|
||
// instance" links the app offers - so a deployment that wants no third-party
|
||
// hostname anywhere can switch the whole thing off. `connectorUrl` points a
|
||
// white-label or self-hosted deployment at its own connector.
|
||
connectorEnabled: { envVar: 'CONNECTOR_ENABLED', type: 'boolean', defaultValue: true },
|
||
connectorUrl: { envVar: 'CONNECTOR_URL', type: 'url', defaultValue: 'https://connector.bulwarkmail.org' },
|
||
// WOPI document editing (#425). `wopiClientUrl` is the editor's base URL
|
||
// (Collabora Online / OnlyOffice / EuroOffice, ...); discovery is fetched
|
||
// from `<url>/hosting/discovery` unless the URL already carries a path.
|
||
// Empty = feature off.
|
||
wopiClientUrl: { envVar: 'WOPI_CLIENT_URL', type: 'url', defaultValue: '' },
|
||
// How the WOPI editor reaches this webmail (WOPISrc base). Empty = the
|
||
// origin the browser uses; set it when the editor sees a different host
|
||
// than the browser (docker networks, split DNS). The base path of a
|
||
// sub-path install is added to a bare origin (#1130).
|
||
wopiHostUrl: { envVar: 'WOPI_HOST_URL', type: 'url', defaultValue: '' },
|
||
};
|
||
|
||
/** Keys that should never be exposed to the client config endpoint */
|
||
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret']);
|
||
|
||
/** Admin session cookie name */
|
||
export const ADMIN_SESSION_COOKIE = 'admin_session';
|
||
|
||
/** Default admin session TTL in seconds */
|
||
export const DEFAULT_ADMIN_SESSION_TTL = 3600;
|