Write a transient state as an intermediate delivery notification

This commit is contained in:
2026-09-04 08:38:29 +02:00
parent 2936c7060d
commit 54493c12b2
6 changed files with 35 additions and 13 deletions
+10
View File
@@ -302,6 +302,16 @@ Grouped by what each one constrains.
transient rather than as saying nothing, maintainer's call, 2026-09-03, since the codec refuses a
zero-length integer TLV and so an absent one cannot land there.
- **A transient state goes out as an intermediate delivery notification (0x20), every other state as
a delivery receipt (0x04).** Appendix B makes a receipt's `stat` the message's final status, so
0x04 over `ENROUTE` emits the two disagreeing spellings of finality the reading side above has to
reconcile, and goal 3 has our own senders write the marker 3.4 defines. `sendDlr()` takes the list
from `transientStates` in `dlr.ts`, the same one the reader uses, so the two cannot drift.
Rejected: 0x04 for every state, for the sake of a peer keyed to that marker alone — such a peer
then reads a transient report as a final one, which is goal 2's wrong answer handed to the peer
instead of to the application. A 0.4.0 client is unaffected either way: its bind declares 0x00, so
it is sent no TLVs and refuses the receipt regardless.
- **`smsIdFormat` names a notation per place, and normalisation never reaches inside a `<base>-<n>`
id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one
transform over both sides cannot make them equal. `submitResp` covers the `receipted_message_id`
+5 -1
View File
@@ -232,7 +232,8 @@ await smpp.close(); // stop listening, then drain and close every live sess
```
`sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
`ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`.
`ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`. `SCHEDULED` and `ENROUTE` go out as intermediate
delivery notifications (`esm_class` 0x20), the rest as delivery receipts (0x04).
A message whose `data_coding` says 8-bit binary arrives as Latin-1, so `Buffer.from(sms.message,
'latin1')` gives you back the original octets.
@@ -429,6 +430,9 @@ have worked around any of these, remove the workaround:
- LATIN1 (`data_coding` 0x03) was silently decoded as ASCII, corrupting the message.
- Delivery receipt dates were a month off, and the status field read `UNDELIVERABLE` where the spec
defines the 7-character `UNDELIV`.
- Every receipt went out as `esm_class` 0x04, which SMPP 3.4 defines as the report of a message's
final state. A receipt for a transient state — `sendDlr('ENROUTE')` — is now marked 0x20, the
intermediate delivery notification.
- `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters.
- The multipart reference counter was shared by every session in the process.
- `tls: true` never performed a handshake, so the connection was not actually encrypted.
+1 -1
View File
@@ -131,7 +131,7 @@ export function parseReceipt(message: string): Receipt {
type MessageType = 'intermediate' | 'other' | 'receipt' | 'unmarked';
/** SMPP 3.4 Appendix B lists every other receipt state as final. */
const transientStates: MessageState[] = ['ENROUTE', 'SCHEDULED'];
export const transientStates: MessageState[] = ['ENROUTE', 'SCHEDULED'];
/** Written by the far-end SME, not by the MC reporting on a message we submitted. */
const smeMessageTypes: number[] = [
+4 -2
View File
@@ -5,7 +5,7 @@ import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts';
import { UnansweredError } from './unanswered-error.ts';
import { consts } from './defs/constants.ts';
import { receiptCodes } from './dlr.ts';
import { receiptCodes, transientStates } from './dlr.ts';
import { smppDate } from './message.ts';
import { uuidv7 } from './uuid.ts';
@@ -192,7 +192,9 @@ async function sendDlr(
cmdName: 'deliver_sm',
params: {
destination_addr: sms.from,
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
esm_class: transientStates.includes(status)
? consts.ESM_CLASS.INTERMEDIATE_DELIVERY
: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
short_message: receiptText(sms, smsId, status),
source_addr: sms.to,
},
+14 -2
View File
@@ -26,7 +26,7 @@ import { closeAfter, closeListenerAfter } from './teardown.ts';
import { consts } from '../src/defs/constants.ts';
import { errors } from '../src/defs/errors.ts';
import { objToPdu } from '../src/pdu.ts';
import { paramText } from '../src/defs/types.ts';
import { paramNumber, paramText } from '../src/defs/types.ts';
import { server } from '../src/server.ts';
import { silentLog } from '../src/log.ts';
import { submitSms } from '../src/send-sms.ts';
@@ -179,8 +179,12 @@ describe('merged delivery reports', () => {
const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
const perSegment: boolean[] = [];
const markers: (number | undefined)[] = [];
session.on('dlr', dlr => perSegment.push(dlr.intermediate));
session.on('dlr', (dlr, pduObj) => {
perSegment.push(dlr.intermediate);
markers.push(paramNumber(pduObj.params.esm_class, 0));
});
const [sms] = await Promise.all([
incoming.then(async received => {
@@ -205,6 +209,14 @@ describe('merged delivery reports', () => {
assert.equal(report.statusMsg, 'DELIVERED');
assert.equal(report.segments.length, 3);
assert.deepEqual(perSegment, [true, true, true, false, false, false]);
const notification = consts.ESM_CLASS.INTERMEDIATE_DELIVERY;
const receipt = consts.ESM_CLASS.MC_DELIVERY_RECEIPT;
assert.deepEqual(
markers,
[notification, notification, notification, receipt, receipt, receipt],
'a transient state goes out under the marker the spec gives it',
);
});
test('reports the worst status across the segments', async t => {
+1 -7
View File
@@ -65,6 +65,7 @@ Rules the API follows:
| Every runnable README example | `test/readme.test.ts` |
| Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` |
| An intermediate delivery notification read as a report marked `intermediate`, as is a receipt reporting `ENROUTE` or `SCHEDULED`, and never counted into a merge | `test/dlr.test.ts`, `test/session.test.ts`, `test/session-extras.test.ts` |
| A transient state sent under the marker the spec gives it, off the same list the reader uses | `test/session-extras.test.ts` |
| A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` |
| Cross-checked against node-smpp both ways and over a live session | `test/interop.test.ts` |
| CI on Node 18/20/22/24, Renovate, tag-triggered publish | `.github/workflows/` |
@@ -132,13 +133,6 @@ session message is a change to every call site.
and applies it to the other is wrong. A budget type both take would close it. Raised by review,
2026-09-01.
- [ ] **Write a transient state as an intermediate delivery notification.** `sendDlr()` marks every
receipt `esm_class` 0x04, so `sendDlr('ENROUTE')` announces a non-final state with the marker
SMPP 3.4 Appendix B reserves for the final one — the two disagreeing spellings of finality the
reading side was taught to reconcile on 2026-09-03. Decided the same day: fix it, off
`transientStates` in `dlr.ts` so the writer and the reader cannot drift, in a PR of its own.
Costs a bullet in README's "Behaviour that changed on the wire".
- [ ] **`once()` is copied into four test files, and two copies never give up.**
`session-extras.test.ts` and `readme.test.ts` reject after 5000 ms; `session.test.ts` and
`tls.test.ts` wait forever, so an event that never fires still hangs the run the way an