feat(admin): adapt new config system (#11360)

feat(server): add test mail api

feat(admin): adapt new config system
This commit is contained in:
forehalo
2025-04-01 15:00:10 +00:00
parent 8427293d36
commit dad858014f
29 changed files with 718 additions and 400 deletions

View File

@@ -6,6 +6,49 @@ Generated by [AVA](https://avajs.dev).
## should render emails
> Test Email from AFFiNE
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody>␊
<tr>␊
<td>␊
<p␊
style="font-size:20px;line-height:28px;margin-bottom:0;margin-top:24px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414">␊
Test Email from AFFiNE␊
</p>␊
</td>␊
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody>␊
<tr>␊
<td>␊
<p␊
style="font-size:15px;line-height:24px;margin-bottom:0;margin-top:24px;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414">␊
This is a test email from your AFFiNE instance.␊
</p>␊
</td>␊
</tr>␊
</tbody>␊
</table>␊
<!--/$-->␊
`
> Sign in to AFFiNE
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊

View File

@@ -14,13 +14,8 @@ defineModuleConfig('graphql', {
apolloDriverConfig: {
desc: 'The config for underlying nestjs GraphQL and apollo driver engine.',
default: {
buildSchemaOptions: {
numberScalarMode: 'integer',
},
useGlobalPrefix: true,
playground: true,
// @TODO(@forehalo): need a flag to tell user `Restart Required` configs
introspection: true,
sortSchema: true,
},
link: 'https://docs.nestjs.com/graphql/quick-start',
},

View File

@@ -26,6 +26,12 @@ export type GraphqlContext = {
useFactory: (config: Config) => {
return {
...config.graphql.apolloDriverConfig,
buildSchemaOptions: {
numberScalarMode: 'integer',
},
useGlobalPrefix: true,
playground: true,
sortSchema: true,
autoSchemaFile: join(
env.projectRoot,
env.testing

View File

@@ -6,11 +6,12 @@ import { DocStorageModule } from '../doc';
import { StorageModule } from '../storage';
import { MailJob } from './job';
import { Mailer } from './mailer';
import { MailResolver } from './resolver';
import { MailSender } from './sender';
@Module({
imports: [DocStorageModule, StorageModule],
providers: [MailSender, Mailer, MailJob],
providers: [MailSender, Mailer, MailJob, MailResolver],
exports: [Mailer],
})
export class MailModule {}

View File

@@ -0,0 +1,49 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { GraphQLJSONObject } from 'graphql-scalars';
import { BadRequest } from '../../base';
import { Renderers } from '../../mails';
import { CurrentUser } from '../auth/session';
import { Admin } from '../common';
import { MailSender } from './sender';
@Admin()
@Resolver(() => Boolean)
export class MailResolver {
@Mutation(() => Boolean)
async sendTestEmail(
@CurrentUser() user: CurrentUser,
@Args('config', { type: () => GraphQLJSONObject })
config: AppConfig['mailer']['SMTP']
) {
const smtp = MailSender.create(config);
using _disposable = {
[Symbol.dispose]: () => {
smtp.close();
},
};
try {
await smtp.verify();
} catch (e) {
throw new BadRequest(
`Failed to verify your SMTP configuration. Cause: ${(e as Error).message}`
);
}
try {
await smtp.sendMail({
from: config.sender,
to: user.email,
...(await Renderers.TestMail({})),
});
} catch (e) {
throw new BadRequest(
`Failed to send test email. Cause: ${(e as Error).message}`
);
}
return true;
}
}

View File

@@ -16,6 +16,22 @@ export type SendOptions = Omit<SendMailOptions, 'to' | 'subject' | 'html'> & {
html: string;
};
function configToSMTPOptions(
config: AppConfig['mailer']['SMTP']
): SMTPTransport.Options {
return {
host: config.host,
port: config.port,
tls: {
rejectUnauthorized: !config.ignoreTLS,
},
auth: {
user: config.username,
pass: config.password,
},
};
}
@Injectable()
export class MailSender {
private readonly logger = new Logger(MailSender.name);
@@ -23,6 +39,10 @@ export class MailSender {
private usingTestAccount = false;
constructor(private readonly config: Config) {}
static create(config: Config['mailer']['SMTP']) {
return createTransport(configToSMTPOptions(config));
}
@OnEvent('config.init')
onConfigInit() {
this.setup();
@@ -43,17 +63,7 @@ export class MailSender {
return;
}
const opts: SMTPTransport.Options = {
host: SMTP.host,
port: SMTP.port,
tls: {
rejectUnauthorized: !SMTP.ignoreTLS,
},
auth: {
user: SMTP.username,
pass: SMTP.password,
},
};
const opts = configToSMTPOptions(SMTP);
if (SMTP.host) {
this.smtp = createTransport(opts);

View File

@@ -195,7 +195,7 @@ export function Template(props: PropsWithChildren) {
</>
);
if (env.testing) {
if (globalThis.env?.testing) {
return content;
}

View File

@@ -12,6 +12,7 @@ import {
TeamWorkspaceDeleted,
TeamWorkspaceUpgraded,
} from './teams';
import TestMail from './test-mail';
import {
ChangeEmail,
ChangeEmailNotification,
@@ -45,7 +46,7 @@ function render(component: React.ReactElement) {
});
}
type Props<T> = T extends React.FC<infer P> ? P : never;
type Props<T> = T extends React.ComponentType<infer P> ? P : never;
export type EmailRenderer<Props> = (props: Props) => Promise<EmailContent>;
function make<T extends React.ComponentType<any>>(
@@ -65,6 +66,10 @@ function make<T extends React.ComponentType<any>>(
}
export const Renderers = {
//#region Test
TestMail: make(TestMail, 'Test Email from AFFiNE'),
//#endregion
//#region User
SignIn: make(SignIn, 'Sign in to AFFiNE'),
SignUp: make(SignUp, 'Your AFFiNE account is waiting for you!'),

View File

@@ -0,0 +1,12 @@
import { Content, P, Template, Title } from './components';
export default function TestMail() {
return (
<Template>
<Title>Test Email from AFFiNE</Title>
<Content>
<P>This is a test email from your AFFiNE instance.</P>
</Content>
</Template>
);
}

View File

@@ -998,6 +998,7 @@ type Mutation {
sendChangeEmail(callbackUrl: String!, email: String): Boolean!
sendChangePasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
sendSetPasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
sendTestEmail(config: JSONObject!): Boolean!
sendVerifyChangeEmail(callbackUrl: String!, email: String!, token: String!): Boolean!
sendVerifyEmail(callbackUrl: String!): Boolean!
setBlob(blob: Upload!, workspaceId: String!): String!