@@ -83,6 +110,38 @@ export function CloudLoginForm() {
{t("Continue")}
+
+
+
+ {findEmailSent ? (
+
+ {t("We've sent you an email with your associated workspaces.")}
+
+ ) : (
+
+ )}
diff --git a/apps/client/src/ee/pages/verify-email.tsx b/apps/client/src/ee/pages/verify-email.tsx
new file mode 100644
index 00000000..623c3202
--- /dev/null
+++ b/apps/client/src/ee/pages/verify-email.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useState } from "react";
+import { useSearchParams, useNavigate } from "react-router-dom";
+import { Container, Title, Text, Button, Box } from "@mantine/core";
+import classes from "../../features/auth/components/auth.module.css";
+import {
+ verifyEmail,
+ resendVerificationEmail,
+} from "@/ee/cloud/service/cloud-service.ts";
+import { notifications } from "@mantine/notifications";
+import APP_ROUTE from "@/lib/app-route.ts";
+import { useTranslation } from "react-i18next";
+
+export default function VerifyEmail() {
+ const { t } = useTranslation();
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const token = searchParams.get("token");
+ const rawEmail = searchParams.get("email");
+ const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null;
+ const sig = searchParams.get("sig");
+ const [isResending, setIsResending] = useState(false);
+ const [resent, setResent] = useState(false);
+
+ useEffect(() => {
+ if (token) {
+ handleVerify(token);
+ }
+ }, [token]);
+
+ async function handleVerify(verifyToken: string) {
+ try {
+ await verifyEmail({ token: verifyToken });
+ navigate(APP_ROUTE.HOME);
+ } catch (err) {
+ notifications.show({
+ message: t("Verification failed. The link may have expired."),
+ color: "red",
+ });
+ navigate(APP_ROUTE.AUTH.LOGIN);
+ }
+ }
+
+ async function handleResend() {
+ if (!email || !sig) return;
+ setIsResending(true);
+
+ try {
+ await resendVerificationEmail({ email, sig });
+ setResent(true);
+ } catch {
+ notifications.show({
+ message: t("Failed to resend verification email. Please try again."),
+ color: "red",
+ });
+ }
+
+ setIsResending(false);
+ }
+
+ if (token) {
+ return (
+
+
+
+ {t("Verifying your email")}
+
+
+ {t("Please wait...")}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("Check your email")}
+
+
+ {email
+ ? t("We sent a verification link to {{email}}.", { email })
+ : t("We sent a verification link to your email.")}
+
+
+ {t("Click the link to verify your email and access your workspace.")}
+
+ {email && sig && !resent && (
+
+ )}
+ {resent && (
+
+ {t("Verification email sent. Please check your inbox.")}
+
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/features/auth/components/setup-workspace-form.tsx b/apps/client/src/features/auth/components/setup-workspace-form.tsx
index 261412a9..6eaf3d12 100644
--- a/apps/client/src/features/auth/components/setup-workspace-form.tsx
+++ b/apps/client/src/features/auth/components/setup-workspace-form.tsx
@@ -22,11 +22,11 @@ import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(),
- name: z.string().min(1).max(50),
+ name: z.string().min(1, { message: "Name is required" }).max(50),
email: z
- .email()
- .min(1, { message: "email is required" }),
- password: z.string().min(8),
+ .email({ message: "Invalid email address" })
+ .min(1, { message: "Email is required" }),
+ password: z.string().min(8, { message: "Password must be at least 8 characters" }),
});
type FormValues = z.infer
;
diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts
index 6e1b4e34..411e04b4 100644
--- a/apps/client/src/features/auth/hooks/use-auth.ts
+++ b/apps/client/src/features/auth/hooks/use-auth.ts
@@ -27,7 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
-import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
+import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
export default function useAuth() {
const { t } = useTranslation();
@@ -52,9 +52,18 @@ export default function useAuth() {
}
} catch (err) {
setIsLoading(false);
- console.log(err);
+
+ const message = err.response?.data?.message;
+ if (isCloud() && message?.includes("verify your email")) {
+ const sig = err.response?.data?.emailSignature;
+ navigate(
+ `${APP_ROUTE.AUTH.VERIFY_EMAIL}?email=${encodeURIComponent(data.email)}${sig ? `&sig=${sig}` : ""}`,
+ );
+ return;
+ }
+
notifications.show({
- message: err.response?.data.message,
+ message,
color: "red",
});
}
@@ -92,6 +101,17 @@ export default function useAuth() {
try {
if (isCloud()) {
const res = await createWorkspace(data);
+
+ if (res?.requiresEmailVerification) {
+ const hostname = res?.workspace?.hostname;
+ if (hostname) {
+ window.location.href =
+ getHostnameUrl(hostname) +
+ `/verify-email?email=${encodeURIComponent(data.email)}&sig=${res.emailSignature}`;
+ }
+ return;
+ }
+
const hostname = res?.workspace?.hostname;
const exchangeToken = res?.exchangeToken;
if (hostname && exchangeToken) {
diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts
index 20e437f3..20552d56 100644
--- a/apps/client/src/features/auth/services/auth-service.ts
+++ b/apps/client/src/features/auth/services/auth-service.ts
@@ -50,4 +50,5 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise {
export async function getCollabToken(): Promise {
const req = await api.post("/auth/collab-token");
return req.data;
-}
\ No newline at end of file
+}
+
diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts
index 20da1146..0ffd6f23 100644
--- a/apps/client/src/features/workspace/services/workspace-service.ts
+++ b/apps/client/src/features/workspace/services/workspace-service.ts
@@ -113,7 +113,7 @@ export async function getInvitationById(data: {
export async function createWorkspace(
data: ISetupWorkspace,
-): Promise<{ workspace: IWorkspace } & { exchangeToken: string }> {
+): Promise<{ workspace: IWorkspace; exchangeToken?: string; requiresEmailVerification?: boolean; emailSignature?: string }> {
const req = await api.post("/workspace/create", data);
return req.data;
}
diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts
index c4a13093..630dd048 100644
--- a/apps/client/src/lib/app-route.ts
+++ b/apps/client/src/lib/app-route.ts
@@ -12,6 +12,7 @@ const APP_ROUTE = {
SELECT_WORKSPACE: "/select",
MFA_CHALLENGE: "/login/mfa",
MFA_SETUP_REQUIRED: "/login/mfa/setup",
+ VERIFY_EMAIL: "/verify-email",
},
SETTINGS: {
ACCOUNT: {
diff --git a/apps/server/package.json b/apps/server/package.json
index 5cffe556..083f8a07 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -107,6 +107,7 @@
"sanitize-filename-ts": "1.0.2",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
+ "tlds": "^1.261.0",
"tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"typesense": "^2.1.0",
diff --git a/apps/server/src/common/validator/is-iso6391.ts b/apps/server/src/common/validators/is-iso6391.ts
similarity index 100%
rename from apps/server/src/common/validator/is-iso6391.ts
rename to apps/server/src/common/validators/is-iso6391.ts
diff --git a/apps/server/src/common/validators/no-urls.validator.spec.ts b/apps/server/src/common/validators/no-urls.validator.spec.ts
new file mode 100644
index 00000000..07ad5721
--- /dev/null
+++ b/apps/server/src/common/validators/no-urls.validator.spec.ts
@@ -0,0 +1,142 @@
+import { containsDomain } from './no-urls.validator';
+
+// containsDomain returns true if value contains a domain-like pattern
+// The full NoUrls validator also checks for https:// URLs separately
+
+describe('containsDomain', () => {
+ describe('bare domains with real TLDs — should block', () => {
+ it.each([
+ 'example.com',
+ 'example.net',
+ 'example.org',
+ 'example.io',
+ 'example.co',
+ 'example.dev',
+ 'example.app',
+ 'example.me',
+ 'example.info',
+ 'example.tech',
+ 'example.aero',
+ 'example.cloud',
+ 'example.museum',
+ 'example.abc',
+ 'example.uk',
+ 'example.de',
+ 'example.fr',
+ 'example.ru',
+ ])('blocks "%s"', (value) => {
+ expect(containsDomain(value)).toBe(true);
+ });
+ });
+
+ describe('domains with paths — should block', () => {
+ it.each([
+ 'example.com/reset',
+ 'example.com/reset-password',
+ 'click example.com/page',
+ 'go to example.net/login',
+ ])('blocks "%s"', (value) => {
+ expect(containsDomain(value)).toBe(true);
+ });
+ });
+
+ describe('multi-part domains — should block', () => {
+ it.each([
+ 'Foo.com.net',
+ 'Foo.com.',
+ 'Foo.mine.net',
+ 'Foo.mine.ne',
+ 'sub.example.com',
+ 'login.example.co.uk',
+ ])('blocks "%s"', (value) => {
+ expect(containsDomain(value)).toBe(true);
+ });
+ });
+
+ describe('domain in sentence — should block', () => {
+ it.each([
+ 'Reset your password at example.com',
+ 'URGENT click example.com/reset',
+ 'Visit example.org for details',
+ 'go to mysite.io now',
+ ])('blocks "%s"', (value) => {
+ expect(containsDomain(value)).toBe(true);
+ });
+ });
+
+ describe('case insensitive — should block', () => {
+ it.each(['EXAMPLE.COM', 'Example.Com', 'example.COM'])('blocks "%s"', (value) => {
+ expect(containsDomain(value)).toBe(true);
+ });
+ });
+
+ describe('fake TLDs — should allow', () => {
+ it.each([
+ 'Foo.mine',
+ 'Foo.blarg',
+ 'Foo.qqq',
+ 'Foo.zz',
+ 'Foo.abcd',
+ 'Foo.abcde',
+ 'Foo.abcdef',
+ 'Foo.abcdefg',
+ ])('allows "%s"', (value) => {
+ expect(containsDomain(value)).toBe(false);
+ });
+ });
+
+ describe('too short suffix — should allow', () => {
+ it.each(['Foo.a', 'Foo.c', 'A.B'])('allows "%s"', (value) => {
+ expect(containsDomain(value)).toBe(false);
+ });
+ });
+
+ describe('multi-part with fake TLD — should allow', () => {
+ it.each(['Foo.mine.', 'Foo.mine.n'])('allows "%s"', (value) => {
+ expect(containsDomain(value)).toBe(false);
+ });
+ });
+
+ describe('emails — should allow', () => {
+ it.each([
+ 'user@example.com',
+ 'admin@company.org',
+ 'test@sub.domain.co.uk',
+ ])('allows "%s"', (value) => {
+ expect(containsDomain(value)).toBe(false);
+ });
+ });
+
+ describe('normal names — should allow', () => {
+ it.each([
+ 'John Smith',
+ 'Dr. Smith',
+ 'A. B. Charlie',
+ 'John',
+ 'Mary Jane',
+ "O'Brien",
+ 'Jean-Pierre',
+ 'José García',
+ ])('allows "%s"', (value) => {
+ expect(containsDomain(value)).toBe(false);
+ });
+ });
+
+ describe('IP addresses — should allow', () => {
+ it.each(['192.168.1.1', '10.0.0.1', '127.0.0.1'])(
+ 'allows "%s"',
+ (value) => {
+ expect(containsDomain(value)).toBe(false);
+ },
+ );
+ });
+
+ describe('edge cases — should allow', () => {
+ it.each(['', ' ', '.', '..', 'hello', '.com', 'a.b'])(
+ 'allows "%s"',
+ (value) => {
+ expect(containsDomain(value)).toBe(false);
+ },
+ );
+ });
+});
diff --git a/apps/server/src/common/validators/no-urls.validator.ts b/apps/server/src/common/validators/no-urls.validator.ts
new file mode 100644
index 00000000..c26faecc
--- /dev/null
+++ b/apps/server/src/common/validators/no-urls.validator.ts
@@ -0,0 +1,42 @@
+import { registerDecorator, ValidationOptions } from 'class-validator';
+import * as tlds from 'tlds';
+
+const URL_PATTERN = /https?:\/\//i;
+const tldSet = new Set(tlds.map((t) => t.toLowerCase()));
+
+export function containsDomain(value: string): boolean {
+ const tokens = value.split(/\s+/);
+ for (const token of tokens) {
+ if (token.includes('@')) continue;
+ const segments = token.split('.');
+ for (let i = 1; i < segments.length; i++) {
+ const suffix = segments[i].replace(/[^\w].*/g, '');
+ if (segments[i - 1] && suffix && tldSet.has(suffix.toLowerCase())) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+export function NoUrls(validationOptions?: ValidationOptions) {
+ return function (object: object, propertyName: string) {
+ registerDecorator({
+ name: 'noUrls',
+ target: object.constructor,
+ propertyName,
+ options: {
+ message: 'Must not contain URLs or domain names',
+ ...validationOptions,
+ },
+ validator: {
+ validate(value: unknown) {
+ if (typeof value !== 'string') return true;
+ if (URL_PATTERN.test(value)) return false;
+ if (containsDomain(value)) return false;
+ return true;
+ },
+ },
+ });
+ };
+}
diff --git a/apps/server/src/core/auth/auth.constants.ts b/apps/server/src/core/auth/auth.constants.ts
index 555149c4..fda2346e 100644
--- a/apps/server/src/core/auth/auth.constants.ts
+++ b/apps/server/src/core/auth/auth.constants.ts
@@ -1,3 +1,4 @@
export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password',
+ EMAIL_VERIFICATION = 'email-verification',
}
diff --git a/apps/server/src/core/auth/auth.util.ts b/apps/server/src/core/auth/auth.util.ts
index cdd46e9b..e2de9bf2 100644
--- a/apps/server/src/core/auth/auth.util.ts
+++ b/apps/server/src/core/auth/auth.util.ts
@@ -1,5 +1,37 @@
import { BadRequestException } from '@nestjs/common';
import { Workspace } from '@docmost/db/types/entity.types';
+import { createHmac } from 'node:crypto';
+
+export function computeEmailSignature(
+ email: string,
+ workspaceId: string,
+ appSecret: string,
+): string {
+ return createHmac('sha256', appSecret)
+ .update(`${email.toLowerCase()}:${workspaceId}`)
+ .digest('hex');
+}
+
+export function throwIfEmailNotVerified(opts: {
+ isCloud: boolean;
+ emailVerifiedAt: Date | null;
+ email: string;
+ workspaceId: string;
+ appSecret: string;
+}): void {
+ if (!opts.isCloud || opts.emailVerifiedAt) return;
+
+ const emailSignature = computeEmailSignature(
+ opts.email,
+ opts.workspaceId,
+ opts.appSecret,
+ );
+ throw new BadRequestException({
+ message:
+ 'Please verify your email address. Check your inbox for the verification link.',
+ emailSignature,
+ });
+}
export function validateSsoEnforcement(workspace: Workspace) {
if (workspace.enforceSso) {
diff --git a/apps/server/src/core/auth/dto/create-admin-user.dto.ts b/apps/server/src/core/auth/dto/create-admin-user.dto.ts
index bdea75fe..9580f98f 100644
--- a/apps/server/src/core/auth/dto/create-admin-user.dto.ts
+++ b/apps/server/src/core/auth/dto/create-admin-user.dto.ts
@@ -7,11 +7,13 @@ import {
} from 'class-validator';
import { CreateUserDto } from './create-user.dto';
import { Transform, TransformFnParams } from 'class-transformer';
+import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty()
@MinLength(1)
@MaxLength(50)
+ @NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
diff --git a/apps/server/src/core/auth/dto/create-user.dto.ts b/apps/server/src/core/auth/dto/create-user.dto.ts
index 3362c722..bd432904 100644
--- a/apps/server/src/core/auth/dto/create-user.dto.ts
+++ b/apps/server/src/core/auth/dto/create-user.dto.ts
@@ -7,12 +7,14 @@ import {
MinLength,
} from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer';
+import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateUserDto {
@IsOptional()
@MinLength(1)
@MaxLength(50)
@IsString()
+ @NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts
index bc907b8e..1bb2c5ee 100644
--- a/apps/server/src/core/auth/services/auth.service.ts
+++ b/apps/server/src/core/auth/services/auth.service.ts
@@ -17,6 +17,7 @@ import {
isUserDisabled,
nanoIdGen,
} from '../../../common/helpers';
+import { throwIfEmailNotVerified } from '../auth.util';
import { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
@@ -36,6 +37,7 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
+import { EnvironmentService } from '../../../integrations/environment/environment.service';
@Injectable()
export class AuthService {
@@ -46,6 +48,7 @@ export class AuthService {
private userTokenRepo: UserTokenRepo,
private mailService: MailService,
private domainService: DomainService,
+ private environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -69,6 +72,14 @@ export class AuthService {
throw new UnauthorizedException(errorMessage);
}
+ throwIfEmailNotVerified({
+ isCloud: this.environmentService.isCloud(),
+ emailVerifiedAt: user.emailVerifiedAt,
+ email: user.email,
+ workspaceId,
+ appSecret: this.environmentService.getAppSecret(),
+ });
+
user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId);
@@ -247,6 +258,14 @@ export class AuthService {
template: emailTemplate,
});
+ if (this.environmentService.isCloud() && !user.emailVerifiedAt) {
+ await this.userRepo.updateUser(
+ { emailVerifiedAt: new Date() },
+ user.id,
+ workspace.id,
+ );
+ }
+
// Check if user has MFA enabled or workspace enforces MFA
const userHasMfa = user?.['mfa']?.isEnabled || false;
const workspaceEnforcesMfa = workspace.enforceMfa || false;
diff --git a/apps/server/src/core/workspace/dto/invitation.dto.ts b/apps/server/src/core/workspace/dto/invitation.dto.ts
index 8e5cccac..187688c4 100644
--- a/apps/server/src/core/workspace/dto/invitation.dto.ts
+++ b/apps/server/src/core/workspace/dto/invitation.dto.ts
@@ -12,6 +12,7 @@ import {
MinLength,
} from 'class-validator';
import { UserRole } from '../../../common/helpers/types/permission';
+import { NoUrls } from '../../../common/validators/no-urls.validator';
export class InviteUserDto {
@IsArray()
@@ -44,6 +45,7 @@ export class AcceptInviteDto extends InvitationIdDto {
@MinLength(2)
@MaxLength(60)
@IsString()
+ @NoUrls()
name: string;
@MinLength(8)
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index 2ef80590..495057a0 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -244,7 +244,7 @@ export class WorkspaceService {
await this.billingQueue.add(
QueueJob.WELCOME_EMAIL,
{ userId: user.id },
- { delay: 60 * 1000 }, // 1m
+ { delay: 30 * 60 * 1000 }, // 30m
);
} catch (err) {
this.logger.error(err);
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 62a8a7e5..52ac3a79 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 62a8a7e548a17b8e3baf4dfdc90f2f432a691ee0
+Subproject commit 52ac3a79de56472a1f77b12ea4cd4c07fd5f5d69
diff --git a/apps/server/src/integrations/environment/environment.validation.ts b/apps/server/src/integrations/environment/environment.validation.ts
index 041d0f4c..5c307da2 100644
--- a/apps/server/src/integrations/environment/environment.validation.ts
+++ b/apps/server/src/integrations/environment/environment.validation.ts
@@ -10,7 +10,7 @@ import {
validateSync,
} from 'class-validator';
import { plainToInstance } from 'class-transformer';
-import { IsISO6391 } from '../../common/validator/is-iso6391';
+import { IsISO6391 } from '../../common/validators/is-iso6391';
export class EnvironmentVariables {
@IsNotEmpty()
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
index fca457e5..0f2a82a1 100644
--- a/apps/server/src/main.ts
+++ b/apps/server/src/main.ts
@@ -67,6 +67,7 @@ async function bootstrap() {
'/api/sso/google',
'/api/workspace/create',
'/api/workspace/joined',
+ '/api/workspace/find-by-email',
];
if (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2049ad4e..30535f29 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -681,6 +681,9 @@ importers:
stripe:
specifier: ^17.5.0
version: 17.5.0
+ tlds:
+ specifier: ^1.261.0
+ version: 1.261.0
tmp-promise:
specifier: ^3.0.3
version: 3.0.3
@@ -9837,6 +9840,10 @@ packages:
tiptap-extension-global-drag-handle@0.1.18:
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
+ tlds@1.261.0:
+ resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==}
+ hasBin: true
+
tldts-core@6.1.72:
resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==}
@@ -21145,6 +21152,8 @@ snapshots:
tiptap-extension-global-drag-handle@0.1.18: {}
+ tlds@1.261.0: {}
+
tldts-core@6.1.72: {}
tldts@6.1.72: