Custom Logos not working for Domains

Issue Description

Custom per-domain logos don’t show up.
On the Enterprise edition you can give each domain (or tenant) its own logo, so that when your users open the login / account page they see your branding instead of the default Stalwart one.

The problem: setting a logo on a domain has no effect. You add the logo, save it, and the login page still shows the default logo, for every domain. It doesn’t matter whether the logo is set correctly; it’s simply never used.

Expected Behavior

With a logo set on a domain (or tenant) principal, GET /logo on that domain’s hostname serves that logo, per the documented hierarchy (domain → tenant → enterprise.logo-url → built-in).

Actual Behavior

The domain/tenant logo is silently ignored; /logo always falls back to enterprise.logo-url or the built-in logo. No error is logged

Reproduction Steps

  1. Enterprise edition, valid
  2. Set logo on a domain principal (e.g. example.org) to any reachable image URL.
  3. GET https://mail.example.org/logo
  4. domain’s logo is never returned

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

PostgreSQL

Blob Storage

S3-compatible

Search Engine

PostgreSQL

Directory Backend

LDAP

Additional Context

Proposed Patch:

Patch 1: URL Images

crates/common/src/enterprise/mod.rs

@@ logo_resource()
         if let Some((d_id, t_id)) = self.domain(domain).await?.map(|d| (d.id, d.id_tenant))
-            && let Some(domain_record) = self.registry().object::<Domain>(domain_id.into()).await?
+            && let Some(domain_record) = self.registry().object::<Domain>(d_id.into()).await?
         {
             logo_url = domain_record.logo;
             domain_id = d_id;

Patch 2: base64-encoded Images

-use mail_parser::DateTime;
+use mail_parser::{DateTime, decoders::base64::base64_decode};
@@ logo_resource()
         let mut logo = None;
+
+        if let Some((meta, data)) = logo_url
+            .as_deref()
+            .and_then(|url| url.strip_prefix("data:"))
+            .and_then(|uri| uri.split_once(','))
+        {
+            let contents = base64_decode(data.as_bytes()).ok_or_else(|| {
+                trc::ResourceEvent::DownloadExternal.into_err().details("Failed to decode logo")
+            })?;
+            if contents.len() > MAX_IMAGE_SIZE {
+                return Err(trc::ResourceEvent::DownloadExternal
+                    .into_err().details("Download exceeded maximum size"));
+            }
+            let content_type = meta
+                .split(';').next().filter(|ct| !ct.is_empty()).unwrap_or("image/svg+xml");
+            logo = Resource::new(content_type.to_string(), contents).into();
+            logo_url = None;
+        }
+
         if let Some(logo_url) = logo_url {

Same symptom was reported and closed in #1856, #2572, #2074 without identifying the cause; it’s still broken in v0.16.12

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

I pulled the current source and can confirm this is a real bug, not a config issue. In logo_resource() (crates/common/src/enterprise/mod.rs), the domain lookup does:

if let Some((d_id, t_id)) = self.domain(domain).await?.map(|d| (d.id, d.id_tenant))
    && let Some(domain_record) = self.registry().object::<Domain>(domain_id.into()).await?

domain_id here is still the initial sentinel u32::MAX set a few lines above (it’s only assigned after this lookup), rather than the just-obtained d_id. So registry().object::<Domain>(...) is always queried with u32::MAX, never finds a match, and domain_record.logo is never read — meaning any per-domain (or tenant, since tenant lookup depends on tenant_id from that same failed branch) logo is silently ignored and the code falls through to enterprise.logo-url or the built-in default. Your Patch 1 (using d_id instead of domain_id) matches the actual cause exactly.

This is a code-level defect, so it needs a fix in a release rather than a config change on your end. A staff member will need to review and merge the correction (your patch looks correct); I can’t push fixes myself, but I’ve flagged the exact location so it’s actionable.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v0.16.13 which will be released this week.