sently 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +9 -7
- package/SECURITY.md +2 -2
- package/dist/transports/hostinger.d.ts +34 -25
- package/dist/transports/hostinger.js +2 -2
- package/dist/transports/hostinger.js.map +3 -3
- package/dist/transports/taqnyat-sms.js +2 -2
- package/dist/transports/taqnyat-sms.js.map +3 -3
- package/package.json +4 -4
- package/site/content/docs/get-started/installation.mdx +10 -1
- package/site/content/docs/get-started/stability.mdx +1 -1
- package/site/content/docs/guides/security.mdx +1 -1
- package/site/content/docs/guides/vendor-extras-otp.mdx +2 -1
- package/site/content/docs/transports/hostinger.mdx +17 -13
- package/site/content/docs/transports/taqnyat.mdx +30 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [1.2.2] — 2026-08-19
|
|
6
|
+
|
|
7
|
+
### ♻️ Changed
|
|
8
|
+
|
|
9
|
+
- **Repository owner** — GitHub, docs, and npm Trusted Publisher coordinates
|
|
10
|
+
moved from `alialnaghmoush/sently` to `omqkhafi/sently`
|
|
11
|
+
- **JSR scope** — the published package is now `@omqkhafi/sently`;
|
|
12
|
+
`@alialnaghmoush/sently` stays on the registry but will not receive new
|
|
13
|
+
versions. README and install docs tell consumers to switch with
|
|
14
|
+
`bunx jsr add @omqkhafi/sently`
|
|
15
|
+
|
|
16
|
+
## [1.2.1] — 2026-08-04
|
|
17
|
+
|
|
18
|
+
### ✨ Added
|
|
19
|
+
|
|
20
|
+
- **Taqnyat Verify OTP live verified** — production `sendOtp` → handset code →
|
|
21
|
+
`verifyOtp` confirmed; docs OTP tab shows the green `LiveVerified` callout
|
|
22
|
+
alongside SMS, WhatsApp, and Email
|
|
23
|
+
|
|
24
|
+
### ♻️ Changed
|
|
25
|
+
|
|
26
|
+
- **Hostinger SMTP unified** — SMTP settings now come from the same name via
|
|
27
|
+
function overloading: `HostingerTransport({ user, pass })` returns a ready
|
|
28
|
+
`SMTPConfig` for `createSMTPMailer`, while `new HostingerTransport({ token,
|
|
29
|
+
mailbox })` stays the Mail API transport; IntelliSense narrows options and
|
|
30
|
+
the return type by config shape; `hostingerSmtpConfig()` remains as a 1.x
|
|
31
|
+
compatibility alias
|
|
32
|
+
|
|
33
|
+
### 🐛 Fixed
|
|
34
|
+
|
|
35
|
+
- **Taqnyat Verify OTP parsing** — live `returnJson: 1` responses put the docs
|
|
36
|
+
result code in `Data.result` and use top-level `status: 1` as a transport
|
|
37
|
+
envelope; `sendOtp` / `verifyOtp` now read `Data.result` (and
|
|
38
|
+
`Data.MessageEn` / `Data.MessageAr`) so a successful send is no longer
|
|
39
|
+
misreported as code `1` (invalid apiKey)
|
|
40
|
+
|
|
3
41
|
## [1.2.0] — 2026-08-03
|
|
4
42
|
|
|
5
43
|
### ✨ Added
|
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
<picture>
|
|
3
3
|
<source
|
|
4
4
|
media="(prefers-color-scheme: dark)"
|
|
5
|
-
srcset="https://shieldcn.dev/header/grid.svg?title=sently&subtitle=One+API.+Four+channels.+Every+runtime.&logo=https://raw.githubusercontent.com/
|
|
5
|
+
srcset="https://shieldcn.dev/header/grid.svg?title=sently&subtitle=One+API.+Four+channels.+Every+runtime.&logo=https://raw.githubusercontent.com/omqkhafi/sently/dev/site/public/sentlyIconLogo-w.svg&theme=zinc&size=banner&mode=dark&font=geist&align=left"
|
|
6
6
|
/>
|
|
7
7
|
<img
|
|
8
8
|
alt="sently — One API. Four channels. Every runtime."
|
|
9
|
-
src="https://shieldcn.dev/header/grid.svg?title=sently&subtitle=One+API.+Four+channels.+Every+runtime.&logo=https://raw.githubusercontent.com/
|
|
9
|
+
src="https://shieldcn.dev/header/grid.svg?title=sently&subtitle=One+API.+Four+channels.+Every+runtime.&logo=https://raw.githubusercontent.com/omqkhafi/sently/dev/site/public/sentlyIconLogo-k.svg&theme=zinc&size=banner&mode=light&font=geist&align=left"
|
|
10
10
|
width="750"
|
|
11
11
|
/>
|
|
12
12
|
</picture>
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
16
|
<a href="https://www.npmjs.com/package/sently"><img alt="npm" src="https://shieldcn.dev/npm/sently.svg?size=sm&variant=outline" /></a>
|
|
17
|
-
<a href="https://jsr.io/@
|
|
17
|
+
<a href="https://jsr.io/@omqkhafi/sently"><img alt="JSR" src="https://shieldcn.dev/jsr/@omqkhafi/sently.svg?size=sm&variant=outline" /></a>
|
|
18
18
|
<a href="https://opensource.org/licenses/MIT"><img alt="MIT" src="https://shieldcn.dev/npm/license/sently.svg?size=sm&variant=outline" /></a>
|
|
19
19
|
<a href="https://bun.sh"><img alt="Bun" src="https://shieldcn.dev/badge/Bun-ready.svg?logo=bun&size=sm&variant=outline" /></a>
|
|
20
|
-
<a href="https://github.com/
|
|
21
|
-
<a href="https://github.com/
|
|
20
|
+
<a href="https://github.com/omqkhafi/sently/stargazers"><img alt="stars" src="https://shieldcn.dev/github/stars/omqkhafi/sently.svg?size=sm&variant=outline" /></a>
|
|
21
|
+
<a href="https://github.com/omqkhafi/sently/actions"><img alt="CI" src="https://shieldcn.dev/github/ci/omqkhafi/sently.svg?size=sm&variant=outline" /></a>
|
|
22
22
|
</p>
|
|
23
23
|
|
|
24
24
|
<p align="center">
|
|
@@ -52,9 +52,11 @@
|
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
54
|
bun add sently # npm / Bun / yarn / pnpm
|
|
55
|
-
bunx jsr add @
|
|
55
|
+
bunx jsr add @omqkhafi/sently # JSR
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
JSR consumers on `@alialnaghmoush/sently`: switch to `@omqkhafi/sently`. The old scope will not receive new versions. npm `sently` is unchanged.
|
|
59
|
+
|
|
58
60
|
Optional peers (React Email only): `react`, `@react-email/render`.
|
|
59
61
|
|
|
60
62
|
## Quick start
|
|
@@ -137,4 +139,4 @@ More detail: [Compare](https://sently.omqkhafi.dev/docs/guides/compare) · [Stab
|
|
|
137
139
|
|
|
138
140
|
Local docs: `bun run site:dev`. Verify: `bun run verify`.
|
|
139
141
|
|
|
140
|
-
Semver-stable channel contracts. Published on [npm](https://www.npmjs.com/package/sently) (provenance + Trusted Publishing) and [JSR](https://jsr.io/@
|
|
142
|
+
Semver-stable channel contracts. Published on [npm](https://www.npmjs.com/package/sently) (provenance + Trusted Publishing) and [JSR](https://jsr.io/@omqkhafi/sently). MIT.
|
package/SECURITY.md
CHANGED
|
@@ -6,7 +6,7 @@ sently is a messaging library that holds provider API keys and tokens in the ins
|
|
|
6
6
|
|
|
7
7
|
**Do not open a public GitHub issue for security reports.**
|
|
8
8
|
|
|
9
|
-
Use [GitHub Security Advisories](https://github.com/
|
|
9
|
+
Use [GitHub Security Advisories](https://github.com/omqkhafi/sently/security/advisories/new) for this repository so the report stays private until a fix is ready.
|
|
10
10
|
|
|
11
11
|
Include:
|
|
12
12
|
|
|
@@ -45,7 +45,7 @@ npm publishes from CI (`.github/workflows/publish.yml`) use:
|
|
|
45
45
|
On [npmjs.com/package/sently](https://www.npmjs.com/package/sently) → **Settings** → **Trusted Publisher**:
|
|
46
46
|
|
|
47
47
|
1. Bind **GitHub Actions**
|
|
48
|
-
2. Repository: `
|
|
48
|
+
2. Repository: `omqkhafi/sently`
|
|
49
49
|
3. Workflow: `publish.yml` (`.github/workflows/publish.yml`)
|
|
50
50
|
4. Environment: `production` (must match the workflow `environment`)
|
|
51
51
|
5. Confirm the npm package repository URL matches `package.json` → `repository`
|
|
@@ -2,7 +2,7 @@ import { SentlyError } from "../core/errors.js";
|
|
|
2
2
|
import type { MailOptions, SendResult, SMTPConfig, Transport, VerifyResult } from "../core/types.js";
|
|
3
3
|
/** Hostinger SMTP hostname. */
|
|
4
4
|
export declare const HOSTINGER_SMTP_HOST = "smtp.hostinger.com";
|
|
5
|
-
/** SSL/TLS-on-connect submission port (default for {@link
|
|
5
|
+
/** SSL/TLS-on-connect submission port (default for {@link HostingerTransport} SMTP). */
|
|
6
6
|
export declare const HOSTINGER_SMTP_PORT_SSL = 465;
|
|
7
7
|
/** STARTTLS submission port. */
|
|
8
8
|
export declare const HOSTINGER_SMTP_PORT_STARTTLS = 587;
|
|
@@ -19,7 +19,7 @@ export interface HostingerConfig {
|
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Ready SMTP options for Hostinger Email.
|
|
22
|
-
* Pass
|
|
22
|
+
* Pass `HostingerTransport({ user, pass })` to `createSMTPMailer`.
|
|
23
23
|
*/
|
|
24
24
|
export interface HostingerSmtpOptions {
|
|
25
25
|
/** Full mailbox address — this is the SMTP username. */
|
|
@@ -58,21 +58,8 @@ export interface HostingerMessageRef {
|
|
|
58
58
|
* Build a ready {@link SMTPConfig} for Hostinger Email.
|
|
59
59
|
*
|
|
60
60
|
* Defaults to port `465` with `secure: true`. Hostinger supports `465` and
|
|
61
|
-
* `587` only — not `2525`.
|
|
62
|
-
*
|
|
63
|
-
* @example
|
|
64
|
-
* ```ts
|
|
65
|
-
* import { createSMTPMailer } from "sently/smtp";
|
|
66
|
-
* import { hostingerSmtpConfig } from "sently/transports/hostinger";
|
|
67
|
-
*
|
|
68
|
-
* const mailer = await createSMTPMailer(
|
|
69
|
-
* hostingerSmtpConfig({
|
|
70
|
-
* user: "you@yourdomain.com",
|
|
71
|
-
* pass: process.env.HOSTINGER_SMTP_PASSWORD!,
|
|
72
|
-
* // port: 587, // STARTTLS instead of SSL
|
|
73
|
-
* }),
|
|
74
|
-
* );
|
|
75
|
-
* ```
|
|
61
|
+
* `587` only — not `2525`. Prefer `HostingerTransport({ user, pass })`; this
|
|
62
|
+
* alias stays for 1.x compatibility.
|
|
76
63
|
*/
|
|
77
64
|
export declare function hostingerSmtpConfig(options: HostingerSmtpOptions): SMTPConfig;
|
|
78
65
|
/** Error thrown when the Hostinger Mail API returns a non-success response. */
|
|
@@ -83,15 +70,10 @@ export declare class HostingerError extends SentlyError {
|
|
|
83
70
|
constructor(message: string, statusCode: number, apiError: unknown);
|
|
84
71
|
}
|
|
85
72
|
/**
|
|
86
|
-
* Hostinger Mail API transport.
|
|
87
|
-
*
|
|
88
|
-
* Sends through `POST /api/v1/mailboxes/{mailbox}/send`. There is no batch
|
|
89
|
-
* endpoint — {@link Mailer.sendBulk} falls back to individual sends.
|
|
90
|
-
*
|
|
91
|
-
* For SMTP relay, use {@link hostingerSmtpConfig} with `createSMTPMailer`
|
|
92
|
-
* instead of this class.
|
|
73
|
+
* Hostinger Mail API transport instance type (`new` / call with API config).
|
|
74
|
+
* Constructed by the {@link HostingerTransport} overload — not exported directly.
|
|
93
75
|
*/
|
|
94
|
-
|
|
76
|
+
declare class HostingerTransportImpl implements Transport {
|
|
95
77
|
readonly provider = "hostinger";
|
|
96
78
|
/** Hostinger Mail API token for Bearer authentication. */
|
|
97
79
|
private readonly token;
|
|
@@ -126,3 +108,30 @@ export declare class HostingerTransport implements Transport {
|
|
|
126
108
|
/** Verifies the API token and that the configured mailbox is in its scope. */
|
|
127
109
|
verify(): Promise<VerifyResult>;
|
|
128
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Hostinger Mail API transport instance (returned by {@link HostingerTransport}
|
|
113
|
+
* overloads for `{ token, mailbox }` config).
|
|
114
|
+
*/
|
|
115
|
+
export type HostingerMailTransport = HostingerTransportImpl;
|
|
116
|
+
/**
|
|
117
|
+
* Function-overloaded constructor for `HostingerTransport`:
|
|
118
|
+
* - `new` / call with {@link HostingerConfig} → {@link HostingerMailTransport}
|
|
119
|
+
* - call with {@link HostingerSmtpOptions} → {@link SMTPConfig}
|
|
120
|
+
*/
|
|
121
|
+
export interface HostingerTransportOverloads {
|
|
122
|
+
new (config: HostingerConfig): HostingerMailTransport;
|
|
123
|
+
(config: HostingerConfig): HostingerMailTransport;
|
|
124
|
+
(config: HostingerSmtpOptions): SMTPConfig;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Hostinger email — one name, two shapes (function overloading).
|
|
128
|
+
*
|
|
129
|
+
* - **Mail API** — `new HostingerTransport({ token, mailbox })` (or the same
|
|
130
|
+
* call without `new`) → a `Transport` for `createMailer`.
|
|
131
|
+
* - **SMTP** — `HostingerTransport({ user, pass })` → a ready `SMTPConfig`
|
|
132
|
+
* for `createSMTPMailer`.
|
|
133
|
+
*
|
|
134
|
+
* IntelliSense narrows options and the return type by the config shape.
|
|
135
|
+
*/
|
|
136
|
+
export declare const HostingerTransport: HostingerTransportOverloads;
|
|
137
|
+
export {};
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{K as
|
|
1
|
+
import{K as $}from"../chunk-1ke3nmyg.js";import{S as V,U as J}from"../chunk-8kpgbrba.js";import{da as X}from"../chunk-hnzmn4s4.js";import{ja as Y,ka as Z}from"../chunk-ttbwyxmh.js";import"../chunk-th4cwrpb.js";var U="smtp.hostinger.com",W=465,A=587,B="https://api.mail.hostinger.com";function v(q){let z=q.port??W;return{host:U,port:z,secure:z===W,auth:{user:q.user,pass:q.pass},...q.pool!==void 0?{pool:q.pool}:{},...q.maxConnections!==void 0?{maxConnections:q.maxConnections}:{}}}class Q extends Y{statusCode;apiError;constructor(q,z,F){super(q,Z(z),{statusCode:z,provider:"hostinger",cause:F});this.statusCode=z;this.apiError=F;this.name="HostingerError"}}class k{provider="hostinger";token;mailbox;baseUrl;constructor(q){this.token=q.token,this.mailbox=q.mailbox,this.baseUrl=q.baseUrl??B}async listMailboxes(){let q=await fetch(`${this.baseUrl}/api/v1/me`,{headers:{Authorization:`Bearer ${this.token}`}}),z=await q.json().catch(()=>({}));if(!q.ok)throw new Q(z.error??`Hostinger API error (HTTP ${q.status})`,q.status,z);return z.data?.mailboxes??[]}async buildEmailBody(q,z){if(z?.inReplyTo&&z.forwardOf)throw new Q("inReplyTo and forwardOf are mutually exclusive",422,{code:"MUTUALLY_EXCLUSIVE",params:{inReplyTo:["conflicts with forwardOf"]}});let F=await $(q.attachments),K=V(q.from)[0];return{to:J(q.to),...K?.name?{displayName:K.name}:{},...q.cc?{cc:J(q.cc)}:{},...q.bcc?{bcc:J(q.bcc)}:{},subject:q.subject,...q.text?{text:q.text}:{},...q.html?{html:q.html}:{},...F.length>0?{attachments:F.map((D)=>({filename:D.filename,content:D.content instanceof Uint8Array||typeof D.content==="string"?X(D.content).replace(/\r\n/g,""):"",...D.contentType?{contentType:D.contentType}:{},...D.contentId?{cid:D.contentId.replace(/^<|>$/g,"")}:{}}))}:{},...z?.inReplyTo?{inReplyTo:z.inReplyTo}:{},...z?.forwardOf?{forwardOf:z.forwardOf}:{}}}toSendResult(q){let z=V(q.from)[0];return{messageId:q.messageId??"",accepted:J(q.to),rejected:[],response:"Message sent and saved to the Sent folder",envelope:{from:z?.address??"",to:[...J(q.to),...q.cc?J(q.cc):[],...q.bcc?J(q.bcc):[]]}}}async postSend(q,z){let F=await this.buildEmailBody(q,z),K=await fetch(`${this.baseUrl}/api/v1/mailboxes/${encodeURIComponent(this.mailbox)}/send`,{method:"POST",headers:{Authorization:`Bearer ${this.token}`,"Content-Type":"application/json"},body:JSON.stringify(F)});if(K.ok)return this.toSendResult(q);let D=await K.json().catch(()=>({}));throw new Q(D.error??`Hostinger API error (HTTP ${K.status})`,K.status,D)}async send(q){return this.postSend(q)}async sendReply(q,z){return this.postSend(q,{inReplyTo:z})}async sendForward(q,z){return this.postSend(q,{forwardOf:z})}async verify(){try{let q=await this.listMailboxes(),z=q.find((F)=>F.resourceId===this.mailbox);if(!z)return{ok:!1,provider:"hostinger",message:`Mailbox "${this.mailbox}" is not in this token's scope`,raw:q};return{ok:!0,provider:"hostinger",message:`API token is valid — sending as ${z.address}`,raw:q}}catch(q){if(q instanceof Q)return{ok:!1,provider:"hostinger",message:q.message};return{ok:!1,provider:"hostinger",message:q instanceof Error?q.message:String(q)}}}}var w=function(q){if("token"in q)return new k(q);return v(q)};export{v as hostingerSmtpConfig,w as HostingerTransport,Q as HostingerError,A as HOSTINGER_SMTP_PORT_STARTTLS,W as HOSTINGER_SMTP_PORT_SSL,U as HOSTINGER_SMTP_HOST,B as HOSTINGER_API_BASE_URL};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=31B59878CFD615A464756E2164756E21
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/transports/hostinger.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * @module\n * Hostinger email — Mail API transport (`api.mail.hostinger.com`) plus ready\n * SMTP config for `createSMTPMailer` (`smtp.hostinger.com`, ports 465 / 587).\n *\n * The Mail API sends from the managed mailbox itself, so `MailOptions.from`\n * only contributes the sender display name. Vendor extras (`listMailboxes`,\n * `sendReply`, `sendForward`) stay on this class — never on the channel sender.\n *\n * @example Mail API\n * ```ts\n * import { HostingerTransport } from \"sently/transports/hostinger\";\n * import { createMailer } from \"sently/mailer\";\n *\n * const hostinger = new HostingerTransport({\n * token: process.env.HOSTINGER_API_TOKEN!,\n * mailbox: process.env.HOSTINGER_MAILBOX_ID!, // e.g. \"AC1a2b3c4d5e6f7g\"\n * });\n * const mailer = await createMailer({ transport: hostinger });\n *\n * await mailer.send({\n * from: \"you@yourdomain.com\",\n * to: \"recipient@example.com\",\n * subject: \"Hello\",\n * html: \"<p>Sent via Hostinger</p>\",\n * });\n * ```\n *\n * @example SMTP\n * ```ts\n * import { createSMTPMailer } from \"sently/smtp\";\n * import { hostingerSmtpConfig } from \"sently/transports/hostinger\";\n *\n * const mailer = await createSMTPMailer(\n * hostingerSmtpConfig({\n * user: \"you@yourdomain.com\",\n * pass: process.env.HOSTINGER_SMTP_PASSWORD!,\n * }),\n * );\n * ```\n */\nimport { extractEmails, parseAddresses } from \"../core/address.js\";\nimport { encodeBase64 } from \"../core/base64.js\";\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport type {\n MailOptions,\n SendResult,\n SMTPConfig,\n Transport,\n VerifyResult,\n} from \"../core/types.js\";\nimport { resolveAttachments } from \"./resolve-attachments.js\";\n\n/** Hostinger SMTP hostname. */\nexport const HOSTINGER_SMTP_HOST = \"smtp.hostinger.com\";\n\n/** SSL/TLS-on-connect submission port (default for {@link hostingerSmtpConfig}). */\nexport const HOSTINGER_SMTP_PORT_SSL = 465;\n\n/** STARTTLS submission port. */\nexport const HOSTINGER_SMTP_PORT_STARTTLS = 587;\n\n/** Default Hostinger Mail API base URL. */\nexport const HOSTINGER_API_BASE_URL = \"https://api.mail.hostinger.com\";\n\n/** Hostinger Mail API configuration. */\nexport interface HostingerConfig {\n /** API token from hPanel → Emails → Agentic Mail → API access (shown once at creation). */\n token: string;\n /** Resource ID of the managed mailbox to send from (e.g. `\"AC1a2b3c4d5e6f7g\"`). */\n mailbox: string;\n /** API base URL. Default: {@link HOSTINGER_API_BASE_URL}. */\n baseUrl?: string;\n}\n\n/**\n * Ready SMTP options for Hostinger Email.\n * Pass the result of {@link hostingerSmtpConfig} to `createSMTPMailer`.\n */\nexport interface HostingerSmtpOptions {\n /** Full mailbox address — this is the SMTP username. */\n user: string;\n /** Mailbox password from hPanel → Emails → Configuration settings. */\n pass: string;\n /**\n * Submission port.\n * - `465` — SSL/TLS on connect (default)\n * - `587` — STARTTLS\n */\n port?: 465 | 587;\n /** Enable the SMTP connection pool. Default: `false`. */\n pool?: boolean;\n /** Max simultaneous SMTP connections when `pool` is true. Default: `5`. */\n maxConnections?: number;\n}\n\n/** A mailbox the API token can manage, as returned by {@link HostingerTransport.listMailboxes}. */\nexport interface HostingerMailbox {\n /** Mailbox resource ID — pass it as {@link HostingerConfig.mailbox}. */\n resourceId: string;\n /** Email address of the mailbox. */\n address: string;\n}\n\n/**\n * Reference to a source message by IMAP UID within a folder.\n * Used by {@link HostingerTransport.sendReply} and {@link HostingerTransport.sendForward}.\n */\nexport interface HostingerMessageRef {\n /** Folder containing the source message (e.g. `\"INBOX\"`). */\n folder: string;\n /** IMAP UID of the source message. */\n uid: number;\n}\n\n/** Error envelope returned by the Hostinger Mail API on non-success responses. */\ninterface HostingerErrorEnvelope {\n error?: string;\n code?: string;\n params?: Record<string, unknown>;\n}\n\n/** Optional reply / forward threading fields for the Mail API send body. */\ninterface HostingerSendExtras {\n inReplyTo?: HostingerMessageRef;\n forwardOf?: HostingerMessageRef;\n}\n\n/**\n * Build a ready {@link SMTPConfig} for Hostinger Email.\n *\n * Defaults to port `465` with `secure: true`. Hostinger supports `465` and\n * `587` only — not `2525`.\n *\n * @example\n * ```ts\n * import { createSMTPMailer } from \"sently/smtp\";\n * import { hostingerSmtpConfig } from \"sently/transports/hostinger\";\n *\n * const mailer = await createSMTPMailer(\n * hostingerSmtpConfig({\n * user: \"you@yourdomain.com\",\n * pass: process.env.HOSTINGER_SMTP_PASSWORD!,\n * // port: 587, // STARTTLS instead of SSL\n * }),\n * );\n * ```\n */\nexport function hostingerSmtpConfig(options: HostingerSmtpOptions): SMTPConfig {\n const port = options.port ?? HOSTINGER_SMTP_PORT_SSL;\n return {\n host: HOSTINGER_SMTP_HOST,\n port,\n secure: port === HOSTINGER_SMTP_PORT_SSL,\n auth: { user: options.user, pass: options.pass },\n ...(options.pool !== undefined ? { pool: options.pool } : {}),\n ...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),\n };\n}\n\n/** Error thrown when the Hostinger Mail API returns a non-success response. */\nexport class HostingerError extends SentlyError {\n /** Creates a Hostinger Mail API error with status code and response payload. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(message, httpStatusToSentlyCode(statusCode), {\n statusCode,\n provider: \"hostinger\",\n cause: apiError,\n });\n this.name = \"HostingerError\";\n }\n}\n\n/**\n * Hostinger Mail API transport.\n *\n * Sends through `POST /api/v1/mailboxes/{mailbox}/send`. There is no batch\n * endpoint — {@link Mailer.sendBulk} falls back to individual sends.\n *\n * For SMTP relay, use {@link hostingerSmtpConfig} with `createSMTPMailer`\n * instead of this class.\n */\nexport class HostingerTransport implements Transport {\n readonly provider = \"hostinger\";\n\n /** Hostinger Mail API token for Bearer authentication. */\n private readonly token: string;\n /** Resource ID of the managed mailbox to send from. */\n private readonly mailbox: string;\n /** Hostinger Mail API base URL. */\n private readonly baseUrl: string;\n\n /** Creates a Hostinger transport with the given API token and mailbox. */\n constructor(config: HostingerConfig) {\n this.token = config.token;\n this.mailbox = config.mailbox;\n this.baseUrl = config.baseUrl ?? HOSTINGER_API_BASE_URL;\n }\n\n /** List the mailboxes this token can manage — use it to discover your mailbox resource ID. */\n async listMailboxes(): Promise<HostingerMailbox[]> {\n const response = await fetch(`${this.baseUrl}/api/v1/me`, {\n headers: { Authorization: `Bearer ${this.token}` },\n });\n\n const payload = (await response.json().catch(() => ({}))) as {\n data?: { mailboxes?: HostingerMailbox[] };\n } & HostingerErrorEnvelope;\n\n if (!response.ok) {\n throw new HostingerError(\n payload.error ?? `Hostinger API error (HTTP ${response.status})`,\n response.status,\n payload,\n );\n }\n\n return payload.data?.mailboxes ?? [];\n }\n\n /** Build the JSON body for a single Hostinger email. */\n private async buildEmailBody(\n options: MailOptions,\n extras?: HostingerSendExtras,\n ): Promise<Record<string, unknown>> {\n if (extras?.inReplyTo && extras.forwardOf) {\n throw new HostingerError(\"inReplyTo and forwardOf are mutually exclusive\", 422, {\n code: \"MUTUALLY_EXCLUSIVE\",\n params: { inReplyTo: [\"conflicts with forwardOf\"] },\n });\n }\n\n const attachments = await resolveAttachments(options.attachments);\n const from = parseAddresses(options.from)[0];\n return {\n to: extractEmails(options.to),\n ...(from?.name ? { displayName: from.name } : {}),\n ...(options.cc ? { cc: extractEmails(options.cc) } : {}),\n ...(options.bcc ? { bcc: extractEmails(options.bcc) } : {}),\n subject: options.subject,\n ...(options.text ? { text: options.text } : {}),\n ...(options.html ? { html: options.html } : {}),\n ...(attachments.length > 0\n ? {\n attachments: attachments.map((att) => ({\n filename: att.filename,\n content:\n att.content instanceof Uint8Array || typeof att.content === \"string\"\n ? encodeBase64(att.content).replace(/\\r\\n/g, \"\")\n : \"\",\n ...(att.contentType ? { contentType: att.contentType } : {}),\n ...(att.contentId ? { cid: att.contentId.replace(/^<|>$/g, \"\") } : {}),\n })),\n }\n : {}),\n ...(extras?.inReplyTo ? { inReplyTo: extras.inReplyTo } : {}),\n ...(extras?.forwardOf ? { forwardOf: extras.forwardOf } : {}),\n };\n }\n\n /** Map a 204 No Content success to a normalized SendResult. */\n private toSendResult(options: MailOptions): SendResult {\n const from = parseAddresses(options.from)[0];\n return {\n messageId: options.messageId ?? \"\",\n accepted: extractEmails(options.to),\n rejected: [],\n response: \"Message sent and saved to the Sent folder\",\n envelope: {\n from: from?.address ?? \"\",\n to: [\n ...extractEmails(options.to),\n ...(options.cc ? extractEmails(options.cc) : []),\n ...(options.bcc ? extractEmails(options.bcc) : []),\n ],\n },\n };\n }\n\n /** POST the send body and map the response. */\n private async postSend(options: MailOptions, extras?: HostingerSendExtras): Promise<SendResult> {\n const body = await this.buildEmailBody(options, extras);\n\n const response = await fetch(\n `${this.baseUrl}/api/v1/mailboxes/${encodeURIComponent(this.mailbox)}/send`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n },\n );\n\n if (response.ok) {\n return this.toSendResult(options);\n }\n\n const payload = (await response.json().catch(() => ({}))) as HostingerErrorEnvelope;\n throw new HostingerError(\n payload.error ?? `Hostinger API error (HTTP ${response.status})`,\n response.status,\n payload,\n );\n }\n\n /** Sends an email via the Hostinger Mail API. */\n async send(options: MailOptions): Promise<SendResult> {\n return this.postSend(options);\n }\n\n /**\n * Reply to a mailbox message.\n * Copies Message-Id / References into In-Reply-To / References and flags the\n * source `\\Answered`. Mutually exclusive with {@link sendForward}.\n */\n async sendReply(options: MailOptions, inReplyTo: HostingerMessageRef): Promise<SendResult> {\n return this.postSend(options, { inReplyTo });\n }\n\n /**\n * Forward a mailbox message.\n * Copies Message-Id / References into In-Reply-To / References and flags the\n * source `$forwarded`. Mutually exclusive with {@link sendReply}.\n */\n async sendForward(options: MailOptions, forwardOf: HostingerMessageRef): Promise<SendResult> {\n return this.postSend(options, { forwardOf });\n }\n\n /** Verifies the API token and that the configured mailbox is in its scope. */\n async verify(): Promise<VerifyResult> {\n try {\n const mailboxes = await this.listMailboxes();\n const configured = mailboxes.find((mailbox) => mailbox.resourceId === this.mailbox);\n\n if (!configured) {\n return {\n ok: false,\n provider: \"hostinger\",\n message: `Mailbox \"${this.mailbox}\" is not in this token's scope`,\n raw: mailboxes,\n };\n }\n\n return {\n ok: true,\n provider: \"hostinger\",\n message: `API token is valid — sending as ${configured.address}`,\n raw: mailboxes,\n };\n } catch (err) {\n if (err instanceof HostingerError) {\n return { ok: false, provider: \"hostinger\", message: err.message };\n }\n return {\n ok: false,\n provider: \"hostinger\",\n message: err instanceof Error ? err.message : String(err),\n };\n }\n }\n}\n"
|
|
5
|
+
"/**\n * @module\n * Hostinger email — Mail API transport (`api.mail.hostinger.com`) plus ready\n * SMTP config for `createSMTPMailer` (`smtp.hostinger.com`, ports 465 / 587).\n *\n * The Mail API sends from the managed mailbox itself, so `MailOptions.from`\n * only contributes the sender display name. Vendor extras (`listMailboxes`,\n * `sendReply`, `sendForward`) stay on this class — never on the channel sender.\n *\n * @example Mail API\n * ```ts\n * import { HostingerTransport } from \"sently/transports/hostinger\";\n * import { createMailer } from \"sently/mailer\";\n *\n * const hostinger = new HostingerTransport({\n * token: process.env.HOSTINGER_API_TOKEN!,\n * mailbox: process.env.HOSTINGER_MAILBOX_ID!, // e.g. \"AC1a2b3c4d5e6f7g\"\n * });\n * const mailer = await createMailer({ transport: hostinger });\n *\n * await mailer.send({\n * from: \"you@yourdomain.com\",\n * to: \"recipient@example.com\",\n * subject: \"Hello\",\n * html: \"<p>Sent via Hostinger</p>\",\n * });\n * ```\n *\n * @example SMTP — same name, SMTP-shaped config (no `new`)\n * ```ts\n * import { createSMTPMailer } from \"sently/smtp\";\n * import { HostingerTransport } from \"sently/transports/hostinger\";\n *\n * const mailer = await createSMTPMailer(\n * HostingerTransport({\n * user: \"you@yourdomain.com\",\n * pass: process.env.HOSTINGER_SMTP_PASSWORD!,\n * }),\n * );\n * ```\n */\nimport { extractEmails, parseAddresses } from \"../core/address.js\";\nimport { encodeBase64 } from \"../core/base64.js\";\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport type {\n MailOptions,\n SendResult,\n SMTPConfig,\n Transport,\n VerifyResult,\n} from \"../core/types.js\";\nimport { resolveAttachments } from \"./resolve-attachments.js\";\n\n/** Hostinger SMTP hostname. */\nexport const HOSTINGER_SMTP_HOST = \"smtp.hostinger.com\";\n\n/** SSL/TLS-on-connect submission port (default for {@link HostingerTransport} SMTP). */\nexport const HOSTINGER_SMTP_PORT_SSL = 465;\n\n/** STARTTLS submission port. */\nexport const HOSTINGER_SMTP_PORT_STARTTLS = 587;\n\n/** Default Hostinger Mail API base URL. */\nexport const HOSTINGER_API_BASE_URL = \"https://api.mail.hostinger.com\";\n\n/** Hostinger Mail API configuration. */\nexport interface HostingerConfig {\n /** API token from hPanel → Emails → Agentic Mail → API access (shown once at creation). */\n token: string;\n /** Resource ID of the managed mailbox to send from (e.g. `\"AC1a2b3c4d5e6f7g\"`). */\n mailbox: string;\n /** API base URL. Default: {@link HOSTINGER_API_BASE_URL}. */\n baseUrl?: string;\n}\n\n/**\n * Ready SMTP options for Hostinger Email.\n * Pass `HostingerTransport({ user, pass })` to `createSMTPMailer`.\n */\nexport interface HostingerSmtpOptions {\n /** Full mailbox address — this is the SMTP username. */\n user: string;\n /** Mailbox password from hPanel → Emails → Configuration settings. */\n pass: string;\n /**\n * Submission port.\n * - `465` — SSL/TLS on connect (default)\n * - `587` — STARTTLS\n */\n port?: 465 | 587;\n /** Enable the SMTP connection pool. Default: `false`. */\n pool?: boolean;\n /** Max simultaneous SMTP connections when `pool` is true. Default: `5`. */\n maxConnections?: number;\n}\n\n/** A mailbox the API token can manage, as returned by {@link HostingerTransport.listMailboxes}. */\nexport interface HostingerMailbox {\n /** Mailbox resource ID — pass it as {@link HostingerConfig.mailbox}. */\n resourceId: string;\n /** Email address of the mailbox. */\n address: string;\n}\n\n/**\n * Reference to a source message by IMAP UID within a folder.\n * Used by {@link HostingerTransport.sendReply} and {@link HostingerTransport.sendForward}.\n */\nexport interface HostingerMessageRef {\n /** Folder containing the source message (e.g. `\"INBOX\"`). */\n folder: string;\n /** IMAP UID of the source message. */\n uid: number;\n}\n\n/** Error envelope returned by the Hostinger Mail API on non-success responses. */\ninterface HostingerErrorEnvelope {\n error?: string;\n code?: string;\n params?: Record<string, unknown>;\n}\n\n/** Optional reply / forward threading fields for the Mail API send body. */\ninterface HostingerSendExtras {\n inReplyTo?: HostingerMessageRef;\n forwardOf?: HostingerMessageRef;\n}\n\n/**\n * Build a ready {@link SMTPConfig} for Hostinger Email.\n *\n * Defaults to port `465` with `secure: true`. Hostinger supports `465` and\n * `587` only — not `2525`. Prefer `HostingerTransport({ user, pass })`; this\n * alias stays for 1.x compatibility.\n */\nexport function hostingerSmtpConfig(options: HostingerSmtpOptions): SMTPConfig {\n const port = options.port ?? HOSTINGER_SMTP_PORT_SSL;\n return {\n host: HOSTINGER_SMTP_HOST,\n port,\n secure: port === HOSTINGER_SMTP_PORT_SSL,\n auth: { user: options.user, pass: options.pass },\n ...(options.pool !== undefined ? { pool: options.pool } : {}),\n ...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),\n };\n}\n\n/** Error thrown when the Hostinger Mail API returns a non-success response. */\nexport class HostingerError extends SentlyError {\n /** Creates a Hostinger Mail API error with status code and response payload. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(message, httpStatusToSentlyCode(statusCode), {\n statusCode,\n provider: \"hostinger\",\n cause: apiError,\n });\n this.name = \"HostingerError\";\n }\n}\n\n/**\n * Hostinger Mail API transport instance type (`new` / call with API config).\n * Constructed by the {@link HostingerTransport} overload — not exported directly.\n */\nclass HostingerTransportImpl implements Transport {\n readonly provider = \"hostinger\";\n\n /** Hostinger Mail API token for Bearer authentication. */\n private readonly token: string;\n /** Resource ID of the managed mailbox to send from. */\n private readonly mailbox: string;\n /** Hostinger Mail API base URL. */\n private readonly baseUrl: string;\n\n /** Creates a Hostinger transport with the given API token and mailbox. */\n constructor(config: HostingerConfig) {\n this.token = config.token;\n this.mailbox = config.mailbox;\n this.baseUrl = config.baseUrl ?? HOSTINGER_API_BASE_URL;\n }\n\n /** List the mailboxes this token can manage — use it to discover your mailbox resource ID. */\n async listMailboxes(): Promise<HostingerMailbox[]> {\n const response = await fetch(`${this.baseUrl}/api/v1/me`, {\n headers: { Authorization: `Bearer ${this.token}` },\n });\n\n const payload = (await response.json().catch(() => ({}))) as {\n data?: { mailboxes?: HostingerMailbox[] };\n } & HostingerErrorEnvelope;\n\n if (!response.ok) {\n throw new HostingerError(\n payload.error ?? `Hostinger API error (HTTP ${response.status})`,\n response.status,\n payload,\n );\n }\n\n return payload.data?.mailboxes ?? [];\n }\n\n /** Build the JSON body for a single Hostinger email. */\n private async buildEmailBody(\n options: MailOptions,\n extras?: HostingerSendExtras,\n ): Promise<Record<string, unknown>> {\n if (extras?.inReplyTo && extras.forwardOf) {\n throw new HostingerError(\"inReplyTo and forwardOf are mutually exclusive\", 422, {\n code: \"MUTUALLY_EXCLUSIVE\",\n params: { inReplyTo: [\"conflicts with forwardOf\"] },\n });\n }\n\n const attachments = await resolveAttachments(options.attachments);\n const from = parseAddresses(options.from)[0];\n return {\n to: extractEmails(options.to),\n ...(from?.name ? { displayName: from.name } : {}),\n ...(options.cc ? { cc: extractEmails(options.cc) } : {}),\n ...(options.bcc ? { bcc: extractEmails(options.bcc) } : {}),\n subject: options.subject,\n ...(options.text ? { text: options.text } : {}),\n ...(options.html ? { html: options.html } : {}),\n ...(attachments.length > 0\n ? {\n attachments: attachments.map((att) => ({\n filename: att.filename,\n content:\n att.content instanceof Uint8Array || typeof att.content === \"string\"\n ? encodeBase64(att.content).replace(/\\r\\n/g, \"\")\n : \"\",\n ...(att.contentType ? { contentType: att.contentType } : {}),\n ...(att.contentId ? { cid: att.contentId.replace(/^<|>$/g, \"\") } : {}),\n })),\n }\n : {}),\n ...(extras?.inReplyTo ? { inReplyTo: extras.inReplyTo } : {}),\n ...(extras?.forwardOf ? { forwardOf: extras.forwardOf } : {}),\n };\n }\n\n /** Map a 204 No Content success to a normalized SendResult. */\n private toSendResult(options: MailOptions): SendResult {\n const from = parseAddresses(options.from)[0];\n return {\n messageId: options.messageId ?? \"\",\n accepted: extractEmails(options.to),\n rejected: [],\n response: \"Message sent and saved to the Sent folder\",\n envelope: {\n from: from?.address ?? \"\",\n to: [\n ...extractEmails(options.to),\n ...(options.cc ? extractEmails(options.cc) : []),\n ...(options.bcc ? extractEmails(options.bcc) : []),\n ],\n },\n };\n }\n\n /** POST the send body and map the response. */\n private async postSend(options: MailOptions, extras?: HostingerSendExtras): Promise<SendResult> {\n const body = await this.buildEmailBody(options, extras);\n\n const response = await fetch(\n `${this.baseUrl}/api/v1/mailboxes/${encodeURIComponent(this.mailbox)}/send`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n },\n );\n\n if (response.ok) {\n return this.toSendResult(options);\n }\n\n const payload = (await response.json().catch(() => ({}))) as HostingerErrorEnvelope;\n throw new HostingerError(\n payload.error ?? `Hostinger API error (HTTP ${response.status})`,\n response.status,\n payload,\n );\n }\n\n /** Sends an email via the Hostinger Mail API. */\n async send(options: MailOptions): Promise<SendResult> {\n return this.postSend(options);\n }\n\n /**\n * Reply to a mailbox message.\n * Copies Message-Id / References into In-Reply-To / References and flags the\n * source `\\Answered`. Mutually exclusive with {@link sendForward}.\n */\n async sendReply(options: MailOptions, inReplyTo: HostingerMessageRef): Promise<SendResult> {\n return this.postSend(options, { inReplyTo });\n }\n\n /**\n * Forward a mailbox message.\n * Copies Message-Id / References into In-Reply-To / References and flags the\n * source `$forwarded`. Mutually exclusive with {@link sendReply}.\n */\n async sendForward(options: MailOptions, forwardOf: HostingerMessageRef): Promise<SendResult> {\n return this.postSend(options, { forwardOf });\n }\n\n /** Verifies the API token and that the configured mailbox is in its scope. */\n async verify(): Promise<VerifyResult> {\n try {\n const mailboxes = await this.listMailboxes();\n const configured = mailboxes.find((mailbox) => mailbox.resourceId === this.mailbox);\n\n if (!configured) {\n return {\n ok: false,\n provider: \"hostinger\",\n message: `Mailbox \"${this.mailbox}\" is not in this token's scope`,\n raw: mailboxes,\n };\n }\n\n return {\n ok: true,\n provider: \"hostinger\",\n message: `API token is valid — sending as ${configured.address}`,\n raw: mailboxes,\n };\n } catch (err) {\n if (err instanceof HostingerError) {\n return { ok: false, provider: \"hostinger\", message: err.message };\n }\n return {\n ok: false,\n provider: \"hostinger\",\n message: err instanceof Error ? err.message : String(err),\n };\n }\n }\n}\n\n/**\n * Hostinger Mail API transport instance (returned by {@link HostingerTransport}\n * overloads for `{ token, mailbox }` config).\n */\nexport type HostingerMailTransport = HostingerTransportImpl;\n\n/**\n * Function-overloaded constructor for `HostingerTransport`:\n * - `new` / call with {@link HostingerConfig} → {@link HostingerMailTransport}\n * - call with {@link HostingerSmtpOptions} → {@link SMTPConfig}\n */\nexport interface HostingerTransportOverloads {\n new (config: HostingerConfig): HostingerMailTransport;\n (config: HostingerConfig): HostingerMailTransport;\n (config: HostingerSmtpOptions): SMTPConfig;\n}\n\n/**\n * Hostinger email — one name, two shapes (function overloading).\n *\n * - **Mail API** — `new HostingerTransport({ token, mailbox })` (or the same\n * call without `new`) → a `Transport` for `createMailer`.\n * - **SMTP** — `HostingerTransport({ user, pass })` → a ready `SMTPConfig`\n * for `createSMTPMailer`.\n *\n * IntelliSense narrows options and the return type by the config shape.\n */\nexport const HostingerTransport = function (\n this: HostingerTransportImpl | undefined,\n config: HostingerConfig | HostingerSmtpOptions,\n) {\n if (\"token\" in config) {\n return new HostingerTransportImpl(config);\n }\n return hostingerSmtpConfig(config);\n} as unknown as HostingerTransportOverloads;\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": "sOAsDO,DAAM,HAAsB,gBAGtB,HAA0B,IAG1B,EAA+B,IAG/B,EAAyB,
|
|
8
|
-
"debugId": "
|
|
7
|
+
"mappings": "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",
|
|
8
|
+
"debugId": "31B59878CFD615A464756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{j as Q}from"../chunk-5es0v1j7.js";import{ja as
|
|
1
|
+
import{j as Q}from"../chunk-5es0v1j7.js";import{ja as Y,ka as Z}from"../chunk-ttbwyxmh.js";import"../chunk-th4cwrpb.js";class J extends Y{statusCode;apiError;constructor(A,B,D){super(A,Z(B>=100&&B<600?B:400),{statusCode:B,provider:"taqnyat-sms",cause:D});this.statusCode=B;this.apiError=D;this.name="TaqnyatSmsError"}}function R(A){if(typeof A==="number"&&Number.isFinite(A))return A;if(typeof A==="string"&&/^-?\d+$/.test(A))return Number(A);return}function W(A){let B=A.Data;if(B&&typeof B==="object"&&!Array.isArray(B)){let G=R(B.result);if(G!==void 0)return G}let D=R(A.code??A.statusCode??A.result);if(D!==void 0)return D;if(A.ResponseStatus===void 0)return R(A.status);return}function U(A){let B=A.Data;if(B&&typeof B==="object"&&!Array.isArray(B)){let D=B;if(typeof D.MessageEn==="string")return D.MessageEn;if(typeof D.MessageAr==="string")return D.MessageAr;if(typeof D.message==="string")return D.message}if(typeof A.message==="string")return A.message;if(typeof A.MessageEn==="string")return A.MessageEn;if(typeof A.Error==="string")return A.Error;return}function X(A){if(Array.isArray(A)){let B=A[0];return B&&typeof B==="object"?B:{}}if(A&&typeof A==="object")return A;return{}}class ${provider="taqnyat-sms";bearerToken;sender;constructor(A){this.bearerToken=A.bearerToken,this.sender=A.sender}async send(A){let D={recipients:[Q(A.to)],body:A.body,sender:A.from??this.sender};if(A.messageId!==void 0)D.smsId=A.messageId;let G=await fetch("https://api.taqnyat.sa/v1/messages",{method:"POST",headers:{Authorization:`Bearer ${this.bearerToken}`,"Content-Type":"application/json"},body:JSON.stringify(D)}),F=await G.json();if(G.status!==201)throw new J(F.message??"Taqnyat SMS API error",F.statusCode??G.status,F);return{messageId:String(F.messageId??A.messageId??""),to:A.to,status:"accepted",response:`cost: ${F.cost??"?"} ${F.currency??"SAR"}`,provider:"taqnyat-sms"}}async sendOtp(A){let B=A.lang??"ar",D=Q(A.to),G=[{apiKey:this.bearerToken,numbers:[D],method:"sms",sender:A.from??this.sender,lang:B,requestId:A.requestId,returnJson:1,...A.note!==void 0?{note:A.note}:{}}],F=await fetch("https://api.taqnyat.sa/verify.php/",{method:"POST",headers:{Authorization:`Bearer ${this.bearerToken}`,"Content-Type":"application/json"},body:JSON.stringify(G)}),N=await F.text(),O;try{O=JSON.parse(N)}catch{throw new J("Taqnyat Verify returned non-JSON response",F.status,N)}let K=X(O),H=W(K);if(!F.ok||H!==5)throw new J(U(K)??`Taqnyat OTP send failed (code ${H??"unknown"})`,H??F.status,K);return{requestId:A.requestId,to:A.to,code:H,response:N,provider:"taqnyat-sms"}}async verifyOtp(A){let B=A.lang??"ar",D=Q(A.to),G=[{apiKey:this.bearerToken,numbers:[D],method:"sms",sender:A.from??this.sender,lang:B,requestId:A.requestId,activeKey:A.code,returnJson:1,...A.note!==void 0?{note:A.note}:{}}],F=await fetch("https://api.taqnyat.sa/verify.php/",{method:"POST",headers:{Authorization:`Bearer ${this.bearerToken}`,"Content-Type":"application/json"},body:JSON.stringify(G)}),N=await F.text(),O;try{O=JSON.parse(N)}catch{throw new J("Taqnyat Verify returned non-JSON response",F.status,N)}let K=X(O),H=W(K),_=new Set([10,13,19]);if(!F.ok||H===void 0||!_.has(H))throw new J(U(K)??`Taqnyat OTP verify failed (code ${H??"unknown"})`,H??F.status,K);return{ok:!0,code:H,message:U(K)??"Activation process completed successfully",response:N,provider:"taqnyat-sms"}}async getBalance(){let A=await fetch(`https://api.taqnyat.sa/account/balance?bearerTokens=${encodeURIComponent(this.bearerToken)}`,{headers:{Authorization:`Bearer ${this.bearerToken}`}}),B=await A.json();if(!A.ok||B.statusCode!==200)throw new J(B.message??"Taqnyat balance API error",B.statusCode??A.status,B);return{accountStatus:B.accountStatus??"",balance:B.balance??"0",currency:B.currency??"SAR",...B.accountExpiryDate!==void 0?{accountExpiryDate:B.accountExpiryDate}:{},provider:"taqnyat-sms"}}async listSenders(){let A=await fetch(`https://api.taqnyat.sa/v1/messages/senders?bearerTokens=${encodeURIComponent(this.bearerToken)}`,{headers:{Authorization:`Bearer ${this.bearerToken}`}}),B=await A.json();if(!A.ok||B.statusCode!==200)throw new J(B.message??"Taqnyat senders API error",B.statusCode??A.status,B);return(B.senders??[]).filter((D)=>typeof D.senderName==="string").map((D)=>({senderName:D.senderName,...D.status!==void 0?{status:D.status}:{}}))}async schedule(A){let D={recipients:[Q(A.to)],body:A.body,sender:A.from??this.sender,scheduledDatetime:A.scheduledDatetime};if(A.deleteId!==void 0)D.deleteId=A.deleteId;if(A.messageId!==void 0)D.smsId=A.messageId;let G=await fetch("https://api.taqnyat.sa/v1/messages",{method:"POST",headers:{Authorization:`Bearer ${this.bearerToken}`,"Content-Type":"application/json"},body:JSON.stringify(D)}),F=await G.json();if(G.status!==201)throw new J(F.message??"Taqnyat schedule SMS API error",F.statusCode??G.status,F);return{messageId:String(F.messageId??A.messageId??""),...A.deleteId!==void 0?{deleteId:String(A.deleteId)}:{},to:A.to,response:`cost: ${F.cost??"?"} ${F.currency??"SAR"}`,provider:"taqnyat-sms"}}async deleteScheduled(A){let B=new URL("https://api.taqnyat.sa/v1/messages/delete");B.searchParams.set("bearerTokens",this.bearerToken),B.searchParams.set("deleteId",String(A));let D=await fetch(B,{method:"DELETE",headers:{Authorization:`Bearer ${this.bearerToken}`}}),G=await D.json();if(D.status!==201&&G.statusCode!==201)throw new J(G.message??"Taqnyat delete scheduled SMS API error",G.statusCode??D.status,G);return{ok:!0,message:G.message??"Deleted successfully",provider:"taqnyat-sms"}}async verify(){return{ok:Boolean(this.bearerToken&&this.sender),provider:"taqnyat-sms",message:this.bearerToken&&this.sender?"Credentials present":"Missing credentials"}}}export{$ as TaqnyatSmsTransport,J as TaqnyatSmsError};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=4CF386F61DA773C164756E2164756E21
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/transports/taqnyat-sms.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * @module\n * Taqnyat SMS API transport (Saudi Arabia).\n *\n * Request/response shapes follow\n * [Taqnyat SMS](https://dev.taqnyat.sa/en/doc/sms/) and\n * [Verify](https://dev.taqnyat.sa/en/doc/verify/):\n * - SMS: `POST /v1/messages` with Bearer + JSON (`recipients`, `body`, `sender`)\n * - OTP: `POST /verify.php/` with JSON array (`apiKey`, `numbers`, `method`, …)\n *\n * Sently-first: wire SMS into {@link createSmsSender}; OTP helpers stay on this class.\n *\n * @example\n * ```ts\n * import { createSmsSender } from \"sently/sms\";\n * import { TaqnyatSmsTransport } from \"sently/transports/taqnyat-sms\";\n *\n * const taqnyat = new TaqnyatSmsTransport({\n * bearerToken: process.env.TAQNYAT_TOKEN!,\n * sender: \"MyBrand\",\n * });\n * const sms = createSmsSender({ transport: taqnyat });\n *\n * await sms.send({ to: \"+9665xxxxxxxx\", body: \"Hello\" });\n * await taqnyat.sendOtp({ to: \"+9665xxxxxxxx\", requestId: \"login-1\", lang: \"en\" });\n * await taqnyat.verifyOtp({\n * to: \"+9665xxxxxxxx\",\n * requestId: \"login-1\",\n * code: \"6240\",\n * lang: \"en\",\n * });\n * const balance = await taqnyat.getBalance();\n * await taqnyat.schedule({\n * to: \"+9665xxxxxxxx\",\n * body: \"Later\",\n * scheduledDatetime: \"2030-01-01T10:00\",\n * deleteId: \"demo-1\",\n * });\n * ```\n */\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport type { SmsOptions, SmsSendResult, SmsTransport } from \"../core/sms-types.js\";\nimport type { VerifyResult } from \"../core/types.js\";\nimport { normalizeTaqnyatPhone } from \"./taqnyat-phone.js\";\n\n/** Taqnyat SMS API configuration. */\nexport interface TaqnyatSmsConfig {\n /** Bearer token from the Taqnyat dashboard. */\n bearerToken: string;\n /** Pre-approved sender name. */\n sender: string;\n}\n\n/** Options for {@link TaqnyatSmsTransport.sendOtp}. */\nexport interface TaqnyatSendOtpOptions {\n /** Recipient phone number (E.164 or international digits). */\n to: string;\n /** Unique id for this verification flow (required again on {@link verifyOtp}). */\n requestId: string;\n /** Message language (`en` or `ar`). Defaults to `\"ar\"` per Taqnyat docs. */\n lang?: \"en\" | \"ar\";\n /** Optional note appended to the OTP SMS. */\n note?: string;\n /** Sender ID override; defaults to transport `sender`. */\n from?: string;\n}\n\n/** Result of a successful {@link TaqnyatSmsTransport.sendOtp} call. */\nexport interface TaqnyatOtpSendResult {\n /** Echo of {@link TaqnyatSendOtpOptions.requestId}. */\n requestId: string;\n /** Recipient as passed in. */\n to: string;\n /** Provider status code (`5` = code sent). */\n code: number;\n /** Raw response body text. */\n response: string;\n /** Provider identifier. */\n provider: \"taqnyat-sms\";\n}\n\n/** Options for {@link TaqnyatSmsTransport.verifyOtp}. */\nexport interface TaqnyatVerifyOtpOptions {\n /** Recipient phone number (same as send). */\n to: string;\n /** Same {@link TaqnyatSendOtpOptions.requestId} used when sending. */\n requestId: string;\n /** OTP code the user entered (`activeKey` in Taqnyat docs). */\n code: string;\n /** Message language (`en` or `ar`). Defaults to `\"ar\"`. */\n lang?: \"en\" | \"ar\";\n /** Sender ID override; defaults to transport `sender`. */\n from?: string;\n /** Optional note (documented on check OTP). */\n note?: string;\n}\n\n/** Result of a successful {@link TaqnyatSmsTransport.verifyOtp} call. */\nexport interface TaqnyatOtpVerifyResult {\n /** Always `true` when the call resolves (failures throw). */\n ok: true;\n /** Provider status code (`10` = completed, `13`/`19` = already verified). */\n code: number;\n /** Provider message when present. */\n message: string;\n /** Raw response body text. */\n response: string;\n /** Provider identifier. */\n provider: \"taqnyat-sms\";\n}\n\n/** Account balance from {@link TaqnyatSmsTransport.getBalance}. */\nexport interface TaqnyatBalance {\n /** Account status string from Taqnyat (e.g. `active`). */\n accountStatus: string;\n /** Balance amount as returned by the API (string). */\n balance: string;\n /** Currency code (usually `SAR`). */\n currency: string;\n /** Account expiry date when present. */\n accountExpiryDate?: string;\n /** Provider identifier. */\n provider: \"taqnyat-sms\";\n}\n\n/** One sender row from {@link TaqnyatSmsTransport.listSenders}. */\nexport interface TaqnyatSender {\n /** Sender name exactly as registered. */\n senderName: string;\n /** Status string when present (e.g. `active`). */\n status?: string;\n}\n\n/** Options for {@link TaqnyatSmsTransport.schedule}. */\nexport interface TaqnyatScheduleOptions {\n /** Recipient phone number (E.164 or international digits). */\n to: string;\n /** Message body. */\n body: string;\n /**\n * Local schedule time in Taqnyat format, e.g. `2020-09-30T14:26`.\n * Required by `POST /v1/messages` schedule docs.\n */\n scheduledDatetime: string;\n /** Sender ID override; defaults to transport `sender`. */\n from?: string;\n /**\n * Client delete key used later with {@link deleteScheduled}.\n * Optional per Taqnyat schedule docs.\n */\n deleteId?: string | number;\n /** Optional client id mapped to `smsId`. */\n messageId?: string;\n}\n\n/** Result of a successful {@link TaqnyatSmsTransport.schedule} call. */\nexport interface TaqnyatScheduleResult {\n /** Provider message id when present. */\n messageId: string;\n /** Echo of {@link TaqnyatScheduleOptions.deleteId} when set. */\n deleteId?: string;\n /** Recipient as passed in. */\n to: string;\n /** Cost summary when present. */\n response: string;\n /** Provider identifier. */\n provider: \"taqnyat-sms\";\n}\n\n/** Error thrown when the Taqnyat SMS / Verify API returns a non-success response. */\nexport class TaqnyatSmsError extends SentlyError {\n /** Creates a Taqnyat SMS API error with status code and response payload. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(\n message,\n httpStatusToSentlyCode(statusCode >= 100 && statusCode < 600 ? statusCode : 400),\n {\n statusCode,\n provider: \"taqnyat-sms\",\n cause: apiError,\n },\n );\n this.name = \"TaqnyatSmsError\";\n }\n}\n\nfunction verifyPayloadCode(payload: Record<string, unknown>): number | undefined {\n const raw = payload.code ?? payload.statusCode ?? payload.status;\n if (typeof raw === \"number\") return raw;\n if (typeof raw === \"string\" && /^-?\\d+$/.test(raw)) return Number(raw);\n return undefined;\n}\n\nfunction verifyPayloadMessage(payload: Record<string, unknown>): string | undefined {\n if (typeof payload.message === \"string\") return payload.message;\n if (typeof payload.MessageEn === \"string\") return payload.MessageEn;\n return undefined;\n}\n\nfunction unwrapVerifyPayload(payload: unknown): Record<string, unknown> {\n if (Array.isArray(payload)) {\n const first = payload[0];\n return first && typeof first === \"object\" ? (first as Record<string, unknown>) : {};\n }\n if (payload && typeof payload === \"object\") {\n return payload as Record<string, unknown>;\n }\n return {};\n}\n\n/**\n * Taqnyat SMS transport via JSON Bearer API, plus Verify OTP helpers.\n */\nexport class TaqnyatSmsTransport implements SmsTransport {\n readonly provider = \"taqnyat-sms\";\n\n private readonly bearerToken: string;\n private readonly sender: string;\n\n /** Creates a Taqnyat SMS transport. */\n constructor(config: TaqnyatSmsConfig) {\n this.bearerToken = config.bearerToken;\n this.sender = config.sender;\n }\n\n /**\n * Sends an SMS via `POST https://api.taqnyat.sa/v1/messages`\n * (Bearer + JSON: `recipients`, `body`, `sender`; optional `smsId`).\n */\n async send(options: SmsOptions): Promise<SmsSendResult> {\n const recipients = [normalizeTaqnyatPhone(options.to)];\n const body: Record<string, unknown> = {\n recipients,\n body: options.body,\n sender: options.from ?? this.sender,\n };\n // Official PHP SDK maps a client id to `smsId`.\n if (options.messageId !== undefined) {\n body.smsId = options.messageId;\n }\n\n const response = await fetch(\"https://api.taqnyat.sa/v1/messages\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n const payload = (await response.json()) as {\n statusCode?: number;\n messageId?: number;\n cost?: number;\n currency?: string;\n message?: string;\n accepted?: string;\n rejected?: string;\n };\n\n // Docs: successful send returns HTTP 201 with statusCode 201.\n if (response.status !== 201) {\n throw new TaqnyatSmsError(\n payload.message ?? \"Taqnyat SMS API error\",\n payload.statusCode ?? response.status,\n payload,\n );\n }\n\n return {\n messageId: String(payload.messageId ?? options.messageId ?? \"\"),\n to: options.to,\n status: \"accepted\",\n response: `cost: ${payload.cost ?? \"?\"} ${payload.currency ?? \"SAR\"}`,\n provider: \"taqnyat-sms\",\n };\n }\n\n /**\n * Vendor extra: generate/send OTP via `POST /verify.php/` (success code `5`).\n * Not part of {@link SmsTransport} — call on {@link TaqnyatSmsTransport} directly.\n */\n async sendOtp(options: TaqnyatSendOtpOptions): Promise<TaqnyatOtpSendResult> {\n const lang = options.lang ?? \"ar\";\n const number = normalizeTaqnyatPhone(options.to);\n const body = [\n {\n apiKey: this.bearerToken,\n numbers: [number],\n method: \"sms\",\n sender: options.from ?? this.sender,\n lang,\n requestId: options.requestId,\n returnJson: 1,\n ...(options.note !== undefined ? { note: options.note } : {}),\n },\n ];\n\n const response = await fetch(\"https://api.taqnyat.sa/verify.php/\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n const text = await response.text();\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n throw new TaqnyatSmsError(\"Taqnyat Verify returned non-JSON response\", response.status, text);\n }\n\n const result = unwrapVerifyPayload(parsed);\n const code = verifyPayloadCode(result);\n\n // Docs: 5 = Activation code sent successfully.\n if (!response.ok || code !== 5) {\n throw new TaqnyatSmsError(\n verifyPayloadMessage(result) ?? `Taqnyat OTP send failed (code ${code ?? \"unknown\"})`,\n code ?? response.status,\n result,\n );\n }\n\n return {\n requestId: options.requestId,\n to: options.to,\n code,\n response: text,\n provider: \"taqnyat-sms\",\n };\n }\n\n /**\n * Vendor extra: check OTP via `POST /verify.php/` with `activeKey`\n * (`10` = completed; `13` / `19` = already verified).\n */\n async verifyOtp(options: TaqnyatVerifyOtpOptions): Promise<TaqnyatOtpVerifyResult> {\n const lang = options.lang ?? \"ar\";\n const number = normalizeTaqnyatPhone(options.to);\n const body = [\n {\n apiKey: this.bearerToken,\n numbers: [number],\n method: \"sms\",\n sender: options.from ?? this.sender,\n lang,\n requestId: options.requestId,\n activeKey: options.code,\n returnJson: 1,\n ...(options.note !== undefined ? { note: options.note } : {}),\n },\n ];\n\n const response = await fetch(\"https://api.taqnyat.sa/verify.php/\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n const text = await response.text();\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n throw new TaqnyatSmsError(\"Taqnyat Verify returned non-JSON response\", response.status, text);\n }\n\n const result = unwrapVerifyPayload(parsed);\n const code = verifyPayloadCode(result);\n const okCodes = new Set([10, 13, 19]);\n\n if (!response.ok || code === undefined || !okCodes.has(code)) {\n throw new TaqnyatSmsError(\n verifyPayloadMessage(result) ?? `Taqnyat OTP verify failed (code ${code ?? \"unknown\"})`,\n code ?? response.status,\n result,\n );\n }\n\n return {\n ok: true,\n code,\n message: verifyPayloadMessage(result) ?? \"Activation process completed successfully\",\n response: text,\n provider: \"taqnyat-sms\",\n };\n }\n\n /**\n * Vendor extra: account balance via `GET /account/balance`.\n * Not part of {@link SmsTransport}.\n */\n async getBalance(): Promise<TaqnyatBalance> {\n const response = await fetch(\n `https://api.taqnyat.sa/account/balance?bearerTokens=${encodeURIComponent(this.bearerToken)}`,\n {\n headers: { Authorization: `Bearer ${this.bearerToken}` },\n },\n );\n const payload = (await response.json()) as {\n statusCode?: number;\n accountStatus?: string;\n balance?: string;\n currency?: string;\n accountExpiryDate?: string;\n message?: string;\n };\n\n if (!response.ok || payload.statusCode !== 200) {\n throw new TaqnyatSmsError(\n payload.message ?? \"Taqnyat balance API error\",\n payload.statusCode ?? response.status,\n payload,\n );\n }\n\n return {\n accountStatus: payload.accountStatus ?? \"\",\n balance: payload.balance ?? \"0\",\n currency: payload.currency ?? \"SAR\",\n ...(payload.accountExpiryDate !== undefined\n ? { accountExpiryDate: payload.accountExpiryDate }\n : {}),\n provider: \"taqnyat-sms\",\n };\n }\n\n /**\n * Vendor extra: list registered sender names via `GET /v1/messages/senders`.\n * Not part of {@link SmsTransport}.\n */\n async listSenders(): Promise<TaqnyatSender[]> {\n const response = await fetch(\n `https://api.taqnyat.sa/v1/messages/senders?bearerTokens=${encodeURIComponent(this.bearerToken)}`,\n {\n headers: { Authorization: `Bearer ${this.bearerToken}` },\n },\n );\n const payload = (await response.json()) as {\n statusCode?: number;\n senders?: Array<{ senderName?: string; status?: string }>;\n message?: string;\n };\n\n if (!response.ok || payload.statusCode !== 200) {\n throw new TaqnyatSmsError(\n payload.message ?? \"Taqnyat senders API error\",\n payload.statusCode ?? response.status,\n payload,\n );\n }\n\n return (payload.senders ?? [])\n .filter(\n (row): row is { senderName: string; status?: string } => typeof row.senderName === \"string\",\n )\n .map((row) => ({\n senderName: row.senderName,\n ...(row.status !== undefined ? { status: row.status } : {}),\n }));\n }\n\n /**\n * Vendor extra: schedule an SMS (`POST /v1/messages` + `scheduledDatetime`).\n * Not part of {@link SmsTransport}.\n */\n async schedule(options: TaqnyatScheduleOptions): Promise<TaqnyatScheduleResult> {\n const recipients = [normalizeTaqnyatPhone(options.to)];\n const body: Record<string, unknown> = {\n recipients,\n body: options.body,\n sender: options.from ?? this.sender,\n scheduledDatetime: options.scheduledDatetime,\n };\n if (options.deleteId !== undefined) {\n body.deleteId = options.deleteId;\n }\n if (options.messageId !== undefined) {\n body.smsId = options.messageId;\n }\n\n const response = await fetch(\"https://api.taqnyat.sa/v1/messages\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n const payload = (await response.json()) as {\n statusCode?: number;\n messageId?: number;\n cost?: number;\n currency?: string;\n message?: string;\n };\n\n if (response.status !== 201) {\n throw new TaqnyatSmsError(\n payload.message ?? \"Taqnyat schedule SMS API error\",\n payload.statusCode ?? response.status,\n payload,\n );\n }\n\n return {\n messageId: String(payload.messageId ?? options.messageId ?? \"\"),\n ...(options.deleteId !== undefined ? { deleteId: String(options.deleteId) } : {}),\n to: options.to,\n response: `cost: ${payload.cost ?? \"?\"} ${payload.currency ?? \"SAR\"}`,\n provider: \"taqnyat-sms\",\n };\n }\n\n /**\n * Vendor extra: delete a scheduled SMS via `DELETE /v1/messages/delete`.\n * Pass the same `deleteId` used in {@link schedule}.\n */\n async deleteScheduled(\n deleteId: string | number,\n ): Promise<{ ok: true; message: string; provider: \"taqnyat-sms\" }> {\n const url = new URL(\"https://api.taqnyat.sa/v1/messages/delete\");\n url.searchParams.set(\"bearerTokens\", this.bearerToken);\n url.searchParams.set(\"deleteId\", String(deleteId));\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: { Authorization: `Bearer ${this.bearerToken}` },\n });\n\n const payload = (await response.json()) as {\n statusCode?: number;\n message?: string;\n };\n\n if (response.status !== 201 && payload.statusCode !== 201) {\n throw new TaqnyatSmsError(\n payload.message ?? \"Taqnyat delete scheduled SMS API error\",\n payload.statusCode ?? response.status,\n payload,\n );\n }\n\n return {\n ok: true,\n message: payload.message ?? \"Deleted successfully\",\n provider: \"taqnyat-sms\",\n };\n }\n\n /** Lightweight credential check. */\n async verify(): Promise<VerifyResult> {\n return {\n ok: Boolean(this.bearerToken && this.sender),\n provider: \"taqnyat-sms\",\n message: this.bearerToken && this.sender ? \"Credentials present\" : \"Missing credentials\",\n };\n }\n}\n"
|
|
5
|
+
"/**\n * @module\n * Taqnyat SMS API transport (Saudi Arabia).\n *\n * Request/response shapes follow\n * [Taqnyat SMS](https://dev.taqnyat.sa/en/doc/sms/) and\n * [Verify](https://dev.taqnyat.sa/en/doc/verify/):\n * - SMS: `POST /v1/messages` with Bearer + JSON (`recipients`, `body`, `sender`)\n * - OTP: `POST /verify.php/` with JSON array (`apiKey`, `numbers`, `method`, …)\n *\n * Sently-first: wire SMS into {@link createSmsSender}; OTP helpers stay on this class.\n *\n * @example\n * ```ts\n * import { createSmsSender } from \"sently/sms\";\n * import { TaqnyatSmsTransport } from \"sently/transports/taqnyat-sms\";\n *\n * const taqnyat = new TaqnyatSmsTransport({\n * bearerToken: process.env.TAQNYAT_TOKEN!,\n * sender: \"MyBrand\",\n * });\n * const sms = createSmsSender({ transport: taqnyat });\n *\n * await sms.send({ to: \"+9665xxxxxxxx\", body: \"Hello\" });\n * await taqnyat.sendOtp({ to: \"+9665xxxxxxxx\", requestId: \"login-1\", lang: \"en\" });\n * await taqnyat.verifyOtp({\n * to: \"+9665xxxxxxxx\",\n * requestId: \"login-1\",\n * code: \"6240\",\n * lang: \"en\",\n * });\n * const balance = await taqnyat.getBalance();\n * await taqnyat.schedule({\n * to: \"+9665xxxxxxxx\",\n * body: \"Later\",\n * scheduledDatetime: \"2030-01-01T10:00\",\n * deleteId: \"demo-1\",\n * });\n * ```\n */\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport type { SmsOptions, SmsSendResult, SmsTransport } from \"../core/sms-types.js\";\nimport type { VerifyResult } from \"../core/types.js\";\nimport { normalizeTaqnyatPhone } from \"./taqnyat-phone.js\";\n\n/** Taqnyat SMS API configuration. */\nexport interface TaqnyatSmsConfig {\n /** Bearer token from the Taqnyat dashboard. */\n bearerToken: string;\n /** Pre-approved sender name. */\n sender: string;\n}\n\n/** Options for {@link TaqnyatSmsTransport.sendOtp}. */\nexport interface TaqnyatSendOtpOptions {\n /** Recipient phone number (E.164 or international digits). */\n to: string;\n /** Unique id for this verification flow (required again on {@link verifyOtp}). */\n requestId: string;\n /** Message language (`en` or `ar`). Defaults to `\"ar\"` per Taqnyat docs. */\n lang?: \"en\" | \"ar\";\n /** Optional note appended to the OTP SMS. */\n note?: string;\n /** Sender ID override; defaults to transport `sender`. */\n from?: string;\n}\n\n/** Result of a successful {@link TaqnyatSmsTransport.sendOtp} call. */\nexport interface TaqnyatOtpSendResult {\n /** Echo of {@link TaqnyatSendOtpOptions.requestId}. */\n requestId: string;\n /** Recipient as passed in. */\n to: string;\n /** Provider status code (`5` = code sent). */\n code: number;\n /** Raw response body text. */\n response: string;\n /** Provider identifier. */\n provider: \"taqnyat-sms\";\n}\n\n/** Options for {@link TaqnyatSmsTransport.verifyOtp}. */\nexport interface TaqnyatVerifyOtpOptions {\n /** Recipient phone number (same as send). */\n to: string;\n /** Same {@link TaqnyatSendOtpOptions.requestId} used when sending. */\n requestId: string;\n /** OTP code the user entered (`activeKey` in Taqnyat docs). */\n code: string;\n /** Message language (`en` or `ar`). Defaults to `\"ar\"`. */\n lang?: \"en\" | \"ar\";\n /** Sender ID override; defaults to transport `sender`. */\n from?: string;\n /** Optional note (documented on check OTP). */\n note?: string;\n}\n\n/** Result of a successful {@link TaqnyatSmsTransport.verifyOtp} call. */\nexport interface TaqnyatOtpVerifyResult {\n /** Always `true` when the call resolves (failures throw). */\n ok: true;\n /** Provider status code (`10` = completed, `13`/`19` = already verified). */\n code: number;\n /** Provider message when present. */\n message: string;\n /** Raw response body text. */\n response: string;\n /** Provider identifier. */\n provider: \"taqnyat-sms\";\n}\n\n/** Account balance from {@link TaqnyatSmsTransport.getBalance}. */\nexport interface TaqnyatBalance {\n /** Account status string from Taqnyat (e.g. `active`). */\n accountStatus: string;\n /** Balance amount as returned by the API (string). */\n balance: string;\n /** Currency code (usually `SAR`). */\n currency: string;\n /** Account expiry date when present. */\n accountExpiryDate?: string;\n /** Provider identifier. */\n provider: \"taqnyat-sms\";\n}\n\n/** One sender row from {@link TaqnyatSmsTransport.listSenders}. */\nexport interface TaqnyatSender {\n /** Sender name exactly as registered. */\n senderName: string;\n /** Status string when present (e.g. `active`). */\n status?: string;\n}\n\n/** Options for {@link TaqnyatSmsTransport.schedule}. */\nexport interface TaqnyatScheduleOptions {\n /** Recipient phone number (E.164 or international digits). */\n to: string;\n /** Message body. */\n body: string;\n /**\n * Local schedule time in Taqnyat format, e.g. `2020-09-30T14:26`.\n * Required by `POST /v1/messages` schedule docs.\n */\n scheduledDatetime: string;\n /** Sender ID override; defaults to transport `sender`. */\n from?: string;\n /**\n * Client delete key used later with {@link deleteScheduled}.\n * Optional per Taqnyat schedule docs.\n */\n deleteId?: string | number;\n /** Optional client id mapped to `smsId`. */\n messageId?: string;\n}\n\n/** Result of a successful {@link TaqnyatSmsTransport.schedule} call. */\nexport interface TaqnyatScheduleResult {\n /** Provider message id when present. */\n messageId: string;\n /** Echo of {@link TaqnyatScheduleOptions.deleteId} when set. */\n deleteId?: string;\n /** Recipient as passed in. */\n to: string;\n /** Cost summary when present. */\n response: string;\n /** Provider identifier. */\n provider: \"taqnyat-sms\";\n}\n\n/** Error thrown when the Taqnyat SMS / Verify API returns a non-success response. */\nexport class TaqnyatSmsError extends SentlyError {\n /** Creates a Taqnyat SMS API error with status code and response payload. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(\n message,\n httpStatusToSentlyCode(statusCode >= 100 && statusCode < 600 ? statusCode : 400),\n {\n statusCode,\n provider: \"taqnyat-sms\",\n cause: apiError,\n },\n );\n this.name = \"TaqnyatSmsError\";\n }\n}\n\nfunction asVerifyCode(raw: unknown): number | undefined {\n if (typeof raw === \"number\" && Number.isFinite(raw)) return raw;\n if (typeof raw === \"string\" && /^-?\\d+$/.test(raw)) return Number(raw);\n return undefined;\n}\n\n/**\n * Resolve the Verify result code.\n *\n * Live `returnJson: 1` responses wrap the docs table code in `Data.result`:\n * `{ status: 1, ResponseStatus: \"success\", Data: { result: 5, MessageEn: \"…\" } }`.\n * Top-level `status` here is a transport envelope (not the docs table) — reading\n * it as the OTP code falsely maps success to \"invalid apiKey\" (code 1).\n * Flat shapes (`{ code: 5 }`) from docs samples / older responses still work.\n */\nfunction verifyPayloadCode(payload: Record<string, unknown>): number | undefined {\n const data = payload.Data;\n if (data && typeof data === \"object\" && !Array.isArray(data)) {\n const nested = asVerifyCode((data as Record<string, unknown>).result);\n if (nested !== undefined) return nested;\n }\n const flat = asVerifyCode(payload.code ?? payload.statusCode ?? payload.result);\n if (flat !== undefined) return flat;\n // Only treat top-level `status` as the result code when this is not the\n // envelope form (which always carries `ResponseStatus`).\n if (payload.ResponseStatus === undefined) {\n return asVerifyCode(payload.status);\n }\n return undefined;\n}\n\nfunction verifyPayloadMessage(payload: Record<string, unknown>): string | undefined {\n const data = payload.Data;\n if (data && typeof data === \"object\" && !Array.isArray(data)) {\n const nested = data as Record<string, unknown>;\n if (typeof nested.MessageEn === \"string\") return nested.MessageEn;\n if (typeof nested.MessageAr === \"string\") return nested.MessageAr;\n if (typeof nested.message === \"string\") return nested.message;\n }\n if (typeof payload.message === \"string\") return payload.message;\n if (typeof payload.MessageEn === \"string\") return payload.MessageEn;\n if (typeof payload.Error === \"string\") return payload.Error;\n return undefined;\n}\n\nfunction unwrapVerifyPayload(payload: unknown): Record<string, unknown> {\n if (Array.isArray(payload)) {\n const first = payload[0];\n return first && typeof first === \"object\" ? (first as Record<string, unknown>) : {};\n }\n if (payload && typeof payload === \"object\") {\n return payload as Record<string, unknown>;\n }\n return {};\n}\n\n/**\n * Taqnyat SMS transport via JSON Bearer API, plus Verify OTP helpers.\n */\nexport class TaqnyatSmsTransport implements SmsTransport {\n readonly provider = \"taqnyat-sms\";\n\n private readonly bearerToken: string;\n private readonly sender: string;\n\n /** Creates a Taqnyat SMS transport. */\n constructor(config: TaqnyatSmsConfig) {\n this.bearerToken = config.bearerToken;\n this.sender = config.sender;\n }\n\n /**\n * Sends an SMS via `POST https://api.taqnyat.sa/v1/messages`\n * (Bearer + JSON: `recipients`, `body`, `sender`; optional `smsId`).\n */\n async send(options: SmsOptions): Promise<SmsSendResult> {\n const recipients = [normalizeTaqnyatPhone(options.to)];\n const body: Record<string, unknown> = {\n recipients,\n body: options.body,\n sender: options.from ?? this.sender,\n };\n // Official PHP SDK maps a client id to `smsId`.\n if (options.messageId !== undefined) {\n body.smsId = options.messageId;\n }\n\n const response = await fetch(\"https://api.taqnyat.sa/v1/messages\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n const payload = (await response.json()) as {\n statusCode?: number;\n messageId?: number;\n cost?: number;\n currency?: string;\n message?: string;\n accepted?: string;\n rejected?: string;\n };\n\n // Docs: successful send returns HTTP 201 with statusCode 201.\n if (response.status !== 201) {\n throw new TaqnyatSmsError(\n payload.message ?? \"Taqnyat SMS API error\",\n payload.statusCode ?? response.status,\n payload,\n );\n }\n\n return {\n messageId: String(payload.messageId ?? options.messageId ?? \"\"),\n to: options.to,\n status: \"accepted\",\n response: `cost: ${payload.cost ?? \"?\"} ${payload.currency ?? \"SAR\"}`,\n provider: \"taqnyat-sms\",\n };\n }\n\n /**\n * Vendor extra: generate/send OTP via `POST /verify.php/` (success code `5`).\n * Not part of {@link SmsTransport} — call on {@link TaqnyatSmsTransport} directly.\n */\n async sendOtp(options: TaqnyatSendOtpOptions): Promise<TaqnyatOtpSendResult> {\n const lang = options.lang ?? \"ar\";\n const number = normalizeTaqnyatPhone(options.to);\n const body = [\n {\n apiKey: this.bearerToken,\n numbers: [number],\n method: \"sms\",\n sender: options.from ?? this.sender,\n lang,\n requestId: options.requestId,\n returnJson: 1,\n ...(options.note !== undefined ? { note: options.note } : {}),\n },\n ];\n\n const response = await fetch(\"https://api.taqnyat.sa/verify.php/\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n const text = await response.text();\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n throw new TaqnyatSmsError(\"Taqnyat Verify returned non-JSON response\", response.status, text);\n }\n\n const result = unwrapVerifyPayload(parsed);\n const code = verifyPayloadCode(result);\n\n // Docs: 5 = Activation code sent successfully.\n if (!response.ok || code !== 5) {\n throw new TaqnyatSmsError(\n verifyPayloadMessage(result) ?? `Taqnyat OTP send failed (code ${code ?? \"unknown\"})`,\n code ?? response.status,\n result,\n );\n }\n\n return {\n requestId: options.requestId,\n to: options.to,\n code,\n response: text,\n provider: \"taqnyat-sms\",\n };\n }\n\n /**\n * Vendor extra: check OTP via `POST /verify.php/` with `activeKey`\n * (`10` = completed; `13` / `19` = already verified).\n */\n async verifyOtp(options: TaqnyatVerifyOtpOptions): Promise<TaqnyatOtpVerifyResult> {\n const lang = options.lang ?? \"ar\";\n const number = normalizeTaqnyatPhone(options.to);\n const body = [\n {\n apiKey: this.bearerToken,\n numbers: [number],\n method: \"sms\",\n sender: options.from ?? this.sender,\n lang,\n requestId: options.requestId,\n activeKey: options.code,\n returnJson: 1,\n ...(options.note !== undefined ? { note: options.note } : {}),\n },\n ];\n\n const response = await fetch(\"https://api.taqnyat.sa/verify.php/\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n const text = await response.text();\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n throw new TaqnyatSmsError(\"Taqnyat Verify returned non-JSON response\", response.status, text);\n }\n\n const result = unwrapVerifyPayload(parsed);\n const code = verifyPayloadCode(result);\n const okCodes = new Set([10, 13, 19]);\n\n if (!response.ok || code === undefined || !okCodes.has(code)) {\n throw new TaqnyatSmsError(\n verifyPayloadMessage(result) ?? `Taqnyat OTP verify failed (code ${code ?? \"unknown\"})`,\n code ?? response.status,\n result,\n );\n }\n\n return {\n ok: true,\n code,\n message: verifyPayloadMessage(result) ?? \"Activation process completed successfully\",\n response: text,\n provider: \"taqnyat-sms\",\n };\n }\n\n /**\n * Vendor extra: account balance via `GET /account/balance`.\n * Not part of {@link SmsTransport}.\n */\n async getBalance(): Promise<TaqnyatBalance> {\n const response = await fetch(\n `https://api.taqnyat.sa/account/balance?bearerTokens=${encodeURIComponent(this.bearerToken)}`,\n {\n headers: { Authorization: `Bearer ${this.bearerToken}` },\n },\n );\n const payload = (await response.json()) as {\n statusCode?: number;\n accountStatus?: string;\n balance?: string;\n currency?: string;\n accountExpiryDate?: string;\n message?: string;\n };\n\n if (!response.ok || payload.statusCode !== 200) {\n throw new TaqnyatSmsError(\n payload.message ?? \"Taqnyat balance API error\",\n payload.statusCode ?? response.status,\n payload,\n );\n }\n\n return {\n accountStatus: payload.accountStatus ?? \"\",\n balance: payload.balance ?? \"0\",\n currency: payload.currency ?? \"SAR\",\n ...(payload.accountExpiryDate !== undefined\n ? { accountExpiryDate: payload.accountExpiryDate }\n : {}),\n provider: \"taqnyat-sms\",\n };\n }\n\n /**\n * Vendor extra: list registered sender names via `GET /v1/messages/senders`.\n * Not part of {@link SmsTransport}.\n */\n async listSenders(): Promise<TaqnyatSender[]> {\n const response = await fetch(\n `https://api.taqnyat.sa/v1/messages/senders?bearerTokens=${encodeURIComponent(this.bearerToken)}`,\n {\n headers: { Authorization: `Bearer ${this.bearerToken}` },\n },\n );\n const payload = (await response.json()) as {\n statusCode?: number;\n senders?: Array<{ senderName?: string; status?: string }>;\n message?: string;\n };\n\n if (!response.ok || payload.statusCode !== 200) {\n throw new TaqnyatSmsError(\n payload.message ?? \"Taqnyat senders API error\",\n payload.statusCode ?? response.status,\n payload,\n );\n }\n\n return (payload.senders ?? [])\n .filter(\n (row): row is { senderName: string; status?: string } => typeof row.senderName === \"string\",\n )\n .map((row) => ({\n senderName: row.senderName,\n ...(row.status !== undefined ? { status: row.status } : {}),\n }));\n }\n\n /**\n * Vendor extra: schedule an SMS (`POST /v1/messages` + `scheduledDatetime`).\n * Not part of {@link SmsTransport}.\n */\n async schedule(options: TaqnyatScheduleOptions): Promise<TaqnyatScheduleResult> {\n const recipients = [normalizeTaqnyatPhone(options.to)];\n const body: Record<string, unknown> = {\n recipients,\n body: options.body,\n sender: options.from ?? this.sender,\n scheduledDatetime: options.scheduledDatetime,\n };\n if (options.deleteId !== undefined) {\n body.deleteId = options.deleteId;\n }\n if (options.messageId !== undefined) {\n body.smsId = options.messageId;\n }\n\n const response = await fetch(\"https://api.taqnyat.sa/v1/messages\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${this.bearerToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n const payload = (await response.json()) as {\n statusCode?: number;\n messageId?: number;\n cost?: number;\n currency?: string;\n message?: string;\n };\n\n if (response.status !== 201) {\n throw new TaqnyatSmsError(\n payload.message ?? \"Taqnyat schedule SMS API error\",\n payload.statusCode ?? response.status,\n payload,\n );\n }\n\n return {\n messageId: String(payload.messageId ?? options.messageId ?? \"\"),\n ...(options.deleteId !== undefined ? { deleteId: String(options.deleteId) } : {}),\n to: options.to,\n response: `cost: ${payload.cost ?? \"?\"} ${payload.currency ?? \"SAR\"}`,\n provider: \"taqnyat-sms\",\n };\n }\n\n /**\n * Vendor extra: delete a scheduled SMS via `DELETE /v1/messages/delete`.\n * Pass the same `deleteId` used in {@link schedule}.\n */\n async deleteScheduled(\n deleteId: string | number,\n ): Promise<{ ok: true; message: string; provider: \"taqnyat-sms\" }> {\n const url = new URL(\"https://api.taqnyat.sa/v1/messages/delete\");\n url.searchParams.set(\"bearerTokens\", this.bearerToken);\n url.searchParams.set(\"deleteId\", String(deleteId));\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: { Authorization: `Bearer ${this.bearerToken}` },\n });\n\n const payload = (await response.json()) as {\n statusCode?: number;\n message?: string;\n };\n\n if (response.status !== 201 && payload.statusCode !== 201) {\n throw new TaqnyatSmsError(\n payload.message ?? \"Taqnyat delete scheduled SMS API error\",\n payload.statusCode ?? response.status,\n payload,\n );\n }\n\n return {\n ok: true,\n message: payload.message ?? \"Deleted successfully\",\n provider: \"taqnyat-sms\",\n };\n }\n\n /** Lightweight credential check. */\n async verify(): Promise<VerifyResult> {\n return {\n ok: Boolean(this.bearerToken && this.sender),\n provider: \"taqnyat-sms\",\n message: this.bearerToken && this.sender ? \"Credentials present\" : \"Missing credentials\",\n };\n }\n}\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": "kIA0KO,CAAM,KAAwB,CAAY,CAI7B,WACA,SAHlB,WAAW,CACT,EACgB,EACA,EAChB,CACA,MACE,EACA,EAAuB,GAAc,KAAO,EAAa,IAAM,EAAa,GAAG,EAC/E,CACE,aACA,SAAU,cACV,MAAO,CACT,CACF,EAXgB,kBACA,gBAWhB,KAAK,KAAO,kBAEhB,CAEA,SAAS,CAAiB,CAAC,EAAsD,CAC/E,IAAM,
|
|
8
|
-
"debugId": "
|
|
7
|
+
"mappings": "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",
|
|
8
|
+
"debugId": "4CF386F61DA773C164756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sently",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Runtime-agnostic channel-delivery library for Node.js, Bun, Deno, and Cloudflare Workers. One sender shape, one error model, and one retry path across email, SMS, WhatsApp, and push — with pluggable provider transports.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -99,11 +99,11 @@
|
|
|
99
99
|
},
|
|
100
100
|
"repository": {
|
|
101
101
|
"type": "git",
|
|
102
|
-
"url": "git+https://github.com/
|
|
102
|
+
"url": "git+https://github.com/omqkhafi/sently.git"
|
|
103
103
|
},
|
|
104
|
-
"homepage": "https://github.com/
|
|
104
|
+
"homepage": "https://github.com/omqkhafi/sently#readme",
|
|
105
105
|
"bugs": {
|
|
106
|
-
"url": "https://github.com/
|
|
106
|
+
"url": "https://github.com/omqkhafi/sently/issues"
|
|
107
107
|
},
|
|
108
108
|
"exports": {
|
|
109
109
|
".": {
|
|
@@ -15,6 +15,8 @@ Install it in the application that owns your delivery credentials and provider t
|
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
bun add sently
|
|
18
|
+
# or
|
|
19
|
+
bunx jsr add @omqkhafi/sently
|
|
18
20
|
```
|
|
19
21
|
|
|
20
22
|
</Step>
|
|
@@ -34,11 +36,15 @@ const mailer = await createMailer({
|
|
|
34
36
|
</Step>
|
|
35
37
|
</Steps>
|
|
36
38
|
|
|
39
|
+
<Callout title="JSR scope">
|
|
40
|
+
Use `@omqkhafi/sently`. `@alialnaghmoush/sently` stays on the registry but will not receive new versions. npm `sently` is unchanged.
|
|
41
|
+
</Callout>
|
|
42
|
+
|
|
37
43
|
## Install and runtime requirements
|
|
38
44
|
|
|
39
45
|
| Requirement | Details |
|
|
40
46
|
| --- | --- |
|
|
41
|
-
| Package manager | Bun: `bun add sently`. |
|
|
47
|
+
| Package manager | Bun: `bun add sently`. JSR: `bunx jsr add @omqkhafi/sently`. |
|
|
42
48
|
| Module format | Use ESM imports. |
|
|
43
49
|
| Node.js | Version 18 or later. |
|
|
44
50
|
| Bun | Version 1 or later. |
|
|
@@ -64,6 +70,9 @@ const mailer = await createMailer({
|
|
|
64
70
|
<Accordion title="Should I import every transport from the main package?">
|
|
65
71
|
No. Provider transports are exported from their own `sently/transports/*` subpaths.
|
|
66
72
|
</Accordion>
|
|
73
|
+
<Accordion title="I already installed @alialnaghmoush/sently">
|
|
74
|
+
Replace it with `@omqkhafi/sently` (`bunx jsr add @omqkhafi/sently`). Imports stay `sently/…`.
|
|
75
|
+
</Accordion>
|
|
67
76
|
</Accordions>
|
|
68
77
|
|
|
69
78
|
## Learn more
|
|
@@ -42,7 +42,7 @@ Apps should depend on channel senders and contracts — not on vendor SDK shapes
|
|
|
42
42
|
|
|
43
43
|
## Security patches
|
|
44
44
|
|
|
45
|
-
See [`SECURITY.md`](https://github.com/
|
|
45
|
+
See [`SECURITY.md`](https://github.com/omqkhafi/sently/blob/main/SECURITY.md) for reporting and which versions receive patches.
|
|
46
46
|
|
|
47
47
|
## Troubleshooting
|
|
48
48
|
|
|
@@ -29,7 +29,7 @@ Keep them out of source control and out of unstructured logs.
|
|
|
29
29
|
Its path can contain a long-lived delivery token. Hook context keeps a short fingerprint.
|
|
30
30
|
</Accordion>
|
|
31
31
|
<Accordion title="Where do I report a vulnerability?">
|
|
32
|
-
Open a private GitHub Security Advisory for this repository. Do not file a public issue. Details and response targets are in [`SECURITY.md`](https://github.com/
|
|
32
|
+
Open a private GitHub Security Advisory for this repository. Do not file a public issue. Details and response targets are in [`SECURITY.md`](https://github.com/omqkhafi/sently/blob/main/SECURITY.md).
|
|
33
33
|
</Accordion>
|
|
34
34
|
</Accordions>
|
|
35
35
|
|
|
@@ -13,7 +13,8 @@ const otp = await msegat.sendOtp({ to: "+9665...", lang: "En" });
|
|
|
13
13
|
await msegat.verifyOtp({ id: otp.id, code: "1234", lang: "En" });
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
Taqnyat SMS also exposes `sendOtp` and `verifyOtp` on its transport
|
|
16
|
+
Taqnyat SMS also exposes `sendOtp` and `verifyOtp` on its transport — live verified
|
|
17
|
+
end-to-end against production (`sendOtp` → handset → `verifyOtp`).
|
|
17
18
|
See [Taqnyat](/docs/transports/taqnyat#sms-vendor-extras) for balance, senders, schedule, WhatsApp templates, opt-in, and failover extras.
|
|
18
19
|
|
|
19
20
|
<Cards><Card title="Msegat" href="/docs/transports/msegat" /><Card title="Taqnyat" href="/docs/transports/taqnyat#sms" /></Cards>
|
|
@@ -8,14 +8,14 @@ source: "src/transports/hostinger.ts"
|
|
|
8
8
|
Hostinger Email gives you a branded mailbox. Wire it into sently two ways — the Mail API over HTTPS, or the SMTP relay with Hostinger defaults already filled in.
|
|
9
9
|
|
|
10
10
|
<Callout title="The one rule">
|
|
11
|
-
|
|
12
|
-
Vendor extras (`listMailboxes`, `sendReply`, `sendForward`) stay on the
|
|
11
|
+
One name, two shapes: `new HostingerTransport({ token, mailbox })` → `createMailer` (Mail API); `HostingerTransport({ user, pass })` → `createSMTPMailer` (SMTP, no `new`).
|
|
12
|
+
IntelliSense narrows options and the return type by config shape. Vendor extras (`listMailboxes`, `sendReply`, `sendForward`) stay on the Mail API instance — never on the channel sender.
|
|
13
13
|
</Callout>
|
|
14
14
|
|
|
15
|
-
| Path |
|
|
16
|
-
| --- | --- | --- |
|
|
17
|
-
| Mail API | `
|
|
18
|
-
| SMTP | `
|
|
15
|
+
| Path | Call | Returns | Wire into |
|
|
16
|
+
| --- | --- | --- | --- |
|
|
17
|
+
| Mail API | `new HostingerTransport({ token, mailbox })` | Transport | `createMailer` |
|
|
18
|
+
| SMTP | `HostingerTransport({ user, pass })` | `SMTPConfig` | `createSMTPMailer` |
|
|
19
19
|
|
|
20
20
|
Official references: [Hostinger API](https://developers.hostinger.com/), [Mail API](https://api.mail.hostinger.com/), [SMTP ports](https://www.hostinger.com/tutorials/smtp-port/).
|
|
21
21
|
|
|
@@ -243,7 +243,7 @@ const viaMailer = await mailer.verify(); // same check through the channel sende
|
|
|
243
243
|
SMTP send against Hostinger’s production relay (`smtp.hostinger.com`) succeeded previously with a real mailbox — SSL port 465 and STARTTLS port 587.
|
|
244
244
|
</LiveVerified>
|
|
245
245
|
|
|
246
|
-
Ready Hostinger relay settings — no host/port guesswork. Pass `
|
|
246
|
+
Ready Hostinger relay settings — no host/port guesswork. Pass `HostingerTransport({ user, pass })` straight into `createSMTPMailer`.
|
|
247
247
|
|
|
248
248
|
| Setting | Value |
|
|
249
249
|
| --- | --- |
|
|
@@ -267,10 +267,10 @@ Hostinger supports ports **465** and **587** only — not `2525` ([SMTP ports gu
|
|
|
267
267
|
|
|
268
268
|
```ts
|
|
269
269
|
import { createSMTPMailer } from "sently/smtp";
|
|
270
|
-
import {
|
|
270
|
+
import { HostingerTransport } from "sently/transports/hostinger";
|
|
271
271
|
|
|
272
272
|
const mailer = await createSMTPMailer(
|
|
273
|
-
|
|
273
|
+
HostingerTransport({
|
|
274
274
|
user: "you@yourdomain.com",
|
|
275
275
|
pass: process.env.HOSTINGER_SMTP_PASSWORD!,
|
|
276
276
|
}),
|
|
@@ -301,7 +301,7 @@ Default — implicit TLS on connect.
|
|
|
301
301
|
|
|
302
302
|
```ts
|
|
303
303
|
const mailer = await createSMTPMailer(
|
|
304
|
-
|
|
304
|
+
HostingerTransport({
|
|
305
305
|
user: "you@yourdomain.com",
|
|
306
306
|
pass: process.env.HOSTINGER_SMTP_PASSWORD!,
|
|
307
307
|
// port: 465, // default
|
|
@@ -318,7 +318,7 @@ Plain connect, then upgrade with STARTTLS.
|
|
|
318
318
|
|
|
319
319
|
```ts
|
|
320
320
|
const mailer = await createSMTPMailer(
|
|
321
|
-
|
|
321
|
+
HostingerTransport({
|
|
322
322
|
user: "you@yourdomain.com",
|
|
323
323
|
pass: process.env.HOSTINGER_SMTP_PASSWORD!,
|
|
324
324
|
port: 587,
|
|
@@ -335,7 +335,7 @@ Reuse SMTP connections under load.
|
|
|
335
335
|
|
|
336
336
|
```ts
|
|
337
337
|
const mailer = await createSMTPMailer(
|
|
338
|
-
|
|
338
|
+
HostingerTransport({
|
|
339
339
|
user: "you@yourdomain.com",
|
|
340
340
|
pass: process.env.HOSTINGER_SMTP_PASSWORD!,
|
|
341
341
|
pool: true,
|
|
@@ -366,7 +366,9 @@ See [SMTP](./smtp) for pooling, DKIM, and adapter details.
|
|
|
366
366
|
</Tab>
|
|
367
367
|
</Tabs>
|
|
368
368
|
|
|
369
|
-
###
|
|
369
|
+
### SMTP options
|
|
370
|
+
|
|
371
|
+
Options for `HostingerTransport({ user, pass })` (the SMTP overload):
|
|
370
372
|
|
|
371
373
|
| Option | Type | Default |
|
|
372
374
|
| --- | --- | --- |
|
|
@@ -376,6 +378,8 @@ See [SMTP](./smtp) for pooling, DKIM, and adapter details.
|
|
|
376
378
|
| `pool` | `boolean` | unset |
|
|
377
379
|
| `maxConnections` | `number` | unset (SMTP default `5` when pooled) |
|
|
378
380
|
|
|
381
|
+
`hostingerSmtpConfig(...)` still works as a 1.x compatibility alias for the same options.
|
|
382
|
+
|
|
379
383
|
## Mail API vs SMTP
|
|
380
384
|
|
|
381
385
|
| Need | Prefer |
|
|
@@ -68,6 +68,10 @@ Optional `from` overrides the transport `sender` for that send.
|
|
|
68
68
|
|
|
69
69
|
Taqnyat Verify API on the transport (`sendOtp` → user enters code → `verifyOtp`).
|
|
70
70
|
|
|
71
|
+
<LiveVerified>
|
|
72
|
+
Verify OTP end-to-end against Taqnyat’s production API: `sendOtp` returned `Data.result: 5`, the handset received the code, and `verifyOtp` completed successfully (live suite + manual confirm).
|
|
73
|
+
</LiveVerified>
|
|
74
|
+
|
|
71
75
|
```ts
|
|
72
76
|
await taqnyat.sendOtp({
|
|
73
77
|
to: "+9665xxxxxxxx",
|
|
@@ -84,7 +88,7 @@ await taqnyat.verifyOtp({
|
|
|
84
88
|
});
|
|
85
89
|
```
|
|
86
90
|
|
|
87
|
-
Success send code is `5
|
|
91
|
+
Success send code is `5` (`Data.result` on live `returnJson` responses). Verify accepts `10`, `13`, or `19`.
|
|
88
92
|
|
|
89
93
|
</Tab>
|
|
90
94
|
<Tab value="Balance">
|
|
@@ -313,11 +317,36 @@ Body is `html` when set, otherwise `text` (`msg` on Taqnyat’s API).
|
|
|
313
317
|
</Tab>
|
|
314
318
|
</Tabs>
|
|
315
319
|
|
|
320
|
+
## Account verification & sender names
|
|
321
|
+
|
|
322
|
+
Portal onboarding from Taqnyat — required before a custom SMS `sender` is active. Not part of the sently API; complete these in the Taqnyat portal.
|
|
323
|
+
|
|
324
|
+
### Account verification
|
|
325
|
+
|
|
326
|
+
1. Fill out the contract, sign it, and stamp it.
|
|
327
|
+
2. If the organization is subject to tax, attach the tax certificate.
|
|
328
|
+
|
|
329
|
+
### New sender name
|
|
330
|
+
|
|
331
|
+
| Rule | Detail |
|
|
332
|
+
| --- | --- |
|
|
333
|
+
| Authorization | Fill out the sender-name authorization form, sign it, and stamp it |
|
|
334
|
+
| Name relation | Sender name must relate to the organization; otherwise attach a supporting document that explains the link |
|
|
335
|
+
| Fee | SAR 230, renewed annually |
|
|
336
|
+
| Service name | Max **11** characters |
|
|
337
|
+
| Promotional name | Max **8** characters, with `-AD` appended at the end |
|
|
338
|
+
| Dual registration | Names ≤ 8 characters can be registered as both service and promotional for one SAR 230 fee (counts as one name) |
|
|
339
|
+
|
|
340
|
+
<Callout title="Tip">
|
|
341
|
+
Use the **Senders** tab above (`listSenders`) after registration to confirm the name is active before hard-coding `sender`.
|
|
342
|
+
</Callout>
|
|
343
|
+
|
|
316
344
|
## Troubleshooting
|
|
317
345
|
|
|
318
346
|
<Accordions>
|
|
319
347
|
<Accordion title="SMS — Sender Name not active / not accepted">
|
|
320
348
|
Open the **Senders** branch and call `listSenders()`, or use an active portal sender exactly as shown (trial accounts often use `Taqnyat.sa`).
|
|
349
|
+
Custom names need portal registration first — see [Account verification & sender names](#account-verification--sender-names).
|
|
321
350
|
</Accordion>
|
|
322
351
|
<Accordion title="WhatsApp — sandbox rejects the number">
|
|
323
352
|
Add the destination under Manage WhatsApp → Sandbox, then use the **Opt-in** branch before business-initiated templates.
|