diff --git a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md index 4796d6973..58209f154 100644 --- a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md +++ b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md @@ -6,6 +6,49 @@ Generated by [AVA](https://avajs.dev). ## should render emails +> Test Email from AFFiNE + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Test Email from AFFiNE␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + This is a test email from your AFFiNE instance.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + > Sign in to AFFiNE `␊ diff --git a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap index 97131d67b..b95aed3e9 100644 Binary files a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap and b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap differ diff --git a/packages/backend/server/src/base/graphql/config.ts b/packages/backend/server/src/base/graphql/config.ts index 1406322c0..22989fe06 100644 --- a/packages/backend/server/src/base/graphql/config.ts +++ b/packages/backend/server/src/base/graphql/config.ts @@ -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', }, diff --git a/packages/backend/server/src/base/graphql/index.ts b/packages/backend/server/src/base/graphql/index.ts index 8338638a2..8f8811bc9 100644 --- a/packages/backend/server/src/base/graphql/index.ts +++ b/packages/backend/server/src/base/graphql/index.ts @@ -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 diff --git a/packages/backend/server/src/core/mail/index.ts b/packages/backend/server/src/core/mail/index.ts index 834c25c74..ffa242989 100644 --- a/packages/backend/server/src/core/mail/index.ts +++ b/packages/backend/server/src/core/mail/index.ts @@ -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 {} diff --git a/packages/backend/server/src/core/mail/resolver.ts b/packages/backend/server/src/core/mail/resolver.ts new file mode 100644 index 000000000..78fe19147 --- /dev/null +++ b/packages/backend/server/src/core/mail/resolver.ts @@ -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; + } +} diff --git a/packages/backend/server/src/core/mail/sender.ts b/packages/backend/server/src/core/mail/sender.ts index fc3beba6b..fbce9584a 100644 --- a/packages/backend/server/src/core/mail/sender.ts +++ b/packages/backend/server/src/core/mail/sender.ts @@ -16,6 +16,22 @@ export type SendOptions = Omit & { 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); diff --git a/packages/backend/server/src/mails/components/template.tsx b/packages/backend/server/src/mails/components/template.tsx index fc4906f5f..116cff3c7 100644 --- a/packages/backend/server/src/mails/components/template.tsx +++ b/packages/backend/server/src/mails/components/template.tsx @@ -195,7 +195,7 @@ export function Template(props: PropsWithChildren) { ); - if (env.testing) { + if (globalThis.env?.testing) { return content; } diff --git a/packages/backend/server/src/mails/index.tsx b/packages/backend/server/src/mails/index.tsx index 8f18ec62e..d6e36240d 100644 --- a/packages/backend/server/src/mails/index.tsx +++ b/packages/backend/server/src/mails/index.tsx @@ -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 extends React.FC ? P : never; +type Props = T extends React.ComponentType ? P : never; export type EmailRenderer = (props: Props) => Promise; function make>( @@ -65,6 +66,10 @@ function make>( } 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!'), diff --git a/packages/backend/server/src/mails/test-mail.tsx b/packages/backend/server/src/mails/test-mail.tsx new file mode 100644 index 000000000..0407cafb4 --- /dev/null +++ b/packages/backend/server/src/mails/test-mail.tsx @@ -0,0 +1,12 @@ +import { Content, P, Template, Title } from './components'; + +export default function TestMail() { + return ( + + ); +} diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 81c31aa70..07e1d10bb 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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! diff --git a/packages/common/graphql/src/graphql/admin/send-test-email.gql b/packages/common/graphql/src/graphql/admin/send-test-email.gql new file mode 100644 index 000000000..7b0e94e9f --- /dev/null +++ b/packages/common/graphql/src/graphql/admin/send-test-email.gql @@ -0,0 +1,10 @@ +mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) { + sendTestEmail(config: { + host: $host, + port: $port, + sender: $sender, + username: $username, + password: $password, + ignoreTLS: $ignoreTLS, + }) +} \ No newline at end of file diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index b72484147..e1c314008 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -220,6 +220,16 @@ export const listUsersQuery = { }`, }; +export const sendTestEmailMutation = { + id: 'sendTestEmailMutation' as const, + op: 'sendTestEmail', + query: `mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) { + sendTestEmail( + config: {host: $host, port: $port, sender: $sender, username: $username, password: $password, ignoreTLS: $ignoreTLS} + ) +}`, +}; + export const updateAccountFeaturesMutation = { id: 'updateAccountFeaturesMutation' as const, op: 'updateAccountFeatures', diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index d2f8d2be8..c153674fc 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -1111,6 +1111,7 @@ export interface Mutation { sendChangeEmail: Scalars['Boolean']['output']; sendChangePasswordEmail: Scalars['Boolean']['output']; sendSetPasswordEmail: Scalars['Boolean']['output']; + sendTestEmail: Scalars['Boolean']['output']; sendVerifyChangeEmail: Scalars['Boolean']['output']; sendVerifyEmail: Scalars['Boolean']['output']; setBlob: Scalars['String']['output']; @@ -1404,6 +1405,10 @@ export interface MutationSendSetPasswordEmailArgs { email?: InputMaybe; } +export interface MutationSendTestEmailArgs { + config: Scalars['JSONObject']['input']; +} + export interface MutationSendVerifyChangeEmailArgs { callbackUrl: Scalars['String']['input']; email: Scalars['String']['input']; @@ -2509,6 +2514,20 @@ export type ListUsersQuery = { }>; }; +export type SendTestEmailMutationVariables = Exact<{ + host: Scalars['String']['input']; + port: Scalars['Int']['input']; + sender: Scalars['String']['input']; + username: Scalars['String']['input']; + password: Scalars['String']['input']; + ignoreTLS: Scalars['Boolean']['input']; +}>; + +export type SendTestEmailMutation = { + __typename?: 'Mutation'; + sendTestEmail: boolean; +}; + export type UpdateAccountFeaturesMutationVariables = Exact<{ userId: Scalars['String']['input']; features: Array | FeatureType; @@ -4587,6 +4606,11 @@ export type Mutations = variables: ImportUsersMutationVariables; response: ImportUsersMutation; } + | { + name: 'sendTestEmailMutation'; + variables: SendTestEmailMutationVariables; + response: SendTestEmailMutation; + } | { name: 'updateAccountFeaturesMutation'; variables: UpdateAccountFeaturesMutationVariables; diff --git a/packages/frontend/admin/src/app.tsx b/packages/frontend/admin/src/app.tsx index b7ee34139..f8ba35b90 100644 --- a/packages/frontend/admin/src/app.tsx +++ b/packages/frontend/admin/src/app.tsx @@ -85,8 +85,8 @@ export const router = _createBrowserRouter( lazy: () => import('./modules/ai'), }, { - path: 'config', - lazy: () => import('./modules/config'), + path: 'about', + lazy: () => import('./modules/about'), }, { path: 'settings', diff --git a/packages/frontend/admin/src/modules/config/about.tsx b/packages/frontend/admin/src/modules/about/about.tsx similarity index 100% rename from packages/frontend/admin/src/modules/config/about.tsx rename to packages/frontend/admin/src/modules/about/about.tsx diff --git a/packages/frontend/admin/src/modules/config/index.tsx b/packages/frontend/admin/src/modules/about/index.tsx similarity index 100% rename from packages/frontend/admin/src/modules/config/index.tsx rename to packages/frontend/admin/src/modules/about/index.tsx diff --git a/packages/frontend/admin/src/modules/layout.tsx b/packages/frontend/admin/src/modules/layout.tsx index 7ea3a11b2..b53602c1b 100644 --- a/packages/frontend/admin/src/modules/layout.tsx +++ b/packages/frontend/admin/src/modules/layout.tsx @@ -39,8 +39,8 @@ export function Layout({ children }: PropsWithChildren) { const leftPanelRef = useRef(null); const [activeTab, setActiveTab] = useState(''); - const [activeSubTab, setActiveSubTab] = useState('auth'); - const [currentModule, setCurrentModule] = useState('auth'); + const [activeSubTab, setActiveSubTab] = useState('server'); + const [currentModule, setCurrentModule] = useState('server'); const handleLeftExpand = useCallback(() => { if (leftPanelRef.current?.getSize() === 0) { diff --git a/packages/frontend/admin/src/modules/nav/collapsible-item.tsx b/packages/frontend/admin/src/modules/nav/collapsible-item.tsx index 753121f4b..f82f1fe95 100644 --- a/packages/frontend/admin/src/modules/nav/collapsible-item.tsx +++ b/packages/frontend/admin/src/modules/nav/collapsible-item.tsx @@ -1,62 +1,25 @@ -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@affine/admin/components/ui/accordion'; import { useCallback } from 'react'; import { NavLink } from 'react-router-dom'; import { buttonVariants } from '../../components/ui/button'; import { cn } from '../../utils'; -export const CollapsibleItem = ({ - title, - changeModule, -}: { - title: string; - changeModule?: (module: string) => void; -}) => { - const handleClick = useCallback(() => { - changeModule?.(title); - }, [changeModule, title]); - return ( - - - { - return isActive - ? 'w-full bg-zinc-100 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50' - : ''; - }} - > - - {title} - - - - - ); -}; - export const NormalSubItem = ({ + module, title, changeModule, }: { + module: string; title: string; changeModule?: (module: string) => void; }) => { const handleClick = useCallback(() => { - changeModule?.(title); - }, [changeModule, title]); + changeModule?.(module); + }, [changeModule, module]); return (
{ return cn( @@ -72,30 +35,3 @@ export const NormalSubItem = ({
); }; - -export const OtherModules = ({ - moduleList, - changeModule, -}: { - moduleList: string[]; - changeModule?: (module: string) => void; -}) => { - return ( - - - - Other - - - {moduleList.map(module => ( - - ))} - - - - ); -}; diff --git a/packages/frontend/admin/src/modules/nav/nav.tsx b/packages/frontend/admin/src/modules/nav/nav.tsx index 5adfe7b8b..a5f67edbd 100644 --- a/packages/frontend/admin/src/modules/nav/nav.tsx +++ b/packages/frontend/admin/src/modules/nav/nav.tsx @@ -98,9 +98,9 @@ export function Nav({ isCollapsed = false }: NavProps) { /> } - label="Server" + label="About" isCollapsed={isCollapsed} /> diff --git a/packages/frontend/admin/src/modules/nav/settings-item.tsx b/packages/frontend/admin/src/modules/nav/settings-item.tsx index 3c57ac258..df4e4459b 100644 --- a/packages/frontend/admin/src/modules/nav/settings-item.tsx +++ b/packages/frontend/admin/src/modules/nav/settings-item.tsx @@ -12,15 +12,10 @@ import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; import { cssVarV2 } from '@toeverything/theme/v2'; import { NavLink } from 'react-router-dom'; -import { ALL_CONFIGURABLE_MODULES } from '../settings/config'; -import { NormalSubItem, OtherModules } from './collapsible-item'; +import { KNOWN_CONFIG_GROUPS, UNKNOWN_CONFIG_GROUPS } from '../settings/config'; +import { NormalSubItem } from './collapsible-item'; import { useNav } from './context'; -const authModule = ALL_CONFIGURABLE_MODULES.find(module => module === 'auth'); -const otherModules = ALL_CONFIGURABLE_MODULES.filter( - module => module !== 'auth' -); - export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => { const { setCurrentModule } = useNav(); @@ -59,10 +54,10 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => { borderColor: cssVarV2('layer/insideBorder/blackBorder'), }} > - {authModule ? ( -
  • + {KNOWN_CONFIG_GROUPS.map(group => ( +
  • { ? cssVarV2('selfhost/button/sidebarButton/bg/select') : undefined, })} - onClick={() => setCurrentModule?.(authModule)} + onClick={() => setCurrentModule?.(group.module)} > - {authModule} + {group.name}
  • - ) : null} - {otherModules.map(module => ( -
  • + ))} + {UNKNOWN_CONFIG_GROUPS.map(group => ( +
  • { ? cssVarV2('selfhost/button/sidebarButton/bg/select') : undefined, })} - onClick={() => setCurrentModule?.(module)} + onClick={() => setCurrentModule?.(group.module)} > - {module} + {group.name}
  • ))} @@ -151,18 +146,31 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => { className={cn('relative overflow-hidden w-full h-full')} > - {authModule && ( + {KNOWN_CONFIG_GROUPS.map(group => ( - )} - {otherModules.length > 0 && ( - - )} + ))} + + + + Experimental + + + {UNKNOWN_CONFIG_GROUPS.map(group => ( + + ))} + + + void; +} & ( + | { + type: 'String' | 'Number' | 'Boolean' | 'JSON'; + } + | { + type: 'Enum'; + options: string[]; + } +); + +const Inputs: Record< + ConfigInputProps['type'], + React.ComponentType<{ + defaultValue: any; + onChange: (value?: any) => void; + options?: string[]; + }> +> = { + Boolean: function SwitchInput({ defaultValue, onChange }) { + const handleSwitchChange = (checked: boolean) => { + onChange(checked); + }; + + return ( + + ); + }, + String: function StringInput({ defaultValue, onChange }) { + const handleInputChange = (e: React.ChangeEvent) => { + onChange(e.target.value); + }; + + return ( + + ); + }, + Number: function NumberInput({ defaultValue, onChange }) { + const handleInputChange = (e: React.ChangeEvent) => { + onChange(parseInt(e.target.value)); + }; + + return ( + + ); + }, + JSON: function ObjectInput({ defaultValue, onChange }) { + const handleInputChange = (e: React.ChangeEvent) => { + try { + const value = JSON.parse(e.target.value); + onChange(value); + } catch {} + }; + + return ( +