fix(auth): explain why a phone number was rejected (#2480)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 03:09:26 +02:00
1 parent 519b3a6127
commit 8734956d86
122 files changed
+2356 -75

No files matched your search

+30 -4
View File
@@ -6,10 +6,17 @@ import {BotUserAuthEndpointAccessDeniedError} from '@fluxer/errors/src/domains/a
import {InvalidPhoneNumberError} from '@fluxer/errors/src/domains/auth/InvalidPhoneNumberError';
import {InvalidPhoneVerificationCodeError} from '@fluxer/errors/src/domains/auth/InvalidPhoneVerificationCodeError';
import {PhoneAlreadyUsedError} from '@fluxer/errors/src/domains/auth/PhoneAlreadyUsedError';
import {PhoneCountryNotSupportedError} from '@fluxer/errors/src/domains/auth/PhoneCountryNotSupportedError';
import {PhoneInboundVerificationRequiredError} from '@fluxer/errors/src/domains/auth/PhoneInboundVerificationRequiredError';
import {PhoneLookupUnavailableError} from '@fluxer/errors/src/domains/auth/PhoneLookupUnavailableError';
import {PhoneNumberNotInServiceError} from '@fluxer/errors/src/domains/auth/PhoneNumberNotInServiceError';
import {PhoneNumberNotMobileError} from '@fluxer/errors/src/domains/auth/PhoneNumberNotMobileError';
import {PhoneVerificationNeedsReviewError} from '@fluxer/errors/src/domains/auth/PhoneVerificationNeedsReviewError';
import {PhoneVerificationRequiredError} from '@fluxer/errors/src/domains/auth/PhoneVerificationRequiredError';
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import {CaptchaVerificationRequiredError} from '@fluxer/errors/src/domains/core/CaptchaVerificationRequiredError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import type {FluxerError} from '@fluxer/errors/src/FluxerError';
import {PHONE_E164_REGEX} from '@fluxer/schema/src/primitives/UserValidators';
import type {RateLimitResult, RateLimitScope} from '@pkgs/rate_limit/src/IRateLimitService';
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
@@ -126,7 +133,7 @@ async function validatePhoneOrThrow(ctx: ApiContext, phone: string, options: Val
'Phone verification rejected at gate',
);
}
throw new InvalidPhoneNumberError();
throw errorForPhoneRejectReason(verdict.rejectReason);
}
if (verdict.verdict === 'require_inbound') {
Logger.info(
@@ -141,7 +148,7 @@ async function validatePhoneOrThrow(ctx: ApiContext, phone: string, options: Val
if (options.inboundCapable) {
throw new PhoneInboundChallengeRequiredError(verdict.inboundReason);
}
throw new InvalidPhoneNumberError();
throw new PhoneInboundVerificationRequiredError();
}
}
@@ -150,7 +157,7 @@ function assertPhoneFormatOrThrow(phone: string): void {
throw new InvalidPhoneNumberError();
}
if (phonePrefixBanCache.isBlocked(phone)) {
throw new InvalidPhoneNumberError();
throw new PhoneCountryNotSupportedError();
}
}
@@ -235,7 +242,7 @@ export async function sendPhoneVerificationCode(
await ctx.services.phoneAttemptRisk.record({...riskInput, rejected: false});
return await issueInboundChallengeOrThrow(ctx, userId, phone, error.reason);
}
rejectedAttempt = true;
rejectedAttempt = !(error instanceof PhoneLookupUnavailableError);
throw error;
}
const cached = await ctx.services.phoneLookup?.getCachedLookup(phone);
@@ -516,6 +523,25 @@ type PhoneVerdictResult =
| {verdict: 'reject'; rejectReason: PhoneAttemptRejectReason}
| {verdict: 'require_inbound'; inboundReason: PhoneAttemptInboundReason};
export function errorForPhoneRejectReason(reason: PhoneAttemptRejectReason): FluxerError {
switch (reason) {
case 'invalid_format':
return new InvalidPhoneNumberError();
case 'banned_prefix':
return new PhoneCountryNotSupportedError();
case 'lookup_unavailable':
return new PhoneLookupUnavailableError();
case 'invalid_number':
return new PhoneNumberNotInServiceError();
case 'line_type_not_mobile':
case 'line_type_hard_rejected':
return new PhoneNumberNotMobileError();
case 'sms_pumping_risk_high':
case 'behavioural_risk_blocked':
return new PhoneVerificationNeedsReviewError();
}
}
function computePhoneVerdict(lookup: PhoneLookupResult | null, phone: string): PhoneVerdictResult {
if (lookup == null) {
return {verdict: 'reject', rejectReason: 'lookup_unavailable'};
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings';
import {getErrorMessageUnsafe} from '@fluxer/errors/src/i18n/ErrorI18n';
import {describe, expect, it} from 'vitest';
import {errorForPhoneRejectReason} from '../AuthPhone';
import type {PhoneAttemptRejectReason} from '../services/PhoneLookupRepository';
const ALL_REJECT_REASONS: ReadonlyArray<PhoneAttemptRejectReason> = [
'invalid_format',
'banned_prefix',
'lookup_unavailable',
'invalid_number',
'line_type_not_mobile',
'line_type_hard_rejected',
'sms_pumping_risk_high',
'behavioural_risk_blocked',
];
const PHONE_GATE_CODES: ReadonlyArray<string> = Array.from(
new Set([
...ALL_REJECT_REASONS.map((reason) => errorForPhoneRejectReason(reason).code),
APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED,
]),
);
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..');
const CLIENT_SWITCH_PATH = path.join(
REPO_ROOT,
'fluxer_app/src/features/auth/components/modals/required_action/RequiredActionShared.tsx',
);
describe('phone gate error codes', () => {
it('produces every code the gate is meant to produce', () => {
expect(PHONE_GATE_CODES.slice().sort()).toEqual(
[
APIErrorCodes.INVALID_PHONE_NUMBER,
APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED,
APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED,
APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE,
APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE,
APIErrorCodes.PHONE_NUMBER_NOT_MOBILE,
APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW,
].sort(),
);
});
it.each(PHONE_GATE_CODES)('%s is a declared APIErrorCode', (code) => {
expect(Object.values(APIErrorCodes)).toContain(code);
});
it.each(PHONE_GATE_CODES)('%s has an ErrorCodeMappings entry', (code) => {
expect(ErrorCodeToI18nKey[code as keyof typeof ErrorCodeToI18nKey]).toBeTruthy();
});
it.each(PHONE_GATE_CODES)('%s resolves to a real English message', (code) => {
const message = getErrorMessageUnsafe(code, 'en-US');
expect(message).not.toBe(code);
expect(message).not.toBe(ErrorCodeToI18nKey[code as keyof typeof ErrorCodeToI18nKey]);
expect(message.length).toBeGreaterThan(0);
});
it.each(PHONE_GATE_CODES)('%s has a case in the required-action client switch', (code) => {
const source = readFileSync(CLIENT_SWITCH_PATH, 'utf8');
expect(source).toContain(`case APIErrorCodes.${code}:`);
});
it('keeps the two fraud reasons on one shared code', () => {
expect(errorForPhoneRejectReason('sms_pumping_risk_high').code).toBe(
errorForPhoneRejectReason('behavioural_risk_blocked').code,
);
});
it('gives every benign reason a code the fraud reasons do not use', () => {
const review = errorForPhoneRejectReason('sms_pumping_risk_high').code;
const benign = ALL_REJECT_REASONS.filter(
(reason) => reason !== 'sms_pumping_risk_high' && reason !== 'behavioural_risk_blocked',
).map((reason) => errorForPhoneRejectReason(reason).code);
expect(benign).not.toContain(review);
});
});
@@ -0,0 +1,262 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {FluxerError} from '@fluxer/errors/src/FluxerError';
import type {PhoneLineType, PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import {afterEach, describe, expect, it, vi} from 'vitest';
import type {ApiContext} from '../../ApiContext';
import type {UserID} from '../../BrandedTypes';
import {errorForPhoneRejectReason, sendPhoneVerificationCode, verifyPhoneCode} from '../AuthPhone';
import {phonePrefixBanCache} from '../PhonePrefixBanCache';
import type {PhoneAttemptInboundReason, PhoneAttemptRejectReason} from '../services/PhoneLookupRepository';
const USER_ID = 1n as UserID;
const MOBILE_US = '+15125550123';
const CANADIAN = '+16045550123';
function lookup(overrides: Partial<PhoneLookupResult> = {}): PhoneLookupResult {
return {
valid: true,
lineType: 'mobile',
countryCode: 'US',
carrierName: 'Test',
smsPumpingRiskScore: 0,
...overrides,
};
}
interface HarnessOptions {
lookupResult?: PhoneLookupResult | null;
userFlags?: bigint;
riskDecision?: 'allow' | 'require_inbound';
challengeFails?: boolean;
}
function buildContext(options: HarnessOptions = {}) {
const issueChallenge = vi.fn(async () => {
if (options.challengeFails) {
throw new Error('inbound challenge service down');
}
return {challengeCode: 'ABCD', ourNumber: '+15005550006', expiresAt: new Date(0)};
});
const record = vi.fn(async (_input: {rejected: boolean}) => undefined);
const services = {
sms: {
lookupPhone: vi.fn(async () => (options.lookupResult === undefined ? lookup() : options.lookupResult)),
checkVerification: vi.fn(async () => true),
startVerificationWithResult: vi.fn(async () => undefined),
},
phoneLookup: null,
users: {
findUnique: vi.fn(async () => ({
id: USER_ID,
isBot: false,
flags: options.userFlags ?? 0n,
hasVerifiedPhone: false,
})),
},
cache: {get: vi.fn(async () => null), ttl: vi.fn(async () => 0), set: vi.fn(async () => undefined)},
phoneAttemptRisk: {
evaluate: vi.fn(async () => ({decision: options.riskDecision ?? 'allow', reason: null, counters: {}})),
record,
},
inboundSmsChallenge: {issueChallenge},
config: {sms: {inboundChallengeNumber: '+15005550006'}},
};
return {ctx: {services} as unknown as ApiContext, issueChallenge, record};
}
async function codeFromVerify(phone: string, options: HarnessOptions = {}): Promise<string> {
const {ctx} = buildContext(options);
try {
await verifyPhoneCode(ctx, phone, '123456', USER_ID);
} catch (error) {
return (error as FluxerError).code;
}
throw new Error(`verifyPhoneCode did not reject ${phone}`);
}
async function inboundReasonFromSend(phone: string, options: HarnessOptions = {}, channel?: 'inbound_challenge') {
const {ctx} = buildContext(options);
const result = await sendPhoneVerificationCode(ctx, phone, USER_ID, channel ? {channel} : {});
if (result.channel !== 'inbound_challenge') {
throw new Error(`sendPhoneVerificationCode returned ${result.channel} for ${phone}`);
}
return result.reason;
}
afterEach(() => {
phonePrefixBanCache.unban('+31970');
});
const REJECT_REASON_CODES: Record<PhoneAttemptRejectReason, string> = {
invalid_format: APIErrorCodes.INVALID_PHONE_NUMBER,
banned_prefix: APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED,
lookup_unavailable: APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE,
invalid_number: APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE,
line_type_not_mobile: APIErrorCodes.PHONE_NUMBER_NOT_MOBILE,
line_type_hard_rejected: APIErrorCodes.PHONE_NUMBER_NOT_MOBILE,
sms_pumping_risk_high: APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW,
behavioural_risk_blocked: APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW,
};
const INBOUND_REASONS: Record<PhoneAttemptInboundReason, true> = {
voip: true,
canadian: true,
unknown_line_type: true,
expensive_destination: true,
account_forced: true,
behavioural_risk: true,
};
describe('every reject reason maps to its own error', () => {
it.each(Object.entries(REJECT_REASON_CODES))('%s produces %s', (reason, code) => {
expect(errorForPhoneRejectReason(reason as PhoneAttemptRejectReason).code).toBe(code);
});
it('covers all eight declared reject reasons', () => {
expect(Object.keys(REJECT_REASON_CODES)).toHaveLength(8);
});
});
describe('reject reasons reaching the caller through the real gate', () => {
it('invalid_format keeps the plain invalid-number error', async () => {
expect(await codeFromVerify('5551234')).toBe(REJECT_REASON_CODES.invalid_format);
});
it('banned_prefix says the country is unsupported', async () => {
phonePrefixBanCache.ban('+31970');
expect(await codeFromVerify('+31970123456')).toBe(REJECT_REASON_CODES.banned_prefix);
});
it('lookup_unavailable blames our own checker', async () => {
expect(await codeFromVerify(MOBILE_US, {lookupResult: null})).toBe(REJECT_REASON_CODES.lookup_unavailable);
});
it('invalid_number says the carrier rejected it', async () => {
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({valid: false})})).toBe(
REJECT_REASON_CODES.invalid_number,
);
});
it.each([
'landline',
'tollFree',
'premium',
'sharedCost',
'uan',
'voicemail',
'pager',
] as const)('line_type_hard_rejected for %s says it is not a mobile', async (lineType) => {
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({lineType})})).toBe(
REJECT_REASON_CODES.line_type_hard_rejected,
);
});
it('sms_pumping_risk_high routes to human review', async () => {
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({smsPumpingRiskScore: 100})})).toBe(
REJECT_REASON_CODES.sms_pumping_risk_high,
);
});
it('line_type_not_mobile is unreachable through the verdict and is covered by the mapper alone', async () => {
const accepted: Array<PhoneLineType> = ['mobile', 'personal'];
for (const lineType of accepted) {
const {ctx} = buildContext({lookupResult: lookup({lineType})});
const rejection = await verifyPhoneCode(ctx, MOBILE_US, '123456', USER_ID).then(
() => null,
(error: FluxerError) => error.code,
);
expect(rejection).not.toBe(APIErrorCodes.PHONE_NUMBER_NOT_MOBILE);
expect(rejection).not.toBe(APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED);
}
expect(errorForPhoneRejectReason('line_type_not_mobile').code).toBe(REJECT_REASON_CODES.line_type_not_mobile);
});
it('behavioural_risk_blocked has no producer in the gate and is covered by the mapper alone', () => {
expect(errorForPhoneRejectReason('behavioural_risk_blocked').code).toBe(
REJECT_REASON_CODES.behavioural_risk_blocked,
);
});
});
describe('every inbound reason', () => {
it('covers all six declared inbound reasons', () => {
expect(Object.keys(INBOUND_REASONS)).toHaveLength(6);
});
it.each(['fixedVoip', 'nonFixedVoip'] as const)('routes %s to the inbound challenge as voip', async (lineType) => {
expect(await inboundReasonFromSend(MOBILE_US, {lookupResult: lookup({lineType})})).toBe('voip');
});
it('routes a Canadian number to the inbound challenge as canadian', async () => {
expect(await inboundReasonFromSend(CANADIAN)).toBe('canadian');
});
it.each([null, 'unknown'] as const)('routes line type %s to the inbound challenge', async (lineType) => {
expect(await inboundReasonFromSend(MOBILE_US, {lookupResult: lookup({lineType})})).toBe('unknown_line_type');
});
it('routes an explicit inbound channel request as expensive_destination', async () => {
expect(await inboundReasonFromSend(MOBILE_US, {}, 'inbound_challenge')).toBe('expensive_destination');
});
it('routes a force-flagged account as account_forced', async () => {
expect(await inboundReasonFromSend(MOBILE_US, {userFlags: UserFlags.FORCE_INBOUND_PHONE_VERIFICATION})).toBe(
'account_forced',
);
});
it('routes a risk-flagged account as behavioural_risk', async () => {
expect(await inboundReasonFromSend(MOBILE_US, {riskDecision: 'require_inbound'})).toBe('behavioural_risk');
});
});
describe('inbound reasons a caller cannot answer', () => {
it.each([
[lookup({lineType: 'fixedVoip'}), MOBILE_US],
[lookup({lineType: 'nonFixedVoip'}), MOBILE_US],
[lookup({lineType: null}), MOBILE_US],
[lookup({lineType: 'unknown'}), MOBILE_US],
[lookup(), CANADIAN],
])('tells the user to restart phone verification instead of calling the number invalid', async (result, phone) => {
expect(await codeFromVerify(phone, {lookupResult: result})).toBe(APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED);
});
it('never reports the inbound reason to the caller', async () => {
const voip = await codeFromVerify(MOBILE_US, {lookupResult: lookup({lineType: 'fixedVoip'})});
const canadian = await codeFromVerify(CANADIAN);
const unknown = await codeFromVerify(MOBILE_US, {lookupResult: lookup({lineType: 'unknown'})});
expect(new Set([voip, canadian, unknown]).size).toBe(1);
});
});
describe('inbound challenge issuance failures', () => {
it.each([
['voip', {lookupResult: lookup({lineType: 'fixedVoip'})}, undefined],
['account_forced', {userFlags: UserFlags.FORCE_INBOUND_PHONE_VERIFICATION}, undefined],
['behavioural_risk', {riskDecision: 'require_inbound' as const}, undefined],
['expensive_destination', {}, 'inbound_challenge' as const],
])('surfaces the send-path outage for %s', async (_reason, options, channel) => {
const {ctx} = buildContext({...(options as HarnessOptions), challengeFails: true});
try {
await sendPhoneVerificationCode(ctx, MOBILE_US, USER_ID, channel ? {channel} : {});
} catch (error) {
expect((error as FluxerError).code).toBe(APIErrorCodes.SMS_VERIFICATION_UNAVAILABLE);
return;
}
throw new Error('sendPhoneVerificationCode did not reject');
});
});
async function recordedRejectionFor(options: HarnessOptions): Promise<boolean> {
const {ctx, record} = buildContext(options);
await sendPhoneVerificationCode(ctx, MOBILE_US, USER_ID, {}).catch(() => undefined);
const call = record.mock.calls.at(-1)?.[0];
if (!call) {
throw new Error('phoneAttemptRisk.record was never called');
}
return call.rejected;
}
describe('what each rejection costs the user', () => {
it('does not count our own lookup outage against them', async () => {
expect(await recordedRejectionFor({lookupResult: null})).toBe(false);
});
it.each([
['invalid_number', lookup({valid: false})],
['line_type_hard_rejected', lookup({lineType: 'landline'})],
['sms_pumping_risk_high', lookup({smsPumpingRiskScore: 100})],
])('still counts %s against them', async (_reason, lookupResult) => {
expect(await recordedRejectionFor({lookupResult})).toBe(true);
});
it('does not count an inbound-challenge routing against them', async () => {
expect(await recordedRejectionFor({lookupResult: lookup({lineType: 'fixedVoip'})})).toBe(false);
});
});
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {describe, expect, it} from 'vitest';
import {errorForPhoneRejectReason} from '../AuthPhone';
import type {PhoneAttemptRejectReason} from '../services/PhoneLookupRepository';
const EXPECTED_CODES: ReadonlyArray<[PhoneAttemptRejectReason, string]> = [
['invalid_format', APIErrorCodes.INVALID_PHONE_NUMBER],
['banned_prefix', APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED],
['lookup_unavailable', APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE],
['invalid_number', APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE],
['line_type_not_mobile', APIErrorCodes.PHONE_NUMBER_NOT_MOBILE],
['line_type_hard_rejected', APIErrorCodes.PHONE_NUMBER_NOT_MOBILE],
['sms_pumping_risk_high', APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW],
['behavioural_risk_blocked', APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW],
];
describe('errorForPhoneRejectReason', () => {
it.each(EXPECTED_CODES)('maps %s to %s', (reason, code) => {
expect(errorForPhoneRejectReason(reason).code).toBe(code);
});
it.each(EXPECTED_CODES)('returns a 400 for %s', (reason) => {
expect(errorForPhoneRejectReason(reason).status).toBe(400);
});
it('gives both line type rejections the same code', () => {
expect(errorForPhoneRejectReason('line_type_not_mobile').code).toBe(
errorForPhoneRejectReason('line_type_hard_rejected').code,
);
});
it('gives both fraud signals the same response body', async () => {
const pumping = await errorForPhoneRejectReason('sms_pumping_risk_high').getResponse().text();
const behavioural = await errorForPhoneRejectReason('behavioural_risk_blocked').getResponse().text();
expect(pumping).toBe(behavioural);
});
it('keeps the fraud signals distinct from every benign reason', () => {
const review = errorForPhoneRejectReason('sms_pumping_risk_high').code;
const benign = EXPECTED_CODES.filter(([, code]) => code !== review).map(([, code]) => code);
expect(benign).not.toContain(review);
});
});
+13 -1
View File
@@ -23692,8 +23692,14 @@
"PASSKEYS_DISABLED",
"PHONE_ADD_NOT_ELIGIBLE",
"PHONE_ALREADY_USED",
"PHONE_COUNTRY_NOT_SUPPORTED",
"PHONE_GATE_ESCAPE_UNAVAILABLE",
"PHONE_INBOUND_VERIFICATION_REQUIRED",
"PHONE_LOOKUP_UNAVAILABLE",
"PHONE_NUMBER_NOT_IN_SERVICE",
"PHONE_NUMBER_NOT_MOBILE",
"PHONE_RATE_LIMIT_EXCEEDED",
"PHONE_VERIFICATION_NEEDS_REVIEW",
"PHONE_VERIFICATION_REQUIRED",
"PREMIUM_PURCHASE_BLOCKED",
"PREVIEW_MUST_BE_JPEG",
@@ -24001,8 +24007,14 @@
"Passkeys are disabled.",
"You are not eligible to add a phone number to your account.",
"Phone number is already in use.",
"Setting the phone verification check aside is not available for this account.",
"We don't send verification texts to this country. Use a mobile number from another country, or email [email protected] and a person will review your account.",
"This account cannot postpone the phone verification check.",
"This number is verified by texting us instead of us texting you. Start phone verification again to get the code and the number to text.",
"Our phone number check is down right now, so we stopped before sending your code. This is on us, not your number. Wait a few minutes and try the same number again.",
"Your carrier says this number isn't in service. Check the number and try again, or email [email protected] if it's correct.",
"This isn't a mobile number, so it can't receive our text. Use a mobile number, or email [email protected] if you think that's wrong.",
"Phone rate limit exceeded.",
"We couldn't verify this number automatically. Email [email protected] and a person will review your account.",
"Phone verification is required.",
"No active subscription.",
"Preview must be JPEG.",