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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user