feat(mail): tenant alias and distribution-list management via Stalwart

Customer-admin Mail settings backed by Stalwart JMAP: per-tenant aliases
(extra addresses routing to a mailbox) and distribution lists (one address
fanning out to many recipients). Adds StalwartClient x:Alias/x:MailingList
methods, a tenant-scoped MailController/MailService, the portal Mail settings
page and its proxy routes, and the mailboxAddress field on TenantUserDoc.
Removes the old mock mail data now that the page reads live data.
This commit is contained in:
Ronni Baslund
2026-06-07 00:16:30 +02:00
parent 04191193c2
commit aee8f13899
18 changed files with 1035 additions and 461 deletions
@@ -268,6 +268,154 @@ export class StalwartClient {
throw new Error(`Stalwart mailbox delete failed (id=${accountId}): ${JSON.stringify(notDestroyed)}`)
}
}
// ── Aliases (extra addresses that route to a mailbox) ──────────────────────
// Every mailbox + its aliases. Stalwart's account query has no domain filter,
// so the caller narrows by domainId. `aliases` is an index-keyed map on the
// wire; we hand back a plain array.
async listAccountsWithAliases(): Promise<StalwartAccountAliases[]> {
const resp = await this.jmap([
['x:Account/query', { filter: {} }, '0'],
[
'x:Account/get',
{
'#ids': { resultOf: '0', name: 'x:Account/query', path: '/ids' },
properties: ['emailAddress', 'aliases'],
},
'1',
],
])
const list = (resp[1]?.[1]?.list ?? []) as Array<{
id: string
emailAddress: string
aliases?: Record<string, StalwartAlias>
}>
return list.map((a) => ({
id: a.id,
emailAddress: a.emailAddress,
aliases: aliasMapToArray(a.aliases),
}))
}
// Add an alias (localpart@domain) that delivers to this mailbox. Idempotent.
async addAlias(accountId: string, name: string, domainId: string): Promise<void> {
const aliases = await this.getAccountAliases(accountId)
if (aliases.some((a) => a.name === name && a.domainId === domainId)) return
aliases.push({ name, domainId, enabled: true })
await this.writeAliases(accountId, aliases)
}
async removeAlias(accountId: string, name: string, domainId: string): Promise<void> {
const aliases = (await this.getAccountAliases(accountId)).filter(
(a) => !(a.name === name && a.domainId === domainId),
)
await this.writeAliases(accountId, aliases)
}
private async getAccountAliases(accountId: string): Promise<StalwartAlias[]> {
const resp = await this.jmap([
['x:Account/get', { ids: [accountId], properties: ['aliases'] }, '0'],
])
return aliasMapToArray(resp[0]?.[1]?.list?.[0]?.aliases)
}
// Stalwart replaces the whole `aliases` field on update, so we always write the
// full set back as an index-keyed map.
private async writeAliases(accountId: string, aliases: StalwartAlias[]): Promise<void> {
const map: Record<string, StalwartAlias> = {}
aliases.forEach((a, i) => {
map[String(i)] = { name: a.name, domainId: a.domainId, enabled: a.enabled }
})
const resp = await this.jmap([
['x:Account/set', { update: { [accountId]: { aliases: map } } }, '0'],
])
const notUpdated = resp[0][1].notUpdated?.[accountId]
if (notUpdated) {
throw new Error(`Stalwart alias update failed (id=${accountId}): ${JSON.stringify(notUpdated)}`)
}
}
// ── Mailing lists (one address fans out to many recipients) ────────────────
async listMailingLists(): Promise<StalwartMailingList[]> {
const resp = await this.jmap([
['x:MailingList/query', { filter: {} }, '0'],
[
'x:MailingList/get',
{
'#ids': { resultOf: '0', name: 'x:MailingList/query', path: '/ids' },
properties: ['name', 'emailAddress', 'domainId', 'recipients', 'description'],
},
'1',
],
])
const list = (resp[1]?.[1]?.list ?? []) as Array<{
id: string
name: string
emailAddress: string
domainId: string
recipients?: Record<string, boolean>
description?: string
}>
return list.map((l) => ({
id: l.id,
name: l.name,
emailAddress: l.emailAddress,
domainId: l.domainId,
recipients: l.recipients ? Object.keys(l.recipients) : [],
description: l.description,
}))
}
async createMailingList(input: {
domainId: string
name: string
recipients: string[]
description?: string
}): Promise<{ id: string }> {
const resp = await this.jmap([
[
'x:MailingList/set',
{
create: {
l1: {
name: input.name,
domainId: input.domainId,
description: input.description ?? null,
recipients: recipientsToMap(input.recipients),
},
},
},
'0',
],
])
const created = resp[0][1].created?.l1
if (!created?.id) {
throw new Error(`Stalwart mailing list create failed: ${JSON.stringify(resp[0][1].notCreated)}`)
}
return { id: created.id }
}
async updateMailingListRecipients(id: string, recipients: string[]): Promise<void> {
const resp = await this.jmap([
['x:MailingList/set', { update: { [id]: { recipients: recipientsToMap(recipients) } } }, '0'],
])
const notUpdated = resp[0][1].notUpdated?.[id]
if (notUpdated) {
throw new Error(`Stalwart mailing list update failed (id=${id}): ${JSON.stringify(notUpdated)}`)
}
}
async deleteMailingList(id: string): Promise<void> {
const resp = await this.jmap([['x:MailingList/set', { destroy: [id] }, '0']])
const result = resp[0][1]
if ((result.destroyed as string[] | undefined)?.includes(id)) return
const notDestroyed = result.notDestroyed?.[id]
if (notDestroyed && notDestroyed.type !== 'notFound') {
throw new Error(`Stalwart mailing list delete failed (id=${id}): ${JSON.stringify(notDestroyed)}`)
}
}
}
export interface StalwartLinkedObject {
@@ -275,6 +423,42 @@ export interface StalwartLinkedObject {
id: string
}
export interface StalwartAlias {
name: string
domainId: string
enabled: boolean
}
export interface StalwartAccountAliases {
id: string
emailAddress: string
aliases: StalwartAlias[]
}
export interface StalwartMailingList {
id: string
name: string
emailAddress: string
domainId: string
recipients: string[]
description?: string
}
function aliasMapToArray(map?: Record<string, StalwartAlias>): StalwartAlias[] {
if (!map) return []
return Object.values(map).map((a) => ({
name: a.name,
domainId: a.domainId,
enabled: a.enabled !== false,
}))
}
function recipientsToMap(recipients: string[]): Record<string, boolean> {
const map: Record<string, boolean> = {}
for (const r of recipients) map[r.trim().toLowerCase()] = true
return map
}
// Thrown when a domain still has accounts, aliases or mailing lists in Stalwart
// and therefore can't be removed. `linkedObjects` excludes the auto-generated
// DKIM signatures (which we remove automatically).