diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx
index 959fc9d0..02365934 100644
--- a/src/components/calendar/EventDetailSheet.tsx
+++ b/src/components/calendar/EventDetailSheet.tsx
@@ -49,6 +49,7 @@ import {
isOrganizer,
} from '../../lib/calendar-participants';
import { getEventEditability } from '../../lib/calendar-editability';
+import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { useSheetDrag } from '../../lib/use-sheet-drag';
@@ -143,6 +144,9 @@ export function EventDetailSheet({
const { locale, t } = useCalendarLocale();
const [rsvpBusy, setRsvpBusy] = React.useState(false);
const styles = React.useMemo(() => makeStyles(c), [c]);
+ // Bare addresses (the organizer above all — Stalwart drops its display
+ // name) render with the contact card's name instead of the raw email.
+ const resolveContactName = useContactNameResolver();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const visible = !!event;
@@ -192,7 +196,7 @@ export function EventDetailSheet({
const range = formatRange(event, timeFormat, locale);
const recurrence = recurrenceLabel(event, t, locale);
const reminders = alertsToReminders(event.alerts);
- const participants = getParticipantList(event);
+ const participants = getParticipantList(event, { resolveName: resolveContactName });
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
const isCancelled = event.status === 'cancelled';
@@ -365,9 +369,9 @@ export function EventDetailSheet({
) : null}
-
- {p.isOrganizer ? '' : statusLabel(p.status, t)}
-
+ {/* The organizer is marked by the suffix; this shows their status
+ (organizers default to accepted), like the dot. */}
+ {statusLabel(p.status, t)}
))}
diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx
index 07b5f496..35d2b7bd 100644
--- a/src/components/calendar/EventModal.tsx
+++ b/src/components/calendar/EventModal.tsx
@@ -68,6 +68,7 @@ import {
} from '../../lib/calendar-participants';
import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
+import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -187,6 +188,7 @@ export function EventModal({
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const timeLabelPattern = timePattern(timeFormat);
const isEdit = !!event;
+ const resolveContactName = useContactNameResolver();
const [title, setTitle] = React.useState('');
const [description, setDescription] = React.useState('');
@@ -352,8 +354,12 @@ export function EventModal({
};
const organizerEmail = currentUserEmails[0];
if (attendees.length > 0 && organizerEmail) {
+ // A new event has no participants yet, so it would store an empty
+ // organizer name; fall back to the contact card / account name.
const organizerName =
- (event ? getParticipantList(event).find((p) => p.isOrganizer)?.name : '') || '';
+ (event ? getParticipantList(event).find((p) => p.isOrganizer)?.name : '')
+ || resolveContactName(organizerEmail)
+ || '';
data.participants = buildParticipantMap(
{ name: organizerName, email: organizerEmail },
attendees,
diff --git a/src/lib/__tests__/calendar-participants.test.ts b/src/lib/__tests__/calendar-participants.test.ts
index 03157401..a5819ec1 100644
--- a/src/lib/__tests__/calendar-participants.test.ts
+++ b/src/lib/__tests__/calendar-participants.test.ts
@@ -3,6 +3,7 @@ import type { CalendarEvent } from '../../api/types';
import {
buildParticipantMap,
collectUserCalendarAddresses,
+ getParticipantCount,
getParticipantList,
getStatusCounts,
getUserParticipantId,
@@ -89,3 +90,84 @@ describe('seedAttendees', () => {
expect(emails).toHaveLength(3);
});
});
+
+describe('getParticipantList dedupe, names and organizer status', () => {
+ const org = { name: 'Alice', email: 'alice@example.com', roles: { owner: true }, participationStatus: 'accepted' as const };
+ const bob = { name: 'Bob', email: 'bob@example.com', roles: { attendee: true }, participationStatus: 'needs-action' as const };
+
+ it('renders the organizer once when the server also emits them as an attendee', () => {
+ const list = getParticipantList({
+ organizerCalendarAddress: 'mailto:alice@example.com',
+ participants: {
+ org: { ...org, name: '' },
+ att1: bob,
+ att2: { ...bob, name: 'Carol', email: 'carol@example.com' },
+ orgDup: { ...bob, name: '', email: 'alice@example.com' },
+ },
+ });
+ expect(list).toHaveLength(3);
+ const alices = list.filter((p) => p.email === 'alice@example.com');
+ expect(alices).toHaveLength(1);
+ expect(alices[0]).toMatchObject({ isOrganizer: true, status: 'accepted' });
+ });
+
+ it('keeps the real RSVP when the first entry of an address never replied', () => {
+ const list = getParticipantList({
+ organizerCalendarAddress: 'mailto:alice@example.com',
+ participants: {
+ att1: { ...bob, name: '', email: 'alice@example.com' },
+ org,
+ },
+ });
+ expect(list).toHaveLength(1);
+ expect(list[0]).toMatchObject({ isOrganizer: true, status: 'accepted', name: 'Alice' });
+ });
+
+ it('merges duplicate attendees case-insensitively, first-seen name wins', () => {
+ const list = getParticipantList({
+ participants: {
+ att1: bob,
+ att2: { ...bob, name: 'Bobby', email: 'BOB@example.com', participationStatus: 'accepted' },
+ },
+ });
+ expect(list).toHaveLength(1);
+ expect(list[0]).toMatchObject({ name: 'Bob', status: 'accepted' });
+ });
+
+ it('shows a statusless organizer as accepted, not pending', () => {
+ const list = getParticipantList(event);
+ expect(list.find((p) => p.isOrganizer)?.status).toBe('accepted');
+ });
+
+ it('fills missing names from resolveName without overriding event data', () => {
+ const resolveName = (email: string) => (email.startsWith('alice') || email.startsWith('bob') ? 'Contact' : undefined);
+ const list = getParticipantList(
+ { participants: { org: { ...org, name: '' }, att1: bob } },
+ { resolveName },
+ );
+ expect(list.find((p) => p.email === 'alice@example.com')?.name).toBe('Contact');
+ expect(list.find((p) => p.email === 'bob@example.com')?.name).toBe('Bob');
+ });
+
+ it('keeps entries without an address unmerged', () => {
+ const list = getParticipantList({
+ participants: {
+ org,
+ ghost1: { name: 'No Address', participationStatus: 'needs-action' },
+ ghost2: { name: 'Also No Address', participationStatus: 'accepted' },
+ },
+ });
+ expect(list).toHaveLength(3);
+ });
+
+ it('counts addresses, not raw entries', () => {
+ expect(getParticipantCount({
+ participants: {
+ org: { ...org, name: '' },
+ att1: bob,
+ orgDup: { ...bob, name: '', email: 'alice@example.com' },
+ ghost: { name: 'No Address' },
+ },
+ })).toBe(3);
+ });
+});
diff --git a/src/lib/__tests__/contact-name-resolver.test.ts b/src/lib/__tests__/contact-name-resolver.test.ts
new file mode 100644
index 00000000..2515b58a
--- /dev/null
+++ b/src/lib/__tests__/contact-name-resolver.test.ts
@@ -0,0 +1,109 @@
+import { describe, it, expect } from 'vitest';
+import type { ContactCard } from '../../api/types';
+import { buildContactNameResolver } from '../contact-name-resolver';
+
+// A bare address in the event data must display the contact card's name,
+// while names the event already carries stay authoritative — the resolver
+// only ever fills (webmail #738).
+
+function makeContact(emails: string[], name?: string, overrides: Partial = {}): ContactCard {
+ return {
+ id: Math.random().toString(36).slice(2),
+ addressBookIds: { ab1: true },
+ emails: Object.fromEntries(emails.map((address, i) => [`${i}`, { address }])),
+ ...(name ? { name: { full: name } } : {}),
+ ...overrides,
+ } as ContactCard;
+}
+
+describe('buildContactNameResolver', () => {
+ it('maps every address of a contact to its display name, case-insensitively', () => {
+ const resolver = buildContactNameResolver([
+ makeContact(['alice@example.com', 'alice.smith@example.com'], 'Alice'),
+ ]);
+ expect(resolver('alice@example.com')).toBe('Alice');
+ expect(resolver(' ALICE.SMITH@EXAMPLE.COM ')).toBe('Alice');
+ });
+
+ it('keeps the first contact when several hold the same address', () => {
+ const resolver = buildContactNameResolver([
+ makeContact(['bob@example.com'], 'First Bob'),
+ makeContact(['bob@example.com'], 'Second Bob'),
+ ]);
+ expect(resolver('bob@example.com')).toBe('First Bob');
+ });
+
+ it('skips cards whose only name is an address, and unknown or blank addresses', () => {
+ const resolver = buildContactNameResolver([makeContact(['noname@example.com'])]);
+ expect(resolver('noname@example.com')).toBeUndefined();
+ expect(resolver('unknown@example.com')).toBeUndefined();
+ expect(resolver(' ')).toBeUndefined();
+ });
+
+ it('falls back to a card with the same local part on a related domain', () => {
+ const resolver = buildContactNameResolver([
+ makeContact(['zhang@example.com'], 'Zhang'),
+ makeContact(['carol@node-example.com'], 'Carol'),
+ ]);
+ expect(resolver('zhang@node-example.com')).toBe('Zhang');
+ expect(resolver('zhang@mail.example.com')).toBe('Zhang');
+ expect(resolver('carol@example.com')).toBe('Carol');
+ });
+
+ it('does not fall back across unrelated or merely string-suffixed domains', () => {
+ const resolver = buildContactNameResolver([makeContact(['dave@example.com'], 'Dave')]);
+ expect(resolver('dave@gmail.com')).toBeUndefined();
+ expect(resolver('dave@xexample.com')).toBeUndefined();
+ });
+
+ it('returns nothing when related-domain cards disagree on the name', () => {
+ const disagree = buildContactNameResolver([
+ makeContact(['eve@example.com'], 'Eve One'),
+ makeContact(['eve@mail.example.com'], 'Eve Two'),
+ ]);
+ expect(disagree('eve@x.mail.example.com')).toBeUndefined();
+ const agree = buildContactNameResolver([
+ makeContact(['eve@example.com'], 'Eve'),
+ makeContact(['eve@mail.example.com'], 'Eve'),
+ ]);
+ expect(agree('eve@x.mail.example.com')).toBe('Eve');
+ });
+
+ it('uses the account name, then the identity name, for the user\'s own addresses', () => {
+ const resolver = buildContactNameResolver(
+ [],
+ [{ name: 'Old Name', email: 'zhang@node-example.com' }, { name: 'Ident', email: 'z@other.example' }],
+ [
+ { name: 'New Name', email: 'zhang@node-example.com', username: 'zhang' },
+ { name: 'Login', email: undefined, username: 'login@example.com' },
+ ],
+ );
+ expect(resolver('zhang@node-example.com')).toBe('New Name');
+ expect(resolver('z@other.example')).toBe('Ident');
+ expect(resolver('login@example.com')).toBe('Login');
+ // A short username is not an address.
+ expect(resolver('zhang@elsewhere.example')).toBeUndefined();
+ });
+
+ it('skips account names that are just the address', () => {
+ const resolver = buildContactNameResolver([], [], [
+ { name: 'zhang@node-example.com', email: 'zhang@node-example.com' },
+ ]);
+ expect(resolver('zhang@node-example.com')).toBeUndefined();
+ });
+
+ it('ranks contact cards (exact, then related-domain) above account and identity names', () => {
+ const exact = buildContactNameResolver(
+ [makeContact(['zhang@node-example.com'], 'Card')],
+ [{ name: 'Identity', email: 'zhang@node-example.com' }],
+ [{ name: 'Account', email: 'zhang@node-example.com' }],
+ );
+ expect(exact('zhang@node-example.com')).toBe('Card');
+ const related = buildContactNameResolver(
+ [makeContact(['zhang@example.com'], 'Card remark')],
+ [],
+ [{ name: 'Account', email: 'zhang@node-example.com' }],
+ );
+ expect(related('zhang@node-example.com')).toBe('Card remark');
+ });
+});
diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts
index 584a2152..ec7842ab 100644
--- a/src/lib/calendar-participants.ts
+++ b/src/lib/calendar-participants.ts
@@ -127,23 +127,83 @@ export function getUserStatus(
return null;
}
-export function getParticipantList(event: Partial): ParticipantInfo[] {
+/** When the same address appears in two participant entries, a real RSVP on
+ * either one beats a missing/"needs-action" one — the duplicate is always the
+ * entry that never replied. Between two real replies the first-seen entry
+ * wins; such conflicts do not occur in practice. */
+function betterStatus(
+ current: Participant['participationStatus'] | undefined,
+ incoming: Participant['participationStatus'] | undefined,
+): Participant['participationStatus'] {
+ if (current === 'needs-action' && incoming && incoming !== 'needs-action') return incoming;
+ return current || 'needs-action';
+}
+
+export interface ParticipantListOptions {
+ /**
+ * Fills `ParticipantInfo.name` for participants whose event data carries no
+ * name. Stalwart drops the ORGANIZER display name on its iCalendar
+ * round-trip, so the organizer (and attendees added by bare address) render
+ * as a bare email until the contact card's name is looked up.
+ */
+ resolveName?: (email: string) => string | undefined;
+}
+
+export function getParticipantList(
+ event: Partial,
+ options?: ParticipantListOptions,
+): ParticipantInfo[] {
if (!event.participants) return [];
// Stalwart rebuilds the ORGANIZER line into a participant that carries only
// `calendarAddress` — no `roles` at all — so `roles.owner` alone would treat
// the organizer as a plain attendee on every re-read (#731).
const organizerEmails = getEventOrganizerEmails(event);
- return Object.entries(event.participants).map(([id, p]) => {
+ const resolveName = options?.resolveName;
+
+ // The same address can legitimately arrive twice: the organizer as both the
+ // ORGANIZER-derived participant and an ATTENDEE line (server-side
+ // scheduling, or events written before #731), or an attendee pasted twice.
+ // Render each address once by folding later entries into the first-seen
+ // one — otherwise every list and count shows a phantom participant.
+ const list: ParticipantInfo[] = [];
+ const byEmail = new Map();
+
+ for (const [id, p] of Object.entries(event.participants)) {
const email = getParticipantEmail(p);
- return {
+ const key = email.trim().toLowerCase();
+ const isOrganizer = !!p.roles?.owner || (!!key && organizerEmails.includes(key));
+
+ const existing = key ? byEmail.get(key) : undefined;
+ if (existing) {
+ if (!existing.name && p.name) existing.name = p.name;
+ if (isOrganizer) existing.isOrganizer = true;
+ existing.status = betterStatus(existing.status, p.participationStatus);
+ continue;
+ }
+
+ const entry: ParticipantInfo = {
id,
name: p.name || '',
email,
status: p.participationStatus || 'needs-action',
- isOrganizer:
- !!p.roles?.owner || (!!email && organizerEmails.includes(email.toLowerCase())),
+ isOrganizer,
};
- });
+ if (key) byEmail.set(key, entry);
+ list.push(entry);
+ }
+
+ for (const entry of list) {
+ if (!entry.name && entry.email && resolveName) {
+ entry.name = resolveName(entry.email) || '';
+ }
+ // The organizer owes no reply to their own invitation; a missing status
+ // must not read as "pending" next to the organizer marker. Matches
+ // getStatusCounts, which excludes the organizer from pending totals.
+ if (entry.isOrganizer && entry.status === 'needs-action') {
+ entry.status = 'accepted';
+ }
+ }
+ return list;
}
export function getStatusCounts(event: Partial): StatusCounts {
@@ -163,7 +223,20 @@ export function getStatusCounts(event: Partial): StatusCounts {
export function getParticipantCount(event: Partial): number {
if (!event.participants) return 0;
- return Object.keys(event.participants).length;
+ // Count addresses, not raw entries: an organizer the server emits twice must
+ // not inflate the count. Entries without any address cannot be merged, so
+ // they count individually.
+ const seen = new Set();
+ let count = 0;
+ for (const p of Object.values(event.participants)) {
+ const key = getParticipantEmail(p).trim().toLowerCase();
+ if (key) {
+ if (seen.has(key)) continue;
+ seen.add(key);
+ }
+ count++;
+ }
+ return count;
}
export interface Attendee {
diff --git a/src/lib/contact-name-resolver.ts b/src/lib/contact-name-resolver.ts
new file mode 100644
index 00000000..be7c7c96
--- /dev/null
+++ b/src/lib/contact-name-resolver.ts
@@ -0,0 +1,158 @@
+import React from 'react';
+import type { ContactCard } from '../api/types';
+import { useAccountStore } from '../stores/account-store';
+import { useContactsStore } from '../stores/contacts-store';
+import { useSettingsStore } from '../stores/settings-store';
+import { getContactDisplayName } from './contact-utils';
+
+// Port of the webmail's hooks/use-contact-name-resolver.ts (8cbc5bc5,
+// 6f99c017): names for participants whose event data carries only a bare
+// address.
+
+/** Domains are related when one extends the other at a label boundary —
+ * "node-example.com" vs "example.com" (host-prefixed) or "mail.example.com" vs
+ * "example.com" (subdomain). Stalwart deployments commonly expose one account
+ * under several such domains, so a participant address and the contact card
+ * holding that person's name may differ in domain while naming the same
+ * person. A pure string suffix ("xexample.com" vs "example.com") is NOT
+ * related — the character at the split must be "." or "-". */
+function isRelatedDomain(a: string, b: string): boolean {
+ if (a === b) return true;
+ const longer = a.length >= b.length ? a : b;
+ const shorter = a.length >= b.length ? b : a;
+ if (!longer.endsWith(shorter)) return false;
+ const boundary = longer[longer.length - shorter.length - 1];
+ return boundary === '.' || boundary === '-';
+}
+
+interface IdentityLike {
+ name?: string;
+ email?: string;
+}
+
+interface AccountLike {
+ name?: string;
+ email?: string;
+ username?: string;
+}
+
+/**
+ * Build an email → display-name lookup. Resolution order (first hit wins):
+ *
+ * 1. Contact card holding the exact address — the user's curated data, so it
+ * outranks everything else.
+ * 2. Contact card with the same local part on a related domain (the
+ * alias-domain case above). Only applied when exactly one name matches;
+ * two cards disagreeing means we cannot know which person it is.
+ * 3. The account's display name for the user's own addresses — the organizer
+ * of a self-created event is the user, and Stalwart strips the participant
+ * name on its iCalendar round-trip.
+ * 4. The identity From-name.
+ *
+ * Cards with no resolvable name contribute nothing, so whatever name the event
+ * itself carries always stays authoritative and this resolver is a pure
+ * fallback for bare addresses.
+ */
+export function buildContactNameResolver(
+ contacts: ReadonlyArray,
+ identities: ReadonlyArray = [],
+ accounts: ReadonlyArray = [],
+): (email: string) => string | undefined {
+ const contactNamesByEmail = new Map();
+ const contactNamesByLocalPart = new Map>();
+ const accountNamesByEmail = new Map();
+ const identityNamesByEmail = new Map();
+
+ /** Register `name` for `address` unless it is just the address itself —
+ * several name sources default to the email and would add nothing over the
+ * bare address every renderer already shows. */
+ const register = (map: Map, name: string | undefined, address: string | undefined) => {
+ const trimmedName = name?.trim();
+ const key = address?.trim().toLowerCase();
+ if (!trimmedName || !key || map.has(key)) return;
+ if (trimmedName.toLowerCase() === key) return;
+ map.set(key, trimmedName);
+ };
+
+ for (const contact of contacts) {
+ const name = getContactDisplayName(contact).trim();
+ if (!name || !contact.emails) continue;
+ // getContactDisplayName falls back to the first email address, so a card
+ // whose "name" is just one of its addresses is equally useless here.
+ const nameIsJustAnAddress = Object.values(contact.emails).some(
+ (e) => e.address?.trim().toLowerCase() === name.toLowerCase(),
+ );
+ if (nameIsJustAnAddress) continue;
+ for (const { address } of Object.values(contact.emails)) {
+ const key = address?.trim().toLowerCase();
+ if (!key || contactNamesByEmail.has(key)) continue;
+ contactNamesByEmail.set(key, name);
+ const [localPart, ...rest] = key.split('@');
+ const domain = rest.join('@');
+ if (localPart && domain) {
+ const bucket = contactNamesByLocalPart.get(localPart) ?? [];
+ bucket.push({ domain, name });
+ contactNamesByLocalPart.set(localPart, bucket);
+ }
+ }
+ }
+
+ for (const account of accounts) {
+ register(accountNamesByEmail, account.name, account.email);
+ // A short login username ("zhang") is not an address; only match
+ // usernames that actually carry a domain.
+ if (account.username?.includes('@')) {
+ register(accountNamesByEmail, account.name, account.username);
+ }
+ }
+
+ for (const identity of identities) {
+ register(identityNamesByEmail, identity.name, identity.email);
+ }
+
+ return (email: string) => {
+ const key = email?.trim().toLowerCase();
+ if (!key) return undefined;
+
+ // 1. Exact address on a contact card.
+ const exact = contactNamesByEmail.get(key);
+ if (exact) return exact;
+
+ // 2. Same local part on a related domain. Ambiguous matches resolve to
+ // nothing rather than to a guess.
+ const [localPart, ...rest] = key.split('@');
+ const domain = rest.join('@');
+ if (localPart && domain) {
+ const candidates = new Set();
+ for (const candidate of contactNamesByLocalPart.get(localPart) ?? []) {
+ if (isRelatedDomain(candidate.domain, domain)) candidates.add(candidate.name);
+ }
+ if (candidates.size === 1) return candidates.values().next().value;
+ }
+
+ // 3. The user's own account display name, then 4. the identity From-name.
+ return accountNamesByEmail.get(key) ?? identityNamesByEmail.get(key);
+ };
+}
+
+/**
+ * Participant rows show a contact's display name when the event data itself
+ * carries only a bare address. Memoised on the contact, account and identity
+ * lists, so it recomputes only when one of them actually changes.
+ */
+export function useContactNameResolver(): (email: string) => string | undefined {
+ const contacts = useContactsStore((s) => s.contacts);
+ const identities = useSettingsStore((s) => s.identities);
+ const accounts = useAccountStore((s) => s.accounts);
+ return React.useMemo(
+ () =>
+ buildContactNameResolver(
+ contacts,
+ identities,
+ // AccountEntry calls the display name `displayName`; map it onto the
+ // generic `name` the resolver works with.
+ accounts.map((a) => ({ name: a.displayName, email: a.email, username: a.username })),
+ ),
+ [contacts, identities, accounts],
+ );
+}