Skip to content

Commit e31054d

Browse files
committed
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.
1 parent 00f5c3d commit e31054d

14 files changed

+461
-55
lines changed

.env.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ RDS_PASSWORD=example
1111
# Stalwart
1212
STALWART_JMAP_URL=http://localhost:8085
1313
STALWART_ADMIN_URL=http://localhost:8085
14-
STALWART_ADMIN_TOKEN=
14+
STALWART_ADMIN_USER=
15+
STALWART_ADMIN_SECRET=
1516

1617
# Auth
1718
JWT_SECRET=

src/modules/email/account-provider.port.ts renamed to src/modules/account/account-provider.port.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,4 @@ export abstract class AccountProvider {
1010
currentName: string,
1111
newPrimaryAddress: string,
1212
): Promise<void>;
13-
abstract updateQuota(name: string, bytes: number): Promise<void>;
14-
abstract createDomain(domain: string): Promise<void>;
15-
abstract deleteDomain(domain: string): Promise<void>;
1613
}

src/modules/account/account.module.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { Module } from '@nestjs/common';
22
import { SequelizeModule } from '@nestjs/sequelize';
3+
import { StalwartModule } from '../infrastructure/stalwart/stalwart.module.js';
4+
import { AccountService } from './account.service.js';
35
import {
46
MailAccountModel,
57
MailAddressModel,
68
MailDomainModel,
79
MailProviderAccountModel,
810
} from './models/index.js';
11+
import { AccountRepository } from './repositories/account.repository.js';
12+
import { AddressRepository } from './repositories/address.repository.js';
13+
import { DomainRepository } from './repositories/domain.repository.js';
914

1015
@Module({
1116
imports: [
@@ -15,7 +20,14 @@ import {
1520
MailDomainModel,
1621
MailProviderAccountModel,
1722
]),
23+
StalwartModule,
1824
],
19-
exports: [SequelizeModule],
25+
providers: [
26+
AccountRepository,
27+
AddressRepository,
28+
DomainRepository,
29+
AccountService,
30+
],
31+
exports: [AccountService],
2032
})
2133
export class AccountModule {}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {
2+
ConflictException,
3+
Injectable,
4+
Logger,
5+
NotFoundException,
6+
UnprocessableEntityException,
7+
} from '@nestjs/common';
8+
import { AccountProvider } from './account-provider.port.js';
9+
import { MailAccount } from './domain/mail-account.domain.js';
10+
import { AccountRepository } from './repositories/account.repository.js';
11+
import { AddressRepository } from './repositories/address.repository.js';
12+
import { DomainRepository } from './repositories/domain.repository.js';
13+
14+
@Injectable()
15+
export class AccountService {
16+
private readonly logger = new Logger(AccountService.name);
17+
18+
constructor(
19+
private readonly provider: AccountProvider,
20+
private readonly accounts: AccountRepository,
21+
private readonly addresses: AddressRepository,
22+
private readonly domains: DomainRepository,
23+
) {}
24+
25+
async getAccount(driveUserUuid: string): Promise<MailAccount> {
26+
return this.getAccountOrFail(driveUserUuid);
27+
}
28+
29+
async deleteAccount(driveUserUuid: string): Promise<void> {
30+
const account = await this.getAccountOrFail(driveUserUuid);
31+
32+
if (account.principalName) {
33+
await this.provider.deleteAccount(account.principalName);
34+
}
35+
36+
await this.accounts.delete(account.id);
37+
this.logger.log(`Deleted account for drive user '${driveUserUuid}'`);
38+
}
39+
40+
async addAddress(
41+
driveUserUuid: string,
42+
address: string,
43+
domainName: string,
44+
): Promise<void> {
45+
const [account, domain, existing] = await Promise.all([
46+
this.accounts.findByDriveUserUuid(driveUserUuid),
47+
this.domains.findByDomain(domainName),
48+
this.addresses.findByAddress(address),
49+
]);
50+
51+
if (!account) {
52+
throw new NotFoundException(
53+
`No mail account for drive user '${driveUserUuid}'`,
54+
);
55+
}
56+
const principalName = this.requirePrincipalName(account);
57+
58+
if (!domain) {
59+
throw new NotFoundException(`Domain '${domainName}' not found`);
60+
}
61+
if (existing) {
62+
throw new ConflictException(`Address '${address}' already exists`);
63+
}
64+
65+
const newAddress = await this.addresses.create({
66+
mailAccountId: account.id,
67+
address,
68+
domainId: domain.id,
69+
isDefault: false,
70+
});
71+
72+
try {
73+
await this.provider.addAddress(principalName, address);
74+
} catch (error) {
75+
await this.addresses.delete(newAddress.id);
76+
throw error;
77+
}
78+
79+
await this.addresses.createProviderLink({
80+
mailAddressId: newAddress.id,
81+
provider: 'stalwart',
82+
externalId: principalName,
83+
});
84+
85+
this.logger.log(`Added address '${address}' to account '${driveUserUuid}'`);
86+
}
87+
88+
async removeAddress(driveUserUuid: string, address: string): Promise<void> {
89+
const account = await this.getAccountOrFail(driveUserUuid);
90+
91+
const addressRecord = account.addresses.find((a) => a.address === address);
92+
if (!addressRecord) {
93+
throw new NotFoundException(
94+
`Address '${address}' not found for this account`,
95+
);
96+
}
97+
98+
if (addressRecord.isDefault) {
99+
throw new UnprocessableEntityException(
100+
'Cannot remove the default address',
101+
);
102+
}
103+
104+
const principalName = this.requirePrincipalName(account);
105+
106+
await this.provider.removeAddress(principalName, address);
107+
await Promise.all([
108+
this.addresses.deleteProviderLink(addressRecord.id),
109+
this.addresses.delete(addressRecord.id),
110+
]);
111+
112+
this.logger.log(
113+
`Removed address '${address}' from account '${driveUserUuid}'`,
114+
);
115+
}
116+
117+
async setPrimaryAddress(
118+
driveUserUuid: string,
119+
newAddress: string,
120+
): Promise<void> {
121+
const account = await this.getAccountOrFail(driveUserUuid);
122+
123+
const addressRecord = account.addresses.find(
124+
(a) => a.address === newAddress,
125+
);
126+
if (!addressRecord) {
127+
throw new NotFoundException(
128+
`Address '${newAddress}' not found for this account`,
129+
);
130+
}
131+
132+
if (addressRecord.isDefault) return;
133+
134+
const oldPrincipalName = this.requirePrincipalName(account);
135+
136+
await this.provider.setPrimaryAddress(oldPrincipalName, newAddress);
137+
138+
await Promise.all([
139+
this.addresses.setDefault(addressRecord.id, account.id),
140+
this.addresses.updateAllProviderExternalIds(account.id, newAddress),
141+
]);
142+
143+
this.logger.log(
144+
`Set primary address to '${newAddress}' for account '${driveUserUuid}'`,
145+
);
146+
}
147+
148+
private async getAccountOrFail(driveUserUuid: string): Promise<MailAccount> {
149+
const account = await this.accounts.findByDriveUserUuid(driveUserUuid);
150+
if (!account) {
151+
throw new NotFoundException(
152+
`No mail account for drive user '${driveUserUuid}'`,
153+
);
154+
}
155+
return account;
156+
}
157+
158+
private requirePrincipalName(account: MailAccount): string {
159+
const name = account.principalName;
160+
if (!name) {
161+
throw new UnprocessableEntityException(
162+
'Account has no primary address with a provider link',
163+
);
164+
}
165+
return name;
166+
}
167+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
MailAddress,
3+
type MailAddressAttributes,
4+
} from './mail-address.domain.js';
5+
6+
export interface MailAccountAttributes {
7+
id: string;
8+
driveUserUuid: string;
9+
addresses: MailAddressAttributes[];
10+
createdAt: Date;
11+
updatedAt: Date;
12+
}
13+
14+
export class MailAccount {
15+
readonly id!: string;
16+
readonly driveUserUuid!: string;
17+
readonly addresses!: MailAddress[];
18+
readonly createdAt!: Date;
19+
readonly updatedAt!: Date;
20+
21+
private constructor(attributes: MailAccountAttributes) {
22+
Object.assign(this, attributes);
23+
this.addresses = attributes.addresses.map((a) => MailAddress.build(a));
24+
}
25+
26+
static build(attributes: MailAccountAttributes): MailAccount {
27+
return new MailAccount(attributes);
28+
}
29+
30+
get defaultAddress(): MailAddress | undefined {
31+
return this.addresses.find((a) => a.isDefault);
32+
}
33+
34+
get principalName(): string | null {
35+
return this.defaultAddress?.providerExternalId ?? null;
36+
}
37+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export interface MailAddressAttributes {
2+
id: string;
3+
mailAccountId: string;
4+
address: string;
5+
domainId: string;
6+
isDefault: boolean;
7+
providerExternalId: string | null;
8+
createdAt: Date;
9+
updatedAt: Date;
10+
}
11+
12+
export class MailAddress {
13+
readonly id!: string;
14+
readonly mailAccountId!: string;
15+
readonly address!: string;
16+
readonly domainId!: string;
17+
readonly isDefault!: boolean;
18+
readonly providerExternalId!: string | null;
19+
readonly createdAt!: Date;
20+
readonly updatedAt!: Date;
21+
22+
private constructor(attributes: MailAddressAttributes) {
23+
Object.assign(this, attributes);
24+
}
25+
26+
static build(attributes: MailAddressAttributes): MailAddress {
27+
return new MailAddress(attributes);
28+
}
29+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export interface MailDomainAttributes {
2+
id: string;
3+
domain: string;
4+
status: string;
5+
createdAt: Date;
6+
updatedAt: Date;
7+
}
8+
9+
export class MailDomain {
10+
readonly id!: string;
11+
readonly domain!: string;
12+
readonly status!: string;
13+
readonly createdAt!: Date;
14+
readonly updatedAt!: Date;
15+
16+
private constructor(attributes: MailDomainAttributes) {
17+
Object.assign(this, attributes);
18+
}
19+
20+
static build(attributes: MailDomainAttributes): MailDomain {
21+
return new MailDomain(attributes);
22+
}
23+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { InjectModel } from '@nestjs/sequelize';
3+
import { MailAccount } from '../domain/mail-account.domain.js';
4+
import { MailAccountModel } from '../models/mail-account.model.js';
5+
import { MailAddressModel } from '../models/mail-address.model.js';
6+
import { MailProviderAccountModel } from '../models/mail-provider-account.model.js';
7+
import { toAddressAttributes } from './address.repository.js';
8+
9+
@Injectable()
10+
export class AccountRepository {
11+
constructor(
12+
@InjectModel(MailAccountModel)
13+
private readonly accountModel: typeof MailAccountModel,
14+
) {}
15+
16+
async findByDriveUserUuid(uuid: string): Promise<MailAccount | null> {
17+
const model = await this.accountModel.findOne({
18+
where: { driveUserUuid: uuid },
19+
include: [
20+
{
21+
model: MailAddressModel,
22+
include: [MailProviderAccountModel],
23+
},
24+
],
25+
});
26+
27+
return model ? this.toDomain(model) : null;
28+
}
29+
30+
async delete(id: string): Promise<void> {
31+
await this.accountModel.destroy({ where: { id } });
32+
}
33+
34+
private toDomain(model: MailAccountModel): MailAccount {
35+
return MailAccount.build({
36+
id: model.id,
37+
driveUserUuid: model.driveUserUuid,
38+
createdAt: model.createdAt as Date,
39+
updatedAt: model.updatedAt as Date,
40+
addresses: (model.addresses ?? []).map(toAddressAttributes),
41+
});
42+
}
43+
}

0 commit comments

Comments
 (0)