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==