From 8bcc895744b9b30ac31bd3e2419a18b9998605e1 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 13 May 2022 22:22:19 -0400 Subject: [PATCH] feat: rewrite the library in typescript for Postal v2 --- .gitignore | 1 + .prettierrc | 8 ++++ Client.js | 43 ------------------- Message.js | 14 ------- README.md | 101 ++++++++++++++++++++++---------------------- SendMessage.js | 74 --------------------------------- SendRawMessage.js | 28 ------------- SendResult.js | 26 ------------ index.js | 9 ---- index.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 39 +++++++++++------ schemas.ts | 52 +++++++++++++++++++++++ tsconfig.json | 21 ++++++++++ types.ts | 75 +++++++++++++++++++++++++++++++++ yarn.lock | 64 ++++++++++++++++++++++++++++ 15 files changed, 401 insertions(+), 258 deletions(-) create mode 100644 .prettierrc delete mode 100644 Client.js delete mode 100644 Message.js delete mode 100644 SendMessage.js delete mode 100644 SendRawMessage.js delete mode 100644 SendResult.js delete mode 100644 index.js create mode 100644 index.ts create mode 100644 schemas.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore index fd4f2b0..0738cb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .DS_Store +dist \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..3b83827 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "arrowParens": "avoid", + "printWidth": 80, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "tabWidth": 4 +} \ No newline at end of file diff --git a/Client.js b/Client.js deleted file mode 100644 index 83dbb2d..0000000 --- a/Client.js +++ /dev/null @@ -1,43 +0,0 @@ -var Promise = require('promise'); -var https = require('https'); -var concatStream = require('concat-stream'); - -function Client(host, serverKey) { - this.host = host; - this.serverKey = serverKey; -} - -Client.prototype.makeRequest = function makeRequest(controller, action, parameters) { - return new Promise(function (resolve, reject) { - var data = JSON.stringify(parameters); - - var request = https.request({ - headers: { - 'Content-Type': 'application/json', - 'X-Server-API-Key': this.serverKey - }, - host: this.host, - method: 'POST', - path: '/api/v1/' + controller + '/' + action - }, function (response) { - response.pipe(concatStream(function (content) { - var json = JSON.parse(content); - if (json.status === 'success') { - resolve(json.data); - } else { - reject(json.data); - } - })); - }); - - request.on('error', function (error) { - reject(error); - }); - - request.write(data); - - request.end(); - }.bind(this)); -}; - -module.exports = Client; diff --git a/Message.js b/Message.js deleted file mode 100644 index bc0341e..0000000 --- a/Message.js +++ /dev/null @@ -1,14 +0,0 @@ -function Message(client, attributes) { - this.client = client; - this.attributes = attributes; -} - -Message.prototype.id = function id() { - return this.attributes.id; -} - -Message.prototype.token = function token() { - return this.attributes.token; -} - -module.exports = Message; diff --git a/README.md b/README.md index f66553a..8fcfc8c 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,68 @@ # Postal for Node This library helps you send e-mails through the open source mail delivery -platform, [Postal](https://github.com/atech/postal) in Node. +platform, [Postal](https://github.com/postalserver/postal) in Node. ## Installation -Install the library using [NPM](https://www.npmjs.com/): +Install the library using [NPM](https://www.npmjs.com/) or [Yarn](https://classic.yarnpkg.com/lang/en/docs/install/): ``` -$ npm install @atech/postal --save +$ npm install @atech/postal +$ yarn install @atech/postal ``` ## Usage Sending an email is very simple. Just follow the example below. Before you can begin, you'll need to login to your installation's web interface and generate -new API credentials. +new API credentials. This package assumes you are on Postal v2. ```javascript // Include the Postal library -var Postal = require('@atech/postal'); - -// Create a new Postal client using a server key generated using your -// installation's web interface -var client = new Postal.Client('https://postal.yourdomain.com', 'your-api-key'); - -// Create a new message -var message = new Postal.SendMessage(client); - -// Add some recipients -message.to('john@example.com'); -message.to('mary@example.com'); -message.cc('mike@example.com'); -message.bcc('secret@awesomeapp.com'); - -// Specify who the message should be from - this must be from a verified domain -// on your mail server -message.from('test@test.postal.io'); - -// Set the subject -message.subject('Hi there!'); - -// Set the content for the e-mail -message.plainBody('Hello world!'); -message.htmlBody('

Hello world!

'); - -// Add any custom headers -message.header('X-PHP-Test', 'value'); - -// Attach any files -message.attach('textmessage.txt', 'text/plain', 'Hello world!'); - -// Send the message and get the result -message.send() - .then(function (result) { - var recipients = result.recipients(); - // Loop through each of the recipients to get the message ID - for (var email in recipients) { - var message = recipients[email]; - console.log(message.id()); // Logs the message ID - console.log(message.token()); // Logs the message's token - } - }).catch(function (error) { - // Do something with the error - console.log(error.code); - console.log(error.message); - }); +const Postal = require('@atech/postal').default; // CommonJS +// OR +import Postal from '@atech/postal' // ES6 import + +// Create a new Postal client using a server key generated using your installation's web interface +const client = new Postal({ + hostname: 'https://postal.yourdomain.com', + apiKey: 'your-api-key', +}); + +// This must be in an async function +try { + // Send a new message + const message = await client.sendMessage({ + // Set the subject + subject: 'Hi there!', + // Specify who the message should be from - this must be from a verified domain on your mail server + from: 'test@test.postal.io', + // Add some recipients + to: ['john@example.com', 'mary@example.com'], + cc: ['mike@example.com'], + bcc: ['secret@awesomeapp.com'], + // Set the content for the e-mail + plain_body: 'Hello world!', + html_body: '

Hello world!

', + // Add any custom headers + headers: { + 'X-PHP-Test': 'value', + }, + // Attach any files + attachments: [ + { + content_type: 'text/plain', + data: Buffer.from('Hello world!').toString('base64'), + name: 'textmessage.txt', + }, + ], + }); + + // Do something with the returned data + console.log(message); +} catch (error) { + // Handle the error + console.log(error); +} ``` diff --git a/SendMessage.js b/SendMessage.js deleted file mode 100644 index ccbe2ea..0000000 --- a/SendMessage.js +++ /dev/null @@ -1,74 +0,0 @@ -var SendResult = require('./SendResult'); - -function SendMessage(client) { - this.attributes = { - to: [], - cc: [], - bcc: [], - headers: {}, - attachments: [] - }; - this.client = client; -} - -SendMessage.prototype.to = function to(address) { - this.attributes.to.push(address); -}; - -SendMessage.prototype.cc = function cc(address) { - this.attributes.cc.push(address); -}; - -SendMessage.prototype.bcc = function bcc(address) { - this.attributes.bcc.push(address); -}; - -SendMessage.prototype.from = function from(address) { - this.attributes.from = address; -}; - -SendMessage.prototype.sender = function sender(address) { - this.attributes.sender = address; -}; - -SendMessage.prototype.subject = function subject(_subject) { - this.attributes.subject = _subject; -}; - -SendMessage.prototype.tag = function tag(_tag) { - this.attributes.tag = _tag; -}; - -SendMessage.prototype.replyTo = function replyTo(_replyTo) { - this.attributes.reply_to = _replyTo; -}; - -SendMessage.prototype.plainBody = function plainBody(content) { - this.attributes.plain_body = content; -}; - -SendMessage.prototype.htmlBody = function htmlBody(content) { - this.attributes.html_body = content; -}; - -SendMessage.prototype.header = function header(key, value) { - this.attributes.headers[key] = value; -}; - -SendMessage.prototype.attach = function attach(filename, contentType, data) { - var attachment = { - content_type: contentType, - data: new Buffer(data).toString('base64'), - name: filename - }; - this.attributes.attachments.push(attachment); -}; - -SendMessage.prototype.send = function send() { - return this.client.makeRequest('send', 'message', this.attributes) - .then(function (result) { - return new SendResult(this.client, result); - }.bind(this)); -}; - -module.exports = SendMessage; diff --git a/SendRawMessage.js b/SendRawMessage.js deleted file mode 100644 index 79c796c..0000000 --- a/SendRawMessage.js +++ /dev/null @@ -1,28 +0,0 @@ -var SendResult = require('./SendResult'); - -function SendRawMessage(client) { - this.attributes = {}; - this.client = client; -} - -SendRawMessage.prototype.mailFrom = function mailFrom(address) { - this.attributes.mail_from = address; -}; - -SendRawMessage.prototype.rcptTo = function rcptTo(address) { - this.attributes.rcpt_to = (this.attributes.rcpt_to || []); - this.attributes.rcpt_to.push(address); -}; - -SendRawMessage.prototype.data = function data(content) { - this.attributes.data = new Buffer(content).toString('base64'); -}; - -SendRawMessage.prototype.send = function send(callback) { - return this.client.makeRequest('send', 'raw', this.attributes) - .then(function (result) { - return new SendResult(this.client, result); - }.bind(this)); -}; - -module.exports = SendRawMessage; diff --git a/SendResult.js b/SendResult.js deleted file mode 100644 index 99e111a..0000000 --- a/SendResult.js +++ /dev/null @@ -1,26 +0,0 @@ -var Message = require('./Message'); - -function SendResult(client, result) { - this.client = client; - this.result = result; -} - -SendResult.prototype.recipients = function recipients() { - var messages; - - if (!this._recipients) { - this._recipients = {}; - messages = this.result.messages; - for (var key in messages) { - this._recipients[key.toLowerCase()] = new Message(this.client, messages[key]); - } - } - - return this._recipients; -}; - -SendResult.prototype.size = function size() { - return this.recipients.length; -}; - -module.exports = SendResult; diff --git a/index.js b/index.js deleted file mode 100644 index ddee6b1..0000000 --- a/index.js +++ /dev/null @@ -1,9 +0,0 @@ -var Client = require('./Client'); -var SendMessage = require('./SendMessage'); -var SendRawMessage = require('./SendRawMessage'); - -module.exports = { - Client: Client, - SendMessage: SendMessage, - SendRawMessage: SendRawMessage -}; diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..4184e66 --- /dev/null +++ b/index.ts @@ -0,0 +1,104 @@ +import axios from 'axios'; +import { z } from 'zod'; +import { SendMessage, GetMessage, SendRawMessage } from './schemas'; +import type { + PostalHash, + PostalMessage, + PostalError, + PostalResponse, +} from './types'; + +class Postal { + #hostname: string; + #apiKey: string; + + constructor({ hostname, apiKey }: { hostname: string; apiKey: string }) { + this.#hostname = hostname; + this.#apiKey = apiKey; + + if (!hostname) { + throw new Error('Hostname is required'); + } + + if (!apiKey) { + throw new Error('API Key is required'); + } + } + + async #sendRequest( + controller: string, + action: string, + parameters: Record, + ): Promise { + return new Promise((resolve, reject) => { + axios + .post( + `${this.#hostname}/api/v1/${controller}/${action}`, + JSON.stringify(parameters), + { + headers: { + 'Content-Type': 'application/json', + 'X-Server-API-Key': this.#apiKey, + }, + }, + ) + .then(({ data: { status, data } }) => { + if (status === 'error') { + reject(data); + } else { + resolve(data); + } + }); + }); + } + + async sendMessage( + payload: z.infer, + ): Promise { + const data = SendMessage.safeParse(payload); + + if (!data.success) { + throw new Error(JSON.stringify(data.error.format())); + } + + return (await this.#sendRequest( + 'send', + 'message', + data.data, + )) as PostalHash; + } + + async sendRawMessage( + payload: z.infer, + ): Promise { + const data = SendRawMessage.safeParse(payload); + + if (!data.success) { + throw new Error(JSON.stringify(data.error.format())); + } + + return (await this.#sendRequest( + 'send', + 'raw', + data.data, + )) as PostalHash; + } + + async getMessage( + payload: z.infer, + ): Promise { + const data = GetMessage.safeParse(payload); + + if (!data.success) { + throw new Error(JSON.stringify(data.error.format())); + } + + return (await this.#sendRequest( + 'messages', + 'message', + data.data, + )) as PostalMessage; + } +} + +export default Postal; diff --git a/package.json b/package.json index 2814e6d..f10c9fb 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,28 @@ { - "name": "@atech/postal", - "version": "1.0.0", - "description": "Node library for open source mail delivery platform, Postal", - "author": "aTech Media ", - "license": "MIT", - "keywords": [ - "postal", - "mail" - ], - "dependencies": { - "concat-stream": "^1.5.2", - "promise": "^7.1.1" - } + "name": "@atech/postal", + "version": "2.0.0", + "description": "Node library for open source mail delivery platform, Postal", + "author": "aTech Media ", + "license": "MIT", + "keywords": [ + "postal", + "mail" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/" + ], + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "prepare": "npm run build" + }, + "dependencies": { + "axios": "^0.27.2", + "zod": "^3.16.0" + }, + "devDependencies": { + "typescript": "^4.6.4" + } } diff --git a/schemas.ts b/schemas.ts new file mode 100644 index 0000000..78347c1 --- /dev/null +++ b/schemas.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +const SendMessage = z.object({ + subject: z.string(), + from: z.string(), + sender: z.string().optional(), + to: z.array(z.string()).max(50).optional(), + cc: z.array(z.string()).max(50).optional(), + bcc: z.array(z.string()).max(50).optional(), + reply_to: z.string().optional(), + plain_body: z.string().optional(), + html_body: z.string().optional(), + tag: z.string().optional(), + bounce: z.boolean().optional(), + headers: z.record(z.string()).optional(), + attachments: z + .array( + z.object({ + content_type: z.string(), + data: z.string(), + name: z.string(), + }), + ) + .optional(), +}); + +const SendRawMessage = z.object({ + mail_from: z.string(), + rcpt_to: z.array(z.string()), + data: z.string(), + bounce: z.boolean().optional(), +}); + +const GetMessage = z.object({ + id: z.number(), + _expansions: z + .enum([ + 'status', + 'details', + 'inspection', + 'plain_body', + 'html_body', + 'attachments', + 'headers', + 'raw_message', + ]) + .array() + .or(z.literal(true)) + .optional(), +}); + +export { SendMessage, SendRawMessage, GetMessage }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..eb097d5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "CommonJS", + "moduleResolution": "node", + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist", + "noEmitOnError": true, + "strict": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "strictPropertyInitialization": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..5beb7f7 --- /dev/null +++ b/types.ts @@ -0,0 +1,75 @@ +type PostalHash = { + message_id: string; + messages: { + [key: string]: { + id: number; + token: string; + }; + }; +}; + +type PostalMessage = { + id: number; + token: string; + status?: { + status: + | 'Pending' + | 'Sent' + | 'Held' + | 'SoftFail' + | 'HardFail' + | 'Bounced'; + last_delivery_attempt: number; + held: boolean; + hold_expiry: number | null; + }; + details?: { + rcpt_to: string; + mail_from: string; + subject: string; + message_id: string; + timestamp: number; + direction: 'incoming' | 'outgoing'; + // This should return a number, but API returns a string for some reason + size: string; + bounce: 0 | 1; + bounce_for_id: number; + tag: string | null; + received_with_ssl: 0 | 1; + }; + inspection?: { + inspected: boolean; + spam: boolean; + spam_score: number; + threat: boolean; + threat_details: string | null; + }; + plain_body?: string | null; + html_body?: string | null; + attachments?: Array<{ + data: string; + content_type: string; + name: string; + }>; + headers?: Record>; + raw_message?: string; + activity_entries?: { + // TODO: Determine the types for loads and clicks + loads: Array; + clicks: Array; + }; +}; + +type PostalError = { + code: string; + message: string; +}; + +type PostalResponse = { + status: 'parameter-error' | 'error' | 'success'; + time: number; + flags: Record; + data: PostalError; +}; + +export type { PostalHash, PostalMessage, PostalError, PostalResponse }; diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..2dd0a65 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,64 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +follow-redirects@^1.14.9: + version "1.15.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.0.tgz#06441868281c86d0dda4ad8bdaead2d02dca89d4" + integrity sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ== + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +typescript@^4.6.4: + version "4.6.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" + integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== + +zod@^3.16.0: + version "3.16.0" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.16.0.tgz#edfdbf77fcc9a5af13a2630a44bdda5b90e759b7" + integrity sha512-szrIkryADbTM+xBt2a1KoS2CJQXec4f9xG78bj5MJeEH/XqmmHpnO+fG3IE115AKBJak+2HrbxLZkc9mhdbDKA==