Files
tehran_taj/apps/api/src/modules/sms/sms.service.ts
T
s.esmaeiliandClaude Opus 5.5 7f851676b5 fix(sms): inside Docker, send the server's public IP as the relay host, not the container's
Messages went out locally but not from the server. The relay gateway
identifies the sending server by `host`, and the owner's ruling (D26)
is that this is the server's IP. With SMS_RELAY_HOST unset, the driver
sent the machine's first external IPv4. On the bare PHP server that was
the real IP; inside the API container it is the container's (172.x).
Reproduced: a container on this machine reports 172.17.0.3.

- relay.driver: when SMS_RELAY_HOST is unset and the machine address is
  private (RFC 1918, CGNAT, link-local), the public IPv4 is looked up
  once (ipify, ifconfig.me, icanhazip in turn) and cached. If every
  lookup fails, it falls back to the old value. SMS_RELAY_HOST still wins.
- SmsService logs the host it will send at boot, and warns when it is
  still private, so a wrong value is visible instead of silent.
- Removed a debug console.log that wrote every recipient's mobile to the
  server log.
- .env.example and SERVER-SETUP-CHECKLIST: SMS_RELAY_HOST = this
  server's public IP.

Tests: relay-host.spec (5). All 254 API tests pass.

اپ: پیامک کد ورود دوباره به‌درستی ارسال می‌شود.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:58:13 +03:30

180 lines
6.4 KiB
TypeScript

import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { MessageAudience } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { envOr, envOrNull } from '../../common/env';
import { DEFAULT_SMS_PROVIDER, SMS_DRIVERS, SmsDriver, SmsSendResult } from './drivers';
import { isPrivateIpv4, resolveRelayHost } from './drivers/relay.driver';
/**
* SMS delivery — port of henza_server/app/Models/Sms.php, with the gateway
* pulled out into drivers (`./drivers`) selected by `SMS_PROVIDER`:
* * `relay` (default, D26) — the owner's createsms gateway
* * `trez` — the reference's Trez panel
* * `none` — send nothing
*
* Reference behaviour kept for every driver:
* * the message is trim()ed before sending (`Sms.php:77`)
* * EVERY message is persisted, via `Sms::set()` (`:80`)
* * the response body is returned and the callers ignore it
*
* The OTP message body is NOT persisted in readable form — see
* CustomerAuthService.deliver() and SEC-11.
*
* Failure handling matches the reference: `Sms::Send` is called inline and its
* result is discarded, so a failed send never aborted the order, the document
* approval or the customer creation that triggered it. `send()` therefore
* never throws — it logs and reports failure in its return value.
*/
export type { SmsSendResult } from './drivers';
export interface SmsSendOptions {
/**
* Persist the message body. Default true, matching `Sms::set()`.
* OTP delivery passes false and writes its own redacted row (SEC-11).
*/
persist?: boolean;
audience?: MessageAudience;
}
/** The selected `SMS_PROVIDER`, lower-cased. */
export function smsProvider(): string {
return envOr('SMS_PROVIDER', DEFAULT_SMS_PROVIDER).toLowerCase();
}
/** The selected driver, or null for `none` and unknown names. */
export function smsDriver(): SmsDriver | null {
return SMS_DRIVERS[smsProvider()] ?? null;
}
/**
* True when a real gateway is configured. When false the service stays in
* development mode: nothing is sent, and OTP codes are logged locally.
*/
export function smsConfigured(): boolean {
const driver = smsDriver();
return driver !== null && driver.missingConfig().length === 0;
}
/**
* Called from main.ts before the app listens. Reports the SMS configuration
* and NEVER throws.
*
* D20 reversed the earlier rule (D14) that a missing credential should abort
* the boot in production. The server now always starts: a missing or unknown
* provider is a warning, `send()` declines silently and logs, and no message —
* including the welcome SMS — can stop an operation. The trade-off is
* deliberate and accepted: with SMS unconfigured, customers cannot receive a
* login code, which is why `OTP_MASTER_CODE` exists for the delivery.
*/
export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void {
const provider = smsProvider();
if (provider === 'none') {
logger.warn('SMS_PROVIDER=none — no SMS will be sent.');
return;
}
const driver = smsDriver();
if (!driver) {
const supported = [...Object.keys(SMS_DRIVERS), 'none'].map((n) => `"${n}"`).join(', ');
logger.warn(
`Unknown SMS_PROVIDER "${provider}" (supported: ${supported}). No SMS will be sent.`,
);
return;
}
const missing = driver.missingConfig();
if (missing.length === 0) {
logger.log(`SMS provider: ${driver.name}`);
return;
}
logger.warn(
`SMS provider "${driver.name}" is selected but these are not set: ${missing.join(', ')}. ` +
'Messages are logged, not sent. Customers cannot receive a login code.',
);
}
@Injectable()
export class SmsService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Reports the `host` the relay gateway will receive, so a wrong one shows in
* the boot log instead of as silent failures. Never throws and never delays
* the boot: the lookup runs in the background.
*/
onApplicationBootstrap() {
if (smsDriver()?.name !== 'relay') return;
void resolveRelayHost()
.then((host) => {
const source = envOrNull('SMS_RELAY_HOST') ? 'SMS_RELAY_HOST' : 'detected';
if (isPrivateIpv4(host)) {
this.logger.warn(
`SMS relay host is a private address (${host}, ${source}); the gateway may not accept it. ` +
"Set SMS_RELAY_HOST to this server's public IP.",
);
} else {
this.logger.log(`SMS relay host: ${host ?? '(none)'} (${source})`);
}
})
.catch(() => undefined);
}
/**
* Sends one message. Never throws — a failed SMS must not roll back the
* business operation that triggered it, exactly as in the reference.
*/
async send(mobile: string, body: string, options: SmsSendOptions = {}): Promise<SmsSendResult> {
const { persist = true, audience = MessageAudience.SINGLE_NUMBER } = options;
// The reference trims before sending (`Sms.php:77`).
const text = body.trim();
const result = await this.deliver(mobile, text);
if (persist) {
// `Sms::set()` records every message, delivered or not.
await this.record(mobile, text, audience);
}
return result;
}
private async deliver(mobile: string, text: string): Promise<SmsSendResult> {
const driver = smsDriver();
if (!driver || driver.missingConfig().length > 0) {
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
return { ok: false, error: 'sms-not-configured' };
}
let result: SmsSendResult;
try {
result = await driver.deliver(mobile, text);
} catch (e) {
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
}
if (!result.ok) {
// Logged, not thrown — see the class comment.
const detail = result.response ? ` ${result.response}` : '';
this.logger.error(`SMS to ${mobile} via ${driver.name} failed: ${result.error}${detail}`);
}
return result;
}
/** The `messages` row the reference writes for every send (`Sms.php:120-129`). */
private async record(mobile: string, body: string, audience: MessageAudience) {
try {
await this.prisma.smsMessage.create({
data: { audience, recipients: mobile, body, sentCount: 1 },
});
} catch (e) {
// Never let the audit write break the caller either.
this.logger.error(`Could not record the SMS to ${mobile}: ${String(e)}`);
}
}
}