@zeroxsolutions/sms
v0.1.1
Published
The SMS seam: a composed message, the port that sends it, its typed refusals, and the eSMS adapter behind that port. Knows nothing of templates, locales or delivery records - those stay in the product.
Readme
@zeroxsolutions/sms
The SMS seam: a composed message, the port that sends it, and its typed refusals.
What it does not own is the reason an SMS exists at all - templates, message keys, locales, delivery records, queues and retries are product decisions and stay in the product. This package takes a message someone else composed and hands back the id the provider filed it under.
It is a sibling of @zeroxsolutions/mail rather than a subpath of it. That package's concern is
named for one channel, and a consumer installing something called mail to send an SMS reads a name
that describes nothing.
Install
pnpm add @zeroxsolutions/smsThe port
A consumer implements ISmsSender for a provider this package has no adapter for, and calls send
with a composed message:
import type { ISmsSender, SendSmsResult, SmsMessage } from '@zeroxsolutions/sms';
/** Yours to write: one per provider, wrapping that provider's SDK. */
declare function sendThroughTheProviderSdk(message: SmsMessage): Promise<string>;
class ProviderSender implements ISmsSender {
async send(message: SmsMessage): Promise<SendSmsResult> {
return { messageId: await sendThroughTheProviderSdk(message) };
}
}
const { messageId } = await new ProviderSender().send({
from: 'TRIPVN',
to: '+84900000000',
text: 'Ma xac thuc cua ban la 123456.',
});from is the brandname or short code registered with the carriers, a single string where a mailbox
takes a name and an address pair. to is in E.164 and is not validated here: which numbers a product
accepts, and how it normalises one, is the product's own decision.
reference is optional: the caller's own id for the message. An adapter whose provider refuses a
repeated id sends it, so a send retried after a lost answer cannot deliver and charge twice.
eSMS
@zeroxsolutions/sms/esms sends through eSMS as customer-care SMS under a
registered brandname, inside Vietnam. It posts to eSMS's REST API over the runtime's own fetch, so it
installs nothing and runs on workerd as on Node.
import { EsmsSmsSender } from '@zeroxsolutions/sms/esms';
const sender = new EsmsSmsSender({
apiKey: env.ESMS_API_KEY,
secretKey: env.ESMS_SECRET_KEY,
sandbox: false,
callbackUrl: 'https://app.example.com/esms-callback/<a secret of yours>',
});
const { messageId } = await sender.send({
from: 'TRIPVN',
to: '+84900000000',
text: 'Ma xac thuc cua ban la 123456.',
reference: 'msg_01j9',
});send resolving means eSMS accepted the message, not that the handset received it. A number that is
not +84 followed by 9 or 10 digits is refused before any request is made.
| eSMS CodeResult | Refusal |
| ------------------------------------------- | --------------------------------------------------------------- |
| 108 | SmsRecipientInvalid |
| 99 | SmsRecipientUnreachable |
| 104, 177 | SmsSenderNotRegistered |
| 146, 201 | SmsMessageRejected |
| 103 | SmsBalanceExhausted |
| 160 | SmsRateLimited |
| not 2xx, not JSON, or no answer in time | SmsProviderUnavailable |
| 124 with a reference | none: answered as sent, with the reference as its messageId |
| any other | SmsSendFailed |
The account has to meet four conditions no code can check:
- The brandname is registered with the carriers.
- The text matches the customer-care template registered for it, or eSMS answers
146. - The account does not require an IP whitelist (
140), orendpointnames a relay with a fixed egress address: a Worker has none. sandbox: trueis set wherever a message must not reach a handset or be charged.
SmsProviderUnavailable may follow a send eSMS already accepted: a timeout (timeoutMs, 10 s by
default), or an answer lost in transit. A caller retrying the same message reuses its reference; a
"send a new code" action uses a new one. Match a delivery report to its send by reference when one
was sent. Whether eSMS records a RequestId on a refused attempt, 103 among them, is open: if it
does, a retry after topping up the account answers 124 and is reported as sent. Check this in the
sandbox before relying on 124.
Delivery reports
With callbackUrl set, eSMS calls it with GET and the message's final status in the query string,
retrying only on a timeout. parseEsmsDeliveryReport reads that query into an SmsDeliveryReport:
import { ESMS_CALLBACK_SOURCE_ADDRESSES, parseEsmsDeliveryReport } from '@zeroxsolutions/sms/esms';
const report = parseEsmsDeliveryReport(new URL(request.url).searchParams);
// { messageId, reference?, outcome: 'delivered' | 'failed' | 'pending', cost?: { amount, currency: 'VND' } }eSMS signs nothing. The route that receives the callback checks the secret in its own path and the
caller's address against ESMS_CALLBACK_SOURCE_ADDRESSES, redacts the query in its access log (it
carries the number), and answers quickly. A query it cannot read throws SmsDeliveryReportInvalid.
Refusals
A refusal is one plain Error subclass per meaning a caller acts on differently - no wire status, no
code, no envelope. A caller maps them by class.
| Class | What happened |
| -------------------------- | --------------------------------------------------------- |
| SmsRecipientInvalid | the number is not routable |
| SmsRecipientUnreachable | the carrier would not deliver to the subscriber |
| SmsSenderNotRegistered | the sender is not a brandname this account may send under |
| SmsMessageRejected | the text breaks a provider or carrier rule |
| SmsRateLimited | the provider is throttling this account |
| SmsBalanceExhausted | the account has no credit left |
| SmsProviderUnavailable | the provider failed on its own side |
| SmsSendFailed | a refusal none of the above names |
| SmsDeliveryReportInvalid | a provider's delivery report could not be read |
Every message is a constant. The provider's own text names the number it refused, so it stays in
cause: log the class, never cause and never String(error).
There is no opt-out class. The first consumer sends transactional codes, which an opt-out does not cover. A consumer that sends marketing adds one, and it arrives as a minor release rather than as a field on an existing class.
