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:
@@ -8,6 +8,7 @@ import { DomainsModule } from './domains/domains.module.js'
|
||||
import { FlagsModule } from './flags/flags.module.js'
|
||||
import { HealthModule } from './health/health.module.js'
|
||||
import { IngestModule } from './ingest/ingest.module.js'
|
||||
import { MailModule } from './mail/mail.module.js'
|
||||
import { MeModule } from './me/me.module.js'
|
||||
import { PartnersModule } from './partners/partners.module.js'
|
||||
import { PricesModule } from './prices/prices.module.js'
|
||||
@@ -27,6 +28,7 @@ import { UsersModule } from './users/users.module.js'
|
||||
HealthModule,
|
||||
TenantsModule,
|
||||
DomainsModule,
|
||||
MailModule,
|
||||
PartnersModule,
|
||||
UsersModule,
|
||||
MeModule,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IsMongoId, IsString, Matches, MaxLength } from 'class-validator'
|
||||
|
||||
// Create an email alias `localPart@domain` that delivers to an existing mailbox
|
||||
// user (the destination). Both must belong to the tenant.
|
||||
export class CreateAliasDto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@Matches(/^[a-zA-Z0-9._-]+$/, { message: 'alias prefix may only contain letters, numbers, dots, hyphens and underscores' })
|
||||
localPart!: string
|
||||
|
||||
@IsString()
|
||||
domain!: string
|
||||
|
||||
// The destination mailbox user (our User _id); the alias is added to their account.
|
||||
@IsMongoId()
|
||||
destinationUserId!: string
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ArrayMaxSize, IsArray, IsEmail, IsOptional, IsString, Matches, MaxLength } from 'class-validator'
|
||||
|
||||
// Create a distribution list `localPart@domain` that fans mail out to recipients.
|
||||
export class CreateListDto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@Matches(/^[a-zA-Z0-9._-]+$/, { message: 'list prefix may only contain letters, numbers, dots, hyphens and underscores' })
|
||||
localPart!: string
|
||||
|
||||
@IsString()
|
||||
domain!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
description?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(1000)
|
||||
@IsEmail({}, { each: true })
|
||||
recipients?: string[]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ArrayMaxSize, IsArray, IsEmail } from 'class-validator'
|
||||
|
||||
// Replace a distribution list's recipients.
|
||||
export class UpdateListDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(1000)
|
||||
@IsEmail({}, { each: true })
|
||||
recipients!: string[]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'
|
||||
import { ActorService } from '../auth/actor.service.js'
|
||||
import { clientIp } from '../auth/client-ip.js'
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||
import type { AuditActor } from '../audit/audit.service.js'
|
||||
import { TenantsService } from '../tenants/tenants.service.js'
|
||||
import { CreateAliasDto } from './dto/create-alias.dto.js'
|
||||
import { CreateListDto } from './dto/create-list.dto.js'
|
||||
import { UpdateListDto } from './dto/update-list.dto.js'
|
||||
import { MailService, type TenantRef } from './mail.service.js'
|
||||
|
||||
function auditActor(
|
||||
user: { _id: unknown; email: string },
|
||||
req: Parameters<typeof clientIp>[0],
|
||||
): AuditActor {
|
||||
return { userId: String(user._id), email: user.email, ip: clientIp(req) }
|
||||
}
|
||||
|
||||
// Customer-admin Mail settings: aliases + distribution lists (the per-tenant
|
||||
// parts of Stalwart mail config). Forwarding / spam filters / retention are
|
||||
// server-global and stay operator-managed, so they aren't exposed here.
|
||||
@Controller('tenants/:slug/mail')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MailController {
|
||||
constructor(
|
||||
private readonly mail: MailService,
|
||||
private readonly tenants: TenantsService,
|
||||
private readonly actor: ActorService,
|
||||
) {}
|
||||
|
||||
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<{ actor: any; tenant: TenantRef }> {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
return { actor, tenant: { _id: tenant._id, slug: tenant.slug } }
|
||||
}
|
||||
|
||||
// ── Aliases ──
|
||||
@Get('aliases')
|
||||
async listAliases(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const { tenant } = await this.gate(slug, jwt)
|
||||
return this.mail.listAliases(tenant)
|
||||
}
|
||||
|
||||
@Post('aliases')
|
||||
async createAlias(
|
||||
@Param('slug') slug: string,
|
||||
@Body() dto: CreateAliasDto,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const { actor, tenant } = await this.gate(slug, jwt)
|
||||
return this.mail.createAlias(tenant, dto, auditActor(actor, req))
|
||||
}
|
||||
|
||||
@Delete('aliases/:address')
|
||||
@HttpCode(204)
|
||||
async deleteAlias(
|
||||
@Param('slug') slug: string,
|
||||
@Param('address') address: string,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const { actor, tenant } = await this.gate(slug, jwt)
|
||||
await this.mail.deleteAlias(tenant, address, auditActor(actor, req))
|
||||
}
|
||||
|
||||
// ── Distribution lists ──
|
||||
@Get('lists')
|
||||
async listLists(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const { tenant } = await this.gate(slug, jwt)
|
||||
return this.mail.listLists(tenant)
|
||||
}
|
||||
|
||||
@Post('lists')
|
||||
async createList(
|
||||
@Param('slug') slug: string,
|
||||
@Body() dto: CreateListDto,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const { actor, tenant } = await this.gate(slug, jwt)
|
||||
return this.mail.createList(tenant, dto, auditActor(actor, req))
|
||||
}
|
||||
|
||||
@Patch('lists/:id')
|
||||
async updateList(
|
||||
@Param('slug') slug: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateListDto,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const { actor, tenant } = await this.gate(slug, jwt)
|
||||
return this.mail.updateListRecipients(tenant, id, dto.recipients, auditActor(actor, req))
|
||||
}
|
||||
|
||||
@Delete('lists/:id')
|
||||
@HttpCode(204)
|
||||
async deleteList(
|
||||
@Param('slug') slug: string,
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const { actor, tenant } = await this.gate(slug, jwt)
|
||||
await this.mail.deleteList(tenant, id, auditActor(actor, req))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuditModule } from '../audit/audit.module.js'
|
||||
import { AuthModule } from '../auth/auth.module.js'
|
||||
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||
import { Domain, DomainSchema } from '../schemas/domain.schema.js'
|
||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||
import { MailController } from './mail.controller.js'
|
||||
import { MailService } from './mail.service.js'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: Domain.name, schema: DomainSchema },
|
||||
{ name: User.name, schema: UserSchema },
|
||||
]),
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
IntegrationsModule,
|
||||
TenantsModule, // TenantsService — resolve tenant by slug for the membership gate
|
||||
],
|
||||
controllers: [MailController],
|
||||
providers: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { StalwartClient } from '../integrations/stalwart.client.js'
|
||||
import { Domain, DomainDocument } from '../schemas/domain.schema.js'
|
||||
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||
|
||||
// Tenant-scoped view of the bits of Stalwart mail config that are per-customer:
|
||||
// aliases (extra addresses on a mailbox) and distribution lists. Both are keyed
|
||||
// to one of the tenant's domains, so they're naturally isolated per workspace.
|
||||
|
||||
export interface AliasView {
|
||||
address: string // localPart@domain
|
||||
localPart: string
|
||||
domain: string
|
||||
destination: string // the mailbox it delivers to
|
||||
accountId: string // Stalwart account holding the alias
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ListView {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
domain: string
|
||||
recipients: string[]
|
||||
members: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface TenantRef {
|
||||
_id: Types.ObjectId
|
||||
slug: string
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
@InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
|
||||
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
||||
private readonly stalwart: StalwartClient,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
// Maps between a tenant's domain names and their Stalwart domain ids, both ways.
|
||||
private async tenantDomains(
|
||||
tenantId: Types.ObjectId,
|
||||
): Promise<{ byStalwartId: Map<string, string>; byName: Map<string, string> }> {
|
||||
const domains = await this.domainModel
|
||||
.find({ tenantId, stalwartId: { $exists: true } }, { domain: 1, stalwartId: 1 })
|
||||
.exec()
|
||||
const byStalwartId = new Map<string, string>()
|
||||
const byName = new Map<string, string>()
|
||||
for (const d of domains) {
|
||||
if (d.stalwartId) {
|
||||
byStalwartId.set(d.stalwartId, d.domain)
|
||||
byName.set(d.domain, d.stalwartId)
|
||||
}
|
||||
}
|
||||
return { byStalwartId, byName }
|
||||
}
|
||||
|
||||
// ── Aliases ────────────────────────────────────────────────────────────────
|
||||
|
||||
async listAliases(tenant: TenantRef): Promise<AliasView[]> {
|
||||
if (!this.stalwart.configured) return []
|
||||
const { byStalwartId } = await this.tenantDomains(tenant._id)
|
||||
if (byStalwartId.size === 0) return []
|
||||
const accounts = await this.stalwart.listAccountsWithAliases()
|
||||
const out: AliasView[] = []
|
||||
for (const acc of accounts) {
|
||||
for (const a of acc.aliases) {
|
||||
const domain = byStalwartId.get(a.domainId)
|
||||
if (!domain) continue // alias on a domain this tenant doesn't own
|
||||
out.push({
|
||||
address: `${a.name}@${domain}`,
|
||||
localPart: a.name,
|
||||
domain,
|
||||
destination: acc.emailAddress,
|
||||
accountId: acc.id,
|
||||
enabled: a.enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out.sort((x, y) => x.address.localeCompare(y.address))
|
||||
}
|
||||
|
||||
async createAlias(
|
||||
tenant: TenantRef,
|
||||
dto: { localPart: string; domain: string; destinationUserId: string },
|
||||
actor: AuditActor,
|
||||
): Promise<AliasView> {
|
||||
const { byName } = await this.tenantDomains(tenant._id)
|
||||
const domain = dto.domain.trim().toLowerCase()
|
||||
const domainStalwartId = byName.get(domain)
|
||||
if (!domainStalwartId) {
|
||||
throw new BadRequestException(`Domain "${domain}" isn't part of this workspace.`)
|
||||
}
|
||||
const localPart = dto.localPart.trim().toLowerCase()
|
||||
const dest = await this.userModel
|
||||
.findOne({ _id: dto.destinationUserId, tenantIds: tenant._id })
|
||||
.exec()
|
||||
if (!dest?.stalwartAccountId) {
|
||||
throw new BadRequestException('The destination must be a mailbox user in this workspace.')
|
||||
}
|
||||
await this.stalwart.addAlias(dest.stalwartAccountId, localPart, domainStalwartId)
|
||||
const address = `${localPart}@${domain}`
|
||||
await this.audit.record(
|
||||
{
|
||||
action: 'mail.alias_created',
|
||||
resourceType: 'system',
|
||||
resourceId: address,
|
||||
resourceName: address,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: { destination: dest.email },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
return { address, localPart, domain, destination: dest.email, accountId: dest.stalwartAccountId, enabled: true }
|
||||
}
|
||||
|
||||
async deleteAlias(tenant: TenantRef, address: string, actor: AuditActor): Promise<void> {
|
||||
const target = address.trim().toLowerCase()
|
||||
const match = (await this.listAliases(tenant)).find((a) => a.address === target)
|
||||
if (!match) throw new NotFoundException(`Alias "${address}" not found.`)
|
||||
const { byName } = await this.tenantDomains(tenant._id)
|
||||
await this.stalwart.removeAlias(match.accountId, match.localPart, byName.get(match.domain)!)
|
||||
await this.audit.record(
|
||||
{
|
||||
action: 'mail.alias_deleted',
|
||||
resourceType: 'system',
|
||||
resourceId: target,
|
||||
resourceName: target,
|
||||
tenantSlug: tenant.slug,
|
||||
},
|
||||
actor,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Distribution lists ───────────────────────────────────────────────────────
|
||||
|
||||
async listLists(tenant: TenantRef): Promise<ListView[]> {
|
||||
if (!this.stalwart.configured) return []
|
||||
const { byStalwartId } = await this.tenantDomains(tenant._id)
|
||||
if (byStalwartId.size === 0) return []
|
||||
const lists = await this.stalwart.listMailingLists()
|
||||
return lists
|
||||
.filter((l) => byStalwartId.has(l.domainId))
|
||||
.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
address: l.emailAddress || `${l.name}@${byStalwartId.get(l.domainId)}`,
|
||||
domain: byStalwartId.get(l.domainId)!,
|
||||
recipients: l.recipients,
|
||||
members: l.recipients.length,
|
||||
description: l.description,
|
||||
}))
|
||||
.sort((x, y) => x.address.localeCompare(y.address))
|
||||
}
|
||||
|
||||
async createList(
|
||||
tenant: TenantRef,
|
||||
dto: { localPart: string; domain: string; recipients?: string[]; description?: string },
|
||||
actor: AuditActor,
|
||||
): Promise<ListView> {
|
||||
const { byName } = await this.tenantDomains(tenant._id)
|
||||
const domain = dto.domain.trim().toLowerCase()
|
||||
const domainStalwartId = byName.get(domain)
|
||||
if (!domainStalwartId) {
|
||||
throw new BadRequestException(`Domain "${domain}" isn't part of this workspace.`)
|
||||
}
|
||||
const localPart = dto.localPart.trim().toLowerCase()
|
||||
const recipients = cleanRecipients(dto.recipients)
|
||||
const { id } = await this.stalwart.createMailingList({
|
||||
domainId: domainStalwartId,
|
||||
name: localPart,
|
||||
recipients,
|
||||
description: dto.description,
|
||||
})
|
||||
const address = `${localPart}@${domain}`
|
||||
await this.audit.record(
|
||||
{
|
||||
action: 'mail.list_created',
|
||||
resourceType: 'system',
|
||||
resourceId: address,
|
||||
resourceName: address,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: { members: recipients.length },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
return { id, name: localPart, address, domain, recipients, members: recipients.length, description: dto.description }
|
||||
}
|
||||
|
||||
async updateListRecipients(
|
||||
tenant: TenantRef,
|
||||
id: string,
|
||||
recipients: string[],
|
||||
actor: AuditActor,
|
||||
): Promise<ListView> {
|
||||
const list = await this.assertListInTenant(tenant, id)
|
||||
const clean = cleanRecipients(recipients)
|
||||
await this.stalwart.updateMailingListRecipients(id, clean)
|
||||
await this.audit.record(
|
||||
{
|
||||
action: 'mail.list_updated',
|
||||
resourceType: 'system',
|
||||
resourceId: list.address,
|
||||
resourceName: list.address,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: { members: clean.length },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
return { ...list, recipients: clean, members: clean.length }
|
||||
}
|
||||
|
||||
async deleteList(tenant: TenantRef, id: string, actor: AuditActor): Promise<void> {
|
||||
const list = await this.assertListInTenant(tenant, id)
|
||||
await this.stalwart.deleteMailingList(id)
|
||||
await this.audit.record(
|
||||
{
|
||||
action: 'mail.list_deleted',
|
||||
resourceType: 'system',
|
||||
resourceId: list.address,
|
||||
resourceName: list.address,
|
||||
tenantSlug: tenant.slug,
|
||||
},
|
||||
actor,
|
||||
)
|
||||
}
|
||||
|
||||
private async assertListInTenant(tenant: TenantRef, id: string): Promise<ListView> {
|
||||
const list = (await this.listLists(tenant)).find((l) => l.id === id)
|
||||
if (!list) throw new NotFoundException('Distribution list not found in this workspace.')
|
||||
return list
|
||||
}
|
||||
}
|
||||
|
||||
function cleanRecipients(recipients?: string[]): string[] {
|
||||
return [...new Set((recipients ?? []).map((r) => r.trim().toLowerCase()).filter(Boolean))]
|
||||
}
|
||||
Reference in New Issue
Block a user