diff --git a/fluxer_api/src/api/auth/AuthPassword.ts b/fluxer_api/src/api/auth/AuthPassword.ts index 0b03c068a..64fba36c2 100644 --- a/fluxer_api/src/api/auth/AuthPassword.ts +++ b/fluxer_api/src/api/auth/AuthPassword.ts @@ -209,7 +209,7 @@ export async function forgotPassword(ctx: ApiContext, {data, request}: ForgotPas } const hasValidDns = await emailDnsValidation.hasValidDnsRecords(data.email); if (!hasValidDns) { - throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS); + throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL); } const user = await users.findByEmail(data.email); if (!user) { diff --git a/fluxer_api/src/api/auth/AuthRegistration.ts b/fluxer_api/src/api/auth/AuthRegistration.ts index 365b51ff0..86247b5f3 100644 --- a/fluxer_api/src/api/auth/AuthRegistration.ts +++ b/fluxer_api/src/api/auth/AuthRegistration.ts @@ -187,7 +187,7 @@ export async function register( contactDomain = normalizePolicyContactDomain(extractEmailDomain(rawEmail)); const hasValidDns = await emailDnsValidation.hasValidDnsRecords(rawEmail); if (!hasValidDns) { - throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS); + throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL); } contactDomainBlocked = accountPolicyEvaluator.isBlockedRegistrationEmailDomain(contactDomain); if (contactDomainBlocked) { diff --git a/fluxer_api/src/api/donation/services/DonationCheckoutService.ts b/fluxer_api/src/api/donation/services/DonationCheckoutService.ts index b19e3978f..e323a9a1e 100644 --- a/fluxer_api/src/api/donation/services/DonationCheckoutService.ts +++ b/fluxer_api/src/api/donation/services/DonationCheckoutService.ts @@ -40,7 +40,7 @@ export class DonationCheckoutService { } const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(params.email); if (!hasValidDns) { - throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS); + throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL); } const isRecurring = params.interval !== null; const existingDonor = await this.donationRepository.findDonorByEmail(params.email); diff --git a/fluxer_api/src/api/donation/services/DonationMagicLinkService.ts b/fluxer_api/src/api/donation/services/DonationMagicLinkService.ts index 38c2d472f..f0a52fd80 100644 --- a/fluxer_api/src/api/donation/services/DonationMagicLinkService.ts +++ b/fluxer_api/src/api/donation/services/DonationMagicLinkService.ts @@ -24,7 +24,7 @@ export class DonationMagicLinkService { async sendMagicLink(email: string): Promise { const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(email); if (!hasValidDns) { - throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS); + throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL); } const donor = await this.donationRepository.findDonorByEmail(email); if (!donor) { diff --git a/fluxer_api/src/api/infrastructure/EmailDnsValidationService.ts b/fluxer_api/src/api/infrastructure/EmailDnsValidationService.ts index 04c9ed156..92e537844 100644 --- a/fluxer_api/src/api/infrastructure/EmailDnsValidationService.ts +++ b/fluxer_api/src/api/infrastructure/EmailDnsValidationService.ts @@ -27,25 +27,51 @@ interface EmailDnsValidationServiceOptions { enforceInTestMode?: boolean; positiveTtlMs?: number; negativeTtlMs?: number; + lookupTimeoutMs?: number; + maxCachedDomains?: number; + isEmailEnabled?: () => Promise; } type DnsResolutionResult = 'valid' | 'invalid' | 'fallback' | 'transient_error'; +type DomainVerdict = 'valid' | 'invalid' | 'unverified'; const DOMAIN_NOT_FOUND_CODES = new Set(['ENOTFOUND', 'ENONAME', 'EAI_NONAME', 'NXDOMAIN']); const DOMAIN_NO_RECORD_CODES = new Set(['ENODATA', 'ENOENT', 'NODATA']); +const DNS_LOOKUP_TIMEOUT_MS = 2000; +const DNS_LOOKUP_TRIES = 1; +const MAX_CACHED_DOMAINS = 10000; + +async function isInstanceEmailEnabled(): Promise { + const {getInstanceConfigRepository} = await import('../middleware/ServiceSingletons'); + return getInstanceConfigRepository().isEmailEnabled(); +} + +function createLookupTimeoutError(): NodeJS.ErrnoException { + const error: NodeJS.ErrnoException = new Error('Email DNS lookup timed out'); + error.code = 'ETIMEOUT'; + return error; +} export class EmailDnsValidationService implements IEmailDnsValidationService { private readonly resolver: IDnsResolver; private readonly enforceInTestMode: boolean; private readonly positiveTtlMs: number; private readonly negativeTtlMs: number; + private readonly lookupTimeoutMs: number; + private readonly maxCachedDomains: number; + private readonly isEmailEnabled: () => Promise; private readonly domainCache = new Map(); constructor(options: EmailDnsValidationServiceOptions = {}) { - this.resolver = options.resolver ?? new Resolver(); + this.lookupTimeoutMs = options.lookupTimeoutMs ?? DNS_LOOKUP_TIMEOUT_MS; + this.resolver = + options.resolver ?? + new Resolver({timeout: this.lookupTimeoutMs, tries: DNS_LOOKUP_TRIES, maxTimeout: this.lookupTimeoutMs}); this.enforceInTestMode = options.enforceInTestMode ?? false; this.positiveTtlMs = options.positiveTtlMs ?? ms('30 minutes'); this.negativeTtlMs = options.negativeTtlMs ?? ms('5 minutes'); + this.maxCachedDomains = options.maxCachedDomains ?? MAX_CACHED_DOMAINS; + this.isEmailEnabled = options.isEmailEnabled ?? isInstanceEmailEnabled; } async hasValidDnsRecords(email: string): Promise { @@ -56,13 +82,19 @@ export class EmailDnsValidationService implements IEmailDnsValidationService { if (!domain) { return false; } + if (!(await this.isEmailEnabled())) { + return true; + } const cached = this.getCachedDomainResult(domain); if (cached !== null) { return cached; } - const isValid = await this.resolveDomain(domain); - this.setCachedDomainResult(domain, isValid); - return isValid; + const verdict = await this.resolveDomain(domain); + if (verdict === 'invalid') { + Logger.warn({domain}, 'Email domain publishes no mail exchange or address records, rejecting the address'); + } + this.setCachedDomainResult(domain, verdict); + return verdict !== 'invalid'; } private extractDomain(email: string): string | null { @@ -82,41 +114,49 @@ export class EmailDnsValidationService implements IEmailDnsValidationService { this.domainCache.delete(domain); return null; } + this.domainCache.delete(domain); + this.domainCache.set(domain, cached); return cached.valid; } - private setCachedDomainResult(domain: string, isValid: boolean): void { - const ttlMs = isValid ? this.positiveTtlMs : this.negativeTtlMs; + private setCachedDomainResult(domain: string, verdict: DomainVerdict): void { + const ttlMs = verdict === 'valid' ? this.positiveTtlMs : this.negativeTtlMs; + if (this.domainCache.size >= this.maxCachedDomains && !this.domainCache.has(domain)) { + const oldestDomain = this.domainCache.keys().next().value; + if (oldestDomain !== undefined) { + this.domainCache.delete(oldestDomain); + } + } this.domainCache.set(domain, { - valid: isValid, + valid: verdict !== 'invalid', expiresAtMs: Date.now() + ttlMs, }); } - private async resolveDomain(domain: string): Promise { + private async resolveDomain(domain: string): Promise { const mxResult = await this.resolveMx(domain); if (mxResult === 'valid') { - return true; + return 'valid'; } if (mxResult === 'invalid') { - return false; + return 'invalid'; } if (mxResult === 'transient_error') { - return true; + return 'unverified'; } const addressResult = await this.resolveAddressRecords(domain); if (addressResult === 'valid') { - return true; + return 'valid'; } if (addressResult === 'invalid') { - return false; + return 'invalid'; } - return true; + return 'unverified'; } private async resolveMx(domain: string): Promise { try { - const records = await this.resolver.resolveMx(domain); + const records = await this.withLookupDeadline(this.resolver.resolveMx(domain)); if (records.length > 0) { return 'valid'; } @@ -128,8 +168,8 @@ export class EmailDnsValidationService implements IEmailDnsValidationService { private async resolveAddressRecords(domain: string): Promise { const [ipv4Result, ipv6Result] = await Promise.allSettled([ - this.resolver.resolve4(domain), - this.resolver.resolve6(domain), + this.withLookupDeadline(this.resolver.resolve4(domain)), + this.withLookupDeadline(this.resolver.resolve6(domain)), ]); if (ipv4Result.status === 'fulfilled' && ipv4Result.value.length > 0) { return 'valid'; @@ -147,6 +187,20 @@ export class EmailDnsValidationService implements IEmailDnsValidationService { return 'invalid'; } + private async withLookupDeadline(lookup: Promise): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + lookup, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(createLookupTimeoutError()), this.lookupTimeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } + } + private classifyResolverError( error: unknown, domain: string, diff --git a/fluxer_api/src/api/infrastructure/tests/EmailDnsValidationService.test.ts b/fluxer_api/src/api/infrastructure/tests/EmailDnsValidationService.test.ts new file mode 100644 index 000000000..a23d4412c --- /dev/null +++ b/fluxer_api/src/api/infrastructure/tests/EmailDnsValidationService.test.ts @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {ms} from 'itty-time'; +import {describe, expect, it} from 'vitest'; +import {Config} from '../../Config'; +import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons'; +import {EmailDnsValidationService} from '../EmailDnsValidationService'; + +interface MxRecord { + exchange: string; + priority: number; +} + +function dnsError(code: string): NodeJS.ErrnoException { + const error: NodeJS.ErrnoException = new Error(`dns lookup failed with ${code}`); + error.code = code; + return error; +} + +class FakeDnsResolver { + readonly lookups: Array = []; + mxRecords: Array = [{exchange: 'mx.example.com', priority: 10}]; + mxErrorCode: string | null = null; + addressErrorCode: string | null = 'ENOTFOUND'; + addresses: Array = []; + stall = false; + + async resolveMx(domain: string): Promise> { + this.lookups.push(`mx:${domain}`); + if (this.stall) { + return new Promise>(() => {}); + } + if (this.mxErrorCode) { + throw dnsError(this.mxErrorCode); + } + return this.mxRecords; + } + + async resolve4(domain: string): Promise> { + this.lookups.push(`a:${domain}`); + if (this.addressErrorCode) { + throw dnsError(this.addressErrorCode); + } + return this.addresses; + } + + async resolve6(domain: string): Promise> { + this.lookups.push(`aaaa:${domain}`); + if (this.addressErrorCode) { + throw dnsError(this.addressErrorCode); + } + return this.addresses; + } +} + +describe('EmailDnsValidationService', () => { + it('skips the lookup when the instance sends no mail', async () => { + const resolver = new FakeDnsResolver(); + resolver.mxErrorCode = 'ENOTFOUND'; + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => false, + }); + expect(await service.hasValidDnsRecords('probe@fluxer.internal')).toBe(true); + expect(resolver.lookups).toEqual([]); + }); + + it('looks the domain up when the instance sends mail', async () => { + const resolver = new FakeDnsResolver(); + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => true, + }); + expect(await service.hasValidDnsRecords('probe@gmail.com')).toBe(true); + expect(resolver.lookups).toEqual(['mx:gmail.com']); + }); + + it('follows the env email flag when no gate is supplied and the operator set nothing', async () => { + expect(Config.email.enabled).toBe(true); + const resolver = new FakeDnsResolver(); + const service = new EmailDnsValidationService({resolver, enforceInTestMode: true}); + expect(await service.hasValidDnsRecords('probe@gmail.com')).toBe(true); + expect(resolver.lookups).toEqual(['mx:gmail.com']); + }); + + it('follows the runtime instance email setting over the env flag', async () => { + expect(Config.email.enabled).toBe(true); + await getInstanceConfigRepository().setInstanceIntegrationsConfig({email: {enabled: false}}); + const resolver = new FakeDnsResolver(); + resolver.mxErrorCode = 'ENOTFOUND'; + const service = new EmailDnsValidationService({resolver, enforceInTestMode: true}); + expect(await service.hasValidDnsRecords('probe@fluxer.internal')).toBe(true); + expect(resolver.lookups).toEqual([]); + }); + + it('still rejects a syntactically broken address when the instance sends no mail', async () => { + const resolver = new FakeDnsResolver(); + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => false, + }); + expect(await service.hasValidDnsRecords('probe@')).toBe(false); + expect(resolver.lookups).toEqual([]); + }); + + it('rejects a domain that does not exist', async () => { + const resolver = new FakeDnsResolver(); + resolver.mxErrorCode = 'ENOTFOUND'; + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => true, + }); + expect(await service.hasValidDnsRecords('probe@asdf.asdf')).toBe(false); + expect(resolver.lookups).toEqual(['mx:asdf.asdf']); + }); + + it('accepts a domain that publishes address records but no mail records', async () => { + const resolver = new FakeDnsResolver(); + resolver.mxErrorCode = 'ENODATA'; + resolver.addressErrorCode = null; + resolver.addresses = ['198.51.100.10']; + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => true, + }); + expect(await service.hasValidDnsRecords('probe@mail-less.test')).toBe(true); + expect(resolver.lookups).toEqual(['mx:mail-less.test', 'a:mail-less.test', 'aaaa:mail-less.test']); + }); + + it('rejects a domain that publishes neither mail nor address records', async () => { + const resolver = new FakeDnsResolver(); + resolver.mxErrorCode = 'ENODATA'; + resolver.addressErrorCode = 'ENODATA'; + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => true, + }); + expect(await service.hasValidDnsRecords('probe@no-records.test')).toBe(false); + expect(resolver.lookups).toEqual(['mx:no-records.test', 'a:no-records.test', 'aaaa:no-records.test']); + }); + + it('allows the address when the resolver fails transiently', async () => { + const resolver = new FakeDnsResolver(); + resolver.mxErrorCode = 'ESERVFAIL'; + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => true, + }); + expect(await service.hasValidDnsRecords('probe@asdf.asdf')).toBe(true); + }); + + it('does not cache a transient failure as a verified domain', async () => { + const resolver = new FakeDnsResolver(); + resolver.mxErrorCode = 'ESERVFAIL'; + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => true, + positiveTtlMs: ms('30 minutes'), + negativeTtlMs: 0, + }); + expect(await service.hasValidDnsRecords('probe@asdf.asdf')).toBe(true); + expect(await service.hasValidDnsRecords('probe@asdf.asdf')).toBe(true); + expect(resolver.lookups).toEqual(['mx:asdf.asdf', 'mx:asdf.asdf']); + }); + + it('caches a verified domain for the positive ttl', async () => { + const resolver = new FakeDnsResolver(); + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => true, + positiveTtlMs: ms('30 minutes'), + negativeTtlMs: 0, + }); + expect(await service.hasValidDnsRecords('probe@gmail.com')).toBe(true); + expect(await service.hasValidDnsRecords('probe@gmail.com')).toBe(true); + expect(resolver.lookups).toEqual(['mx:gmail.com']); + }); + + it('gives up on a stalled resolver instead of hanging', async () => { + const resolver = new FakeDnsResolver(); + resolver.stall = true; + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => true, + lookupTimeoutMs: 10, + }); + const startedAtMs = Date.now(); + expect(await service.hasValidDnsRecords('probe@stalled.test')).toBe(true); + expect(Date.now() - startedAtMs).toBeLessThan(ms('5 seconds')); + expect(resolver.lookups).toEqual(['mx:stalled.test']); + }); + + it('bounds the domain cache', async () => { + const resolver = new FakeDnsResolver(); + resolver.mxErrorCode = 'ENOTFOUND'; + const service = new EmailDnsValidationService({ + resolver, + enforceInTestMode: true, + isEmailEnabled: async () => true, + maxCachedDomains: 2, + }); + expect(await service.hasValidDnsRecords('probe@first.test')).toBe(false); + expect(await service.hasValidDnsRecords('probe@second.test')).toBe(false); + expect(await service.hasValidDnsRecords('probe@third.test')).toBe(false); + expect(await service.hasValidDnsRecords('probe@third.test')).toBe(false); + expect(resolver.lookups).toEqual(['mx:first.test', 'mx:second.test', 'mx:third.test']); + expect(await service.hasValidDnsRecords('probe@first.test')).toBe(false); + expect(resolver.lookups).toEqual(['mx:first.test', 'mx:second.test', 'mx:third.test', 'mx:first.test']); + }); +}); diff --git a/fluxer_api/src/api/report/ReportService.ts b/fluxer_api/src/api/report/ReportService.ts index f425e8a0d..9668197ce 100644 --- a/fluxer_api/src/api/report/ReportService.ts +++ b/fluxer_api/src/api/report/ReportService.ts @@ -343,7 +343,7 @@ export class ReportService { const normalizedEmail = this.normalizeEmail(email); const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(normalizedEmail); if (!hasValidDns) { - throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS); + throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL); } const verificationCode = this.generateDsaVerificationCode(); const expiresAt = new Date(Date.now() + ms('10 minutes')); diff --git a/fluxer_api/src/api/user/services/EmailChangeService.ts b/fluxer_api/src/api/user/services/EmailChangeService.ts index 4182c77df..842bffbc6 100644 --- a/fluxer_api/src/api/user/services/EmailChangeService.ts +++ b/fluxer_api/src/api/user/services/EmailChangeService.ts @@ -178,7 +178,7 @@ export class EmailChangeService { } const hasValidDns = await emailDnsValidation.hasValidDnsRecords(trimmedEmail); if (!hasValidDns) { - throw InputValidationError.fromCode('new_email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS); + throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL); } const existing = await users.findByEmail(trimmedEmail.toLowerCase()); if (existing && existing.id !== user.id) { diff --git a/fluxer_app/src/features/auth/components/modals/required_action/RequiredActionShared.tsx b/fluxer_app/src/features/auth/components/modals/required_action/RequiredActionShared.tsx index 5012d4285..dcc28c2bc 100644 --- a/fluxer_app/src/features/auth/components/modals/required_action/RequiredActionShared.tsx +++ b/fluxer_app/src/features/auth/components/modals/required_action/RequiredActionShared.tsx @@ -83,6 +83,7 @@ const EMAIL_VALIDATION_CODES = new Set([ ValidationErrorCodes.INVALID_EMAIL_FORMAT, ValidationErrorCodes.INVALID_EMAIL_LOCAL_PART, ValidationErrorCodes.INVALID_EMAIL_ADDRESS, + ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL, ]); const CODE_SESSION_VALIDATION_CODES = new Set([ ValidationErrorCodes.EMAIL_TOKEN_EXPIRED, diff --git a/packages/constants/src/ValidationErrorCodes.ts b/packages/constants/src/ValidationErrorCodes.ts index 003a440c1..e3046a49a 100644 --- a/packages/constants/src/ValidationErrorCodes.ts +++ b/packages/constants/src/ValidationErrorCodes.ts @@ -72,6 +72,7 @@ export const ValidationErrorCodes = { EMAIL_LENGTH_INVALID: 'EMAIL_LENGTH_INVALID', EMAIL_MUST_BE_CHANGED_VIA_TOKEN: 'EMAIL_MUST_BE_CHANGED_VIA_TOKEN', EMAIL_TOKEN_EXPIRED: 'EMAIL_TOKEN_EXPIRED', + EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL: 'EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL', EMAIL_DOMAIN_NOT_ALLOWED_FOR_SSO: 'EMAIL_DOMAIN_NOT_ALLOWED_FOR_SSO', EMBED_INDEX_OUT_OF_BOUNDS: 'EMBED_INDEX_OUT_OF_BOUNDS', EMBED_SPLASH_REQUIRES_FEATURE: 'EMBED_SPLASH_REQUIRES_FEATURE', diff --git a/packages/errors/src/i18n/ErrorCodeMappings.ts b/packages/errors/src/i18n/ErrorCodeMappings.ts index 05bb6d287..7130e0ab7 100644 --- a/packages/errors/src/i18n/ErrorCodeMappings.ts +++ b/packages/errors/src/i18n/ErrorCodeMappings.ts @@ -128,6 +128,7 @@ export const ErrorCodeToI18nKey = { [ValidationErrorCodes.INVALID_BASE64_FORMAT]: 'admin_and_system.invalid_format', [ValidationErrorCodes.INVALID_EMAIL_FORMAT]: 'email.invalid_format', [ValidationErrorCodes.INVALID_EMAIL_LOCAL_PART]: 'email.invalid_format', + [ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL]: 'email.domain_cannot_receive_mail', [ValidationErrorCodes.EMAIL_DOMAIN_NOT_ALLOWED_FOR_SSO]: 'auth_and_oauth.email_domain_not_allowed_for_sso', [APIErrorCodes.INVALID_HANDOFF_CODE]: 'auth_and_oauth.handoff_code_invalid', [ValidationErrorCodes.INVALID_OR_EXPIRED_AUTHORIZATION_TICKET]: diff --git a/packages/errors/src/i18n/ErrorI18nMessages.ts b/packages/errors/src/i18n/ErrorI18nMessages.ts index a9bb24576..77edad91e 100644 --- a/packages/errors/src/i18n/ErrorI18nMessages.ts +++ b/packages/errors/src/i18n/ErrorI18nMessages.ts @@ -254,6 +254,8 @@ export const ERROR_I18N_MESSAGES = { 'donation.magic_link_expired': 'Magic link has expired.', 'donation.magic_link_invalid': 'Magic link is invalid.', 'donation.magic_link_used': 'Magic link has already been used.', + 'email.domain_cannot_receive_mail': + 'That email domain cannot receive mail. Use an address on a domain that accepts email.', 'email.email_already_in_use': 'Email is already in use.', 'email.email_required': 'Email is required.', 'email.email_service_not_testable': 'Email service is temporarily unavailable. Please try again later.',