diff --git a/.env.example b/.env.example
index 0f1c608..6ae7959 100644
--- a/.env.example
+++ b/.env.example
@@ -26,6 +26,9 @@ UPLOAD_DIR=uploads
CORS_ORIGINS=https://montime.ir,https://adm.montime.ir,http://localhost:5173,http://localhost:5174
# 0098sms.com — used when AUTH_RETURN_OTP is not true
+# Transport: soap (default, recommended) or link (legacy)
+# SMS_TRANSPORT=soap
+# SMS_SOAP_URL=https://webservice.0098sms.com/service.asmx
# From panel: account username, password (or web service password), approved sender line (FROM)
SMS_USERNAME=
SMS_PASSWORD=
diff --git a/scripts/test-sms.mjs b/scripts/test-sms.mjs
new file mode 100644
index 0000000..d6b0644
--- /dev/null
+++ b/scripts/test-sms.mjs
@@ -0,0 +1,53 @@
+import 'dotenv/config';
+
+const username = process.env.SMS_USERNAME;
+const password = process.env.SMS_PASSWORD;
+const sender = process.env.SMS_SENDER;
+
+if (!username || !password || !sender) {
+ console.error('Missing SMS env vars');
+ process.exit(1);
+}
+
+const text = 'Montime code: 123456';
+const targets = process.argv.slice(2).length
+ ? process.argv.slice(2)
+ : ['09397439581', '09128081392'];
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+for (const mobile of targets) {
+ const soapBody = `
+
+
+
+ ${escapeXml(username)}
+ ${escapeXml(password)}
+ ${escapeXml(text)}
+ ${escapeXml(mobile)}
+ ${escapeXml(sender)}
+
+
+`;
+
+ const res = await fetch('https://webservice.0098sms.com/service.asmx', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'text/xml; charset=utf-8',
+ SOAPAction: 'http://tempuri.org/SendSMS',
+ },
+ body: soapBody,
+ signal: AbortSignal.timeout(20000),
+ });
+
+ const body = await res.text();
+ const match = body.match(/]*>([^<]*)<\/SendSMSResult>/);
+ console.log(mobile, 'HTTP', res.status, 'RESULT', match?.[1] ?? body.slice(0, 200));
+}
diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts
index 22b2e3d..f7b1f54 100644
--- a/src/modules/auth/auth.service.ts
+++ b/src/modules/auth/auth.service.ts
@@ -98,13 +98,13 @@ export class AuthService {
'SMS is not configured. Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER.',
);
}
- const sent = await this.sms.sendOtpSms(mobile, code);
- if (!sent) {
+ const result = await this.sms.sendOtpSmsDetailed(mobile, code);
+ if (!result.ok) {
this.logger.warn(
- `OTP SMS failed for ${this.maskMobile(mobile)}`,
+ `OTP SMS failed for ${this.maskMobile(mobile)} transport=${result.transport} reason=${result.reason}`,
);
throw new ServiceUnavailableException(
- 'Could not send SMS. Please try again later.',
+ `Could not send SMS. Please try again later. (${result.reason})`,
);
}
}
diff --git a/src/modules/auth/sms.service.ts b/src/modules/auth/sms.service.ts
index 824bb5b..b4d3dba 100644
--- a/src/modules/auth/sms.service.ts
+++ b/src/modules/auth/sms.service.ts
@@ -1,9 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
-/** HTTP endpoint documented by 0098sms.com for sending via query string. */
-const SMS_0098_SEND_URL =
- 'http://www.0098sms.com/sendsmslink.aspx';
+/** Legacy link API — often returns HTTP 500; kept as fallback only. */
+const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx';
+
+/** Current 0098 SOAP endpoint (works reliably). */
+const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
const SMS_0098_ERROR_CODES: Record = {
'1': 'invalid phone number',
@@ -21,6 +23,13 @@ const SMS_0098_ERROR_CODES: Record = {
'13': 'invalid characters in message text',
};
+type SendResult = {
+ ok: boolean;
+ code?: string;
+ reason: string;
+ transport: 'soap' | 'link';
+};
+
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
@@ -36,7 +45,7 @@ export class SmsService {
}
/**
- * Formats mobile for 0098 `To` (Iranian mobiles as 09xxxxxxxxx).
+ * Formats mobile for 0098 (Iranian mobiles as 09xxxxxxxxx).
*/
normalizePhoneForSms(mobile: string): string {
const digits = String(mobile ?? '').replace(/\D/g, '');
@@ -53,28 +62,147 @@ export class SmsService {
}
async sendOtpSms(mobile: string, code: string): Promise {
+ const result = await this.sendOtpSmsDetailed(mobile, code);
+ return result.ok;
+ }
+
+ async sendOtpSmsDetailed(mobile: string, code: string): Promise {
const template =
this.config.get('SMS_OTP_TEMPLATE') ??
'کد ورود منتایم: {code}';
const text = template.replace('{code}', code);
- return this.sendSms(mobile, text);
+ return this.sendSmsDetailed(mobile, text);
}
async sendSms(phone: string, text: string): Promise {
+ const result = await this.sendSmsDetailed(phone, text);
+ return result.ok;
+ }
+
+ async sendSmsDetailed(phone: string, text: string): Promise {
const username = this.config.get('SMS_USERNAME');
const password = this.getPassword();
const sender = this.config.get('SMS_SENDER');
- const domain = this.config.get('SMS_DOMAIN') ?? '0098';
- const sendUrl = this.config.get('SMS_SEND_URL') ?? SMS_0098_SEND_URL;
- const timeoutMs = this.readPositiveInt('SMS_TIMEOUT_MS', 15000);
if (!username || !password || !sender) {
- return false;
+ return { ok: false, reason: 'SMS not configured', transport: 'soap' };
}
const to = this.normalizePhoneForSms(phone);
+ const transport =
+ (this.config.get('SMS_TRANSPORT') ?? 'soap').toLowerCase() ===
+ 'link'
+ ? 'link'
+ : 'soap';
+
+ if (transport === 'link') {
+ const linkResult = await this.sendViaLink(
+ username,
+ password,
+ sender,
+ to,
+ text,
+ );
+ if (linkResult.ok) {
+ return linkResult;
+ }
+ this.logger.warn(
+ `SMS link transport failed to=${this.maskPhone(to)} reason=${linkResult.reason}; trying SOAP`,
+ );
+ }
+
+ const soapResult = await this.sendViaSoap(
+ username,
+ password,
+ sender,
+ to,
+ text,
+ );
+ if (soapResult.ok || transport === 'soap') {
+ return soapResult;
+ }
+
+ return soapResult;
+ }
+
+ private async sendViaSoap(
+ username: string,
+ password: string,
+ sender: string,
+ to: string,
+ text: string,
+ ): Promise {
+ const soapUrl =
+ this.config.get('SMS_SOAP_URL') ?? SMS_0098_SOAP_URL;
+ const timeoutMs = this.readPositiveInt('SMS_TIMEOUT_MS', 20000);
+
+ const body = `
+
+
+
+ ${this.escapeXml(username)}
+ ${this.escapeXml(password)}
+ ${this.escapeXml(text)}
+ ${this.escapeXml(to)}
+ ${this.escapeXml(sender)}
+
+
+`;
+
+ try {
+ const res = await fetch(soapUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'text/xml; charset=utf-8',
+ SOAPAction: 'http://tempuri.org/SendSMS',
+ },
+ body,
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+
+ const responseBody = (await res.text()).trim();
+ if (!res.ok) {
+ this.logger.warn(
+ `SMS SOAP HTTP ${res.status} to=${this.maskPhone(to)} body=${responseBody.slice(0, 300)}`,
+ );
+ return {
+ ok: false,
+ reason: `SOAP HTTP ${res.status}`,
+ transport: 'soap',
+ };
+ }
+
+ const match = responseBody.match(
+ /]*>([^<]*)<\/SendSMSResult>/i,
+ );
+ const code = match?.[1]?.trim() ?? responseBody;
+ const parsed = this.parse0098Response(code);
+ if (!parsed.ok) {
+ this.logger.warn(
+ `SMS SOAP rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason}`,
+ );
+ }
+ return { ...parsed, transport: 'soap' };
+ } catch (e) {
+ this.logger.error(
+ `SMS SOAP request failed to=${this.maskPhone(to)}`,
+ e instanceof Error ? e.stack : e,
+ );
+ return { ok: false, reason: 'SOAP request failed', transport: 'soap' };
+ }
+ }
+
+ private async sendViaLink(
+ username: string,
+ password: string,
+ sender: string,
+ to: string,
+ text: string,
+ ): Promise {
+ const domain = this.config.get('SMS_DOMAIN') ?? '0098';
+ const sendUrl = this.config.get('SMS_SEND_URL') ?? SMS_0098_LINK_URL;
+ const timeoutMs = this.readPositiveInt('SMS_TIMEOUT_MS', 20000);
- // 0098 link API uses mixed-case param names (see 0098sms/WebAPI samples).
const params = new URLSearchParams({
FROM: sender,
To: to,
@@ -94,26 +222,28 @@ export class SmsService {
if (!res.ok) {
this.logger.warn(
- `SMS HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`,
+ `SMS link HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`,
);
- return false;
+ return {
+ ok: false,
+ reason: `link HTTP ${res.status}`,
+ transport: 'link',
+ };
}
const parsed = this.parse0098Response(body);
if (!parsed.ok) {
this.logger.warn(
- `SMS provider rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason} body=${body.slice(0, 300)}`,
+ `SMS link rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason} body=${body.slice(0, 300)}`,
);
- return false;
}
-
- return true;
+ return { ...parsed, transport: 'link' };
} catch (e) {
this.logger.error(
- `SMS request failed to=${this.maskPhone(to)}`,
+ `SMS link request failed to=${this.maskPhone(to)}`,
e instanceof Error ? e.stack : e,
);
- return false;
+ return { ok: false, reason: 'link request failed', transport: 'link' };
}
}
@@ -128,7 +258,6 @@ export class SmsService {
const trimmed = body.trim();
- // 0098 often returns a numeric code; 0 = success.
if (/^\d+$/.test(trimmed)) {
if (trimmed === '0') {
return { ok: true, code: '0', reason: 'successful' };
@@ -153,6 +282,15 @@ export class SmsService {
return { ok: true, reason: 'accepted' };
}
+ private escapeXml(value: string): string {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
private readPositiveInt(key: string, fallback: number): number {
const raw = this.config.get(key);
const n = raw ? Number.parseInt(raw, 10) : NaN;