diff --git a/README.md b/README.md
index 963d220b90..0f925e8f06 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ Mifos® X Web App is a modern single-page application (SPA) built on top of the
- [Date Formats](#date-and-datetime-format-settings)
- [Session & Performance](#session--performance-settings)
- [Password Policy](#password-policy-settings)
+ - [Email Validation](#email-validation-settings)
- [UI Display](#ui-display-settings)
- [OAUTH](#oauth-settings)
- [OIDC](#oidc-settings)
@@ -380,6 +381,14 @@ MIFOS_MIN_PASSWORD_LENGTH=8
MIFOS_PASSWORD_REGEX=^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,50}$
```
+#### Email Validation Settings
+
+| Variable | Description | Default Value |
+| -------------------- | ----------------------------------------------------- | --------------------------------------------------------------------- |
+| EXTERNAL_EMAIL_REGEX | Regex pattern used to validate the client email field | `^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$` |
+
+If `EXTERNAL_EMAIL_REGEX` is not set, or is not a valid regular expression, the application falls back to the default pattern above. The email field is optional; the pattern is only checked when a value is entered.
+
#### UI Display Settings
| Variable | Description | Default Value |
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
index 1a5f30468d..819e9f0eb9 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
@@ -230,9 +230,9 @@
{{ 'labels.inputs.Email Address' | translate }}
- @if (createClientForm.controls.emailAddress.errors?.email) {
+ @if (createClientForm.controls.emailAddress.errors?.pattern) {
- {{ 'error.Email not valid' | translate }}
+ {{ 'errors.validation.emailInvalid' | translate }}
}
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.spec.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.spec.ts
index 246b9aaa04..940e03ae52 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.spec.ts
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.spec.ts
@@ -112,3 +112,76 @@ describe('ClientGeneralStepComponent WEB-1103 incorporation validity date', () =
expect(incorpValidityControl().hasError('matDatepickerMin')).toBe(true);
});
});
+
+describe('ClientGeneralStepComponent WEB-748 email validation', () => {
+ let fixture: ComponentFixture;
+ let component: ClientGeneralStepComponent;
+
+ const clientTemplate: any = {
+ officeOptions: [{ id: 1, name: 'Head Office' }],
+ staffOptions: [],
+ clientLegalFormOptions: [{ id: LegalFormId.PERSON, value: 'PERSON' }],
+ clientTypeOptions: [],
+ clientClassificationOptions: [],
+ clientNonPersonMainBusinessLineOptions: [],
+ clientNonPersonConstitutionOptions: [],
+ genderOptions: [],
+ savingProductOptions: []
+ };
+
+ function emailControl() {
+ return component.createClientForm.get('emailAddress');
+ }
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ ClientGeneralStepComponent,
+ TranslateModule.forRoot()
+ ],
+ providers: [
+ provideNativeDateAdapter(),
+ provideNoopAnimations(),
+ { provide: CdkStepper, useValue: { next: jest.fn(), previous: jest.fn() } },
+ { provide: Dates, useValue: { formatDate: jest.fn(() => '15 January 2026') } },
+ {
+ provide: SettingsService,
+ useValue: { businessDate: new Date(2026, 0, 15), dateFormat: 'dd MMMM yyyy', language: { code: 'en-US' } }
+ },
+ { provide: ClientsService, useValue: { getClientWithOfficeTemplate: jest.fn(() => of(clientTemplate)) } }
+ ]
+ }).compileComponents();
+
+ const faIconLibrary = TestBed.inject(FaIconLibrary);
+ faIconLibrary.addIcons(
+ ...Object.keys(solidIcons)
+ .filter((key) => key !== 'fas' && key !== 'prefix' && key.startsWith('fa'))
+ .map((icon) => (solidIcons as any)[icon])
+ );
+
+ fixture = TestBed.createComponent(ClientGeneralStepComponent);
+ component = fixture.componentInstance;
+ component.clientTemplate = clientTemplate;
+ fixture.detectChanges();
+ });
+
+ it('stays optional when left blank', () => {
+ emailControl().patchValue('');
+ expect(emailControl().valid).toBe(true);
+ });
+
+ it('accepts a well-formed email', () => {
+ emailControl().patchValue('client@example.com');
+ expect(emailControl().valid).toBe(true);
+ });
+
+ it('rejects a malformed email', () => {
+ emailControl().patchValue('not-an-email');
+ expect(emailControl().hasError('pattern')).toBe(true);
+ });
+
+ it('rejects a double-dot domain', () => {
+ emailControl().patchValue('client@example..com');
+ expect(emailControl().hasError('pattern')).toBe(true);
+ });
+});
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
index 7302b76d55..4377fcfdd5 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
@@ -24,6 +24,7 @@ import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { LegalFormId } from 'app/clients/models/legal-form.enum';
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
+import { environment } from 'environments/environment';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
@@ -137,7 +138,7 @@ export class ClientGeneralStepComponent implements OnInit {
mobileNo: [''],
emailAddress: [
'',
- Validators.email
+ Validators.pattern(environment.externalEmailRegex)
],
dateOfBirth: [''],
clientTypeId: [''],
diff --git a/src/assets/env.js b/src/assets/env.js
index fcb8706d9e..99f60360b8 100644
--- a/src/assets/env.js
+++ b/src/assets/env.js
@@ -124,6 +124,9 @@
window['env']['externalNationalIdSystemApiKey'] = '';
window['env']['externalNationalIdRegex'] = '';
+ // Email format validation regex (optional override; leave empty to use the built-in default)
+ window['env']['externalEmailRegex'] = '';
+
// Postal Code Lookup (auto-fill address from postal code via external API)
// Set to 'true' to enable, 'false' (default) to disable
window['env']['enablePostalCodeLookup'] = 'false';
diff --git a/src/assets/env.template.js b/src/assets/env.template.js
index 4905ab6176..279b6e6fe7 100644
--- a/src/assets/env.template.js
+++ b/src/assets/env.template.js
@@ -139,6 +139,9 @@
window['env']['externalNationalIdSystemApiKey'] = '$EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY';
window['env']['externalNationalIdRegex'] = '$EXTERNAL_NATIONAL_ID_REGEX';
+ // Email format validation regex (optional override; leave unset to use the built-in default)
+ window['env']['externalEmailRegex'] = '$EXTERNAL_EMAIL_REGEX';
+
// Client Address Location (latitude, longitude, and map)
window['env']['enableClientAddressLocation'] = '$MIFOS_ENABLE_CLIENT_ADDRESS_LOCATION';
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts
index 96fd5d4bf7..5dc3932220 100644
--- a/src/environments/environment.prod.ts
+++ b/src/environments/environment.prod.ts
@@ -17,6 +17,23 @@ const provider = loadedEnv['apiProvider'];
const parsedMinLength = Number(loadedEnv.minPasswordLength);
const resolvedMinPasswordLength = Number.isInteger(parsedMinLength) && parsedMinLength > 0 ? parsedMinLength : 8;
+// Domain labels can't contain dots, so `example..com` can't slip through as a valid host.
+const DEFAULT_EMAIL_REGEX = '^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}$';
+function isValidRegex(pattern: string): boolean {
+ try {
+ new RegExp(pattern);
+ return true;
+ } catch {
+ return false;
+ }
+}
+// Guards against a malformed override (e.g. an unbalanced `[`) throwing inside
+// Validators.pattern and preventing the Create Client form from opening.
+const resolvedEmailRegex =
+ loadedEnv['externalEmailRegex'] && isValidRegex(loadedEnv['externalEmailRegex'])
+ ? loadedEnv['externalEmailRegex']
+ : DEFAULT_EMAIL_REGEX;
+
export const environment = {
production: true,
version: env.mifos_x.version,
@@ -133,6 +150,9 @@ export const environment = {
externalNationalIdSystemApiKey: loadedEnv['externalNationalIdSystemApiKey'] || '',
externalNationalIdRegex: loadedEnv['externalNationalIdRegex'] || '',
+ /** Email format validation regex, overridable per deployment. */
+ externalEmailRegex: resolvedEmailRegex,
+
/**
* Hide client data information (mask client names with *)
* Set via MIFOS_COMPLIANCE_HIDE_CLIENT_DATA env var
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 73c5c4dbeb..98350cfaa0 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -20,6 +20,23 @@ const loadedEnv = window.env || {};
const parsedMinLength = Number(loadedEnv.minPasswordLength);
const resolvedMinPasswordLength = Number.isInteger(parsedMinLength) && parsedMinLength > 0 ? parsedMinLength : 8;
+// Domain labels can't contain dots, so `example..com` can't slip through as a valid host.
+const DEFAULT_EMAIL_REGEX = '^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}$';
+function isValidRegex(pattern: string): boolean {
+ try {
+ new RegExp(pattern);
+ return true;
+ } catch {
+ return false;
+ }
+}
+// Guards against a malformed override (e.g. an unbalanced `[`) throwing inside
+// Validators.pattern and preventing the Create Client form from opening.
+const resolvedEmailRegex =
+ loadedEnv.externalEmailRegex && isValidRegex(loadedEnv.externalEmailRegex)
+ ? loadedEnv.externalEmailRegex
+ : DEFAULT_EMAIL_REGEX;
+
export const environment = {
production: false,
version: env.mifos_x.version,
@@ -122,6 +139,9 @@ export const environment = {
externalNationalIdSystemApiKey: loadedEnv.externalNationalIdSystemApiKey || '',
externalNationalIdRegex: loadedEnv.externalNationalIdRegex || '',
+ /** Email format validation regex, overridable per deployment. */
+ externalEmailRegex: resolvedEmailRegex,
+
/**
* Postal Code Lookup — auto-fill city/state/country from postal code.
* Uses external Zippopotam.us API. Disable for deployments with strict privacy requirements.