diff --git a/packages/twenty-emails/src/components/Footer.tsx b/packages/twenty-emails/src/components/Footer.tsx
new file mode 100644
index 000000000..919686fb8
--- /dev/null
+++ b/packages/twenty-emails/src/components/Footer.tsx
@@ -0,0 +1,55 @@
+import { Column, Row } from '@react-email/components';
+import { Link } from 'src/components/Link';
+import { ShadowText } from 'src/components/ShadowText';
+
+export const Footer = () => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Twenty.com Public Benefit Corporation
+
+ 2261 Market Street #5275
+
+ San Francisco, CA 94114
+
+ >
+ );
+};
diff --git a/packages/twenty-emails/src/components/WhatIsTwenty.tsx b/packages/twenty-emails/src/components/WhatIsTwenty.tsx
index 9b3913f78..6b905a1f3 100644
--- a/packages/twenty-emails/src/components/WhatIsTwenty.tsx
+++ b/packages/twenty-emails/src/components/WhatIsTwenty.tsx
@@ -1,8 +1,7 @@
-import { Column, Row } from '@react-email/components';
-import { Link } from 'src/components/Link';
+import { Footer } from 'src/components/Footer';
import { MainText } from 'src/components/MainText';
-import { ShadowText } from 'src/components/ShadowText';
import { SubTitle } from 'src/components/SubTitle';
+
export const WhatIsTwenty = () => {
return (
<>
@@ -11,35 +10,7 @@ export const WhatIsTwenty = () => {
It's a CRM, a software to help businesses manage their customer data and
relationships efficiently.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Twenty.com Public Benefit Corporation
-
- 2261 Market Street #5275
-
- San Francisco, CA 94114
-
+
>
);
};
diff --git a/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx b/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx
new file mode 100644
index 000000000..e157ad6ea
--- /dev/null
+++ b/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx
@@ -0,0 +1,28 @@
+import { BaseEmail } from 'src/components/BaseEmail';
+import { CallToAction } from 'src/components/CallToAction';
+import { Footer } from 'src/components/Footer';
+import { MainText } from 'src/components/MainText';
+import { Title } from 'src/components/Title';
+
+type SendEmailVerificationLinkEmailProps = {
+ link: string;
+};
+
+export const SendEmailVerificationLinkEmail = ({
+ link,
+}: SendEmailVerificationLinkEmailProps) => {
+ return (
+
+
+
+
+
+
+ Thanks for registering for an account on Twenty! Before we get started,
+ we just need to confirm that this is you. Click above to verify your
+ email address.
+
+
+
+ );
+};
diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts
index ddecb05c8..5bb9cd0c9 100644
--- a/packages/twenty-emails/src/index.ts
+++ b/packages/twenty-emails/src/index.ts
@@ -2,4 +2,5 @@ export * from './emails/clean-inactive-workspaces.email';
export * from './emails/delete-inactive-workspaces.email';
export * from './emails/password-reset-link.email';
export * from './emails/password-update-notify.email';
+export * from './emails/send-email-verification-link.email';
export * from './emails/send-invite-link.email';
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 21b44ffb0..bf7297616 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -183,6 +183,7 @@ export type ClientConfig = {
debugMode: Scalars['Boolean']['output'];
defaultSubdomain?: Maybe;
frontDomain: Scalars['String']['output'];
+ isEmailVerificationRequired: Scalars['Boolean']['output'];
isMultiWorkspaceEnabled: Scalars['Boolean']['output'];
isSSOEnabled: Scalars['Boolean']['output'];
sentry: Sentry;
@@ -404,7 +405,6 @@ export enum FeatureFlagKey {
IsSsoEnabled = 'IsSSOEnabled',
IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled',
IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled',
- IsViewGroupsEnabled = 'IsViewGroupsEnabled',
IsWorkflowEnabled = 'IsWorkflowEnabled'
}
@@ -612,9 +612,11 @@ export type Mutation = {
generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken;
getAuthorizationUrl: GetAuthorizationUrlOutput;
+ getLoginTokenFromEmailVerificationToken: LoginToken;
impersonate: ImpersonateOutput;
publishServerlessFunction: ServerlessFunction;
renewToken: AuthTokens;
+ resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
resendWorkspaceInvitation: SendInvitationsOutput;
runWorkflowVersion: WorkflowRun;
sendInvitations: SendInvitationsOutput;
@@ -811,6 +813,12 @@ export type MutationGetAuthorizationUrlArgs = {
};
+export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
+ captchaToken?: InputMaybe;
+ emailVerificationToken: Scalars['String']['input'];
+};
+
+
export type MutationImpersonateArgs = {
userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
@@ -827,6 +835,11 @@ export type MutationRenewTokenArgs = {
};
+export type MutationResendEmailVerificationTokenArgs = {
+ email: Scalars['String']['input'];
+};
+
+
export type MutationResendWorkspaceInvitationArgs = {
appTokenId: Scalars['String']['input'];
};
@@ -1289,6 +1302,11 @@ export enum RemoteTableStatus {
Synced = 'SYNCED'
}
+export type ResendEmailVerificationTokenOutput = {
+ __typename?: 'ResendEmailVerificationTokenOutput';
+ success: Scalars['Boolean']['output'];
+};
+
export type RunWorkflowVersionInput = {
/** Execution result in JSON format */
payload?: InputMaybe;
@@ -1631,9 +1649,9 @@ export type User = {
deletedAt?: Maybe;
disabled?: Maybe;
email: Scalars['String']['output'];
- emailVerified: Scalars['Boolean']['output'];
firstName: Scalars['String']['output'];
id: Scalars['UUID']['output'];
+ isEmailVerified: Scalars['Boolean']['output'];
lastName: Scalars['String']['output'];
onboardingStatus?: Maybe;
passwordHash?: Maybe;
@@ -1657,6 +1675,7 @@ export type UserExists = {
__typename?: 'UserExists';
availableWorkspaces: Array;
exists: Scalars['Boolean']['output'];
+ isEmailVerified: Scalars['Boolean']['output'];
};
export type UserExistsOutput = UserExists | UserNotExists;
diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx
index 247fa0b48..6edec2ec3 100644
--- a/packages/twenty-front/src/generated/graphql.tsx
+++ b/packages/twenty-front/src/generated/graphql.tsx
@@ -177,6 +177,7 @@ export type ClientConfig = {
debugMode: Scalars['Boolean'];
defaultSubdomain?: Maybe;
frontDomain: Scalars['String'];
+ isEmailVerificationRequired: Scalars['Boolean'];
isMultiWorkspaceEnabled: Scalars['Boolean'];
sentry: Sentry;
signInPrefilled: Scalars['Boolean'];
@@ -515,9 +516,11 @@ export type Mutation = {
generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken;
getAuthorizationUrl: GetAuthorizationUrlOutput;
+ getLoginTokenFromEmailVerificationToken: LoginToken;
impersonate: ImpersonateOutput;
publishServerlessFunction: ServerlessFunction;
renewToken: AuthTokens;
+ resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
resendWorkspaceInvitation: SendInvitationsOutput;
runWorkflowVersion: WorkflowRun;
sendInvitations: SendInvitationsOutput;
@@ -680,6 +683,12 @@ export type MutationGetAuthorizationUrlArgs = {
};
+export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
+ captchaToken?: InputMaybe;
+ emailVerificationToken: Scalars['String'];
+};
+
+
export type MutationImpersonateArgs = {
userId: Scalars['String'];
workspaceId: Scalars['String'];
@@ -696,6 +705,11 @@ export type MutationRenewTokenArgs = {
};
+export type MutationResendEmailVerificationTokenArgs = {
+ email: Scalars['String'];
+};
+
+
export type MutationResendWorkspaceInvitationArgs = {
appTokenId: Scalars['String'];
};
@@ -1062,6 +1076,11 @@ export enum RemoteTableStatus {
Synced = 'SYNCED'
}
+export type ResendEmailVerificationTokenOutput = {
+ __typename?: 'ResendEmailVerificationTokenOutput';
+ success: Scalars['Boolean'];
+};
+
export type RunWorkflowVersionInput = {
/** Execution result in JSON format */
payload?: InputMaybe;
@@ -1396,9 +1415,9 @@ export type User = {
deletedAt?: Maybe;
disabled?: Maybe;
email: Scalars['String'];
- emailVerified: Scalars['Boolean'];
firstName: Scalars['String'];
id: Scalars['UUID'];
+ isEmailVerified: Scalars['Boolean'];
lastName: Scalars['String'];
onboardingStatus?: Maybe;
passwordHash?: Maybe;
@@ -1422,6 +1441,7 @@ export type UserExists = {
__typename?: 'UserExists';
availableWorkspaces: Array;
exists: Scalars['Boolean'];
+ isEmailVerified: Scalars['Boolean'];
};
export type UserExistsOutput = UserExists | UserNotExists;
@@ -1957,6 +1977,14 @@ export type GetAuthorizationUrlMutationVariables = Exact<{
export type GetAuthorizationUrlMutation = { __typename?: 'Mutation', getAuthorizationUrl: { __typename?: 'GetAuthorizationUrlOutput', id: string, type: string, authorizationURL: string } };
+export type GetLoginTokenFromEmailVerificationTokenMutationVariables = Exact<{
+ emailVerificationToken: Scalars['String'];
+ captchaToken?: InputMaybe;
+}>;
+
+
+export type GetLoginTokenFromEmailVerificationTokenMutation = { __typename?: 'Mutation', getLoginTokenFromEmailVerificationToken: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
+
export type ImpersonateMutationVariables = Exact<{
userId: Scalars['String'];
workspaceId: Scalars['String'];
@@ -1972,6 +2000,13 @@ export type RenewTokenMutationVariables = Exact<{
export type RenewTokenMutation = { __typename?: 'Mutation', renewToken: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
+export type ResendEmailVerificationTokenMutationVariables = Exact<{
+ email: Scalars['String'];
+}>;
+
+
+export type ResendEmailVerificationTokenMutation = { __typename?: 'Mutation', resendEmailVerificationToken: { __typename?: 'ResendEmailVerificationTokenOutput', success: boolean } };
+
export type SignUpMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
@@ -2012,7 +2047,7 @@ export type CheckUserExistsQueryVariables = Exact<{
}>;
-export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
+export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, isEmailVerified: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ [key: string]: never; }>;
@@ -2058,7 +2093,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
-export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
+export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
@@ -2915,6 +2950,45 @@ export function useGetAuthorizationUrlMutation(baseOptions?: Apollo.MutationHook
export type GetAuthorizationUrlMutationHookResult = ReturnType;
export type GetAuthorizationUrlMutationResult = Apollo.MutationResult;
export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions;
+export const GetLoginTokenFromEmailVerificationTokenDocument = gql`
+ mutation GetLoginTokenFromEmailVerificationToken($emailVerificationToken: String!, $captchaToken: String) {
+ getLoginTokenFromEmailVerificationToken(
+ emailVerificationToken: $emailVerificationToken
+ captchaToken: $captchaToken
+ ) {
+ loginToken {
+ ...AuthTokenFragment
+ }
+ }
+}
+ ${AuthTokenFragmentFragmentDoc}`;
+export type GetLoginTokenFromEmailVerificationTokenMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useGetLoginTokenFromEmailVerificationTokenMutation__
+ *
+ * To run a mutation, you first call `useGetLoginTokenFromEmailVerificationTokenMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useGetLoginTokenFromEmailVerificationTokenMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [getLoginTokenFromEmailVerificationTokenMutation, { data, loading, error }] = useGetLoginTokenFromEmailVerificationTokenMutation({
+ * variables: {
+ * emailVerificationToken: // value for 'emailVerificationToken'
+ * captchaToken: // value for 'captchaToken'
+ * },
+ * });
+ */
+export function useGetLoginTokenFromEmailVerificationTokenMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(GetLoginTokenFromEmailVerificationTokenDocument, options);
+ }
+export type GetLoginTokenFromEmailVerificationTokenMutationHookResult = ReturnType;
+export type GetLoginTokenFromEmailVerificationTokenMutationResult = Apollo.MutationResult;
+export type GetLoginTokenFromEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions;
export const ImpersonateDocument = gql`
mutation Impersonate($userId: String!, $workspaceId: String!) {
impersonate(userId: $userId, workspaceId: $workspaceId) {
@@ -2990,6 +3064,39 @@ export function useRenewTokenMutation(baseOptions?: Apollo.MutationHookOptions;
export type RenewTokenMutationResult = Apollo.MutationResult;
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions;
+export const ResendEmailVerificationTokenDocument = gql`
+ mutation ResendEmailVerificationToken($email: String!) {
+ resendEmailVerificationToken(email: $email) {
+ success
+ }
+}
+ `;
+export type ResendEmailVerificationTokenMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useResendEmailVerificationTokenMutation__
+ *
+ * To run a mutation, you first call `useResendEmailVerificationTokenMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useResendEmailVerificationTokenMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [resendEmailVerificationTokenMutation, { data, loading, error }] = useResendEmailVerificationTokenMutation({
+ * variables: {
+ * email: // value for 'email'
+ * },
+ * });
+ */
+export function useResendEmailVerificationTokenMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(ResendEmailVerificationTokenDocument, options);
+ }
+export type ResendEmailVerificationTokenMutationHookResult = ReturnType;
+export type ResendEmailVerificationTokenMutationResult = Apollo.MutationResult;
+export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions;
export const SignUpDocument = gql`
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String) {
signUp(
@@ -3179,6 +3286,7 @@ export const CheckUserExistsDocument = gql`
status
}
}
+ isEmailVerified
}
... on UserNotExists {
exists
@@ -3471,6 +3579,7 @@ export const GetClientConfigDocument = gql`
}
signInPrefilled
isMultiWorkspaceEnabled
+ isEmailVerificationRequired
defaultSubdomain
frontDomain
debugMode
diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
index 5328c307a..a7316f98a 100644
--- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
+++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
@@ -58,6 +58,17 @@ const testCases = [
{ loc: AppPath.Verify, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam },
{ loc: AppPath.Verify, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath },
+ { loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: undefined },
+ { loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: undefined },
+ { loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: undefined },
+ { loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: undefined },
+ { loc: AppPath.VerifyEmail, isLoggedIn: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: undefined },
+ { loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: undefined },
+ { loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: undefined },
+ { loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: undefined },
+ { loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: undefined },
+ { loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined },
+
{ loc: AppPath.SignInUp, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired },
{ loc: AppPath.SignInUp, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
{ loc: AppPath.SignInUp, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
index 3cd7948a2..ff2821aee 100644
--- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
+++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
@@ -16,7 +16,8 @@ export const usePageChangeEffectNavigateLocation = () => {
const isMatchingOpenRoute =
isMatchingLocation(AppPath.Invite) ||
- isMatchingLocation(AppPath.ResetPassword);
+ isMatchingLocation(AppPath.ResetPassword) ||
+ isMatchingLocation(AppPath.VerifyEmail);
const isMatchingOngoingUserCreationRoute =
isMatchingOpenRoute ||
diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
index e1a65bc85..bae199e35 100644
--- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
+++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
@@ -1,6 +1,8 @@
import { AppRouterProviders } from '@/app/components/AppRouterProviders';
import { SettingsRoutes } from '@/app/components/SettingsRoutes';
+
import { VerifyEffect } from '@/auth/components/VerifyEffect';
+import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import indexAppPath from '@/navigation/utils/indexAppPath';
import { AppPath } from '@/types/AppPath';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
@@ -40,6 +42,7 @@ export const useCreateAppRouter = (
>
}>
} />
+ } />
} />
} />
} />
diff --git a/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx
index a6230e9ff..d68187d86 100644
--- a/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx
+++ b/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx
@@ -5,9 +5,9 @@ import { useAuth } from '@/auth/hooks/useAuth';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { AppPath } from '@/types/AppPath';
-import { useSetRecoilState } from 'recoil';
-import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const VerifyEffect = () => {
@@ -29,6 +29,7 @@ export const VerifyEffect = () => {
useEffect(() => {
if (isDefined(errorMessage)) {
enqueueSnackBar(errorMessage, {
+ dedupeKey: 'verify-failed-dedupe-key',
variant: SnackBarVariant.Error,
});
}
diff --git a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx
new file mode 100644
index 000000000..96c6c2268
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx
@@ -0,0 +1,68 @@
+import { useAuth } from '@/auth/hooks/useAuth';
+import { AppPath } from '@/types/AppPath';
+import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+
+import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
+import { useEffect, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificationSent';
+
+export const VerifyEmailEffect = () => {
+ const { getLoginTokenFromEmailVerificationToken } = useAuth();
+
+ const { enqueueSnackBar } = useSnackBar();
+
+ const [searchParams] = useSearchParams();
+ const [isError, setIsError] = useState(false);
+ const email = searchParams.get('email');
+ const emailVerificationToken = searchParams.get('emailVerificationToken');
+
+ const navigate = useNavigate();
+ const { readCaptchaToken } = useReadCaptchaToken();
+
+ useEffect(() => {
+ const verifyEmailToken = async () => {
+ if (!email || !emailVerificationToken) {
+ enqueueSnackBar(`Invalid email verification link.`, {
+ dedupeKey: 'email-verification-link-dedupe-key',
+ variant: SnackBarVariant.Error,
+ });
+ return navigate(AppPath.SignInUp);
+ }
+
+ const captchaToken = await readCaptchaToken();
+
+ try {
+ const { loginToken } = await getLoginTokenFromEmailVerificationToken(
+ emailVerificationToken,
+ captchaToken,
+ );
+
+ enqueueSnackBar('Email verified.', {
+ dedupeKey: 'email-verification-dedupe-key',
+ variant: SnackBarVariant.Success,
+ });
+
+ navigate(`${AppPath.Verify}?loginToken=${loginToken.token}`);
+ } catch (error) {
+ enqueueSnackBar('Email verification failed.', {
+ dedupeKey: 'email-verification-dedupe-key',
+ variant: SnackBarVariant.Error,
+ });
+ setIsError(true);
+ }
+ };
+
+ verifyEmailToken();
+
+ // Verify email only needs to run once at mount
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ if (isError) {
+ return ;
+ }
+
+ return <>>;
+};
diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/getLoginTokenFromEmailVerificationToken.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/getLoginTokenFromEmailVerificationToken.ts
new file mode 100644
index 000000000..67631c89e
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/graphql/mutations/getLoginTokenFromEmailVerificationToken.ts
@@ -0,0 +1,17 @@
+import { gql } from '@apollo/client';
+
+export const GET_LOGIN_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
+ mutation GetLoginTokenFromEmailVerificationToken(
+ $emailVerificationToken: String!
+ $captchaToken: String
+ ) {
+ getLoginTokenFromEmailVerificationToken(
+ emailVerificationToken: $emailVerificationToken
+ captchaToken: $captchaToken
+ ) {
+ loginToken {
+ ...AuthTokenFragment
+ }
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/resendEmailVerificationToken.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/resendEmailVerificationToken.ts
new file mode 100644
index 000000000..7040fd2e2
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/graphql/mutations/resendEmailVerificationToken.ts
@@ -0,0 +1,9 @@
+import { gql } from '@apollo/client';
+
+export const RESEND_EMAIL_VERIFICATION_TOKEN = gql`
+ mutation ResendEmailVerificationToken($email: String!) {
+ resendEmailVerificationToken(email: $email) {
+ success
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts
index 0a3c9b0ac..9ecd200ab 100644
--- a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts
+++ b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts
@@ -19,6 +19,7 @@ export const CHECK_USER_EXISTS = gql`
status
}
}
+ isEmailVerified
}
... on UserNotExists {
exists
diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx
index 99bea9925..073607e15 100644
--- a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx
+++ b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx
@@ -2,6 +2,7 @@ import { useApolloClient } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing';
import { expect } from '@storybook/test';
import { ReactNode, act } from 'react';
+import { MemoryRouter } from 'react-router-dom';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { iconsState } from 'twenty-ui';
@@ -13,12 +14,14 @@ import { supportChatState } from '@/client-config/states/supportChatState';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
-import { email, mocks, password, results, token } from '../__mocks__/useAuth';
import { renderHook } from '@testing-library/react';
+import { email, mocks, password, results, token } from '../__mocks__/useAuth';
const Wrapper = ({ children }: { children: ReactNode }) => (
- {children}
+
+ {children}
+
);
diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
index 6a74491d8..88749f2d1 100644
--- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
@@ -1,4 +1,4 @@
-import { useApolloClient } from '@apollo/client';
+import { ApolloError, useApolloClient } from '@apollo/client';
import { useCallback } from 'react';
import {
snapshot_UNSTABLE,
@@ -26,6 +26,7 @@ import {
useChallengeMutation,
useCheckUserExistsLazyQuery,
useGetCurrentUserLazyQuery,
+ useGetLoginTokenFromEmailVerificationTokenMutation,
useSignUpMutation,
useVerifyMutation,
} from '~/generated/graphql';
@@ -44,7 +45,12 @@ import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTi
import { currentUserState } from '../states/currentUserState';
import { tokenPairState } from '../states/tokenPairState';
+import {
+ SignInUpStep,
+ signInUpStepState,
+} from '@/auth/states/signInUpStepState';
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
+import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
@@ -54,6 +60,7 @@ import { domainConfigurationState } from '@/domain-manager/states/domainConfigur
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
+import { useSearchParams } from 'react-router-dom';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
@@ -68,7 +75,11 @@ export const useAuth = () => {
currentWorkspaceMembersState,
);
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
+ const isEmailVerificationRequired = useRecoilValue(
+ isEmailVerificationRequiredState,
+ );
+ const setSignInUpStep = useSetRecoilState(signInUpStepState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState);
const setWorkspaces = useSetRecoilState(workspacesState);
@@ -78,6 +89,8 @@ export const useAuth = () => {
const [challenge] = useChallengeMutation();
const [signUp] = useSignUpMutation();
const [verify] = useVerifyMutation();
+ const [getLoginTokenFromEmailVerificationToken] =
+ useGetLoginTokenFromEmailVerificationTokenMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery();
const { isOnAWorkspaceSubdomain } =
@@ -96,6 +109,8 @@ export const useAuth = () => {
const setDateTimeFormat = useSetRecoilState(dateTimeFormatState);
+ const [, setSearchParams] = useSearchParams();
+
const clearSession = useRecoilCallback(
({ snapshot }) =>
async () => {
@@ -154,25 +169,59 @@ export const useAuth = () => {
const handleChallenge = useCallback(
async (email: string, password: string, captchaToken?: string) => {
- const challengeResult = await challenge({
+ try {
+ const challengeResult = await challenge({
+ variables: {
+ email,
+ password,
+ captchaToken,
+ },
+ });
+ if (isDefined(challengeResult.errors)) {
+ throw challengeResult.errors;
+ }
+
+ if (!challengeResult.data?.challenge) {
+ throw new Error('No login token');
+ }
+
+ return challengeResult.data.challenge;
+ } catch (error) {
+ // TODO: Get intellisense for graphql error extensions code (codegen?)
+ if (
+ error instanceof ApolloError &&
+ error.graphQLErrors[0]?.extensions?.code === 'EMAIL_NOT_VERIFIED'
+ ) {
+ setSearchParams({ email });
+ setSignInUpStep(SignInUpStep.EmailVerification);
+ throw error;
+ }
+ throw error;
+ }
+ },
+ [challenge, setSearchParams, setSignInUpStep],
+ );
+
+ const handleGetLoginTokenFromEmailVerificationToken = useCallback(
+ async (emailVerificationToken: string, captchaToken?: string) => {
+ const loginTokenResult = await getLoginTokenFromEmailVerificationToken({
variables: {
- email,
- password,
+ emailVerificationToken,
captchaToken,
},
});
- if (isDefined(challengeResult.errors)) {
- throw challengeResult.errors;
+ if (isDefined(loginTokenResult.errors)) {
+ throw loginTokenResult.errors;
}
- if (!challengeResult.data?.challenge) {
+ if (!loginTokenResult.data?.getLoginTokenFromEmailVerificationToken) {
throw new Error('No login token');
}
- return challengeResult.data.challenge;
+ return loginTokenResult.data.getLoginTokenFromEmailVerificationToken;
},
- [challenge],
+ [getLoginTokenFromEmailVerificationToken],
);
const loadCurrentUser = useCallback(async () => {
@@ -343,12 +392,21 @@ export const useAuth = () => {
throw new Error('No login token');
}
+ if (isEmailVerificationRequired) {
+ setSearchParams({ email });
+ setSignInUpStep(SignInUpStep.EmailVerification);
+ return null;
+ }
+
if (isMultiWorkspaceEnabled) {
return redirectToWorkspaceDomain(
signUpResult.data.signUp.workspace.subdomain,
- AppPath.Verify,
+ isEmailVerificationRequired ? AppPath.SignInUp : AppPath.Verify,
{
- loginToken: signUpResult.data.signUp.loginToken.token,
+ ...(!isEmailVerificationRequired && {
+ loginToken: signUpResult.data.signUp.loginToken.token,
+ }),
+ email,
},
);
}
@@ -361,6 +419,9 @@ export const useAuth = () => {
workspacePublicData,
isMultiWorkspaceEnabled,
handleVerify,
+ setSignInUpStep,
+ setSearchParams,
+ isEmailVerificationRequired,
redirectToWorkspaceDomain,
],
);
@@ -424,6 +485,8 @@ export const useAuth = () => {
return {
challenge: handleChallenge,
+ getLoginTokenFromEmailVerificationToken:
+ handleGetLoginTokenFromEmailVerificationToken,
verify: handleVerify,
loadCurrentUser,
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/EmailVerificationSent.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/EmailVerificationSent.tsx
new file mode 100644
index 000000000..1205e2689
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/EmailVerificationSent.tsx
@@ -0,0 +1,79 @@
+import styled from '@emotion/styled';
+
+import { SubTitle } from '@/auth/components/SubTitle';
+import { Title } from '@/auth/components/Title';
+import { useHandleResendEmailVerificationToken } from '@/auth/sign-in-up/hooks/useHandleResendEmailVerificationToken';
+import { useTheme } from '@emotion/react';
+import { AnimatedEaseIn, IconMail, Loader, MainButton, RGBA } from 'twenty-ui';
+
+const StyledMailContainer = styled.div`
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ border: 2px solid ${(props) => props.color};
+ border-radius: ${({ theme }) => theme.border.radius.rounded};
+ box-shadow: ${(props) =>
+ props.color && `-4px 4px 0 -2px ${RGBA(props.color, 1)}`};
+ height: 36px;
+ width: 36px;
+ margin-bottom: ${({ theme }) => theme.spacing(4)};
+`;
+
+const StyledEmail = styled.span`
+ font-weight: ${({ theme }) => theme.font.weight.medium};
+`;
+
+const StyledButtonContainer = styled.div`
+ margin-top: ${({ theme }) => theme.spacing(8)};
+`;
+
+export const EmailVerificationSent = ({
+ email,
+ isError = false,
+}: {
+ email: string | null;
+ isError?: boolean;
+}) => {
+ const theme = useTheme();
+ const color =
+ theme.name === 'light' ? theme.grayScale.gray90 : theme.grayScale.gray10;
+
+ const { handleResendEmailVerificationToken, loading: isLoading } =
+ useHandleResendEmailVerificationToken();
+
+ return (
+ <>
+
+
+
+
+
+
+ {isError ? 'Email Verification Failed' : 'Confirm Your Email Address'}
+
+
+ {isError ? (
+ <>
+ Oops! We encountered an issue verifying{' '}
+ {email}. Please request a new
+ verification email and try again.
+ >
+ ) : (
+ <>
+ A verification email has been sent to{' '}
+ {email}. Please check your inbox and
+ click the link in the email to activate your account.
+ >
+ )}
+
+
+ (isLoading ? : undefined)}
+ width={200}
+ />
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResendEmailVerificationToken.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResendEmailVerificationToken.ts
new file mode 100644
index 000000000..d1c52dfd9
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResendEmailVerificationToken.ts
@@ -0,0 +1,47 @@
+import { useCallback } from 'react';
+
+import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { useResendEmailVerificationTokenMutation } from '~/generated/graphql';
+
+export const useHandleResendEmailVerificationToken = () => {
+ const { enqueueSnackBar } = useSnackBar();
+ const [resendEmailVerificationToken, { loading }] =
+ useResendEmailVerificationTokenMutation();
+
+ const handleResendEmailVerificationToken = useCallback(
+ (email: string | null) => {
+ return async () => {
+ if (!email) {
+ enqueueSnackBar('Invalid email', {
+ variant: SnackBarVariant.Error,
+ });
+ return;
+ }
+
+ try {
+ const { data } = await resendEmailVerificationToken({
+ variables: { email },
+ });
+
+ if (data?.resendEmailVerificationToken?.success === true) {
+ enqueueSnackBar('Email verification link resent!', {
+ variant: SnackBarVariant.Success,
+ });
+ } else {
+ enqueueSnackBar('There was some issue', {
+ variant: SnackBarVariant.Error,
+ });
+ }
+ } catch (error) {
+ enqueueSnackBar((error as Error).message, {
+ variant: SnackBarVariant.Error,
+ });
+ }
+ };
+ },
+ [enqueueSnackBar, resendEmailVerificationToken],
+ );
+
+ return { handleResendEmailVerificationToken, loading };
+};
diff --git a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts
index 81a402371..613d3a019 100644
--- a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts
+++ b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts
@@ -4,6 +4,7 @@ export enum SignInUpStep {
Init = 'init',
Email = 'email',
Password = 'password',
+ EmailVerification = 'emailVerification',
WorkspaceSelection = 'workspaceSelection',
SSOIdentityProviderSelection = 'SSOIdentityProviderSelection',
}
diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx
index 565afcba7..449ae0d3e 100644
--- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx
+++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx
@@ -8,6 +8,7 @@ import { clientConfigApiStatusState } from '@/client-config/states/clientConfigA
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
+import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
import { supportChatState } from '@/client-config/states/supportChatState';
@@ -29,6 +30,9 @@ export const ClientConfigProviderEffect = () => {
const setIsMultiWorkspaceEnabled = useSetRecoilState(
isMultiWorkspaceEnabledState,
);
+ const setIsEmailVerificationRequired = useSetRecoilState(
+ isEmailVerificationRequiredState,
+ );
const setBilling = useSetRecoilState(billingState);
const setSupportChat = useSetRecoilState(supportChatState);
@@ -89,6 +93,9 @@ export const ClientConfigProviderEffect = () => {
setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled);
setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled);
setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled);
+ setIsEmailVerificationRequired(
+ data?.clientConfig.isEmailVerificationRequired,
+ );
setBilling(data?.clientConfig.billing);
setSupportChat(data?.clientConfig.support);
@@ -115,6 +122,7 @@ export const ClientConfigProviderEffect = () => {
setIsDebugMode,
setIsDeveloperDefaultSignInPrefilled,
setIsMultiWorkspaceEnabled,
+ setIsEmailVerificationRequired,
setSupportChat,
setBilling,
setSentryConfig,
diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts
index 27509df4d..577614bce 100644
--- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts
+++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts
@@ -22,6 +22,7 @@ export const GET_CLIENT_CONFIG = gql`
}
signInPrefilled
isMultiWorkspaceEnabled
+ isEmailVerificationRequired
defaultSubdomain
frontDomain
debugMode
diff --git a/packages/twenty-front/src/modules/client-config/states/isEmailVerificationRequiredState.ts b/packages/twenty-front/src/modules/client-config/states/isEmailVerificationRequiredState.ts
new file mode 100644
index 000000000..e06d73779
--- /dev/null
+++ b/packages/twenty-front/src/modules/client-config/states/isEmailVerificationRequiredState.ts
@@ -0,0 +1,6 @@
+import { createState } from 'twenty-ui';
+
+export const isEmailVerificationRequiredState = createState({
+ key: 'isEmailVerificationRequired',
+ defaultValue: false,
+});
diff --git a/packages/twenty-front/src/modules/types/AppPath.ts b/packages/twenty-front/src/modules/types/AppPath.ts
index 8cafb18e7..598d9a17b 100644
--- a/packages/twenty-front/src/modules/types/AppPath.ts
+++ b/packages/twenty-front/src/modules/types/AppPath.ts
@@ -1,6 +1,7 @@
export enum AppPath {
// Not logged-in
Verify = '/verify',
+ VerifyEmail = '/verify-email',
SignInUp = '/welcome',
Invite = '/invite/:workspaceInviteHash',
ResetPassword = '/reset-password/:passwordResetToken',
diff --git a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
index 1ebc61329..089f9e922 100644
--- a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
+++ b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
@@ -35,6 +35,7 @@ export type SnackBarProps = Pick, 'id'> & {
onClose?: () => void;
role?: 'alert' | 'status';
variant?: SnackBarVariant;
+ dedupeKey?: string;
};
const StyledContainer = styled.div`
diff --git a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts
index d60a9d6b3..04684c2ac 100644
--- a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts
+++ b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
+import { isDefined } from '~/utils/isDefined';
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
import {
@@ -27,8 +28,17 @@ export const useSnackBar = () => {
const setSnackBarQueue = useRecoilCallback(
({ set }) =>
- (newValue) =>
+ (newValue: SnackBarOptions) =>
set(snackBarInternalScopedState({ scopeId }), (prev) => {
+ if (
+ isDefined(newValue.dedupeKey) &&
+ prev.queue.some(
+ (snackBar) => snackBar.dedupeKey === newValue.dedupeKey,
+ )
+ ) {
+ return prev;
+ }
+
if (prev.queue.length >= prev.maxQueue) {
return {
...prev,
diff --git a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx
index dc7d8477f..a70fb8c4f 100644
--- a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx
@@ -67,6 +67,17 @@ const testCases = [
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: false },
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false },
+ { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
+ { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true },
+ { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: true },
+ { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: true },
+ { loc: AppPath.VerifyEmail, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true },
+ { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: true },
+ { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: true },
+ { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: true },
+ { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true },
+ { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: true },
+
{ loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
{ loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true },
{ loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: true },
diff --git a/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts b/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts
index a12dbf590..d801d50e5 100644
--- a/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts
+++ b/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts
@@ -28,6 +28,7 @@ export const useShowAuthModal = () => {
if (
isMatchingLocation(AppPath.Invite) ||
isMatchingLocation(AppPath.ResetPassword) ||
+ isMatchingLocation(AppPath.VerifyEmail) ||
isMatchingLocation(AppPath.SignInUp)
) {
return isDefaultLayoutAuthModalVisible;
diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx
index 2c8a1f6b8..ce52288ea 100644
--- a/packages/twenty-front/src/pages/auth/SignInUp.tsx
+++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx
@@ -1,12 +1,12 @@
-import { useRecoilValue } from 'recoil';
-
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { SignInUpStep } from '@/auth/states/signInUpStepState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
+import { useRecoilValue } from 'recoil';
import { Logo } from '@/auth/components/Logo';
import { Title } from '@/auth/components/Title';
+import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm';
import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection';
@@ -21,6 +21,37 @@ import { useMemo } from 'react';
import { AnimatedEaseIn } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
+import { useSearchParams } from 'react-router-dom';
+import { PublicWorkspaceDataOutput } from '~/generated-metadata/graphql';
+
+const StandardContent = ({
+ workspacePublicData,
+ signInUpForm,
+ signInUpStep,
+}: {
+ workspacePublicData: PublicWorkspaceDataOutput | null;
+ signInUpForm: JSX.Element | null;
+ signInUpStep: SignInUpStep;
+}) => {
+ return (
+ <>
+
+
+
+
+ Welcome to{' '}
+ {!isDefined(workspacePublicData?.displayName)
+ ? DEFAULT_WORKSPACE_NAME
+ : workspacePublicData?.displayName === ''
+ ? 'Your Workspace'
+ : workspacePublicData?.displayName}
+
+ {signInUpForm}
+ {signInUpStep !== SignInUpStep.Password && }
+ >
+ );
+};
+
export const SignInUp = () => {
const { form } = useSignInUpForm();
const { signInUpStep } = useSignInUp(form);
@@ -31,6 +62,8 @@ export const SignInUp = () => {
const { loading } = useGetPublicWorkspaceDataBySubdomain();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
+ const [searchParams] = useSearchParams();
+
const signInUpForm = useMemo(() => {
if (loading) return null;
@@ -68,16 +101,15 @@ export const SignInUp = () => {
workspacePublicData,
]);
+ if (signInUpStep === SignInUpStep.EmailVerification) {
+ return ;
+ }
+
return (
- <>
-
-
-
-
- {`Welcome to ${workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME}`}
-
- {signInUpForm}
- {signInUpStep !== SignInUpStep.Password && }
- >
+
);
};
diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts
index 7f59a37f2..5ef9f4520 100644
--- a/packages/twenty-front/src/testing/mock-data/config.ts
+++ b/packages/twenty-front/src/testing/mock-data/config.ts
@@ -3,6 +3,7 @@ import { CaptchaDriverType, ClientConfig } from '~/generated/graphql';
export const mockedClientConfig: ClientConfig = {
signInPrefilled: true,
isMultiWorkspaceEnabled: false,
+ isEmailVerificationRequired: false,
authProviders: {
google: true,
magicLink: false,
diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example
index 9891092b1..038b42d8b 100644
--- a/packages/twenty-server/.env.example
+++ b/packages/twenty-server/.env.example
@@ -56,6 +56,8 @@ FRONT_PORT=3001
# WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=30
# WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=60
# Email Server Settings, see this doc for more info: https://docs.twenty.com/start/self-hosting/#email
+# IS_EMAIL_VERIFICATION_REQUIRED=false
+# EMAIL_VERIFICATION_TOKEN_EXPIRES_IN=1h
# EMAIL_FROM_ADDRESS=contact@yourdomain.com
# EMAIL_SYSTEM_ADDRESS=system@yourdomain.com
# EMAIL_FROM_NAME='John from YourDomain'
diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1736050161854-renameEmailVerifiedColumn.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1736050161854-renameEmailVerifiedColumn.ts
new file mode 100644
index 000000000..8480e5acf
--- /dev/null
+++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1736050161854-renameEmailVerifiedColumn.ts
@@ -0,0 +1,19 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class RenameEmailVerifiedColumn1736050161854
+ implements MigrationInterface
+{
+ name = 'RenameEmailVerifiedColumn1736050161854';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "core"."user" RENAME COLUMN "emailVerified" TO "isEmailVerified"`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "core"."user" RENAME COLUMN "isEmailVerified" TO "emailVerified"`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts
index 38f17ddaa..4c2f2f1fc 100644
--- a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts
@@ -1,21 +1,21 @@
import { Field, ObjectType } from '@nestjs/graphql';
-import {
- Entity,
- Column,
- PrimaryGeneratedColumn,
- ManyToOne,
- JoinColumn,
- CreateDateColumn,
- UpdateDateColumn,
- Relation,
-} from 'typeorm';
import { BeforeCreateOne, IDField } from '@ptc-org/nestjs-query-graphql';
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ JoinColumn,
+ ManyToOne,
+ PrimaryGeneratedColumn,
+ Relation,
+ UpdateDateColumn,
+} from 'typeorm';
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { BeforeCreateOneAppToken } from 'src/engine/core-modules/app-token/hooks/before-create-one-app-token.hook';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
-import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
export enum AppTokenType {
RefreshToken = 'REFRESH_TOKEN',
CodeChallenge = 'CODE_CHALLENGE',
@@ -23,6 +23,7 @@ export enum AppTokenType {
PasswordResetToken = 'PASSWORD_RESET_TOKEN',
InvitationToken = 'INVITATION_TOKEN',
OIDCCodeVerifier = 'OIDC_CODE_VERIFIER',
+ EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN',
}
@Entity({ name: 'appToken', schema: 'core' })
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts
index 514246339..0a7b7a451 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts
@@ -9,6 +9,7 @@ export class AuthException extends CustomException {
export enum AuthExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',
+ EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
INVALID_INPUT = 'INVALID_INPUT',
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
index 8a01a8782..75657d81b 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
@@ -25,6 +25,7 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
+import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
@@ -80,6 +81,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
WorkspaceSSOModule,
FeatureFlagModule,
WorkspaceInvitationModule,
+ EmailVerificationModule,
],
controllers: [
GoogleAuthController,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts
index bf7d07189..5cd11f2ae 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts
@@ -3,11 +3,12 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
+import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
+import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
-import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { AuthResolver } from './auth.resolver';
@@ -16,6 +17,7 @@ import { AuthService } from './services/auth.service';
// import { OAuthService } from './services/oauth.service';
import { ResetPasswordService } from './services/reset-password.service';
import { SwitchWorkspaceService } from './services/switch-workspace.service';
+import { EmailVerificationTokenService } from './token/services/email-verification-token.service';
import { LoginTokenService } from './token/services/login-token.service';
import { RenewTokenService } from './token/services/renew-token.service';
import { TransientTokenService } from './token/services/transient-token.service';
@@ -80,6 +82,14 @@ describe('AuthResolver', () => {
provide: TransientTokenService,
useValue: {},
},
+ {
+ provide: EmailVerificationService,
+ useValue: {},
+ },
+ {
+ provide: EmailVerificationTokenService,
+ useValue: {},
+ },
// {
// provide: OAuthService,
// useValue: {},
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
index 091a9fa1d..994662fda 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
@@ -18,30 +18,34 @@ import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dt
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
-import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
-import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
-import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
-import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
-import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
-import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
-import { UserService } from 'src/engine/core-modules/user/services/user.service';
-import { User } from 'src/engine/core-modules/user/user.entity';
-import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
-import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
-import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
-import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
-import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
-import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
-import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
-import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
-import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
+import { GetLoginTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input';
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
+import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
+import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
+import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
+import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
+import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
+import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
+import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
+import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
+import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
+import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
+import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
+import { UserService } from 'src/engine/core-modules/user/services/user.service';
+import { User } from 'src/engine/core-modules/user/user.entity';
+import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
+import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
+import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
+import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
+import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
+import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
+import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ChallengeInput } from './dto/challenge.input';
import { LoginToken } from './dto/login-token.entity';
@@ -68,8 +72,11 @@ export class AuthResolver {
private loginTokenService: LoginTokenService,
private switchWorkspaceService: SwitchWorkspaceService,
private transientTokenService: TransientTokenService,
+ private emailVerificationService: EmailVerificationService,
// private oauthService: OAuthService,
private domainManagerService: DomainManagerService,
+ private userWorkspaceService: UserWorkspaceService,
+ private emailVerificationTokenService: EmailVerificationTokenService,
) {}
@UseGuards(CaptchaGuard)
@@ -116,7 +123,6 @@ export class AuthResolver {
AuthExceptionCode.WORKSPACE_NOT_FOUND,
),
);
-
const user = await this.authService.challenge(challengeInput, workspace);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
@@ -126,6 +132,41 @@ export class AuthResolver {
return { loginToken };
}
+ @UseGuards(CaptchaGuard)
+ @Mutation(() => LoginToken)
+ async getLoginTokenFromEmailVerificationToken(
+ @Args()
+ getLoginTokenFromEmailVerificationTokenInput: GetLoginTokenFromEmailVerificationTokenInput,
+ @OriginHeader() origin: string,
+ ) {
+ const workspace =
+ await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
+ origin,
+ );
+
+ workspaceValidator.assertIsDefinedOrThrow(
+ workspace,
+ new AuthException(
+ 'Workspace not found',
+ AuthExceptionCode.WORKSPACE_NOT_FOUND,
+ ),
+ );
+
+ const user =
+ await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow(
+ getLoginTokenFromEmailVerificationTokenInput.emailVerificationToken,
+ );
+
+ await this.userService.markEmailAsVerified(user.id);
+
+ const loginToken = await this.loginTokenService.generateLoginToken(
+ user.email,
+ workspace.id,
+ );
+
+ return { loginToken };
+ }
+
@UseGuards(CaptchaGuard)
@Mutation(() => SignUpOutput)
async signUp(@Args() signUpInput: SignUpInput): Promise {
@@ -170,6 +211,12 @@ export class AuthResolver {
},
});
+ await this.emailVerificationService.sendVerificationEmail(
+ user.id,
+ user.email,
+ workspace.subdomain,
+ );
+
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
@@ -333,6 +380,6 @@ export class AuthResolver {
async findAvailableWorkspacesByEmail(
@Args('email') email: string,
): Promise {
- return this.authService.findAvailableWorkspacesByEmail(email);
+ return this.userWorkspaceService.findAvailableWorkspacesByEmail(email);
}
}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input.ts
new file mode 100644
index 000000000..333174e75
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input.ts
@@ -0,0 +1,16 @@
+import { ArgsType, Field } from '@nestjs/graphql';
+
+import { IsNotEmpty, IsString, IsOptional } from 'class-validator';
+
+@ArgsType()
+export class GetLoginTokenFromEmailVerificationTokenInput {
+ @Field(() => String)
+ @IsNotEmpty()
+ @IsString()
+ emailVerificationToken: string;
+
+ @Field(() => String, { nullable: true })
+ @IsString()
+ @IsOptional()
+ captchaToken?: string;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts
index e8f7f9a3e..4b914a8a4 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts
@@ -9,6 +9,9 @@ export class UserExists {
@Field(() => [AvailableWorkspaceOutput])
availableWorkspaces: Array;
+
+ @Field(() => Boolean)
+ isEmailVerified: boolean;
}
@ObjectType()
diff --git a/packages/twenty-server/src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter.ts
index 0cf81224c..818fc399f 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter.ts
@@ -6,6 +6,7 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import {
AuthenticationError,
+ EmailNotVerifiedError,
ForbiddenError,
InternalServerError,
NotFoundError,
@@ -20,6 +21,8 @@ export class AuthGraphqlApiExceptionFilter implements ExceptionFilter {
throw new NotFoundError(exception.message);
case AuthExceptionCode.INVALID_INPUT:
throw new UserInputError(exception.message);
+ case AuthExceptionCode.EMAIL_NOT_VERIFIED:
+ throw new EmailNotVerifiedError(exception.message);
case AuthExceptionCode.FORBIDDEN_EXCEPTION:
throw new ForbiddenError(exception.message);
case AuthExceptionCode.UNAUTHENTICATED:
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts
index 6c99c2a47..e34def799 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts
@@ -1,23 +1,23 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
-import bcrypt from 'bcrypt';
import { expect, jest } from '@jest/globals';
import { Repository } from 'typeorm';
+import bcrypt from 'bcrypt';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
-import { User } from 'src/engine/core-modules/user/user.entity';
-import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
-import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
-import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
-import { EmailService } from 'src/engine/core-modules/email/email.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
-import { UserService } from 'src/engine/core-modules/user/services/user.service';
-import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
+import { EmailService } from 'src/engine/core-modules/email/email.service';
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
+import { UserService } from 'src/engine/core-modules/user/services/user.service';
+import { User } from 'src/engine/core-modules/user/user.entity';
+import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service';
+import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthService } from './auth.service';
@@ -31,9 +31,10 @@ const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn();
const workspaceInvitationValidateInvitationMock = jest.fn();
const userWorkspaceAddUserToWorkspaceMock = jest.fn();
+const environmentServiceGetMock = jest.fn();
+
describe('AuthService', () => {
let service: AuthService;
- let appTokenRepository: Repository;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -66,7 +67,9 @@ describe('AuthService', () => {
},
{
provide: EnvironmentService,
- useValue: {},
+ useValue: {
+ get: environmentServiceGetMock,
+ },
},
{
provide: DomainManagerService,
@@ -112,10 +115,10 @@ describe('AuthService', () => {
}).compile();
service = module.get(AuthService);
+ });
- appTokenRepository = module.get>(
- getRepositoryToken(AppToken, 'core'),
- );
+ beforeEach(() => {
+ environmentServiceGetMock.mockReturnValue(false);
});
it('should be defined', async () => {
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
index 83bb428ba..be93cf7bf 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
@@ -26,7 +26,6 @@ import {
} from 'src/engine/core-modules/auth/auth.util';
import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
-import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity';
@@ -157,6 +156,17 @@ export class AuthService {
);
}
+ const isEmailVerificationRequired = this.environmentService.get(
+ 'IS_EMAIL_VERIFICATION_REQUIRED',
+ );
+
+ if (isEmailVerificationRequired && !user.isEmailVerified) {
+ throw new AuthException(
+ 'Email is not verified',
+ AuthExceptionCode.EMAIL_NOT_VERIFIED,
+ );
+ }
+
return user;
}
@@ -260,7 +270,9 @@ export class AuthService {
if (userValidator.isDefined(user)) {
return {
exists: true,
- availableWorkspaces: await this.findAvailableWorkspacesByEmail(email),
+ availableWorkspaces:
+ await this.userWorkspaceService.findAvailableWorkspacesByEmail(email),
+ isEmailVerified: user.isEmailVerified,
};
}
@@ -463,48 +475,6 @@ export class AuthService {
return url.toString();
}
- async findAvailableWorkspacesByEmail(email: string) {
- const user = await this.userRepository.findOne({
- where: {
- email,
- },
- relations: [
- 'workspaces',
- 'workspaces.workspace',
- 'workspaces.workspace.workspaceSSOIdentityProviders',
- ],
- });
-
- userValidator.assertIsDefinedOrThrow(
- user,
- new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
- );
-
- return user.workspaces.map((userWorkspace) => ({
- id: userWorkspace.workspaceId,
- displayName: userWorkspace.workspace.displayName,
- subdomain: userWorkspace.workspace.subdomain,
- logo: userWorkspace.workspace.logo,
- sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce(
- (acc, identityProvider) =>
- acc.concat(
- identityProvider.status === 'Inactive'
- ? []
- : [
- {
- id: identityProvider.id,
- name: identityProvider.name,
- issuer: identityProvider.issuer,
- type: identityProvider.type,
- status: identityProvider.status,
- },
- ],
- ),
- [] as AvailableWorkspaceOutput['sso'],
- ),
- }));
- }
-
async findInvitationForSignInUp({
currentWorkspace,
workspacePersonalInviteToken,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts
index a5caf1e95..390d9e49c 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts
@@ -22,6 +22,7 @@ import {
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
+import { UserService } from 'src/engine/core-modules/user/services/user.service';
jest.mock('src/utils/image', () => {
return {
@@ -36,8 +37,6 @@ describe('SignInUpService', () => {
let fileUploadService: FileUploadService;
let workspaceInvitationService: WorkspaceInvitationService;
let userWorkspaceService: UserWorkspaceService;
- let onboardingService: OnboardingService;
- let httpService: HttpService;
let environmentService: EnvironmentService;
let domainManagerService: DomainManagerService;
@@ -98,6 +97,16 @@ describe('SignInUpService', () => {
get: jest.fn(),
},
},
+ {
+ provide: UserService,
+ useValue: {
+ markEmailAsVerified: jest.fn().mockReturnValue({
+ id: 'test-user-id',
+ email: 'test@test.com',
+ isEmailVerified: true,
+ } as User),
+ },
+ },
{
provide: DomainManagerService,
useValue: {
@@ -116,8 +125,6 @@ describe('SignInUpService', () => {
);
userWorkspaceService =
module.get(UserWorkspaceService);
- onboardingService = module.get(OnboardingService);
- httpService = module.get(HttpService);
environmentService = module.get(EnvironmentService);
domainManagerService =
module.get(DomainManagerService);
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts
index 7429ac608..fe6db1031 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts
@@ -41,6 +41,7 @@ import {
SignInUpBaseParams,
SignInUpNewUserPayload,
} from 'src/engine/core-modules/auth/types/signInUp.type';
+import { UserService } from 'src/engine/core-modules/user/services/user.service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@@ -57,6 +58,7 @@ export class SignInUpService {
private readonly httpService: HttpService,
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
+ private readonly userService: UserService,
) {}
async computeParamsForNewUser(
@@ -194,6 +196,8 @@ export class SignInUpService {
email,
);
+ await this.userService.markEmailAsVerified(updatedUser.id);
+
return updatedUser;
}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/email-verification-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/email-verification-token.service.spec.ts
new file mode 100644
index 000000000..1ee9e31e9
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/email-verification-token.service.spec.ts
@@ -0,0 +1,187 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
+
+import crypto from 'crypto';
+
+import { Repository } from 'typeorm';
+
+import {
+ AppToken,
+ AppTokenType,
+} from 'src/engine/core-modules/app-token/app-token.entity';
+import {
+ EmailVerificationException,
+ EmailVerificationExceptionCode,
+} from 'src/engine/core-modules/email-verification/email-verification.exception';
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+
+import { EmailVerificationTokenService } from './email-verification-token.service';
+
+describe('EmailVerificationTokenService', () => {
+ let service: EmailVerificationTokenService;
+ let appTokenRepository: Repository;
+ let environmentService: EnvironmentService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ EmailVerificationTokenService,
+ {
+ provide: getRepositoryToken(AppToken, 'core'),
+ useClass: Repository,
+ },
+ {
+ provide: EnvironmentService,
+ useValue: {
+ get: jest.fn(),
+ },
+ },
+ ],
+ }).compile();
+
+ service = module.get(
+ EmailVerificationTokenService,
+ );
+ appTokenRepository = module.get>(
+ getRepositoryToken(AppToken, 'core'),
+ );
+ environmentService = module.get(EnvironmentService);
+ });
+
+ describe('generateToken', () => {
+ it('should generate a verification token successfully', async () => {
+ const userId = 'test-user-id';
+ const email = 'test@example.com';
+ const mockExpiresIn = '24h';
+
+ jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn);
+ jest.spyOn(appTokenRepository, 'create').mockReturnValue({} as AppToken);
+ jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken);
+
+ const result = await service.generateToken(userId, email);
+
+ expect(result).toHaveProperty('token');
+ expect(result).toHaveProperty('expiresAt');
+ expect(result.token).toHaveLength(64); // 32 bytes in hex = 64 characters
+ expect(appTokenRepository.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId,
+ type: AppTokenType.EmailVerificationToken,
+ context: { email },
+ }),
+ );
+ expect(appTokenRepository.save).toHaveBeenCalled();
+ });
+ });
+
+ describe('validateEmailVerificationTokenOrThrow', () => {
+ it('should validate token successfully and return user', async () => {
+ const plainToken = 'plain-token';
+ const hashedToken = crypto
+ .createHash('sha256')
+ .update(plainToken)
+ .digest('hex');
+ const mockUser = { id: 'user-id', email: 'test@example.com' };
+ const mockAppToken = {
+ type: AppTokenType.EmailVerificationToken,
+ expiresAt: new Date(Date.now() + 86400000), // 24h from now
+ context: { email: 'test@example.com' },
+ user: mockUser,
+ };
+
+ jest
+ .spyOn(appTokenRepository, 'findOne')
+ .mockResolvedValue(mockAppToken as AppToken);
+ jest
+ .spyOn(appTokenRepository, 'remove')
+ .mockResolvedValue(mockAppToken as AppToken);
+
+ const result =
+ await service.validateEmailVerificationTokenOrThrow(plainToken);
+
+ expect(result).toEqual(mockUser);
+ expect(appTokenRepository.findOne).toHaveBeenCalledWith({
+ where: {
+ value: hashedToken,
+ type: AppTokenType.EmailVerificationToken,
+ },
+ relations: ['user'],
+ });
+ expect(appTokenRepository.remove).toHaveBeenCalledWith(mockAppToken);
+ });
+
+ it('should throw exception for invalid token', async () => {
+ jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
+
+ await expect(
+ service.validateEmailVerificationTokenOrThrow('invalid-token'),
+ ).rejects.toThrow(
+ new EmailVerificationException(
+ 'Invalid email verification token',
+ EmailVerificationExceptionCode.INVALID_TOKEN,
+ ),
+ );
+ });
+
+ it('should throw exception for wrong token type', async () => {
+ const mockAppToken = {
+ type: AppTokenType.PasswordResetToken,
+ expiresAt: new Date(Date.now() + 86400000),
+ };
+
+ jest
+ .spyOn(appTokenRepository, 'findOne')
+ .mockResolvedValue(mockAppToken as AppToken);
+
+ await expect(
+ service.validateEmailVerificationTokenOrThrow('wrong-type-token'),
+ ).rejects.toThrow(
+ new EmailVerificationException(
+ 'Invalid email verification token type',
+ EmailVerificationExceptionCode.INVALID_APP_TOKEN_TYPE,
+ ),
+ );
+ });
+
+ it('should throw exception for expired token', async () => {
+ const mockAppToken = {
+ type: AppTokenType.EmailVerificationToken,
+ expiresAt: new Date(Date.now() - 86400000), // 24h ago
+ };
+
+ jest
+ .spyOn(appTokenRepository, 'findOne')
+ .mockResolvedValue(mockAppToken as AppToken);
+
+ await expect(
+ service.validateEmailVerificationTokenOrThrow('expired-token'),
+ ).rejects.toThrow(
+ new EmailVerificationException(
+ 'Email verification token expired',
+ EmailVerificationExceptionCode.TOKEN_EXPIRED,
+ ),
+ );
+ });
+
+ it('should throw exception when email is missing in context', async () => {
+ const mockAppToken = {
+ type: AppTokenType.EmailVerificationToken,
+ expiresAt: new Date(Date.now() + 86400000),
+ context: {},
+ };
+
+ jest
+ .spyOn(appTokenRepository, 'findOne')
+ .mockResolvedValue(mockAppToken as AppToken);
+
+ await expect(
+ service.validateEmailVerificationTokenOrThrow('valid-token'),
+ ).rejects.toThrow(
+ new EmailVerificationException(
+ 'Email missing in email verification token context',
+ EmailVerificationExceptionCode.EMAIL_MISSING,
+ ),
+ );
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/email-verification-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/email-verification-token.service.ts
new file mode 100644
index 000000000..02b6cdb4c
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/email-verification-token.service.ts
@@ -0,0 +1,103 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import crypto from 'crypto';
+
+import { addMilliseconds } from 'date-fns';
+import ms from 'ms';
+import { Repository } from 'typeorm';
+
+import {
+ AppToken,
+ AppTokenType,
+} from 'src/engine/core-modules/app-token/app-token.entity';
+import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
+import {
+ EmailVerificationException,
+ EmailVerificationExceptionCode,
+} from 'src/engine/core-modules/email-verification/email-verification.exception';
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+
+@Injectable()
+export class EmailVerificationTokenService {
+ constructor(
+ @InjectRepository(AppToken, 'core')
+ private readonly appTokenRepository: Repository,
+ private readonly environmentService: EnvironmentService,
+ ) {}
+
+ async generateToken(userId: string, email: string): Promise {
+ const expiresIn = this.environmentService.get(
+ 'EMAIL_VERIFICATION_TOKEN_EXPIRES_IN',
+ );
+ const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
+
+ const plainToken = crypto.randomBytes(32).toString('hex');
+ const hashedToken = crypto
+ .createHash('sha256')
+ .update(plainToken)
+ .digest('hex');
+
+ const verificationToken = this.appTokenRepository.create({
+ userId,
+ expiresAt,
+ type: AppTokenType.EmailVerificationToken,
+ value: hashedToken,
+ context: { email },
+ });
+
+ await this.appTokenRepository.save(verificationToken);
+
+ return {
+ token: plainToken,
+ expiresAt,
+ };
+ }
+
+ async validateEmailVerificationTokenOrThrow(emailVerificationToken: string) {
+ const hashedToken = crypto
+ .createHash('sha256')
+ .update(emailVerificationToken)
+ .digest('hex');
+
+ const appToken = await this.appTokenRepository.findOne({
+ where: {
+ value: hashedToken,
+ type: AppTokenType.EmailVerificationToken,
+ },
+ relations: ['user'],
+ });
+
+ if (!appToken) {
+ throw new EmailVerificationException(
+ 'Invalid email verification token',
+ EmailVerificationExceptionCode.INVALID_TOKEN,
+ );
+ }
+
+ if (appToken.type !== AppTokenType.EmailVerificationToken) {
+ throw new EmailVerificationException(
+ 'Invalid email verification token type',
+ EmailVerificationExceptionCode.INVALID_APP_TOKEN_TYPE,
+ );
+ }
+
+ if (new Date() > appToken.expiresAt) {
+ throw new EmailVerificationException(
+ 'Email verification token expired',
+ EmailVerificationExceptionCode.TOKEN_EXPIRED,
+ );
+ }
+
+ if (!appToken.context?.email) {
+ throw new EmailVerificationException(
+ 'Email missing in email verification token context',
+ EmailVerificationExceptionCode.EMAIL_MISSING,
+ );
+ }
+
+ await this.appTokenRepository.remove(appToken);
+
+ return appToken.user;
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
index 6cc8750a5..fcdab3c0c 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
@@ -65,6 +65,9 @@ export class ClientConfig {
@Field(() => Boolean)
isMultiWorkspaceEnabled: boolean;
+ @Field(() => Boolean)
+ isEmailVerificationRequired: boolean;
+
@Field(() => String, { nullable: true })
defaultSubdomain: string;
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts
index e9342a4c6..9f6282f88 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts
@@ -33,6 +33,9 @@ export class ClientConfigResolver {
isMultiWorkspaceEnabled: this.environmentService.get(
'IS_MULTIWORKSPACE_ENABLED',
),
+ isEmailVerificationRequired: this.environmentService.get(
+ 'IS_EMAIL_VERIFICATION_REQUIRED',
+ ),
defaultSubdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
frontDomain: this.domainManagerService.getFrontUrl().hostname,
debugMode: this.environmentService.get('DEBUG_MODE'),
diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts
index 865320472..73b95ac42 100644
--- a/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts
@@ -59,6 +59,22 @@ export class DomainManagerService {
return baseUrl;
}
+ buildEmailVerificationURL({
+ emailVerificationToken,
+ email,
+ workspaceSubdomain,
+ }: {
+ emailVerificationToken: string;
+ email: string;
+ workspaceSubdomain?: string;
+ }) {
+ return this.buildWorkspaceURL({
+ subdomain: workspaceSubdomain,
+ pathname: 'verify-email',
+ searchParams: { emailVerificationToken, email },
+ });
+ }
+
buildWorkspaceURL({
subdomain,
pathname,
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/dtos/resend-email-verification-token.input.ts b/packages/twenty-server/src/engine/core-modules/email-verification/dtos/resend-email-verification-token.input.ts
new file mode 100644
index 000000000..b97cf1bf4
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/dtos/resend-email-verification-token.input.ts
@@ -0,0 +1,11 @@
+import { ArgsType, Field } from '@nestjs/graphql';
+
+import { IsEmail, IsNotEmpty } from 'class-validator';
+
+@ArgsType()
+export class ResendEmailVerificationTokenInput {
+ @Field(() => String)
+ @IsEmail()
+ @IsNotEmpty()
+ email: string;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/dtos/resend-email-verification-token.output.ts b/packages/twenty-server/src/engine/core-modules/email-verification/dtos/resend-email-verification-token.output.ts
new file mode 100644
index 000000000..57434c564
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/dtos/resend-email-verification-token.output.ts
@@ -0,0 +1,10 @@
+import { Field, ObjectType } from '@nestjs/graphql';
+
+import { IsBoolean } from 'class-validator';
+
+@ObjectType()
+export class ResendEmailVerificationTokenOutput {
+ @IsBoolean()
+ @Field(() => Boolean)
+ success: boolean;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.exception.ts b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.exception.ts
new file mode 100644
index 000000000..cd3c49d96
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.exception.ts
@@ -0,0 +1,17 @@
+import { CustomException } from 'src/utils/custom-exception';
+
+export class EmailVerificationException extends CustomException {
+ constructor(message: string, code: EmailVerificationExceptionCode) {
+ super(message, code);
+ }
+}
+
+export enum EmailVerificationExceptionCode {
+ EMAIL_VERIFICATION_NOT_REQUIRED = 'EMAIL_VERIFICATION_NOT_REQUIRED',
+ INVALID_TOKEN = 'INVALID_TOKEN',
+ INVALID_APP_TOKEN_TYPE = 'INVALID_APP_TOKEN_TYPE',
+ TOKEN_EXPIRED = 'TOKEN_EXPIRED',
+ EMAIL_MISSING = 'EMAIL_MISSING',
+ EMAIL_ALREADY_VERIFIED = 'EMAIL_ALREADY_VERIFIED',
+ RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
+}
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts
new file mode 100644
index 000000000..233dcb1f3
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts
@@ -0,0 +1,30 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
+import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
+import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
+import { EmailVerificationResolver } from 'src/engine/core-modules/email-verification/email-verification.resolver';
+import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
+import { EmailModule } from 'src/engine/core-modules/email/email.module';
+import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
+import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
+import { UserModule } from 'src/engine/core-modules/user/user.module';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([AppToken], 'core'),
+ EmailModule,
+ EnvironmentModule,
+ DomainManagerModule,
+ UserModule,
+ UserWorkspaceModule,
+ ],
+ providers: [
+ EmailVerificationService,
+ EmailVerificationResolver,
+ EmailVerificationTokenService,
+ ],
+ exports: [EmailVerificationService, EmailVerificationTokenService],
+})
+export class EmailVerificationModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.resolver.ts b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.resolver.ts
new file mode 100644
index 000000000..f9d80f339
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.resolver.ts
@@ -0,0 +1,35 @@
+import { Args, Mutation, Resolver } from '@nestjs/graphql';
+
+import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
+import { ResendEmailVerificationTokenInput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.input';
+import { ResendEmailVerificationTokenOutput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.output';
+import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
+import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
+import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
+
+@Resolver()
+export class EmailVerificationResolver {
+ constructor(
+ private readonly emailVerificationService: EmailVerificationService,
+ private readonly domainManagerService: DomainManagerService,
+ ) {}
+
+ @Mutation(() => ResendEmailVerificationTokenOutput)
+ async resendEmailVerificationToken(
+ @Args()
+ resendEmailVerificationTokenInput: ResendEmailVerificationTokenInput,
+ @OriginHeader() origin: string,
+ ): Promise {
+ const workspace =
+ await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
+ origin,
+ );
+
+ workspaceValidator.assertIsDefinedOrThrow(workspace);
+
+ return await this.emailVerificationService.resendEmailVerificationToken(
+ resendEmailVerificationTokenInput.email,
+ workspace.subdomain,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
new file mode 100644
index 000000000..e7e900109
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
@@ -0,0 +1,131 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import { render } from '@react-email/render';
+import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
+import ms from 'ms';
+import { SendEmailVerificationLinkEmail } from 'twenty-emails';
+import { Repository } from 'typeorm';
+
+import {
+ AppToken,
+ AppTokenType,
+} from 'src/engine/core-modules/app-token/app-token.entity';
+import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
+import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
+import {
+ EmailVerificationException,
+ EmailVerificationExceptionCode,
+} from 'src/engine/core-modules/email-verification/email-verification.exception';
+import { EmailService } from 'src/engine/core-modules/email/email.service';
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+import { UserService } from 'src/engine/core-modules/user/services/user.service';
+
+@Injectable()
+// eslint-disable-next-line @nx/workspace-inject-workspace-repository
+export class EmailVerificationService {
+ constructor(
+ @InjectRepository(AppToken, 'core')
+ private readonly appTokenRepository: Repository,
+ private readonly domainManagerService: DomainManagerService,
+ private readonly emailService: EmailService,
+ private readonly environmentService: EnvironmentService,
+ private readonly userService: UserService,
+ private readonly emailVerificationTokenService: EmailVerificationTokenService,
+ ) {}
+
+ async sendVerificationEmail(
+ userId: string,
+ email: string,
+ workspaceSubdomain?: string,
+ ) {
+ if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
+ return { success: false };
+ }
+
+ const { token: emailVerificationToken } =
+ await this.emailVerificationTokenService.generateToken(userId, email);
+
+ const verificationLink =
+ this.domainManagerService.buildEmailVerificationURL({
+ emailVerificationToken,
+ email,
+ workspaceSubdomain,
+ });
+
+ const emailData = {
+ link: verificationLink.toString(),
+ };
+
+ const emailTemplate = SendEmailVerificationLinkEmail(emailData);
+
+ const html = render(emailTemplate, {
+ pretty: true,
+ });
+
+ const text = render(emailTemplate, {
+ plainText: true,
+ });
+
+ await this.emailService.send({
+ from: `${this.environmentService.get(
+ 'EMAIL_FROM_NAME',
+ )} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
+ to: email,
+ subject: 'Welcome to Twenty: Please Confirm Your Email',
+ text,
+ html,
+ });
+
+ return { success: true };
+ }
+
+ async resendEmailVerificationToken(
+ email: string,
+ workspaceSubdomain?: string,
+ ) {
+ if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
+ throw new EmailVerificationException(
+ 'Email verification token cannot be sent because email verification is not required',
+ EmailVerificationExceptionCode.EMAIL_VERIFICATION_NOT_REQUIRED,
+ );
+ }
+
+ const user = await this.userService.getUserByEmail(email);
+
+ if (user.isEmailVerified) {
+ throw new EmailVerificationException(
+ 'Email already verified',
+ EmailVerificationExceptionCode.EMAIL_ALREADY_VERIFIED,
+ );
+ }
+
+ const existingToken = await this.appTokenRepository.findOne({
+ where: {
+ userId: user.id,
+ type: AppTokenType.EmailVerificationToken,
+ },
+ });
+
+ if (existingToken) {
+ const cooldownDuration = ms('1m');
+ const timeToWaitMs = differenceInMilliseconds(
+ addMilliseconds(existingToken.createdAt, cooldownDuration),
+ new Date(),
+ );
+
+ if (timeToWaitMs > 0) {
+ throw new EmailVerificationException(
+ `Please wait ${ms(timeToWaitMs, { long: true })} before requesting another verification email`,
+ EmailVerificationExceptionCode.RATE_LIMIT_EXCEEDED,
+ );
+ }
+
+ await this.appTokenRepository.delete(existingToken.id);
+ }
+
+ await this.sendVerificationEmail(user.id, email, workspaceSubdomain);
+
+ return { success: true };
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts
index 1edd01417..318321994 100644
--- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts
+++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts
@@ -413,6 +413,15 @@ export class EnvironmentVariables {
MESSAGE_QUEUE_TYPE: string = MessageQueueDriverType.BullMQ;
+ @CastToBoolean()
+ @IsOptional()
+ @IsBoolean()
+ IS_EMAIL_VERIFICATION_REQUIRED = false;
+
+ @IsDuration()
+ @IsOptional()
+ EMAIL_VERIFICATION_TOKEN_EXPIRES_IN = '1h';
+
EMAIL_FROM_ADDRESS = 'noreply@yourdomain.com';
EMAIL_SYSTEM_ADDRESS = 'system@yourdomain.com';
diff --git a/packages/twenty-server/src/engine/core-modules/graphql/utils/graphql-errors.util.ts b/packages/twenty-server/src/engine/core-modules/graphql/utils/graphql-errors.util.ts
index 799ccedc4..d91ef60ad 100644
--- a/packages/twenty-server/src/engine/core-modules/graphql/utils/graphql-errors.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/graphql/utils/graphql-errors.util.ts
@@ -24,6 +24,7 @@ export enum ErrorCode {
PERSISTED_QUERY_NOT_SUPPORTED = 'PERSISTED_QUERY_NOT_SUPPORTED',
BAD_USER_INPUT = 'BAD_USER_INPUT',
NOT_FOUND = 'NOT_FOUND',
+ EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
CONFLICT = 'CONFLICT',
TIMEOUT = 'TIMEOUT',
@@ -160,6 +161,14 @@ export class NotFoundError extends BaseGraphQLError {
}
}
+export class EmailNotVerifiedError extends BaseGraphQLError {
+ constructor(message: string) {
+ super(message, ErrorCode.EMAIL_NOT_VERIFIED);
+
+ Object.defineProperty(this, 'name', { value: 'EmailNotVerifiedError' });
+ }
+}
+
export class MethodNotAllowedError extends BaseGraphQLError {
constructor(message: string) {
super(message, ErrorCode.METHOD_NOT_ALLOWED);
diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts
index 19413c902..488c8a2a2 100644
--- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts
@@ -7,8 +7,14 @@ import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
+import {
+ AuthException,
+ AuthExceptionCode,
+} from 'src/engine/core-modules/auth/auth.exception';
+import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
+import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -161,4 +167,46 @@ export class UserWorkspaceService extends TypeOrmQueryService {
},
});
}
+
+ async findAvailableWorkspacesByEmail(email: string) {
+ const user = await this.userRepository.findOne({
+ where: {
+ email,
+ },
+ relations: [
+ 'workspaces',
+ 'workspaces.workspace',
+ 'workspaces.workspace.workspaceSSOIdentityProviders',
+ ],
+ });
+
+ userValidator.assertIsDefinedOrThrow(
+ user,
+ new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
+ );
+
+ return user.workspaces.map((userWorkspace) => ({
+ id: userWorkspace.workspaceId,
+ displayName: userWorkspace.workspace.displayName,
+ subdomain: userWorkspace.workspace.subdomain,
+ logo: userWorkspace.workspace.logo,
+ sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce(
+ (acc, identityProvider) =>
+ acc.concat(
+ identityProvider.status === 'Inactive'
+ ? []
+ : [
+ {
+ id: identityProvider.id,
+ name: identityProvider.name,
+ issuer: identityProvider.issuer,
+ type: identityProvider.type,
+ status: identityProvider.status,
+ },
+ ],
+ ),
+ [] as AvailableWorkspaceOutput['sso'],
+ ),
+ }));
+ }
}
diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts
index 60ed6c29a..7eab38aea 100644
--- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts
@@ -165,4 +165,30 @@ export class UserService extends TypeOrmQueryService {
),
);
}
+
+ async getUserByEmail(email: string) {
+ const user = await this.userRepository.findOne({
+ where: {
+ email,
+ },
+ });
+
+ userValidator.assertIsDefinedOrThrow(user);
+
+ return user;
+ }
+
+ async markEmailAsVerified(userId: string) {
+ const user = await this.userRepository.findOne({
+ where: {
+ id: userId,
+ },
+ });
+
+ userValidator.assertIsDefinedOrThrow(user);
+
+ user.isEmailVerified = true;
+
+ return await this.userRepository.save(user);
+ }
}
diff --git a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts
index 0a33443bf..1ac68ca4b 100644
--- a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts
@@ -55,7 +55,7 @@ export class User {
@Field()
@Column({ default: false })
- emailVerified: boolean;
+ isEmailVerified: boolean;
@Field({ nullable: true })
@Column({ default: false })
diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts
index f1eb58a07..c868d700f 100644
--- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts
+++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts
@@ -48,6 +48,8 @@ export class GraphQLHydrateRequestFromTokenMiddleware
'CheckUserExists',
'Challenge',
'Verify',
+ 'GetLoginTokenFromEmailVerificationToken',
+ 'ResendEmailVerificationToken',
'SignUp',
'RenewToken',
'EmailPasswordResetLink',
diff --git a/packages/twenty-website/src/content/developers/self-hosting/setup.mdx b/packages/twenty-website/src/content/developers/self-hosting/setup.mdx
index e7edb4588..434c5f875 100644
--- a/packages/twenty-website/src/content/developers/self-hosting/setup.mdx
+++ b/packages/twenty-website/src/content/developers/self-hosting/setup.mdx
@@ -185,6 +185,8 @@ yarn command:prod cron:calendar:ongoing-stale
### Email
- Keep in mind that if you have 2FA enabled, you will need to provision an [App Password](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9).
- - EMAIL_DRIVER=smtp
- - EMAIL_SMTP_HOST=smtp.office365.com
- - EMAIL_SMTP_PORT=587
- - EMAIL_SMTP_USER=office365_email_address
+ Keep in mind that if you have 2FA enabled, you will need to provision an [App Password](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9).
+ - EMAIL_DRIVER=smtp
+ - EMAIL_SMTP_HOST=smtp.office365.com
+ - EMAIL_SMTP_PORT=587
+ - EMAIL_SMTP_USER=office365_email_address
- EMAIL_SMTP_PASSWORD='office365_password'
- **smtp4dev** is a fake SMTP email server for development and testing.
- - Run the smtp4dev image: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev`
- - Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090)
- - Set the following env variables:
- - EMAIL_DRIVER=smtp
- - EMAIL_SMTP_HOST=localhost
- - EMAIL_SMTP_PORT=2525
+ **smtp4dev** is a fake SMTP email server for development and testing.
+ - Run the smtp4dev image: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev`
+ - Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090)
+ - Set the following env variables:
+ - EMAIL_DRIVER=smtp
+ - EMAIL_SMTP_HOST=localhost
+ - EMAIL_SMTP_PORT=2525