feat: enhance branding options with custom favicon and logos in configuration

This commit is contained in:
Linus Rath
2026-03-17 15:25:06 +01:00
parent 54f4d37595
commit 7c5785e9e8
7 changed files with 109 additions and 17 deletions
+19 -2
View File
@@ -89,10 +89,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# LOG_LEVEL=info
# =============================================================================
# Login Page Customization (all optional)
# Branding (all optional)
# =============================================================================
# Custom logo images for the login page (PNG, SVG, etc.)
# Custom favicon for the browser tab.
# Supported formats: SVG (recommended), PNG, ICO.
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling).
# Can be an absolute URL or a path relative to the public/ directory.
# Defaults to the Bulwark favicon if not set.
# FAVICON_URL=/branding/my-favicon.svg
# Custom logos for the sidebar (shown in the main app after login).
# Supported formats: SVG (recommended), PNG, WebP.
# Recommended size: min 24×24px, max 128×128px
# Can be absolute URLs or paths relative to the public/ directory.
# If not set, no logo is shown in the sidebar.
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
# Custom logo images for the login page.
# Supported formats: SVG (recommended), PNG, WebP.
# Recommended size: min 32×32px, max 512×512px
# Can be absolute URLs or paths relative to the public/ directory.
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo.
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
+3
View File
@@ -26,6 +26,9 @@ export async function GET() {
settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET,
stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false',
devMode: process.env.DEV_MOCK_JMAP === 'true',
faviconUrl: process.env.FAVICON_URL || '/branding/Bulwark_Favicon.svg',
appLogoLightUrl: process.env.APP_LOGO_LIGHT_URL || '',
appLogoDarkUrl: process.env.APP_LOGO_DARK_URL || '',
loginLogoLightUrl: process.env.LOGIN_LOGO_LIGHT_URL || '/branding/Bulwark_Logo_Color.svg',
loginLogoDarkUrl: process.env.LOGIN_LOGO_DARK_URL || '/branding/Bulwark_Logo_White.svg',
loginCompanyName: process.env.LOGIN_COMPANY_NAME || '',
+9 -4
View File
@@ -14,10 +14,15 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol",
};
export async function generateMetadata(): Promise<Metadata> {
const faviconUrl = process.env.FAVICON_URL;
return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol",
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
};
}
export default async function RootLayout({
children,
+15
View File
@@ -37,6 +37,8 @@ import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/s
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
interface SidebarProps {
mailboxes: Mailbox[];
@@ -361,6 +363,8 @@ export function Sidebar({
}: SidebarProps) {
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity } = useAuthStore();
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [tagsExpanded, setTagsExpanded] = useState(() => {
try {
@@ -460,6 +464,17 @@ export function Sidebar({
<X className="w-5 h-5" />
</Button>
{(() => {
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
return logoUrl ? (
<img
src={logoUrl}
alt=""
className={cn("object-contain flex-shrink-0", isCollapsed ? "w-6 h-6" : "w-6 h-6")}
/>
) : null;
})()}
<Button
variant="ghost"
size="icon"
+12
View File
@@ -13,6 +13,9 @@ interface ConfigData {
settingsSyncEnabled: boolean;
stalwartFeaturesEnabled: boolean;
devMode: boolean;
faviconUrl: string;
appLogoLightUrl: string;
appLogoDarkUrl: string;
loginLogoLightUrl: string;
loginLogoDarkUrl: string;
loginCompanyName: string;
@@ -79,6 +82,9 @@ export function useConfig(): AppConfig {
settingsSyncEnabled: configCache?.settingsSyncEnabled || false,
stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true,
devMode: configCache?.devMode || false,
faviconUrl: configCache?.faviconUrl || '/branding/Bulwark_Favicon.svg',
appLogoLightUrl: configCache?.appLogoLightUrl || '',
appLogoDarkUrl: configCache?.appLogoDarkUrl || '',
loginLogoLightUrl: configCache?.loginLogoLightUrl || '/branding/Bulwark_Logo_Color.svg',
loginLogoDarkUrl: configCache?.loginLogoDarkUrl || '/branding/Bulwark_Logo_White.svg',
loginCompanyName: configCache?.loginCompanyName || '',
@@ -103,6 +109,9 @@ export function useConfig(): AppConfig {
settingsSyncEnabled: configCache.settingsSyncEnabled,
stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled,
devMode: configCache.devMode,
faviconUrl: configCache.faviconUrl,
appLogoLightUrl: configCache.appLogoLightUrl,
appLogoDarkUrl: configCache.appLogoDarkUrl,
loginLogoLightUrl: configCache.loginLogoLightUrl,
loginLogoDarkUrl: configCache.loginLogoDarkUrl,
loginCompanyName: configCache.loginCompanyName,
@@ -128,6 +137,9 @@ export function useConfig(): AppConfig {
settingsSyncEnabled: data.settingsSyncEnabled,
stalwartFeaturesEnabled: data.stalwartFeaturesEnabled,
devMode: data.devMode,
faviconUrl: data.faviconUrl,
appLogoLightUrl: data.appLogoLightUrl,
appLogoDarkUrl: data.appLogoDarkUrl,
loginLogoLightUrl: data.loginLogoLightUrl,
loginLogoDarkUrl: data.loginLogoDarkUrl,
loginCompanyName: data.loginCompanyName,
+18
View File
@@ -27,6 +27,9 @@ describe('config API route', () => {
delete process.env.SETTINGS_SYNC_ENABLED;
delete process.env.STALWART_FEATURES;
delete process.env.DEV_MOCK_JMAP;
delete process.env.FAVICON_URL;
delete process.env.APP_LOGO_LIGHT_URL;
delete process.env.APP_LOGO_DARK_URL;
delete process.env.LOGIN_COMPANY_NAME;
delete process.env.LOGIN_IMPRINT_URL;
delete process.env.LOGIN_PRIVACY_POLICY_URL;
@@ -60,6 +63,9 @@ describe('config API route', () => {
expect(config.loginImprintUrl).toBe('');
expect(config.loginPrivacyPolicyUrl).toBe('');
expect(config.loginWebsiteUrl).toBe('');
expect(config.faviconUrl).toBe('/branding/Bulwark_Favicon.svg');
expect(config.appLogoLightUrl).toBe('');
expect(config.appLogoDarkUrl).toBe('');
});
it('should use runtime env vars over defaults', async () => {
@@ -142,4 +148,16 @@ describe('config API route', () => {
expect(config.stalwartFeaturesEnabled).toBe(false);
});
it('should return custom favicon and app logo URLs', async () => {
process.env.FAVICON_URL = '/branding/custom-favicon.svg';
process.env.APP_LOGO_LIGHT_URL = '/branding/my-logo.svg';
process.env.APP_LOGO_DARK_URL = '/branding/my-logo-white.svg';
const config = await getConfig();
expect(config.faviconUrl).toBe('/branding/custom-favicon.svg');
expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg');
expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg');
});
});
+33 -11
View File
@@ -67,6 +67,9 @@ CFG_LOG_LEVEL="info"
CFG_LOGIN_COMPANY_NAME=""
CFG_LOGIN_LOGO_LIGHT_URL=""
CFG_LOGIN_LOGO_DARK_URL=""
CFG_FAVICON_URL=""
CFG_APP_LOGO_LIGHT_URL=""
CFG_APP_LOGO_DARK_URL=""
CFG_LOGIN_IMPRINT_URL=""
CFG_LOGIN_PRIVACY_POLICY_URL=""
CFG_LOGIN_WEBSITE_URL=""
@@ -761,20 +764,30 @@ screen_login_customization() {
CURRENT_STEP=5
draw_header
section_header 5 "Login Page Branding" \
"Add your company name and legal links to the login page."
section_header 5 "Branding" \
"Customize logos, favicon, and login page for your domain identity."
echo -e " All fields are ${BOLD}optional${RESET}. Press Enter to skip any field."
echo ""
echo -e " ${DIM}Custom favicon for the browser tab (SVG recommended, 32×32 to 512×512px)${RESET}"
echo -e " ${DIM}Leave blank to use the default Bulwark favicon.${RESET}"
prompt_value "Favicon URL" "$CFG_FAVICON_URL" "CFG_FAVICON_URL"
echo ""
echo -e " ${DIM}App logo shown in the sidebar (SVG recommended, 24×24 to 128×128px)${RESET}"
echo -e " ${DIM}Leave blank for no sidebar logo.${RESET}"
prompt_value "App logo URL (light mode)" "$CFG_APP_LOGO_LIGHT_URL" "CFG_APP_LOGO_LIGHT_URL"
echo ""
prompt_value "App logo URL (dark mode)" "$CFG_APP_LOGO_DARK_URL" "CFG_APP_LOGO_DARK_URL"
echo ""
echo -e " ${DIM}Shown on the login page footer. Example: Acme Corp${RESET}"
prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME"
echo ""
echo -e " ${DIM}Custom logo URLs for the login page (PNG, SVG, etc.)${RESET}"
echo -e " ${DIM}Custom logo URLs for the login page (SVG recommended, 32×32 to 512×512px)${RESET}"
echo -e " ${DIM}Leave blank to use the default Bulwark logo.${RESET}"
prompt_value "Logo URL (light mode)" "$CFG_LOGIN_LOGO_LIGHT_URL" "CFG_LOGIN_LOGO_LIGHT_URL"
prompt_value "Login logo URL (light mode)" "$CFG_LOGIN_LOGO_LIGHT_URL" "CFG_LOGIN_LOGO_LIGHT_URL"
echo ""
prompt_value "Logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL"
prompt_value "Login logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL"
echo ""
prompt_value "Website URL" "$CFG_LOGIN_WEBSITE_URL" "CFG_LOGIN_WEBSITE_URL"
echo ""
@@ -782,9 +795,9 @@ screen_login_customization() {
echo ""
prompt_value "Privacy policy URL" "$CFG_LOGIN_PRIVACY_POLICY_URL" "CFG_LOGIN_PRIVACY_POLICY_URL"
if [[ -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
if [[ -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_FAVICON_URL" && -z "$CFG_APP_LOGO_LIGHT_URL" && -z "$CFG_APP_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
echo ""
note "No branding configured. The login page will show defaults."
note "No branding configured. The app will use defaults."
fi
draw_footer
@@ -892,13 +905,19 @@ screen_summary() {
# Login page
echo -e " ${CYAN}${BOLD}BRANDING${RESET}"
if [[ -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
if [[ -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_FAVICON_URL" || -n "$CFG_APP_LOGO_LIGHT_URL" || -n "$CFG_APP_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
[[ -n "$CFG_FAVICON_URL" ]] && \
echo -e " Favicon URL ........... ${BOLD}${CFG_FAVICON_URL}${RESET}"
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && \
echo -e " App Logo (light) ...... ${BOLD}${CFG_APP_LOGO_LIGHT_URL}${RESET}"
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && \
echo -e " App Logo (dark) ....... ${BOLD}${CFG_APP_LOGO_DARK_URL}${RESET}"
[[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && \
echo -e " Company Name .......... ${BOLD}${CFG_LOGIN_COMPANY_NAME}${RESET}"
[[ -n "$CFG_LOGIN_LOGO_LIGHT_URL" ]] && \
echo -e " Logo (light mode) ..... ${BOLD}${CFG_LOGIN_LOGO_LIGHT_URL}${RESET}"
echo -e " Login Logo (light) .... ${BOLD}${CFG_LOGIN_LOGO_LIGHT_URL}${RESET}"
[[ -n "$CFG_LOGIN_LOGO_DARK_URL" ]] && \
echo -e " Logo (dark mode) ...... ${BOLD}${CFG_LOGIN_LOGO_DARK_URL}${RESET}"
echo -e " Login Logo (dark) ..... ${BOLD}${CFG_LOGIN_LOGO_DARK_URL}${RESET}"
[[ -n "$CFG_LOGIN_WEBSITE_URL" ]] && \
echo -e " Website URL ........... ${BOLD}${CFG_LOGIN_WEBSITE_URL}${RESET}"
[[ -n "$CFG_LOGIN_IMPRINT_URL" ]] && \
@@ -1003,9 +1022,12 @@ ENVEOF
LOG_FORMAT=${CFG_LOG_FORMAT}
LOG_LEVEL=${CFG_LOG_LEVEL}
# -- Login Page Customization --------------------------------------------------
# -- Branding ------------------------------------------------------------------
ENVEOF
[[ -n "$CFG_FAVICON_URL" ]] && echo "FAVICON_URL=${CFG_FAVICON_URL}" >> "$ENV_FILE"
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && echo "APP_LOGO_LIGHT_URL=${CFG_APP_LOGO_LIGHT_URL}" >> "$ENV_FILE"
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && echo "APP_LOGO_DARK_URL=${CFG_APP_LOGO_DARK_URL}" >> "$ENV_FILE"
[[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && echo "LOGIN_COMPANY_NAME=${CFG_LOGIN_COMPANY_NAME}" >> "$ENV_FILE"
[[ -n "$CFG_LOGIN_LOGO_LIGHT_URL" ]] && echo "LOGIN_LOGO_LIGHT_URL=${CFG_LOGIN_LOGO_LIGHT_URL}" >> "$ENV_FILE"
[[ -n "$CFG_LOGIN_LOGO_DARK_URL" ]] && echo "LOGIN_LOGO_DARK_URL=${CFG_LOGIN_LOGO_DARK_URL}" >> "$ENV_FILE"