From 73eb8285d30bd8e36622ae3a164d892bf3bdf3e2 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:17:10 -0600 Subject: [PATCH 1/7] feat: implement Stalwart account provider and related types - Added AccountProvider interface for managing email accounts with methods for creating, deleting, and updating accounts and addresses. - Implemented StalwartAccountProvider class to interact with the Stalwart service for account management. - Introduced CreateAccountParams and AccountInfo types for structured account data handling. - Created StalwartService to manage API interactions with the Stalwart backend, including principal creation, retrieval, and updates. - Established StalwartModule to provide the StalwartAccountProvider as a dependency for other modules. --- src/modules/email/account-provider.port.ts | 16 ++ src/modules/email/account.types.ts | 14 ++ .../stalwart/stalwart-account.provider.ts | 113 ++++++++++++ .../stalwart/stalwart.module.ts | 13 ++ .../stalwart/stalwart.service.ts | 163 ++++++++++++++++++ 5 files changed, 319 insertions(+) create mode 100644 src/modules/email/account-provider.port.ts create mode 100644 src/modules/email/account.types.ts create mode 100644 src/modules/infrastructure/stalwart/stalwart-account.provider.ts create mode 100644 src/modules/infrastructure/stalwart/stalwart.module.ts create mode 100644 src/modules/infrastructure/stalwart/stalwart.service.ts diff --git a/src/modules/email/account-provider.port.ts b/src/modules/email/account-provider.port.ts new file mode 100644 index 0000000..17fa3eb --- /dev/null +++ b/src/modules/email/account-provider.port.ts @@ -0,0 +1,16 @@ +import type { AccountInfo, CreateAccountParams } from './account.types.js'; + +export abstract class AccountProvider { + abstract createAccount(params: CreateAccountParams): Promise; + abstract deleteAccount(name: string): Promise; + abstract getAccount(name: string): Promise; + abstract addAddress(name: string, address: string): Promise; + abstract removeAddress(name: string, address: string): Promise; + abstract setPrimaryAddress( + currentName: string, + newPrimaryAddress: string, + ): Promise; + abstract updateQuota(name: string, bytes: number): Promise; + abstract createDomain(domain: string): Promise; + abstract deleteDomain(domain: string): Promise; +} diff --git a/src/modules/email/account.types.ts b/src/modules/email/account.types.ts new file mode 100644 index 0000000..4fb8f02 --- /dev/null +++ b/src/modules/email/account.types.ts @@ -0,0 +1,14 @@ +export interface CreateAccountParams { + accountId: string; + primaryAddress: string; + displayName: string; + password: string; + quota?: number; +} + +export interface AccountInfo { + name: string; + displayName: string; + emails: string[]; + quota: number; +} diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts new file mode 100644 index 0000000..aa475ea --- /dev/null +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AccountProvider } from '../../email/account-provider.port.js'; +import type { + AccountInfo, + CreateAccountParams, +} from '../../email/account.types.js'; +import { StalwartService } from './stalwart.service.js'; + +@Injectable() +export class StalwartAccountProvider extends AccountProvider { + private readonly logger = new Logger(StalwartAccountProvider.name); + + constructor(private readonly stalwart: StalwartService) { + super(); + } + + async createAccount(params: CreateAccountParams): Promise { + await this.stalwart.createPrincipal({ + type: 'individual', + name: params.primaryAddress, + description: params.displayName, + secrets: [params.password], + emails: [params.primaryAddress], + quota: params.quota ?? 0, + }); + + this.logger.log(`Created account '${params.primaryAddress}'`); + } + + async deleteAccount(name: string): Promise { + await this.stalwart.deletePrincipal(name); + this.logger.log(`Deleted account '${name}'`); + } + + async getAccount(name: string): Promise { + const principal = await this.stalwart.getPrincipal(name); + if (!principal) return null; + + return { + name: principal.name, + displayName: principal.description ?? '', + emails: principal.emails ?? [], + quota: principal.quota ?? 0, + }; + } + + async addAddress(name: string, address: string): Promise { + await this.stalwart.patchPrincipal(name, [ + { action: 'addItem', field: 'emails', value: address }, + ]); + + this.logger.log(`Added address '${address}' to '${name}'`); + } + + async removeAddress(name: string, address: string): Promise { + await this.stalwart.patchPrincipal(name, [ + { action: 'removeItem', field: 'emails', value: address }, + ]); + + this.logger.log(`Removed address '${address}' from '${name}'`); + } + + async setPrimaryAddress( + currentName: string, + newPrimaryAddress: string, + ): Promise { + // Stalwart uses the principal name as the login. + // Changing the primary address means renaming the principal. + // Current REST API does not support rename — we must recreate. + const existing = await this.stalwart.getPrincipal(currentName); + if (!existing) { + throw new Error(`Account '${currentName}' not found`); + } + + const updatedEmails = [ + newPrimaryAddress, + ...(existing.emails ?? []).filter((e) => e !== newPrimaryAddress), + ]; + + await this.stalwart.deletePrincipal(currentName); + await this.stalwart.createPrincipal({ + ...existing, + name: newPrimaryAddress, + emails: updatedEmails, + }); + + this.logger.warn( + `Renamed account '${currentName}' → '${newPrimaryAddress}' (delete + recreate)`, + ); + } + + async updateQuota(name: string, bytes: number): Promise { + await this.stalwart.patchPrincipal(name, [ + { action: 'set', field: 'quota', value: bytes }, + ]); + + this.logger.log(`Updated quota for '${name}' to ${bytes} bytes`); + } + + async createDomain(domain: string): Promise { + await this.stalwart.createPrincipal({ + type: 'domain', + name: domain, + }); + + this.logger.log(`Created domain '${domain}'`); + } + + async deleteDomain(domain: string): Promise { + await this.stalwart.deletePrincipal(domain); + this.logger.log(`Deleted domain '${domain}'`); + } +} diff --git a/src/modules/infrastructure/stalwart/stalwart.module.ts b/src/modules/infrastructure/stalwart/stalwart.module.ts new file mode 100644 index 0000000..d833acf --- /dev/null +++ b/src/modules/infrastructure/stalwart/stalwart.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AccountProvider } from '../../email/account-provider.port.js'; +import { StalwartService } from './stalwart.service.js'; +import { StalwartAccountProvider } from './stalwart-account.provider.js'; + +@Module({ + providers: [ + StalwartService, + { provide: AccountProvider, useClass: StalwartAccountProvider }, + ], + exports: [AccountProvider], +}) +export class StalwartModule {} diff --git a/src/modules/infrastructure/stalwart/stalwart.service.ts b/src/modules/infrastructure/stalwart/stalwart.service.ts new file mode 100644 index 0000000..8efec57 --- /dev/null +++ b/src/modules/infrastructure/stalwart/stalwart.service.ts @@ -0,0 +1,163 @@ +import { + Injectable, + Logger, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Client } from 'undici'; + +interface StalwartPrincipal { + name: string; + type: string; + description?: string; + secrets?: string[]; + emails?: string[]; + quota?: number; + memberOf?: string[]; + roles?: string[]; + lists?: string[]; + enabledPermissions?: string[]; + disabledPermissions?: string[]; +} + +type PatchAction = 'set' | 'addItem' | 'removeItem'; + +interface PatchOperation { + action: PatchAction; + field: string; + value: unknown; +} + +@Injectable() +export class StalwartService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(StalwartService.name); + private readonly adminUrl: string; + private readonly adminToken: string; + private httpClient!: Client; + + constructor(private readonly configService: ConfigService) { + this.adminUrl = this.configService.getOrThrow('stalwart.adminUrl'); + this.adminToken = this.configService.getOrThrow( + 'stalwart.adminToken', + ); + } + + onModuleInit() { + this.httpClient = new Client(this.adminUrl, { + allowH2: true, + keepAliveTimeout: 30_000, + pipelining: 1, + }); + this.logger.log( + `Stalwart admin client initialized targeting ${this.adminUrl}`, + ); + } + + async onModuleDestroy() { + await this.httpClient.close(); + } + + async createPrincipal(principal: StalwartPrincipal): Promise { + const { statusCode, body } = await this.httpClient.request({ + method: 'POST', + path: '/api/principal', + headers: this.headers(), + body: JSON.stringify(principal), + }); + + const text = await body.text(); + + if (statusCode !== 200 && statusCode !== 201) { + throw new StalwartApiError( + `Failed to create principal '${principal.name}': HTTP ${statusCode}`, + statusCode, + text, + ); + } + } + + async getPrincipal(name: string): Promise { + const { statusCode, body } = await this.httpClient.request({ + method: 'GET', + path: `/api/principal/${encodeURIComponent(name)}`, + headers: this.headers(), + }); + + const text = await body.text(); + + if (statusCode === 404) { + return null; + } + + if (statusCode !== 200) { + throw new StalwartApiError( + `Failed to get principal '${name}': HTTP ${statusCode}`, + statusCode, + text, + ); + } + + const response = JSON.parse(text) as { data: StalwartPrincipal }; + return response.data; + } + + async patchPrincipal( + name: string, + operations: PatchOperation[], + ): Promise { + const { statusCode, body } = await this.httpClient.request({ + method: 'PATCH', + path: `/api/principal/${encodeURIComponent(name)}`, + headers: this.headers(), + body: JSON.stringify(operations), + }); + + const text = await body.text(); + + if (statusCode !== 200 && statusCode !== 204) { + throw new StalwartApiError( + `Failed to patch principal '${name}': HTTP ${statusCode}`, + statusCode, + text, + ); + } + } + + async deletePrincipal(name: string): Promise { + const { statusCode, body } = await this.httpClient.request({ + method: 'DELETE', + path: `/api/principal/${encodeURIComponent(name)}`, + headers: this.headers(), + }); + + const text = await body.text(); + + if (statusCode !== 200 && statusCode !== 204) { + throw new StalwartApiError( + `Failed to delete principal '${name}': HTTP ${statusCode}`, + statusCode, + text, + ); + } + } + + private headers(): Record { + return { + authorization: `Bearer ${this.adminToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }; + } +} + +export class StalwartApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly details: string, + ) { + super(message); + this.name = 'StalwartApiError'; + } +} From 2c8dbb00582fe0629f37d2e34d322601186d9506 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:20:52 -0600 Subject: [PATCH 2/7] refactor: update Stalwart service configuration for authentication - Replaced adminToken with adminUser and adminSecret in the configuration. - Updated StalwartService to use basic authentication with adminUser and adminSecret instead of bearer token. - Adjusted headers method to include basic auth credentials for API requests. --- src/config/configuration.ts | 3 ++- .../infrastructure/stalwart/stalwart.service.ts | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 475b1f2..3601416 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -15,7 +15,8 @@ export default () => ({ stalwart: { url: process.env.STALWART_JMAP_URL ?? 'http://localhost:8085', adminUrl: process.env.STALWART_ADMIN_URL ?? 'http://localhost:8085', - adminToken: process.env.STALWART_ADMIN_TOKEN ?? '', + adminUser: process.env.STALWART_ADMIN_USER ?? 'mail-api', + adminSecret: process.env.STALWART_ADMIN_SECRET ?? '', masterUser: process.env.STALWART_MASTER_USER ?? 'master', masterPassword: process.env.STALWART_MASTER_PASSWORD ?? '', }, diff --git a/src/modules/infrastructure/stalwart/stalwart.service.ts b/src/modules/infrastructure/stalwart/stalwart.service.ts index 8efec57..ea7337d 100644 --- a/src/modules/infrastructure/stalwart/stalwart.service.ts +++ b/src/modules/infrastructure/stalwart/stalwart.service.ts @@ -33,13 +33,16 @@ interface PatchOperation { export class StalwartService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(StalwartService.name); private readonly adminUrl: string; - private readonly adminToken: string; + private readonly adminUser: string; + private readonly adminSecret: string; private httpClient!: Client; constructor(private readonly configService: ConfigService) { this.adminUrl = this.configService.getOrThrow('stalwart.adminUrl'); - this.adminToken = this.configService.getOrThrow( - 'stalwart.adminToken', + this.adminUser = + this.configService.getOrThrow('stalwart.adminUser'); + this.adminSecret = this.configService.getOrThrow( + 'stalwart.adminSecret', ); } @@ -143,8 +146,11 @@ export class StalwartService implements OnModuleInit, OnModuleDestroy { } private headers(): Record { + const credentials = Buffer.from( + `${this.adminUser}:${this.adminSecret}`, + ).toString('base64'); return { - authorization: `Bearer ${this.adminToken}`, + authorization: `Basic ${credentials}`, 'content-type': 'application/json', accept: 'application/json', }; From 6e8080c840dcfe95e82df3ac1c803c5d9fb7fde2 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:39:13 -0600 Subject: [PATCH 3/7] feat: implement account management features and repositories - Introduced AccountProvider interface for managing email accounts, including methods for creating, deleting, and updating accounts and addresses. - Added AccountService to handle business logic for account operations, including address management and primary address setting. - Created repositories for account, address, and domain management, integrating with Sequelize for data persistence. - Defined domain models for MailAccount, MailAddress, and MailDomain to structure account-related data. - Updated .env.template to include new configuration parameters for Stalwart account management. --- .env.template | 3 +- .../account-provider.port.ts | 3 - src/modules/account/account.module.ts | 14 +- src/modules/account/account.service.ts | 167 ++++++++++++++++++ .../{email => account}/account.types.ts | 0 .../account/domain/mail-account.domain.ts | 37 ++++ .../account/domain/mail-address.domain.ts | 29 +++ .../account/domain/mail-domain.domain.ts | 23 +++ .../repositories/account.repository.ts | 43 +++++ .../repositories/address.repository.ts | 108 +++++++++++ .../account/repositories/domain.repository.ts | 27 +++ .../stalwart/stalwart-account.provider.ts | 26 +-- .../stalwart/stalwart.module.ts | 2 +- test/fixtures.ts | 34 +--- 14 files changed, 461 insertions(+), 55 deletions(-) rename src/modules/{email => account}/account-provider.port.ts (75%) create mode 100644 src/modules/account/account.service.ts rename src/modules/{email => account}/account.types.ts (100%) create mode 100644 src/modules/account/domain/mail-account.domain.ts create mode 100644 src/modules/account/domain/mail-address.domain.ts create mode 100644 src/modules/account/domain/mail-domain.domain.ts create mode 100644 src/modules/account/repositories/account.repository.ts create mode 100644 src/modules/account/repositories/address.repository.ts create mode 100644 src/modules/account/repositories/domain.repository.ts diff --git a/.env.template b/.env.template index 83cec43..483848d 100644 --- a/.env.template +++ b/.env.template @@ -11,7 +11,8 @@ RDS_PASSWORD=example # Stalwart STALWART_JMAP_URL=http://localhost:8085 STALWART_ADMIN_URL=http://localhost:8085 -STALWART_ADMIN_TOKEN= +STALWART_ADMIN_USER= +STALWART_ADMIN_SECRET= # Auth JWT_SECRET= diff --git a/src/modules/email/account-provider.port.ts b/src/modules/account/account-provider.port.ts similarity index 75% rename from src/modules/email/account-provider.port.ts rename to src/modules/account/account-provider.port.ts index 17fa3eb..4b43479 100644 --- a/src/modules/email/account-provider.port.ts +++ b/src/modules/account/account-provider.port.ts @@ -10,7 +10,4 @@ export abstract class AccountProvider { currentName: string, newPrimaryAddress: string, ): Promise; - abstract updateQuota(name: string, bytes: number): Promise; - abstract createDomain(domain: string): Promise; - abstract deleteDomain(domain: string): Promise; } diff --git a/src/modules/account/account.module.ts b/src/modules/account/account.module.ts index ddbe841..ec529b8 100644 --- a/src/modules/account/account.module.ts +++ b/src/modules/account/account.module.ts @@ -1,11 +1,16 @@ import { Module } from '@nestjs/common'; import { SequelizeModule } from '@nestjs/sequelize'; +import { StalwartModule } from '../infrastructure/stalwart/stalwart.module.js'; +import { AccountService } from './account.service.js'; import { MailAccountModel, MailAddressModel, MailDomainModel, MailProviderAccountModel, } from './models/index.js'; +import { AccountRepository } from './repositories/account.repository.js'; +import { AddressRepository } from './repositories/address.repository.js'; +import { DomainRepository } from './repositories/domain.repository.js'; @Module({ imports: [ @@ -15,7 +20,14 @@ import { MailDomainModel, MailProviderAccountModel, ]), + StalwartModule, ], - exports: [SequelizeModule], + providers: [ + AccountRepository, + AddressRepository, + DomainRepository, + AccountService, + ], + exports: [AccountService], }) export class AccountModule {} diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts new file mode 100644 index 0000000..0be8151 --- /dev/null +++ b/src/modules/account/account.service.ts @@ -0,0 +1,167 @@ +import { + ConflictException, + Injectable, + Logger, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { AccountProvider } from './account-provider.port.js'; +import { MailAccount } from './domain/mail-account.domain.js'; +import { AccountRepository } from './repositories/account.repository.js'; +import { AddressRepository } from './repositories/address.repository.js'; +import { DomainRepository } from './repositories/domain.repository.js'; + +@Injectable() +export class AccountService { + private readonly logger = new Logger(AccountService.name); + + constructor( + private readonly provider: AccountProvider, + private readonly accounts: AccountRepository, + private readonly addresses: AddressRepository, + private readonly domains: DomainRepository, + ) {} + + async getAccount(driveUserUuid: string): Promise { + return this.getAccountOrFail(driveUserUuid); + } + + async deleteAccount(driveUserUuid: string): Promise { + const account = await this.getAccountOrFail(driveUserUuid); + + if (account.principalName) { + await this.provider.deleteAccount(account.principalName); + } + + await this.accounts.delete(account.id); + this.logger.log(`Deleted account for drive user '${driveUserUuid}'`); + } + + async addAddress( + driveUserUuid: string, + address: string, + domainName: string, + ): Promise { + const [account, domain, existing] = await Promise.all([ + this.accounts.findByDriveUserUuid(driveUserUuid), + this.domains.findByDomain(domainName), + this.addresses.findByAddress(address), + ]); + + if (!account) { + throw new NotFoundException( + `No mail account for drive user '${driveUserUuid}'`, + ); + } + const principalName = this.requirePrincipalName(account); + + if (!domain) { + throw new NotFoundException(`Domain '${domainName}' not found`); + } + if (existing) { + throw new ConflictException(`Address '${address}' already exists`); + } + + const newAddress = await this.addresses.create({ + mailAccountId: account.id, + address, + domainId: domain.id, + isDefault: false, + }); + + try { + await this.provider.addAddress(principalName, address); + } catch (error) { + await this.addresses.delete(newAddress.id); + throw error; + } + + await this.addresses.createProviderLink({ + mailAddressId: newAddress.id, + provider: 'stalwart', + externalId: principalName, + }); + + this.logger.log(`Added address '${address}' to account '${driveUserUuid}'`); + } + + async removeAddress(driveUserUuid: string, address: string): Promise { + const account = await this.getAccountOrFail(driveUserUuid); + + const addressRecord = account.addresses.find((a) => a.address === address); + if (!addressRecord) { + throw new NotFoundException( + `Address '${address}' not found for this account`, + ); + } + + if (addressRecord.isDefault) { + throw new UnprocessableEntityException( + 'Cannot remove the default address', + ); + } + + const principalName = this.requirePrincipalName(account); + + await this.provider.removeAddress(principalName, address); + await Promise.all([ + this.addresses.deleteProviderLink(addressRecord.id), + this.addresses.delete(addressRecord.id), + ]); + + this.logger.log( + `Removed address '${address}' from account '${driveUserUuid}'`, + ); + } + + async setPrimaryAddress( + driveUserUuid: string, + newAddress: string, + ): Promise { + const account = await this.getAccountOrFail(driveUserUuid); + + const addressRecord = account.addresses.find( + (a) => a.address === newAddress, + ); + if (!addressRecord) { + throw new NotFoundException( + `Address '${newAddress}' not found for this account`, + ); + } + + if (addressRecord.isDefault) return; + + const oldPrincipalName = this.requirePrincipalName(account); + + await this.provider.setPrimaryAddress(oldPrincipalName, newAddress); + + await Promise.all([ + this.addresses.setDefault(addressRecord.id, account.id), + this.addresses.updateAllProviderExternalIds(account.id, newAddress), + ]); + + this.logger.log( + `Set primary address to '${newAddress}' for account '${driveUserUuid}'`, + ); + } + + private async getAccountOrFail(driveUserUuid: string): Promise { + const account = await this.accounts.findByDriveUserUuid(driveUserUuid); + if (!account) { + throw new NotFoundException( + `No mail account for drive user '${driveUserUuid}'`, + ); + } + return account; + } + + private requirePrincipalName(account: MailAccount): string { + const name = account.principalName; + if (!name) { + throw new UnprocessableEntityException( + 'Account has no primary address with a provider link', + ); + } + return name; + } +} diff --git a/src/modules/email/account.types.ts b/src/modules/account/account.types.ts similarity index 100% rename from src/modules/email/account.types.ts rename to src/modules/account/account.types.ts diff --git a/src/modules/account/domain/mail-account.domain.ts b/src/modules/account/domain/mail-account.domain.ts new file mode 100644 index 0000000..a66fd60 --- /dev/null +++ b/src/modules/account/domain/mail-account.domain.ts @@ -0,0 +1,37 @@ +import { + MailAddress, + type MailAddressAttributes, +} from './mail-address.domain.js'; + +export interface MailAccountAttributes { + id: string; + driveUserUuid: string; + addresses: MailAddressAttributes[]; + createdAt: Date; + updatedAt: Date; +} + +export class MailAccount { + readonly id!: string; + readonly driveUserUuid!: string; + readonly addresses!: MailAddress[]; + readonly createdAt!: Date; + readonly updatedAt!: Date; + + private constructor(attributes: MailAccountAttributes) { + Object.assign(this, attributes); + this.addresses = attributes.addresses.map((a) => MailAddress.build(a)); + } + + static build(attributes: MailAccountAttributes): MailAccount { + return new MailAccount(attributes); + } + + get defaultAddress(): MailAddress | undefined { + return this.addresses.find((a) => a.isDefault); + } + + get principalName(): string | null { + return this.defaultAddress?.providerExternalId ?? null; + } +} diff --git a/src/modules/account/domain/mail-address.domain.ts b/src/modules/account/domain/mail-address.domain.ts new file mode 100644 index 0000000..c50f292 --- /dev/null +++ b/src/modules/account/domain/mail-address.domain.ts @@ -0,0 +1,29 @@ +export interface MailAddressAttributes { + id: string; + mailAccountId: string; + address: string; + domainId: string; + isDefault: boolean; + providerExternalId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export class MailAddress { + readonly id!: string; + readonly mailAccountId!: string; + readonly address!: string; + readonly domainId!: string; + readonly isDefault!: boolean; + readonly providerExternalId!: string | null; + readonly createdAt!: Date; + readonly updatedAt!: Date; + + private constructor(attributes: MailAddressAttributes) { + Object.assign(this, attributes); + } + + static build(attributes: MailAddressAttributes): MailAddress { + return new MailAddress(attributes); + } +} diff --git a/src/modules/account/domain/mail-domain.domain.ts b/src/modules/account/domain/mail-domain.domain.ts new file mode 100644 index 0000000..b88cb24 --- /dev/null +++ b/src/modules/account/domain/mail-domain.domain.ts @@ -0,0 +1,23 @@ +export interface MailDomainAttributes { + id: string; + domain: string; + status: string; + createdAt: Date; + updatedAt: Date; +} + +export class MailDomain { + readonly id!: string; + readonly domain!: string; + readonly status!: string; + readonly createdAt!: Date; + readonly updatedAt!: Date; + + private constructor(attributes: MailDomainAttributes) { + Object.assign(this, attributes); + } + + static build(attributes: MailDomainAttributes): MailDomain { + return new MailDomain(attributes); + } +} diff --git a/src/modules/account/repositories/account.repository.ts b/src/modules/account/repositories/account.repository.ts new file mode 100644 index 0000000..47f3696 --- /dev/null +++ b/src/modules/account/repositories/account.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { MailAccount } from '../domain/mail-account.domain.js'; +import { MailAccountModel } from '../models/mail-account.model.js'; +import { MailAddressModel } from '../models/mail-address.model.js'; +import { MailProviderAccountModel } from '../models/mail-provider-account.model.js'; +import { toAddressAttributes } from './address.repository.js'; + +@Injectable() +export class AccountRepository { + constructor( + @InjectModel(MailAccountModel) + private readonly accountModel: typeof MailAccountModel, + ) {} + + async findByDriveUserUuid(uuid: string): Promise { + const model = await this.accountModel.findOne({ + where: { driveUserUuid: uuid }, + include: [ + { + model: MailAddressModel, + include: [MailProviderAccountModel], + }, + ], + }); + + return model ? this.toDomain(model) : null; + } + + async delete(id: string): Promise { + await this.accountModel.destroy({ where: { id } }); + } + + private toDomain(model: MailAccountModel): MailAccount { + return MailAccount.build({ + id: model.id, + driveUserUuid: model.driveUserUuid, + createdAt: model.createdAt as Date, + updatedAt: model.updatedAt as Date, + addresses: (model.addresses ?? []).map(toAddressAttributes), + }); + } +} diff --git a/src/modules/account/repositories/address.repository.ts b/src/modules/account/repositories/address.repository.ts new file mode 100644 index 0000000..82dfbc9 --- /dev/null +++ b/src/modules/account/repositories/address.repository.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { Sequelize } from 'sequelize-typescript'; +import { + MailAddress, + type MailAddressAttributes, +} from '../domain/mail-address.domain.js'; +import { MailAddressModel } from '../models/mail-address.model.js'; +import { MailProviderAccountModel } from '../models/mail-provider-account.model.js'; + +export function toAddressAttributes( + model: MailAddressModel, +): MailAddressAttributes { + return { + id: model.id, + mailAccountId: model.mailAccountId, + address: model.address, + domainId: model.domainId, + isDefault: model.isDefault, + providerExternalId: model.providerAccount?.externalId ?? null, + createdAt: model.createdAt as Date, + updatedAt: model.updatedAt as Date, + }; +} + +@Injectable() +export class AddressRepository { + constructor( + @InjectModel(MailAddressModel) + private readonly addressModel: typeof MailAddressModel, + @InjectModel(MailProviderAccountModel) + private readonly providerAccountModel: typeof MailProviderAccountModel, + private readonly sequelize: Sequelize, + ) {} + + async findByAddress(address: string): Promise { + const model = await this.addressModel.findOne({ + where: { address }, + include: [MailProviderAccountModel], + }); + + return model ? MailAddress.build(toAddressAttributes(model)) : null; + } + + async findDefaultForAccount( + mailAccountId: string, + ): Promise { + const model = await this.addressModel.findOne({ + where: { mailAccountId, isDefault: true }, + include: [MailProviderAccountModel], + }); + + return model ? MailAddress.build(toAddressAttributes(model)) : null; + } + + async findAllForAccount(mailAccountId: string): Promise { + const models = await this.addressModel.findAll({ + where: { mailAccountId }, + include: [MailProviderAccountModel], + }); + + return models.map((m) => MailAddress.build(toAddressAttributes(m))); + } + + async create(params: { + mailAccountId: string; + address: string; + domainId: string; + isDefault: boolean; + }): Promise { + const model = await this.addressModel.create(params); + return MailAddress.build(toAddressAttributes(model)); + } + + async delete(id: string): Promise { + await this.addressModel.destroy({ where: { id } }); + } + + async setDefault(addressId: string, mailAccountId: string): Promise { + await this.sequelize.query( + `UPDATE mail_addresses SET is_default = (id = :addressId) WHERE mail_account_id = :mailAccountId`, + { replacements: { addressId, mailAccountId } }, + ); + } + + async createProviderLink(params: { + mailAddressId: string; + provider: string; + externalId: string; + }): Promise { + await this.providerAccountModel.create(params); + } + + async deleteProviderLink(mailAddressId: string): Promise { + await this.providerAccountModel.destroy({ where: { mailAddressId } }); + } + + async updateAllProviderExternalIds( + mailAccountId: string, + newExternalId: string, + ): Promise { + await this.sequelize.query( + `UPDATE mail_provider_accounts SET external_id = :newExternalId + WHERE mail_address_id IN (SELECT id FROM mail_addresses WHERE mail_account_id = :mailAccountId)`, + { replacements: { newExternalId, mailAccountId } }, + ); + } +} diff --git a/src/modules/account/repositories/domain.repository.ts b/src/modules/account/repositories/domain.repository.ts new file mode 100644 index 0000000..92a7452 --- /dev/null +++ b/src/modules/account/repositories/domain.repository.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { MailDomain } from '../domain/mail-domain.domain.js'; +import { MailDomainModel } from '../models/mail-domain.model.js'; + +@Injectable() +export class DomainRepository { + constructor( + @InjectModel(MailDomainModel) + private readonly domainModel: typeof MailDomainModel, + ) {} + + async findByDomain(domain: string): Promise { + const model = await this.domainModel.findOne({ where: { domain } }); + return model ? this.toDomain(model) : null; + } + + private toDomain(model: MailDomainModel): MailDomain { + return MailDomain.build({ + id: model.id, + domain: model.domain, + status: model.status, + createdAt: model.createdAt as Date, + updatedAt: model.updatedAt as Date, + }); + } +} diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts index aa475ea..674926a 100644 --- a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts @@ -1,9 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; -import { AccountProvider } from '../../email/account-provider.port.js'; +import { AccountProvider } from '../../account/account-provider.port.js'; import type { AccountInfo, CreateAccountParams, -} from '../../email/account.types.js'; +} from '../../account/account.types.js'; import { StalwartService } from './stalwart.service.js'; @Injectable() @@ -88,26 +88,4 @@ export class StalwartAccountProvider extends AccountProvider { `Renamed account '${currentName}' → '${newPrimaryAddress}' (delete + recreate)`, ); } - - async updateQuota(name: string, bytes: number): Promise { - await this.stalwart.patchPrincipal(name, [ - { action: 'set', field: 'quota', value: bytes }, - ]); - - this.logger.log(`Updated quota for '${name}' to ${bytes} bytes`); - } - - async createDomain(domain: string): Promise { - await this.stalwart.createPrincipal({ - type: 'domain', - name: domain, - }); - - this.logger.log(`Created domain '${domain}'`); - } - - async deleteDomain(domain: string): Promise { - await this.stalwart.deletePrincipal(domain); - this.logger.log(`Deleted domain '${domain}'`); - } } diff --git a/src/modules/infrastructure/stalwart/stalwart.module.ts b/src/modules/infrastructure/stalwart/stalwart.module.ts index d833acf..0d838f0 100644 --- a/src/modules/infrastructure/stalwart/stalwart.module.ts +++ b/src/modules/infrastructure/stalwart/stalwart.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { AccountProvider } from '../../email/account-provider.port.js'; +import { AccountProvider } from '../../account/account-provider.port.js'; import { StalwartService } from './stalwart.service.js'; import { StalwartAccountProvider } from './stalwart-account.provider.js'; diff --git a/test/fixtures.ts b/test/fixtures.ts index 703c6ab..74a9bfb 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -25,14 +25,12 @@ function randomId(): string { } function randomISODate(): string { - return random.date({ year: 2025 }).toISOString(); + return random.date({ year: 2025 }).toString(); } // ── Domain Fixtures ──────────────────────────────────────────────── -export function newEmailAddress( - attrs?: Partial, -): EmailAddress { +export function newEmailAddress(attrs?: Partial): EmailAddress { return { name: random.name(), email: random.email(), @@ -40,9 +38,7 @@ export function newEmailAddress( }; } -export function newMailbox( - attrs?: Partial, -): Mailbox { +export function newMailbox(attrs?: Partial): Mailbox { return { id: randomId(), name: random.word(), @@ -61,9 +57,7 @@ export function newMailbox( }; } -export function newEmailSummary( - attrs?: Partial, -): EmailSummary { +export function newEmailSummary(attrs?: Partial): EmailSummary { return { id: randomId(), threadId: randomId(), @@ -80,9 +74,7 @@ export function newEmailSummary( }; } -export function newEmail( - attrs?: Partial, -): Email { +export function newEmail(attrs?: Partial): Email { const summary = newEmailSummary(attrs); return { ...summary, @@ -96,9 +88,7 @@ export function newEmail( }; } -export function newSendEmailDto( - attrs?: Partial, -): SendEmailDto { +export function newSendEmailDto(attrs?: Partial): SendEmailDto { return { to: [newEmailAddress()], subject: random.sentence({ words: 5 }), @@ -125,9 +115,7 @@ export const newJmapEmailAddress = newEmailAddress as ( attrs?: Partial, ) => JmapEmailAddress; -export function newJmapMailbox( - attrs?: Partial, -): JmapMailbox { +export function newJmapMailbox(attrs?: Partial): JmapMailbox { return { id: randomId(), name: random.word(), @@ -150,9 +138,7 @@ export function newJmapMailbox( }; } -export function newJmapEmail( - attrs?: Partial, -): JmapEmail { +export function newJmapEmail(attrs?: Partial): JmapEmail { const textPartId = randomId(); const htmlPartId = randomId(); @@ -194,9 +180,7 @@ export function newJmapEmail( }; } -export function newJmapIdentity( - attrs?: Partial, -): Identity { +export function newJmapIdentity(attrs?: Partial): Identity { return { id: randomId(), name: random.name(), From 3219709b01061ff4203fa7596f3e2e3a296e2e3d Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:27:17 -0600 Subject: [PATCH 4/7] feat: add unit tests for AccountService and StalwartAccountProvider - Introduced comprehensive unit tests for AccountService, covering account retrieval, deletion, and address management functionalities. - Added unit tests for StalwartAccountProvider, validating account creation, deletion, and address manipulation methods. - Updated ESLint configuration to disable unbound-method rule for test files. - Enhanced test fixtures to support new account and address attributes for improved test coverage. --- eslint.config.mjs | 6 + src/modules/account/account.service.spec.ts | 361 ++++++++++++++++++ .../stalwart-account.provider.spec.ts | 160 ++++++++ .../stalwart/stalwart.service.spec.ts | 210 ++++++++++ test/fixtures.ts | 83 ++++ 5 files changed, 820 insertions(+) create mode 100644 src/modules/account/account.service.spec.ts create mode 100644 src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts create mode 100644 src/modules/infrastructure/stalwart/stalwart.service.spec.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 015a68f..39a9d18 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -39,4 +39,10 @@ export default [ ], }, }, + { + files: ['**/*.spec.ts', '**/*.test.ts'], + rules: { + '@typescript-eslint/unbound-method': 'off', + }, + }, ]; diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts new file mode 100644 index 0000000..48328ae --- /dev/null +++ b/src/modules/account/account.service.spec.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { + ConflictException, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { AccountService } from './account.service.js'; +import { AccountProvider } from './account-provider.port.js'; +import { MailAccount } from './domain/mail-account.domain.js'; +import { MailDomain } from './domain/mail-domain.domain.js'; +import { MailAddress } from './domain/mail-address.domain.js'; +import { AccountRepository } from './repositories/account.repository.js'; +import { AddressRepository } from './repositories/address.repository.js'; +import { DomainRepository } from './repositories/domain.repository.js'; +import { + newMailAccountAttributes, + newMailAddressAttributes, + newMailDomainAttributes, +} from '../../../test/fixtures.js'; + +describe('AccountService', () => { + let service: AccountService; + let provider: DeepMocked; + let accounts: DeepMocked; + let addresses: DeepMocked; + let domains: DeepMocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AccountService], + }) + .useMocker(() => createMock()) + .compile(); + + service = module.get(AccountService); + provider = module.get(AccountProvider); + accounts = module.get(AccountRepository); + addresses = module.get(AddressRepository); + domains = module.get(DomainRepository); + }); + + describe('getAccount', () => { + it('when account exists, then returns it', async () => { + const attrs = newMailAccountAttributes(); + const account = MailAccount.build(attrs); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + const result = await service.getAccount(attrs.driveUserUuid); + + expect(accounts.findByDriveUserUuid).toHaveBeenCalledWith( + attrs.driveUserUuid, + ); + expect(result).toBe(account); + }); + + it('when account does not exist, then throws NotFoundException', async () => { + accounts.findByDriveUserUuid.mockResolvedValue(null); + + await expect(service.getAccount('unknown-uuid')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('deleteAccount', () => { + it('when account has a principal name, then deletes from provider and DB', async () => { + const attrs = newMailAccountAttributes(); + const account = MailAccount.build(attrs); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await service.deleteAccount(attrs.driveUserUuid); + + expect(provider.deleteAccount).toHaveBeenCalledWith( + account.principalName, + ); + expect(accounts.delete).toHaveBeenCalledWith(account.id); + }); + + it('when account has no principal name, then only deletes from DB', async () => { + const attrs = newMailAccountAttributes({ + addresses: [ + newMailAddressAttributes({ + isDefault: true, + providerExternalId: null, + }), + ], + }); + const account = MailAccount.build(attrs); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await service.deleteAccount(attrs.driveUserUuid); + + expect(provider.deleteAccount).not.toHaveBeenCalled(); + expect(accounts.delete).toHaveBeenCalledWith(account.id); + }); + + it('when account does not exist, then throws NotFoundException', async () => { + accounts.findByDriveUserUuid.mockResolvedValue(null); + + await expect(service.deleteAccount('unknown')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('addAddress', () => { + it('when all conditions met, then creates address and links provider', async () => { + const accountAttrs = newMailAccountAttributes(); + const account = MailAccount.build(accountAttrs); + const domainAttrs = newMailDomainAttributes(); + const domain = MailDomain.build(domainAttrs); + const newAddr = 'new@example.com'; + const createdAddress = MailAddress.build( + newMailAddressAttributes({ address: newAddr }), + ); + + accounts.findByDriveUserUuid.mockResolvedValue(account); + domains.findByDomain.mockResolvedValue(domain); + addresses.findByAddress.mockResolvedValue(null); + addresses.create.mockResolvedValue(createdAddress); + + await service.addAddress( + accountAttrs.driveUserUuid, + newAddr, + domainAttrs.domain, + ); + + expect(addresses.create).toHaveBeenCalledWith({ + mailAccountId: account.id, + address: newAddr, + domainId: domain.id, + isDefault: false, + }); + expect(provider.addAddress).toHaveBeenCalledWith( + account.principalName, + newAddr, + ); + expect(addresses.createProviderLink).toHaveBeenCalledWith({ + mailAddressId: createdAddress.id, + provider: 'stalwart', + externalId: account.principalName, + }); + }); + + it('when account not found, then throws NotFoundException', async () => { + accounts.findByDriveUserUuid.mockResolvedValue(null); + domains.findByDomain.mockResolvedValue( + MailDomain.build(newMailDomainAttributes()), + ); + addresses.findByAddress.mockResolvedValue(null); + + await expect( + service.addAddress('unknown', 'a@b.com', 'b.com'), + ).rejects.toThrow(NotFoundException); + }); + + it('when domain not found, then throws NotFoundException', async () => { + const account = MailAccount.build(newMailAccountAttributes()); + accounts.findByDriveUserUuid.mockResolvedValue(account); + domains.findByDomain.mockResolvedValue(null); + addresses.findByAddress.mockResolvedValue(null); + + await expect( + service.addAddress(account.driveUserUuid, 'a@b.com', 'unknown.com'), + ).rejects.toThrow(NotFoundException); + }); + + it('when address already exists, then throws ConflictException', async () => { + const account = MailAccount.build(newMailAccountAttributes()); + const domain = MailDomain.build(newMailDomainAttributes()); + const existing = MailAddress.build(newMailAddressAttributes()); + + accounts.findByDriveUserUuid.mockResolvedValue(account); + domains.findByDomain.mockResolvedValue(domain); + addresses.findByAddress.mockResolvedValue(existing); + + await expect( + service.addAddress( + account.driveUserUuid, + existing.address, + domain.domain, + ), + ).rejects.toThrow(ConflictException); + }); + + it('when provider fails, then rolls back the created address', async () => { + const account = MailAccount.build(newMailAccountAttributes()); + const domain = MailDomain.build(newMailDomainAttributes()); + const createdAddress = MailAddress.build( + newMailAddressAttributes({ address: 'new@example.com' }), + ); + + accounts.findByDriveUserUuid.mockResolvedValue(account); + domains.findByDomain.mockResolvedValue(domain); + addresses.findByAddress.mockResolvedValue(null); + addresses.create.mockResolvedValue(createdAddress); + provider.addAddress.mockRejectedValue(new Error('provider down')); + + await expect( + service.addAddress( + account.driveUserUuid, + 'new@example.com', + domain.domain, + ), + ).rejects.toThrow('provider down'); + + expect(addresses.delete).toHaveBeenCalledWith(createdAddress.id); + expect(addresses.createProviderLink).not.toHaveBeenCalled(); + }); + + it('when account has no principal name, then throws UnprocessableEntityException', async () => { + const account = MailAccount.build( + newMailAccountAttributes({ + addresses: [ + newMailAddressAttributes({ + isDefault: true, + providerExternalId: null, + }), + ], + }), + ); + const domain = MailDomain.build(newMailDomainAttributes()); + + accounts.findByDriveUserUuid.mockResolvedValue(account); + domains.findByDomain.mockResolvedValue(domain); + addresses.findByAddress.mockResolvedValue(null); + + await expect( + service.addAddress( + account.driveUserUuid, + 'new@example.com', + domain.domain, + ), + ).rejects.toThrow(UnprocessableEntityException); + }); + }); + + describe('removeAddress', () => { + it('when address exists and is not default, then removes it', async () => { + const nonDefaultAddr = newMailAddressAttributes({ isDefault: false }); + const account = MailAccount.build( + newMailAccountAttributes({ + addresses: [ + newMailAddressAttributes({ isDefault: true }), + nonDefaultAddr, + ], + }), + ); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await service.removeAddress( + account.driveUserUuid, + nonDefaultAddr.address, + ); + + expect(provider.removeAddress).toHaveBeenCalledWith( + account.principalName, + nonDefaultAddr.address, + ); + expect(addresses.deleteProviderLink).toHaveBeenCalledWith( + nonDefaultAddr.id, + ); + expect(addresses.delete).toHaveBeenCalledWith(nonDefaultAddr.id); + }); + + it('when address is default, then throws UnprocessableEntityException', async () => { + const defaultAddr = newMailAddressAttributes({ isDefault: true }); + const account = MailAccount.build( + newMailAccountAttributes({ addresses: [defaultAddr] }), + ); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await expect( + service.removeAddress(account.driveUserUuid, defaultAddr.address), + ).rejects.toThrow(UnprocessableEntityException); + }); + + it('when address not found for account, then throws NotFoundException', async () => { + const account = MailAccount.build(newMailAccountAttributes()); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await expect( + service.removeAddress(account.driveUserUuid, 'nonexistent@mail.com'), + ).rejects.toThrow(NotFoundException); + }); + + it('when account not found, then throws NotFoundException', async () => { + accounts.findByDriveUserUuid.mockResolvedValue(null); + + await expect(service.removeAddress('unknown', 'a@b.com')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('setPrimaryAddress', () => { + it('when address exists and is not default, then sets it as primary', async () => { + const defaultAddr = newMailAddressAttributes({ isDefault: true }); + const otherAddr = newMailAddressAttributes({ isDefault: false }); + const account = MailAccount.build( + newMailAccountAttributes({ + addresses: [defaultAddr, otherAddr], + }), + ); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await service.setPrimaryAddress(account.driveUserUuid, otherAddr.address); + + expect(provider.setPrimaryAddress).toHaveBeenCalledWith( + account.principalName, + otherAddr.address, + ); + expect(addresses.setDefault).toHaveBeenCalledWith( + otherAddr.id, + account.id, + ); + expect(addresses.updateAllProviderExternalIds).toHaveBeenCalledWith( + account.id, + otherAddr.address, + ); + }); + + it('when address is already default, then does nothing', async () => { + const defaultAddr = newMailAddressAttributes({ isDefault: true }); + const account = MailAccount.build( + newMailAccountAttributes({ addresses: [defaultAddr] }), + ); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await service.setPrimaryAddress( + account.driveUserUuid, + defaultAddr.address, + ); + + expect(provider.setPrimaryAddress).not.toHaveBeenCalled(); + expect(addresses.setDefault).not.toHaveBeenCalled(); + }); + + it('when address not found for account, then throws NotFoundException', async () => { + const account = MailAccount.build(newMailAccountAttributes()); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await expect( + service.setPrimaryAddress( + account.driveUserUuid, + 'nonexistent@mail.com', + ), + ).rejects.toThrow(NotFoundException); + }); + + it('when account not found, then throws NotFoundException', async () => { + accounts.findByDriveUserUuid.mockResolvedValue(null); + + await expect( + service.setPrimaryAddress('unknown', 'a@b.com'), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts new file mode 100644 index 0000000..fe44ab9 --- /dev/null +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { StalwartAccountProvider } from './stalwart-account.provider.js'; +import { StalwartService } from './stalwart.service.js'; +import { newCreateAccountParams } from '../../../../test/fixtures.js'; + +describe('StalwartAccountProvider', () => { + let provider: StalwartAccountProvider; + let stalwart: DeepMocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [StalwartAccountProvider], + }) + .useMocker(() => createMock()) + .compile(); + + provider = module.get(StalwartAccountProvider); + stalwart = module.get(StalwartService); + }); + + describe('createAccount', () => { + it('when called, then creates principal with correct shape', async () => { + const params = newCreateAccountParams(); + + await provider.createAccount(params); + + expect(stalwart.createPrincipal).toHaveBeenCalledWith({ + type: 'individual', + name: params.primaryAddress, + description: params.displayName, + secrets: [params.password], + emails: [params.primaryAddress], + quota: params.quota, + }); + }); + + it('when quota is undefined, then defaults to 0', async () => { + const params = newCreateAccountParams({ quota: undefined }); + + await provider.createAccount(params); + + expect(stalwart.createPrincipal).toHaveBeenCalledWith( + expect.objectContaining({ quota: 0 }), + ); + }); + }); + + describe('deleteAccount', () => { + it('when called, then delegates to stalwart service', async () => { + await provider.deleteAccount('user@example.com'); + + expect(stalwart.deletePrincipal).toHaveBeenCalledWith('user@example.com'); + }); + }); + + describe('getAccount', () => { + it('when principal exists, then returns account info', async () => { + stalwart.getPrincipal.mockResolvedValue({ + name: 'user@example.com', + type: 'individual', + description: 'User Name', + emails: ['user@example.com', 'alias@example.com'], + quota: 5_000_000, + }); + + const result = await provider.getAccount('user@example.com'); + + expect(result).toEqual({ + name: 'user@example.com', + displayName: 'User Name', + emails: ['user@example.com', 'alias@example.com'], + quota: 5_000_000, + }); + }); + + it('when principal does not exist, then returns null', async () => { + stalwart.getPrincipal.mockResolvedValue(null); + + const result = await provider.getAccount('nonexistent@example.com'); + + expect(result).toBeNull(); + }); + + it('when principal has no optional fields, then uses defaults', async () => { + stalwart.getPrincipal.mockResolvedValue({ + name: 'user@example.com', + type: 'individual', + }); + + const result = await provider.getAccount('user@example.com'); + + expect(result).toEqual({ + name: 'user@example.com', + displayName: '', + emails: [], + quota: 0, + }); + }); + }); + + describe('addAddress', () => { + it('when called, then patches principal with addItem action', async () => { + await provider.addAddress('user@example.com', 'alias@example.com'); + + expect(stalwart.patchPrincipal).toHaveBeenCalledWith('user@example.com', [ + { action: 'addItem', field: 'emails', value: 'alias@example.com' }, + ]); + }); + }); + + describe('removeAddress', () => { + it('when called, then patches principal with removeItem action', async () => { + await provider.removeAddress('user@example.com', 'alias@example.com'); + + expect(stalwart.patchPrincipal).toHaveBeenCalledWith('user@example.com', [ + { + action: 'removeItem', + field: 'emails', + value: 'alias@example.com', + }, + ]); + }); + }); + + describe('setPrimaryAddress', () => { + it('when account exists, then recreates with new name and reordered emails', async () => { + const existingPrincipal = { + name: 'old@example.com', + type: 'individual', + description: 'User', + secrets: ['pass'], + emails: ['old@example.com', 'new@example.com', 'other@example.com'], + quota: 1000, + }; + stalwart.getPrincipal.mockResolvedValue(existingPrincipal); + + await provider.setPrimaryAddress('old@example.com', 'new@example.com'); + + expect(stalwart.deletePrincipal).toHaveBeenCalledWith('old@example.com'); + expect(stalwart.createPrincipal).toHaveBeenCalledWith({ + ...existingPrincipal, + name: 'new@example.com', + emails: ['new@example.com', 'old@example.com', 'other@example.com'], + }); + }); + + it('when account does not exist, then throws error', async () => { + stalwart.getPrincipal.mockResolvedValue(null); + + await expect( + provider.setPrimaryAddress( + 'nonexistent@example.com', + 'new@example.com', + ), + ).rejects.toThrow("Account 'nonexistent@example.com' not found"); + }); + }); +}); diff --git a/src/modules/infrastructure/stalwart/stalwart.service.spec.ts b/src/modules/infrastructure/stalwart/stalwart.service.spec.ts new file mode 100644 index 0000000..e9f27cf --- /dev/null +++ b/src/modules/infrastructure/stalwart/stalwart.service.spec.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { type ConfigService } from '@nestjs/config'; +import { StalwartService, StalwartApiError } from './stalwart.service.js'; + +// Mock undici Client +const mockRequest = vi.fn(); +vi.mock('undici', () => ({ + Client: vi.fn().mockImplementation(() => ({ + request: mockRequest, + close: vi.fn(), + })), +})); + +function createConfigService(): ConfigService { + const config: Record = { + 'stalwart.adminUrl': 'http://localhost:8080', + 'stalwart.adminUser': 'admin', + 'stalwart.adminSecret': 'secret', + }; + return { + getOrThrow: vi.fn((key: string) => { + const value = config[key]; + if (!value) throw new Error(`Missing config: ${key}`); + return value; + }), + } as unknown as ConfigService; +} + +function mockResponse(statusCode: number, responseBody: string | object) { + const text = + typeof responseBody === 'string' + ? responseBody + : JSON.stringify(responseBody); + return { statusCode, body: { text: vi.fn().mockResolvedValue(text) } }; +} + +describe('StalwartService', () => { + let service: StalwartService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new StalwartService(createConfigService()); + service.onModuleInit(); + }); + + describe('createPrincipal', () => { + it('when server returns 201, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(201, 'ok')); + + await expect( + service.createPrincipal({ + name: 'user@test.com', + type: 'individual', + }), + ).resolves.toBeUndefined(); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/api/principal', + }), + ); + }); + + it('when server returns 200, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(200, 'ok')); + + await expect( + service.createPrincipal({ + name: 'user@test.com', + type: 'individual', + }), + ).resolves.toBeUndefined(); + }); + + it('when server returns error, then throws StalwartApiError', async () => { + mockRequest.mockResolvedValue(mockResponse(409, 'conflict')); + + await expect( + service.createPrincipal({ + name: 'user@test.com', + type: 'individual', + }), + ).rejects.toThrow(StalwartApiError); + }); + }); + + describe('getPrincipal', () => { + it('when principal exists, then returns parsed data', async () => { + const principal = { + name: 'user@test.com', + type: 'individual', + emails: ['user@test.com'], + }; + mockRequest.mockResolvedValue(mockResponse(200, { data: principal })); + + const result = await service.getPrincipal('user@test.com'); + + expect(result).toEqual(principal); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/api/principal/user%40test.com', + }), + ); + }); + + it('when principal not found, then returns null', async () => { + mockRequest.mockResolvedValue(mockResponse(404, 'not found')); + + const result = await service.getPrincipal('unknown@test.com'); + + expect(result).toBeNull(); + }); + + it('when server returns error, then throws StalwartApiError', async () => { + mockRequest.mockResolvedValue(mockResponse(500, 'internal error')); + + await expect(service.getPrincipal('user@test.com')).rejects.toThrow( + StalwartApiError, + ); + }); + }); + + describe('patchPrincipal', () => { + it('when server returns 200, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(200, 'ok')); + + await expect( + service.patchPrincipal('user@test.com', [ + { action: 'addItem', field: 'emails', value: 'alias@test.com' }, + ]), + ).resolves.toBeUndefined(); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PATCH', + path: '/api/principal/user%40test.com', + }), + ); + }); + + it('when server returns 204, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(204, '')); + + await expect( + service.patchPrincipal('user@test.com', [ + { action: 'set', field: 'quota', value: 1000 }, + ]), + ).resolves.toBeUndefined(); + }); + + it('when server returns error, then throws StalwartApiError', async () => { + mockRequest.mockResolvedValue(mockResponse(400, 'bad request')); + + await expect(service.patchPrincipal('user@test.com', [])).rejects.toThrow( + StalwartApiError, + ); + }); + }); + + describe('deletePrincipal', () => { + it('when server returns 200, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(200, 'ok')); + + await expect( + service.deletePrincipal('user@test.com'), + ).resolves.toBeUndefined(); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + path: '/api/principal/user%40test.com', + }), + ); + }); + + it('when server returns 204, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(204, '')); + + await expect( + service.deletePrincipal('user@test.com'), + ).resolves.toBeUndefined(); + }); + + it('when server returns error, then throws StalwartApiError', async () => { + mockRequest.mockResolvedValue(mockResponse(404, 'not found')); + + await expect(service.deletePrincipal('unknown@test.com')).rejects.toThrow( + StalwartApiError, + ); + }); + }); + + describe('headers', () => { + it('when request is made, then includes Basic auth with correct credentials', async () => { + mockRequest.mockResolvedValue(mockResponse(200, { data: {} })); + + await service.getPrincipal('test'); + + const expectedAuth = `Basic ${Buffer.from('admin:secret').toString('base64')}`; + const callArgs = mockRequest.mock.calls[0]![0] as { + headers: Record; + }; + expect(callArgs.headers.authorization).toBe(expectedAuth); + expect(callArgs.headers['content-type']).toBe('application/json'); + expect(callArgs.headers.accept).toBe('application/json'); + }); + }); +}); diff --git a/test/fixtures.ts b/test/fixtures.ts index 74a9bfb..d608133 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -16,6 +16,14 @@ import type { Identity, } from '../src/modules/infrastructure/jmap/jmap.types.js'; +import type { MailAccountAttributes } from '../src/modules/account/domain/mail-account.domain.js'; +import type { MailAddressAttributes } from '../src/modules/account/domain/mail-address.domain.js'; +import type { MailDomainAttributes } from '../src/modules/account/domain/mail-domain.domain.js'; +import type { + AccountInfo, + CreateAccountParams, +} from '../src/modules/account/account.types.js'; + const random = new Chance(); // ── Helpers ──────────────────────────────────────────────────────── @@ -24,6 +32,10 @@ function randomId(): string { return random.hash({ length: 24 }); } +function randomUuid(): string { + return random.guid({ version: 4 }); +} + function randomISODate(): string { return random.date({ year: 2025 }).toString(); } @@ -108,6 +120,77 @@ export function newDraftEmailDto( }; } +export function newMailAddressAttributes( + attrs?: Partial, +): MailAddressAttributes { + return { + id: randomUuid(), + mailAccountId: randomUuid(), + address: random.email(), + domainId: randomUuid(), + isDefault: false, + providerExternalId: random.email(), + createdAt: new Date(), + updatedAt: new Date(), + ...attrs, + }; +} + +export function newMailAccountAttributes( + attrs?: Partial, +): MailAccountAttributes { + const accountId = attrs?.id ?? randomUuid(); + return { + id: accountId, + driveUserUuid: randomUuid(), + addresses: [ + newMailAddressAttributes({ + mailAccountId: accountId, + isDefault: true, + }), + ], + createdAt: new Date(), + updatedAt: new Date(), + ...attrs, + }; +} + +export function newMailDomainAttributes( + attrs?: Partial, +): MailDomainAttributes { + return { + id: randomUuid(), + domain: random.domain(), + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + ...attrs, + }; +} + +export function newCreateAccountParams( + attrs?: Partial, +): CreateAccountParams { + return { + accountId: randomUuid(), + primaryAddress: random.email(), + displayName: random.name(), + password: random.hash({ length: 16 }), + quota: random.natural({ min: 1_000_000, max: 10_000_000 }), + ...attrs, + }; +} + +export function newAccountInfo(attrs?: Partial): AccountInfo { + return { + name: random.email(), + displayName: random.name(), + emails: [random.email(), random.email()], + quota: random.natural({ min: 1_000_000, max: 10_000_000 }), + ...attrs, + }; +} + // ── JMAP Fixtures ────────────────────────────────────────────────── // EmailAddress is structurally identical in both domain and JMAP types From 2c0c5f3910d45a8611217b42278b0164daf1f601 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:01:42 -0600 Subject: [PATCH 5/7] refactor: add deleted_at column to mail_accounts and mail_addresses tables - Introduced migrations to add a nullable deleted_at column to both mail_accounts and mail_addresses tables for soft deletion functionality. - Updated the MailAccount and MailAddress models to include the deletedAt field with paranoid option enabled for Sequelize. - Refactored AccountService to replace references from principalName to providerAccountId for consistency in account management. --- ...5105400-add-deleted-at-to-mail-accounts.js | 16 ++++++++++++ ...105401-add-deleted-at-to-mail-addresses.js | 16 ++++++++++++ src/modules/account/account.service.spec.ts | 10 +++---- src/modules/account/account.service.ts | 26 +++++++++---------- .../account/domain/mail-account.domain.ts | 2 +- .../account/models/mail-account.model.ts | 4 +++ .../account/models/mail-address.model.ts | 4 +++ 7 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 migrations/20260325105400-add-deleted-at-to-mail-accounts.js create mode 100644 migrations/20260325105401-add-deleted-at-to-mail-addresses.js diff --git a/migrations/20260325105400-add-deleted-at-to-mail-accounts.js b/migrations/20260325105400-add-deleted-at-to-mail-accounts.js new file mode 100644 index 0000000..91984af --- /dev/null +++ b/migrations/20260325105400-add-deleted-at-to-mail-accounts.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('mail_accounts', 'deleted_at', { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('mail_accounts', 'deleted_at'); + }, +}; diff --git a/migrations/20260325105401-add-deleted-at-to-mail-addresses.js b/migrations/20260325105401-add-deleted-at-to-mail-addresses.js new file mode 100644 index 0000000..1cd6d00 --- /dev/null +++ b/migrations/20260325105401-add-deleted-at-to-mail-addresses.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('mail_addresses', 'deleted_at', { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('mail_addresses', 'deleted_at'); + }, +}; diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index 48328ae..3b79473 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -73,7 +73,7 @@ describe('AccountService', () => { await service.deleteAccount(attrs.driveUserUuid); expect(provider.deleteAccount).toHaveBeenCalledWith( - account.principalName, + account.providerAccountId, ); expect(accounts.delete).toHaveBeenCalledWith(account.id); }); @@ -134,13 +134,13 @@ describe('AccountService', () => { isDefault: false, }); expect(provider.addAddress).toHaveBeenCalledWith( - account.principalName, + account.providerAccountId, newAddr, ); expect(addresses.createProviderLink).toHaveBeenCalledWith({ mailAddressId: createdAddress.id, provider: 'stalwart', - externalId: account.principalName, + externalId: account.providerAccountId, }); }); @@ -256,7 +256,7 @@ describe('AccountService', () => { ); expect(provider.removeAddress).toHaveBeenCalledWith( - account.principalName, + account.providerAccountId, nonDefaultAddr.address, ); expect(addresses.deleteProviderLink).toHaveBeenCalledWith( @@ -309,7 +309,7 @@ describe('AccountService', () => { await service.setPrimaryAddress(account.driveUserUuid, otherAddr.address); expect(provider.setPrimaryAddress).toHaveBeenCalledWith( - account.principalName, + account.providerAccountId, otherAddr.address, ); expect(addresses.setDefault).toHaveBeenCalledWith( diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 0be8151..8bc679b 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -29,8 +29,8 @@ export class AccountService { async deleteAccount(driveUserUuid: string): Promise { const account = await this.getAccountOrFail(driveUserUuid); - if (account.principalName) { - await this.provider.deleteAccount(account.principalName); + if (account.providerAccountId) { + await this.provider.deleteAccount(account.providerAccountId); } await this.accounts.delete(account.id); @@ -53,7 +53,7 @@ export class AccountService { `No mail account for drive user '${driveUserUuid}'`, ); } - const principalName = this.requirePrincipalName(account); + const providerAccountId = this.requireProviderAccountId(account); if (!domain) { throw new NotFoundException(`Domain '${domainName}' not found`); @@ -70,7 +70,7 @@ export class AccountService { }); try { - await this.provider.addAddress(principalName, address); + await this.provider.addAddress(providerAccountId, address); } catch (error) { await this.addresses.delete(newAddress.id); throw error; @@ -79,7 +79,7 @@ export class AccountService { await this.addresses.createProviderLink({ mailAddressId: newAddress.id, provider: 'stalwart', - externalId: principalName, + externalId: providerAccountId, }); this.logger.log(`Added address '${address}' to account '${driveUserUuid}'`); @@ -101,9 +101,9 @@ export class AccountService { ); } - const principalName = this.requirePrincipalName(account); + const providerAccountId = this.requireProviderAccountId(account); - await this.provider.removeAddress(principalName, address); + await this.provider.removeAddress(providerAccountId, address); await Promise.all([ this.addresses.deleteProviderLink(addressRecord.id), this.addresses.delete(addressRecord.id), @@ -131,9 +131,9 @@ export class AccountService { if (addressRecord.isDefault) return; - const oldPrincipalName = this.requirePrincipalName(account); + const providerAccountId = this.requireProviderAccountId(account); - await this.provider.setPrimaryAddress(oldPrincipalName, newAddress); + await this.provider.setPrimaryAddress(providerAccountId, newAddress); await Promise.all([ this.addresses.setDefault(addressRecord.id, account.id), @@ -155,13 +155,13 @@ export class AccountService { return account; } - private requirePrincipalName(account: MailAccount): string { - const name = account.principalName; - if (!name) { + private requireProviderAccountId(account: MailAccount): string { + const id = account.providerAccountId; + if (!id) { throw new UnprocessableEntityException( 'Account has no primary address with a provider link', ); } - return name; + return id; } } diff --git a/src/modules/account/domain/mail-account.domain.ts b/src/modules/account/domain/mail-account.domain.ts index a66fd60..f0fda32 100644 --- a/src/modules/account/domain/mail-account.domain.ts +++ b/src/modules/account/domain/mail-account.domain.ts @@ -31,7 +31,7 @@ export class MailAccount { return this.addresses.find((a) => a.isDefault); } - get principalName(): string | null { + get providerAccountId(): string | null { return this.defaultAddress?.providerExternalId ?? null; } } diff --git a/src/modules/account/models/mail-account.model.ts b/src/modules/account/models/mail-account.model.ts index f97e870..de99767 100644 --- a/src/modules/account/models/mail-account.model.ts +++ b/src/modules/account/models/mail-account.model.ts @@ -14,6 +14,7 @@ import { MailAddressModel } from './mail-address.model.js'; @Table({ underscored: true, timestamps: true, + paranoid: true, tableName: 'mail_accounts', }) export class MailAccountModel extends Model { @@ -27,6 +28,9 @@ export class MailAccountModel extends Model { @Column(DataType.UUID) declare driveUserUuid: string; + @Column(DataType.DATE) + declare deletedAt: Date | null; + @HasMany(() => MailAddressModel) declare addresses: MailAddressModel[]; } diff --git a/src/modules/account/models/mail-address.model.ts b/src/modules/account/models/mail-address.model.ts index 1d07754..8b90abf 100644 --- a/src/modules/account/models/mail-address.model.ts +++ b/src/modules/account/models/mail-address.model.ts @@ -19,6 +19,7 @@ import { MailProviderAccountModel } from './mail-provider-account.model.js'; @Table({ underscored: true, timestamps: true, + paranoid: true, tableName: 'mail_addresses', }) export class MailAddressModel extends Model { @@ -49,6 +50,9 @@ export class MailAddressModel extends Model { @Column(DataType.BOOLEAN) declare isDefault: boolean; + @Column(DataType.DATE) + declare deletedAt: Date | null; + @BelongsTo(() => MailAccountModel) declare account: MailAccountModel; From 956048b125c4121b6c056ca4d1108d4fc2d8c1bd Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:29:21 -0600 Subject: [PATCH 6/7] refactor: rename drive_user_uuid to user_id in mail_accounts - Created a migration to rename the column drive_user_uuid to user_id in the mail_accounts table for improved clarity and consistency. - Updated related code in seeders, services, and repositories to reflect the new user_id field. - Adjusted unit tests to ensure proper functionality with the updated user identification method. --- ...00000-rename-drive-user-uuid-to-user-id.js | 20 +++ seeders/20260318200321-test-user.js | 6 +- src/modules/account/account.service.spec.ts | 148 +++++------------- src/modules/account/account.service.ts | 64 +++----- .../account/domain/mail-account.domain.ts | 8 +- .../account/models/mail-account.model.ts | 2 +- .../repositories/account.repository.ts | 6 +- test/fixtures.ts | 9 +- 8 files changed, 96 insertions(+), 167 deletions(-) create mode 100644 migrations/20260326100000-rename-drive-user-uuid-to-user-id.js diff --git a/migrations/20260326100000-rename-drive-user-uuid-to-user-id.js b/migrations/20260326100000-rename-drive-user-uuid-to-user-id.js new file mode 100644 index 0000000..f61207f --- /dev/null +++ b/migrations/20260326100000-rename-drive-user-uuid-to-user-id.js @@ -0,0 +1,20 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.renameColumn( + 'mail_accounts', + 'drive_user_uuid', + 'user_id', + ); + }, + + async down(queryInterface) { + await queryInterface.renameColumn( + 'mail_accounts', + 'user_id', + 'drive_user_uuid', + ); + }, +}; diff --git a/seeders/20260318200321-test-user.js b/seeders/20260318200321-test-user.js index 8c77f88..33a311c 100644 --- a/seeders/20260318200321-test-user.js +++ b/seeders/20260318200321-test-user.js @@ -14,7 +14,7 @@ const domain = { const account = { id: 'a1b2c3d4-0000-0000-0000-000000000002', // Matches the uuid of the test user in drive-server-wip seeders - drive_user_uuid: '87204d6b-c4a7-4f38-bd99-f7f47964a643', + user_id: '87204d6b-c4a7-4f38-bd99-f7f47964a643', created_at: new Date(), updated_at: new Date(), }; @@ -49,8 +49,8 @@ module.exports = { } const [existingAccount] = await queryInterface.sequelize.query( - 'SELECT id FROM mail_accounts WHERE drive_user_uuid = :uuid', - { replacements: { uuid: account.drive_user_uuid }, type: queryInterface.sequelize.QueryTypes.SELECT }, + 'SELECT id FROM mail_accounts WHERE user_id = :uuid', + { replacements: { uuid: account.user_id }, type: queryInterface.sequelize.QueryTypes.SELECT }, ); if (!existingAccount) { await queryInterface.bulkInsert('mail_accounts', [account]); diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index 3b79473..6852848 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -45,18 +45,16 @@ describe('AccountService', () => { it('when account exists, then returns it', async () => { const attrs = newMailAccountAttributes(); const account = MailAccount.build(attrs); - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); - const result = await service.getAccount(attrs.driveUserUuid); + const result = await service.getAccount(attrs.userId); - expect(accounts.findByDriveUserUuid).toHaveBeenCalledWith( - attrs.driveUserUuid, - ); + expect(accounts.findByUserId).toHaveBeenCalledWith(attrs.userId); expect(result).toBe(account); }); it('when account does not exist, then throws NotFoundException', async () => { - accounts.findByDriveUserUuid.mockResolvedValue(null); + accounts.findByUserId.mockResolvedValue(null); await expect(service.getAccount('unknown-uuid')).rejects.toThrow( NotFoundException, @@ -68,9 +66,9 @@ describe('AccountService', () => { it('when account has a principal name, then deletes from provider and DB', async () => { const attrs = newMailAccountAttributes(); const account = MailAccount.build(attrs); - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); - await service.deleteAccount(attrs.driveUserUuid); + await service.deleteAccount(attrs.userId); expect(provider.deleteAccount).toHaveBeenCalledWith( account.providerAccountId, @@ -78,26 +76,8 @@ describe('AccountService', () => { expect(accounts.delete).toHaveBeenCalledWith(account.id); }); - it('when account has no principal name, then only deletes from DB', async () => { - const attrs = newMailAccountAttributes({ - addresses: [ - newMailAddressAttributes({ - isDefault: true, - providerExternalId: null, - }), - ], - }); - const account = MailAccount.build(attrs); - accounts.findByDriveUserUuid.mockResolvedValue(account); - - await service.deleteAccount(attrs.driveUserUuid); - - expect(provider.deleteAccount).not.toHaveBeenCalled(); - expect(accounts.delete).toHaveBeenCalledWith(account.id); - }); - it('when account does not exist, then throws NotFoundException', async () => { - accounts.findByDriveUserUuid.mockResolvedValue(null); + accounts.findByUserId.mockResolvedValue(null); await expect(service.deleteAccount('unknown')).rejects.toThrow( NotFoundException, @@ -112,17 +92,15 @@ describe('AccountService', () => { const domainAttrs = newMailDomainAttributes(); const domain = MailDomain.build(domainAttrs); const newAddr = 'new@example.com'; - const createdAddress = MailAddress.build( - newMailAddressAttributes({ address: newAddr }), - ); + const newAddressId = 'new-address-id'; - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); domains.findByDomain.mockResolvedValue(domain); addresses.findByAddress.mockResolvedValue(null); - addresses.create.mockResolvedValue(createdAddress); + addresses.create.mockResolvedValue(newAddressId); await service.addAddress( - accountAttrs.driveUserUuid, + accountAttrs.userId, newAddr, domainAttrs.domain, ); @@ -138,14 +116,14 @@ describe('AccountService', () => { newAddr, ); expect(addresses.createProviderLink).toHaveBeenCalledWith({ - mailAddressId: createdAddress.id, + mailAddressId: newAddressId, provider: 'stalwart', externalId: account.providerAccountId, }); }); it('when account not found, then throws NotFoundException', async () => { - accounts.findByDriveUserUuid.mockResolvedValue(null); + accounts.findByUserId.mockResolvedValue(null); domains.findByDomain.mockResolvedValue( MailDomain.build(newMailDomainAttributes()), ); @@ -158,12 +136,12 @@ describe('AccountService', () => { it('when domain not found, then throws NotFoundException', async () => { const account = MailAccount.build(newMailAccountAttributes()); - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); domains.findByDomain.mockResolvedValue(null); addresses.findByAddress.mockResolvedValue(null); await expect( - service.addAddress(account.driveUserUuid, 'a@b.com', 'unknown.com'), + service.addAddress(account.userId, 'a@b.com', 'unknown.com'), ).rejects.toThrow(NotFoundException); }); @@ -172,69 +150,33 @@ describe('AccountService', () => { const domain = MailDomain.build(newMailDomainAttributes()); const existing = MailAddress.build(newMailAddressAttributes()); - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); domains.findByDomain.mockResolvedValue(domain); addresses.findByAddress.mockResolvedValue(existing); await expect( - service.addAddress( - account.driveUserUuid, - existing.address, - domain.domain, - ), + service.addAddress(account.userId, existing.address, domain.domain), ).rejects.toThrow(ConflictException); }); it('when provider fails, then rolls back the created address', async () => { const account = MailAccount.build(newMailAccountAttributes()); const domain = MailDomain.build(newMailDomainAttributes()); - const createdAddress = MailAddress.build( - newMailAddressAttributes({ address: 'new@example.com' }), - ); + const newAddressId = 'new-address-id'; - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); domains.findByDomain.mockResolvedValue(domain); addresses.findByAddress.mockResolvedValue(null); - addresses.create.mockResolvedValue(createdAddress); + addresses.create.mockResolvedValue(newAddressId); provider.addAddress.mockRejectedValue(new Error('provider down')); await expect( - service.addAddress( - account.driveUserUuid, - 'new@example.com', - domain.domain, - ), + service.addAddress(account.userId, 'new@example.com', domain.domain), ).rejects.toThrow('provider down'); - expect(addresses.delete).toHaveBeenCalledWith(createdAddress.id); + expect(addresses.delete).toHaveBeenCalledWith(newAddressId); expect(addresses.createProviderLink).not.toHaveBeenCalled(); }); - - it('when account has no principal name, then throws UnprocessableEntityException', async () => { - const account = MailAccount.build( - newMailAccountAttributes({ - addresses: [ - newMailAddressAttributes({ - isDefault: true, - providerExternalId: null, - }), - ], - }), - ); - const domain = MailDomain.build(newMailDomainAttributes()); - - accounts.findByDriveUserUuid.mockResolvedValue(account); - domains.findByDomain.mockResolvedValue(domain); - addresses.findByAddress.mockResolvedValue(null); - - await expect( - service.addAddress( - account.driveUserUuid, - 'new@example.com', - domain.domain, - ), - ).rejects.toThrow(UnprocessableEntityException); - }); }); describe('removeAddress', () => { @@ -248,12 +190,9 @@ describe('AccountService', () => { ], }), ); - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); - await service.removeAddress( - account.driveUserUuid, - nonDefaultAddr.address, - ); + await service.removeAddress(account.userId, nonDefaultAddr.address); expect(provider.removeAddress).toHaveBeenCalledWith( account.providerAccountId, @@ -270,24 +209,24 @@ describe('AccountService', () => { const account = MailAccount.build( newMailAccountAttributes({ addresses: [defaultAddr] }), ); - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); await expect( - service.removeAddress(account.driveUserUuid, defaultAddr.address), + service.removeAddress(account.userId, defaultAddr.address), ).rejects.toThrow(UnprocessableEntityException); }); it('when address not found for account, then throws NotFoundException', async () => { const account = MailAccount.build(newMailAccountAttributes()); - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); await expect( - service.removeAddress(account.driveUserUuid, 'nonexistent@mail.com'), + service.removeAddress(account.userId, 'nonexistent@mail.com'), ).rejects.toThrow(NotFoundException); }); it('when account not found, then throws NotFoundException', async () => { - accounts.findByDriveUserUuid.mockResolvedValue(null); + accounts.findByUserId.mockResolvedValue(null); await expect(service.removeAddress('unknown', 'a@b.com')).rejects.toThrow( NotFoundException, @@ -304,22 +243,14 @@ describe('AccountService', () => { addresses: [defaultAddr, otherAddr], }), ); - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); - await service.setPrimaryAddress(account.driveUserUuid, otherAddr.address); + await service.setPrimaryAddress(account.userId, otherAddr.address); - expect(provider.setPrimaryAddress).toHaveBeenCalledWith( - account.providerAccountId, - otherAddr.address, - ); expect(addresses.setDefault).toHaveBeenCalledWith( otherAddr.id, account.id, ); - expect(addresses.updateAllProviderExternalIds).toHaveBeenCalledWith( - account.id, - otherAddr.address, - ); }); it('when address is already default, then does nothing', async () => { @@ -327,31 +258,24 @@ describe('AccountService', () => { const account = MailAccount.build( newMailAccountAttributes({ addresses: [defaultAddr] }), ); - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); - await service.setPrimaryAddress( - account.driveUserUuid, - defaultAddr.address, - ); + await service.setPrimaryAddress(account.userId, defaultAddr.address); - expect(provider.setPrimaryAddress).not.toHaveBeenCalled(); expect(addresses.setDefault).not.toHaveBeenCalled(); }); it('when address not found for account, then throws NotFoundException', async () => { const account = MailAccount.build(newMailAccountAttributes()); - accounts.findByDriveUserUuid.mockResolvedValue(account); + accounts.findByUserId.mockResolvedValue(account); await expect( - service.setPrimaryAddress( - account.driveUserUuid, - 'nonexistent@mail.com', - ), + service.setPrimaryAddress(account.userId, 'nonexistent@mail.com'), ).rejects.toThrow(NotFoundException); }); it('when account not found, then throws NotFoundException', async () => { - accounts.findByDriveUserUuid.mockResolvedValue(null); + accounts.findByUserId.mockResolvedValue(null); await expect( service.setPrimaryAddress('unknown', 'a@b.com'), diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 8bc679b..697518d 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -22,36 +22,34 @@ export class AccountService { private readonly domains: DomainRepository, ) {} - async getAccount(driveUserUuid: string): Promise { - return this.getAccountOrFail(driveUserUuid); + async getAccount(userId: string): Promise { + return this.getAccountOrFail(userId); } - async deleteAccount(driveUserUuid: string): Promise { - const account = await this.getAccountOrFail(driveUserUuid); + async deleteAccount(userId: string): Promise { + const account = await this.getAccountOrFail(userId); if (account.providerAccountId) { await this.provider.deleteAccount(account.providerAccountId); } await this.accounts.delete(account.id); - this.logger.log(`Deleted account for drive user '${driveUserUuid}'`); + this.logger.log(`Deleted account for user '${userId}'`); } async addAddress( - driveUserUuid: string, + userId: string, address: string, domainName: string, ): Promise { const [account, domain, existing] = await Promise.all([ - this.accounts.findByDriveUserUuid(driveUserUuid), + this.accounts.findByUserId(userId), this.domains.findByDomain(domainName), this.addresses.findByAddress(address), ]); if (!account) { - throw new NotFoundException( - `No mail account for drive user '${driveUserUuid}'`, - ); + throw new NotFoundException(`No mail account for user '${userId}'`); } const providerAccountId = this.requireProviderAccountId(account); @@ -62,7 +60,7 @@ export class AccountService { throw new ConflictException(`Address '${address}' already exists`); } - const newAddress = await this.addresses.create({ + const newAddressId = await this.addresses.create({ mailAccountId: account.id, address, domainId: domain.id, @@ -72,21 +70,21 @@ export class AccountService { try { await this.provider.addAddress(providerAccountId, address); } catch (error) { - await this.addresses.delete(newAddress.id); + await this.addresses.delete(newAddressId); throw error; } await this.addresses.createProviderLink({ - mailAddressId: newAddress.id, + mailAddressId: newAddressId, provider: 'stalwart', externalId: providerAccountId, }); - this.logger.log(`Added address '${address}' to account '${driveUserUuid}'`); + this.logger.log(`Added address '${address}' to account '${userId}'`); } - async removeAddress(driveUserUuid: string, address: string): Promise { - const account = await this.getAccountOrFail(driveUserUuid); + async removeAddress(userId: string, address: string): Promise { + const account = await this.getAccountOrFail(userId); const addressRecord = account.addresses.find((a) => a.address === address); if (!addressRecord) { @@ -109,16 +107,11 @@ export class AccountService { this.addresses.delete(addressRecord.id), ]); - this.logger.log( - `Removed address '${address}' from account '${driveUserUuid}'`, - ); + this.logger.log(`Removed address '${address}' from account '${userId}'`); } - async setPrimaryAddress( - driveUserUuid: string, - newAddress: string, - ): Promise { - const account = await this.getAccountOrFail(driveUserUuid); + async setPrimaryAddress(userId: string, newAddress: string): Promise { + const account = await this.getAccountOrFail(userId); const addressRecord = account.addresses.find( (a) => a.address === newAddress, @@ -131,26 +124,17 @@ export class AccountService { if (addressRecord.isDefault) return; - const providerAccountId = this.requireProviderAccountId(account); - - await this.provider.setPrimaryAddress(providerAccountId, newAddress); - - await Promise.all([ - this.addresses.setDefault(addressRecord.id, account.id), - this.addresses.updateAllProviderExternalIds(account.id, newAddress), - ]); + await this.addresses.setDefault(addressRecord.id, account.id); this.logger.log( - `Set primary address to '${newAddress}' for account '${driveUserUuid}'`, + `Set primary address to '${newAddress}' for account '${userId}'`, ); } - private async getAccountOrFail(driveUserUuid: string): Promise { - const account = await this.accounts.findByDriveUserUuid(driveUserUuid); + private async getAccountOrFail(userId: string): Promise { + const account = await this.accounts.findByUserId(userId); if (!account) { - throw new NotFoundException( - `No mail account for drive user '${driveUserUuid}'`, - ); + throw new NotFoundException(`No mail account for user '${userId}'`); } return account; } @@ -158,9 +142,7 @@ export class AccountService { private requireProviderAccountId(account: MailAccount): string { const id = account.providerAccountId; if (!id) { - throw new UnprocessableEntityException( - 'Account has no primary address with a provider link', - ); + throw new UnprocessableEntityException('Account has no primary address'); } return id; } diff --git a/src/modules/account/domain/mail-account.domain.ts b/src/modules/account/domain/mail-account.domain.ts index f0fda32..10dd1ae 100644 --- a/src/modules/account/domain/mail-account.domain.ts +++ b/src/modules/account/domain/mail-account.domain.ts @@ -5,7 +5,7 @@ import { export interface MailAccountAttributes { id: string; - driveUserUuid: string; + userId: string; addresses: MailAddressAttributes[]; createdAt: Date; updatedAt: Date; @@ -13,7 +13,7 @@ export interface MailAccountAttributes { export class MailAccount { readonly id!: string; - readonly driveUserUuid!: string; + readonly userId!: string; readonly addresses!: MailAddress[]; readonly createdAt!: Date; readonly updatedAt!: Date; @@ -31,7 +31,7 @@ export class MailAccount { return this.addresses.find((a) => a.isDefault); } - get providerAccountId(): string | null { - return this.defaultAddress?.providerExternalId ?? null; + get providerAccountId(): string | undefined { + return this.defaultAddress?.providerExternalId; } } diff --git a/src/modules/account/models/mail-account.model.ts b/src/modules/account/models/mail-account.model.ts index de99767..8928f84 100644 --- a/src/modules/account/models/mail-account.model.ts +++ b/src/modules/account/models/mail-account.model.ts @@ -26,7 +26,7 @@ export class MailAccountModel extends Model { @AllowNull(false) @Unique @Column(DataType.UUID) - declare driveUserUuid: string; + declare userId: string; @Column(DataType.DATE) declare deletedAt: Date | null; diff --git a/src/modules/account/repositories/account.repository.ts b/src/modules/account/repositories/account.repository.ts index 47f3696..1185ff4 100644 --- a/src/modules/account/repositories/account.repository.ts +++ b/src/modules/account/repositories/account.repository.ts @@ -13,9 +13,9 @@ export class AccountRepository { private readonly accountModel: typeof MailAccountModel, ) {} - async findByDriveUserUuid(uuid: string): Promise { + async findByUserId(userId: string): Promise { const model = await this.accountModel.findOne({ - where: { driveUserUuid: uuid }, + where: { userId }, include: [ { model: MailAddressModel, @@ -34,7 +34,7 @@ export class AccountRepository { private toDomain(model: MailAccountModel): MailAccount { return MailAccount.build({ id: model.id, - driveUserUuid: model.driveUserUuid, + userId: model.userId, createdAt: model.createdAt as Date, updatedAt: model.updatedAt as Date, addresses: (model.addresses ?? []).map(toAddressAttributes), diff --git a/test/fixtures.ts b/test/fixtures.ts index d608133..1b6e7dd 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -18,7 +18,10 @@ import type { import type { MailAccountAttributes } from '../src/modules/account/domain/mail-account.domain.js'; import type { MailAddressAttributes } from '../src/modules/account/domain/mail-address.domain.js'; -import type { MailDomainAttributes } from '../src/modules/account/domain/mail-domain.domain.js'; +import { + type MailDomainAttributes, + MailDomainStatus, +} from '../src/modules/account/domain/mail-domain.domain.js'; import type { AccountInfo, CreateAccountParams, @@ -142,7 +145,7 @@ export function newMailAccountAttributes( const accountId = attrs?.id ?? randomUuid(); return { id: accountId, - driveUserUuid: randomUuid(), + userId: randomUuid(), addresses: [ newMailAddressAttributes({ mailAccountId: accountId, @@ -161,7 +164,7 @@ export function newMailDomainAttributes( return { id: randomUuid(), domain: random.domain(), - status: 'active', + status: MailDomainStatus.Active, createdAt: new Date(), updatedAt: new Date(), ...attrs, From ec2b9e95b4e6145c1e13744e97cb0f8ae003878a Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:07:42 -0600 Subject: [PATCH 7/7] refactor: simplify account management by removing address-related methods - Removed addAddress, removeAddress, and setPrimaryAddress methods from the AccountProvider interface and StalwartAccountProvider implementation to streamline account management. - Updated AccountService to handle address management directly, ensuring proper deletion and linking of addresses during account operations. - Adjusted related unit tests to reflect the changes in address handling and account deletion processes. --- ...dd-deleted-at-to-mail-provider-accounts.js | 16 +++++ src/modules/account/account-provider.port.ts | 6 -- src/modules/account/account.service.spec.ts | 61 +++++++++++++------ src/modules/account/account.service.ts | 34 +++++------ .../account/domain/mail-account.domain.ts | 4 -- .../account/domain/mail-address.domain.ts | 4 +- .../account/domain/mail-domain.domain.ts | 9 ++- .../models/mail-provider-account.model.ts | 4 ++ .../repositories/address.repository.ts | 22 +++---- .../account/repositories/domain.repository.ts | 4 +- .../stalwart-account.provider.spec.ts | 58 ------------------ .../stalwart/stalwart-account.provider.ts | 45 -------------- 12 files changed, 96 insertions(+), 171 deletions(-) create mode 100644 migrations/20260326130000-add-deleted-at-to-mail-provider-accounts.js diff --git a/migrations/20260326130000-add-deleted-at-to-mail-provider-accounts.js b/migrations/20260326130000-add-deleted-at-to-mail-provider-accounts.js new file mode 100644 index 0000000..4d9b0f7 --- /dev/null +++ b/migrations/20260326130000-add-deleted-at-to-mail-provider-accounts.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('mail_provider_accounts', 'deleted_at', { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('mail_provider_accounts', 'deleted_at'); + }, +}; diff --git a/src/modules/account/account-provider.port.ts b/src/modules/account/account-provider.port.ts index 4b43479..1431179 100644 --- a/src/modules/account/account-provider.port.ts +++ b/src/modules/account/account-provider.port.ts @@ -4,10 +4,4 @@ export abstract class AccountProvider { abstract createAccount(params: CreateAccountParams): Promise; abstract deleteAccount(name: string): Promise; abstract getAccount(name: string): Promise; - abstract addAddress(name: string, address: string): Promise; - abstract removeAddress(name: string, address: string): Promise; - abstract setPrimaryAddress( - currentName: string, - newPrimaryAddress: string, - ): Promise; } diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index 6852848..92f9710 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -63,16 +63,24 @@ describe('AccountService', () => { }); describe('deleteAccount', () => { - it('when account has a principal name, then deletes from provider and DB', async () => { - const attrs = newMailAccountAttributes(); - const account = MailAccount.build(attrs); + it('when account has addresses, then deletes all principals and account', async () => { + const addr1 = newMailAddressAttributes({ isDefault: true }); + const addr2 = newMailAddressAttributes({ isDefault: false }); + const account = MailAccount.build( + newMailAccountAttributes({ addresses: [addr1, addr2] }), + ); accounts.findByUserId.mockResolvedValue(account); - await service.deleteAccount(attrs.userId); + await service.deleteAccount(account.userId); expect(provider.deleteAccount).toHaveBeenCalledWith( - account.providerAccountId, + addr1.providerExternalId, + ); + expect(provider.deleteAccount).toHaveBeenCalledWith( + addr2.providerExternalId, ); + expect(addresses.deleteProviderLink).toHaveBeenCalledWith(addr1.id); + expect(addresses.deleteProviderLink).toHaveBeenCalledWith(addr2.id); expect(accounts.delete).toHaveBeenCalledWith(account.id); }); @@ -86,7 +94,7 @@ describe('AccountService', () => { }); describe('addAddress', () => { - it('when all conditions met, then creates address and links provider', async () => { + it('when all conditions met, then creates principal and links provider', async () => { const accountAttrs = newMailAccountAttributes(); const account = MailAccount.build(accountAttrs); const domainAttrs = newMailDomainAttributes(); @@ -103,6 +111,8 @@ describe('AccountService', () => { accountAttrs.userId, newAddr, domainAttrs.domain, + 'password123', + 'Display Name', ); expect(addresses.create).toHaveBeenCalledWith({ @@ -111,14 +121,16 @@ describe('AccountService', () => { domainId: domain.id, isDefault: false, }); - expect(provider.addAddress).toHaveBeenCalledWith( - account.providerAccountId, - newAddr, - ); + expect(provider.createAccount).toHaveBeenCalledWith({ + accountId: newAddressId, + primaryAddress: newAddr, + displayName: 'Display Name', + password: 'password123', + }); expect(addresses.createProviderLink).toHaveBeenCalledWith({ mailAddressId: newAddressId, provider: 'stalwart', - externalId: account.providerAccountId, + externalId: newAddr, }); }); @@ -130,7 +142,7 @@ describe('AccountService', () => { addresses.findByAddress.mockResolvedValue(null); await expect( - service.addAddress('unknown', 'a@b.com', 'b.com'), + service.addAddress('unknown', 'a@b.com', 'b.com', 'pass'), ).rejects.toThrow(NotFoundException); }); @@ -141,7 +153,7 @@ describe('AccountService', () => { addresses.findByAddress.mockResolvedValue(null); await expect( - service.addAddress(account.userId, 'a@b.com', 'unknown.com'), + service.addAddress(account.userId, 'a@b.com', 'unknown.com', 'pass'), ).rejects.toThrow(NotFoundException); }); @@ -155,7 +167,12 @@ describe('AccountService', () => { addresses.findByAddress.mockResolvedValue(existing); await expect( - service.addAddress(account.userId, existing.address, domain.domain), + service.addAddress( + account.userId, + existing.address, + domain.domain, + 'pass', + ), ).rejects.toThrow(ConflictException); }); @@ -168,10 +185,15 @@ describe('AccountService', () => { domains.findByDomain.mockResolvedValue(domain); addresses.findByAddress.mockResolvedValue(null); addresses.create.mockResolvedValue(newAddressId); - provider.addAddress.mockRejectedValue(new Error('provider down')); + provider.createAccount.mockRejectedValue(new Error('provider down')); await expect( - service.addAddress(account.userId, 'new@example.com', domain.domain), + service.addAddress( + account.userId, + 'new@example.com', + domain.domain, + 'pass', + ), ).rejects.toThrow('provider down'); expect(addresses.delete).toHaveBeenCalledWith(newAddressId); @@ -180,7 +202,7 @@ describe('AccountService', () => { }); describe('removeAddress', () => { - it('when address exists and is not default, then removes it', async () => { + it('when address exists and is not default, then deletes principal and address', async () => { const nonDefaultAddr = newMailAddressAttributes({ isDefault: false }); const account = MailAccount.build( newMailAccountAttributes({ @@ -194,9 +216,8 @@ describe('AccountService', () => { await service.removeAddress(account.userId, nonDefaultAddr.address); - expect(provider.removeAddress).toHaveBeenCalledWith( - account.providerAccountId, - nonDefaultAddr.address, + expect(provider.deleteAccount).toHaveBeenCalledWith( + nonDefaultAddr.providerExternalId, ); expect(addresses.deleteProviderLink).toHaveBeenCalledWith( nonDefaultAddr.id, diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 697518d..ac98f73 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -29,9 +29,12 @@ export class AccountService { async deleteAccount(userId: string): Promise { const account = await this.getAccountOrFail(userId); - if (account.providerAccountId) { - await this.provider.deleteAccount(account.providerAccountId); - } + await Promise.all( + account.addresses.map(async (a) => { + await this.provider.deleteAccount(a.providerExternalId); + await this.addresses.deleteProviderLink(a.id); + }), + ); await this.accounts.delete(account.id); this.logger.log(`Deleted account for user '${userId}'`); @@ -41,6 +44,8 @@ export class AccountService { userId: string, address: string, domainName: string, + password: string, + displayName?: string, ): Promise { const [account, domain, existing] = await Promise.all([ this.accounts.findByUserId(userId), @@ -51,8 +56,6 @@ export class AccountService { if (!account) { throw new NotFoundException(`No mail account for user '${userId}'`); } - const providerAccountId = this.requireProviderAccountId(account); - if (!domain) { throw new NotFoundException(`Domain '${domainName}' not found`); } @@ -68,7 +71,12 @@ export class AccountService { }); try { - await this.provider.addAddress(providerAccountId, address); + await this.provider.createAccount({ + accountId: newAddressId, + primaryAddress: address, + displayName: displayName ?? '', + password, + }); } catch (error) { await this.addresses.delete(newAddressId); throw error; @@ -77,7 +85,7 @@ export class AccountService { await this.addresses.createProviderLink({ mailAddressId: newAddressId, provider: 'stalwart', - externalId: providerAccountId, + externalId: address, }); this.logger.log(`Added address '${address}' to account '${userId}'`); @@ -99,9 +107,7 @@ export class AccountService { ); } - const providerAccountId = this.requireProviderAccountId(account); - - await this.provider.removeAddress(providerAccountId, address); + await this.provider.deleteAccount(addressRecord.providerExternalId); await Promise.all([ this.addresses.deleteProviderLink(addressRecord.id), this.addresses.delete(addressRecord.id), @@ -138,12 +144,4 @@ export class AccountService { } return account; } - - private requireProviderAccountId(account: MailAccount): string { - const id = account.providerAccountId; - if (!id) { - throw new UnprocessableEntityException('Account has no primary address'); - } - return id; - } } diff --git a/src/modules/account/domain/mail-account.domain.ts b/src/modules/account/domain/mail-account.domain.ts index 10dd1ae..3aefaad 100644 --- a/src/modules/account/domain/mail-account.domain.ts +++ b/src/modules/account/domain/mail-account.domain.ts @@ -30,8 +30,4 @@ export class MailAccount { get defaultAddress(): MailAddress | undefined { return this.addresses.find((a) => a.isDefault); } - - get providerAccountId(): string | undefined { - return this.defaultAddress?.providerExternalId; - } } diff --git a/src/modules/account/domain/mail-address.domain.ts b/src/modules/account/domain/mail-address.domain.ts index c50f292..02a37af 100644 --- a/src/modules/account/domain/mail-address.domain.ts +++ b/src/modules/account/domain/mail-address.domain.ts @@ -4,7 +4,7 @@ export interface MailAddressAttributes { address: string; domainId: string; isDefault: boolean; - providerExternalId: string | null; + providerExternalId: string; createdAt: Date; updatedAt: Date; } @@ -15,7 +15,7 @@ export class MailAddress { readonly address!: string; readonly domainId!: string; readonly isDefault!: boolean; - readonly providerExternalId!: string | null; + readonly providerExternalId!: string; readonly createdAt!: Date; readonly updatedAt!: Date; diff --git a/src/modules/account/domain/mail-domain.domain.ts b/src/modules/account/domain/mail-domain.domain.ts index b88cb24..26595ed 100644 --- a/src/modules/account/domain/mail-domain.domain.ts +++ b/src/modules/account/domain/mail-domain.domain.ts @@ -1,7 +1,12 @@ +export enum MailDomainStatus { + Active = 'active', + Inactive = 'inactive', +} + export interface MailDomainAttributes { id: string; domain: string; - status: string; + status: MailDomainStatus; createdAt: Date; updatedAt: Date; } @@ -9,7 +14,7 @@ export interface MailDomainAttributes { export class MailDomain { readonly id!: string; readonly domain!: string; - readonly status!: string; + readonly status!: MailDomainStatus; readonly createdAt!: Date; readonly updatedAt!: Date; diff --git a/src/modules/account/models/mail-provider-account.model.ts b/src/modules/account/models/mail-provider-account.model.ts index 5714268..9219e33 100644 --- a/src/modules/account/models/mail-provider-account.model.ts +++ b/src/modules/account/models/mail-provider-account.model.ts @@ -15,6 +15,7 @@ import { MailAddressModel } from './mail-address.model.js'; @Table({ underscored: true, timestamps: true, + paranoid: true, tableName: 'mail_provider_accounts', indexes: [{ unique: true, fields: ['provider', 'external_id'] }], }) @@ -38,6 +39,9 @@ export class MailProviderAccountModel extends Model { @Column(DataType.STRING(255)) declare externalId: string; + @Column(DataType.DATE) + declare deletedAt: Date | null; + @BelongsTo(() => MailAddressModel) declare address: MailAddressModel; } diff --git a/src/modules/account/repositories/address.repository.ts b/src/modules/account/repositories/address.repository.ts index 82dfbc9..12baf6f 100644 --- a/src/modules/account/repositories/address.repository.ts +++ b/src/modules/account/repositories/address.repository.ts @@ -11,13 +11,18 @@ import { MailProviderAccountModel } from '../models/mail-provider-account.model. export function toAddressAttributes( model: MailAddressModel, ): MailAddressAttributes { + const providerExternalId = model.providerAccount?.externalId; + if (!providerExternalId) { + throw new Error(`Address '${model.id}' has no provider link`); + } + return { id: model.id, mailAccountId: model.mailAccountId, address: model.address, domainId: model.domainId, isDefault: model.isDefault, - providerExternalId: model.providerAccount?.externalId ?? null, + providerExternalId, createdAt: model.createdAt as Date, updatedAt: model.updatedAt as Date, }; @@ -67,9 +72,9 @@ export class AddressRepository { address: string; domainId: string; isDefault: boolean; - }): Promise { + }): Promise { const model = await this.addressModel.create(params); - return MailAddress.build(toAddressAttributes(model)); + return model.id; } async delete(id: string): Promise { @@ -94,15 +99,4 @@ export class AddressRepository { async deleteProviderLink(mailAddressId: string): Promise { await this.providerAccountModel.destroy({ where: { mailAddressId } }); } - - async updateAllProviderExternalIds( - mailAccountId: string, - newExternalId: string, - ): Promise { - await this.sequelize.query( - `UPDATE mail_provider_accounts SET external_id = :newExternalId - WHERE mail_address_id IN (SELECT id FROM mail_addresses WHERE mail_account_id = :mailAccountId)`, - { replacements: { newExternalId, mailAccountId } }, - ); - } } diff --git a/src/modules/account/repositories/domain.repository.ts b/src/modules/account/repositories/domain.repository.ts index 92a7452..96ef5e6 100644 --- a/src/modules/account/repositories/domain.repository.ts +++ b/src/modules/account/repositories/domain.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; -import { MailDomain } from '../domain/mail-domain.domain.js'; +import { MailDomain, MailDomainStatus } from '../domain/mail-domain.domain.js'; import { MailDomainModel } from '../models/mail-domain.model.js'; @Injectable() @@ -19,7 +19,7 @@ export class DomainRepository { return MailDomain.build({ id: model.id, domain: model.domain, - status: model.status, + status: model.status as MailDomainStatus, createdAt: model.createdAt as Date, updatedAt: model.updatedAt as Date, }); diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts index fe44ab9..82d06d7 100644 --- a/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts @@ -99,62 +99,4 @@ describe('StalwartAccountProvider', () => { }); }); }); - - describe('addAddress', () => { - it('when called, then patches principal with addItem action', async () => { - await provider.addAddress('user@example.com', 'alias@example.com'); - - expect(stalwart.patchPrincipal).toHaveBeenCalledWith('user@example.com', [ - { action: 'addItem', field: 'emails', value: 'alias@example.com' }, - ]); - }); - }); - - describe('removeAddress', () => { - it('when called, then patches principal with removeItem action', async () => { - await provider.removeAddress('user@example.com', 'alias@example.com'); - - expect(stalwart.patchPrincipal).toHaveBeenCalledWith('user@example.com', [ - { - action: 'removeItem', - field: 'emails', - value: 'alias@example.com', - }, - ]); - }); - }); - - describe('setPrimaryAddress', () => { - it('when account exists, then recreates with new name and reordered emails', async () => { - const existingPrincipal = { - name: 'old@example.com', - type: 'individual', - description: 'User', - secrets: ['pass'], - emails: ['old@example.com', 'new@example.com', 'other@example.com'], - quota: 1000, - }; - stalwart.getPrincipal.mockResolvedValue(existingPrincipal); - - await provider.setPrimaryAddress('old@example.com', 'new@example.com'); - - expect(stalwart.deletePrincipal).toHaveBeenCalledWith('old@example.com'); - expect(stalwart.createPrincipal).toHaveBeenCalledWith({ - ...existingPrincipal, - name: 'new@example.com', - emails: ['new@example.com', 'old@example.com', 'other@example.com'], - }); - }); - - it('when account does not exist, then throws error', async () => { - stalwart.getPrincipal.mockResolvedValue(null); - - await expect( - provider.setPrimaryAddress( - 'nonexistent@example.com', - 'new@example.com', - ), - ).rejects.toThrow("Account 'nonexistent@example.com' not found"); - }); - }); }); diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts index 674926a..be4551c 100644 --- a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts @@ -43,49 +43,4 @@ export class StalwartAccountProvider extends AccountProvider { quota: principal.quota ?? 0, }; } - - async addAddress(name: string, address: string): Promise { - await this.stalwart.patchPrincipal(name, [ - { action: 'addItem', field: 'emails', value: address }, - ]); - - this.logger.log(`Added address '${address}' to '${name}'`); - } - - async removeAddress(name: string, address: string): Promise { - await this.stalwart.patchPrincipal(name, [ - { action: 'removeItem', field: 'emails', value: address }, - ]); - - this.logger.log(`Removed address '${address}' from '${name}'`); - } - - async setPrimaryAddress( - currentName: string, - newPrimaryAddress: string, - ): Promise { - // Stalwart uses the principal name as the login. - // Changing the primary address means renaming the principal. - // Current REST API does not support rename — we must recreate. - const existing = await this.stalwart.getPrincipal(currentName); - if (!existing) { - throw new Error(`Account '${currentName}' not found`); - } - - const updatedEmails = [ - newPrimaryAddress, - ...(existing.emails ?? []).filter((e) => e !== newPrimaryAddress), - ]; - - await this.stalwart.deletePrincipal(currentName); - await this.stalwart.createPrincipal({ - ...existing, - name: newPrimaryAddress, - emails: updatedEmails, - }); - - this.logger.warn( - `Renamed account '${currentName}' → '${newPrimaryAddress}' (delete + recreate)`, - ); - } }