diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e348933 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +LISTMONK_HOST= +LISTMONK_USERNAME= +LISTMONK_PASSWORD= \ No newline at end of file diff --git a/.gitignore b/.gitignore index c6bba59..af8438c 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,9 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Private Repository Configuration +.npmrc + +# Individualized automation script +unpublish-publish.sh \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..eea9325 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Listmonk Node Client Library +The idea of this package is to create a more natural feeling client library (particularly for anybody with [OOP](https://en.wikipedia.org/wiki/Object-oriented_programming) experiences) than the existing [Listmonk Node API library](https://github.com/mihairaulea/listmonk-nodejs-api). + +In particular this means abstracting away a lot of the API structure and calls. And trying to make it feel like the way you would give instructions to someone if you were telling them how to work with the software through the web UI. + +That said, still pretty heavy in development and not particularly feature complete. If you need completeness, performance, etc... I'd recommend looking at that other library linked above. + +## Getting Started +Granted, this is largely meant as a software package that you would list in your dependencies. +But, for demo purposes you'll need to copy and fill out the [`.env.example` file](./.env.example) into a `.env` file. +And then run: `yarn demo` (or equivalent). This will create a campaign, template, list and subscriber in the instance you point it to. +Under th hood this just runs the "compiled" version of [`demo.ts`](./src/demo.ts) + +[`demo.ts`](./src/demo.ts) is also where I would start if your looking to understand how to use this package. Though it should be fairly straightforward and intuitive for anyone with an OOP and typescript background. + +## Some Notes +- Forgetting to remember to call `save()` on objects to sync with Listmonk I suspect will be a popular issue for people +- The `fromData` method is an instance method, NOT a static one, so follows a similar theory to ["Hydration"](https://en.wikipedia.org/wiki/Hydration_(web_development)). That is, the instance is usually created and then is filled in with data (these are meant to chain - ex. `new ().fromData(...)`) +- You could, call constructors but using the static `.create` methods are usually easier/better. +- I publish to my own private package repository not NPM (hence the `@BridgemanAccessible/` at the beginning of the package name) + +## Small Promotions +- [Azure Communication Services (ACS) SNS Relay - including Listmonk endpoint](https://github.com/AlanBridgeman/acs-sms-relay) +- [My Business (Bridgeman Accessible)](https://bridgemanaccessible.ca) + +## Contributing, Issues and Pull Request +Feel free to submit issues or pull requests when applicable. I make no promises about answering or any kind of updating/maintenance (particularly on any kind of schedule). But I will try to work with others to have this work for them as I can. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..4503714 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "@BridgemanAccessible/listmonk-node-client", + "version": "1.0.0", + "description": "A Node client library for Listmonk", + "repository": "https://github.com/AlanBridgeman/listmonk-nodejs-client.git", + "author": "Alan Bridgeman", + "license": "MIT", + "scripts": { + "demo": "yarn build && node dist/demo.js", + "build": "tsc" + }, + "devDependencies": { + "@types/node": "^22.7.5", + "dotenv": "^16.4.5", + "typescript": "^5.6.3" + }, + "dependencies": { + "axios": "^1.7.7" + } +} diff --git a/src/API.ts b/src/API.ts new file mode 100644 index 0000000..6bd9719 --- /dev/null +++ b/src/API.ts @@ -0,0 +1,142 @@ +import axios, { AxiosResponse } from 'axios'; + +/** + * Type defining the credentials to use when authenticating with the Listmonk API. + */ +export type APICredentials = { + host: string, + username: string, + password: string +} + +/** + * A class that provides an interface to the Listmonk API. + */ +export class API { + /** The hostname of the Listmonk instance. */ + private host: string; + /** The username to use when authenticating with Listmonk. */ + private username: string; + /** The password to use when authenticating with Listmonk. */ + private password: string; + + /** + * Create a new Listmonk API instance. + * + * @param credentials The credentials to use when authenticating with Listmonk. + */ + constructor(credentials?: APICredentials) { + if(typeof credentials === 'undefined') { + credentials = this.getDefaultCredentialsFromEnvVars(); + } + + this.host = credentials.host; + this.username = credentials.username; + this.password = credentials.password; + } + + /** + * Get the default credentials for the Listmonk API (as found in the environment variables). + * + * The table below describes the environment variables that are used: + * + * | Environment Variable | Description | + * | -------------------- | ------------------------------------------------------ | + * | LISTMONK_HOST | The hostname of the Listmonk instance. | + * | LISTMONK_USERNAME | The username to use when authenticating with Listmonk. | + * | LISTMONK_PASSWORD | The password to use when authenticating with Listmonk. | + * + * @returns The default credentials for the Listmonk API (as found in the environment variables). + */ + private getDefaultCredentialsFromEnvVars(): APICredentials { + if(typeof process.env.LISTMONK_HOST === 'undefined' || process.env.LISTMONK_HOST === null || process.env.LISTMONK_HOST === '') { + throw new Error('LISTMONK_HOST is not defined'); + } + + if(typeof process.env.LISTMONK_USERNAME === 'undefined' || process.env.LISTMONK_USERNAME === null || process.env.LISTMONK_USERNAME === '') { + throw new Error('LISTMONK_USERNAME is not defined'); + } + + if(typeof process.env.LISTMONK_PASSWORD === 'undefined' || process.env.LISTMONK_PASSWORD === null || process.env.LISTMONK_PASSWORD === '') { + throw new Error('LISTMONK_PASSWORD is not defined'); + } + + return { + host: process.env.LISTMONK_HOST, + username: process.env.LISTMONK_USERNAME, + password: process.env.LISTMONK_PASSWORD + }; + } + + /** + * @returns The credentials for the Listmonk API. + */ + getCredentials(): APICredentials { + return { + host: this.host, + username: this.username, + password: this.password + } + } + + /** + * Make a GET request to the Listmonk API. + * + * T (type parameter 1) is the type of the data returned by the API call. + * + * @param endpoint The endpoint/path to send the GET request to (that is, the URL without the hostname). + * @returns A promise that resolves to the results of the API call. + */ + async get(endpoint: string): Promise { + const results = await axios.get<{ data: { results: T } }>(endpoint.startsWith('/') ? this.host + '/api' + endpoint : this.host + '/api/' + endpoint, { auth: { username: this.username, password: this.password } }) + .then(response => { /*console.log(`${endpoint}: ${JSON.stringify(response.data, null, 4)}`);*/ return typeof response.data.data.results !== 'undefined' ? response.data.data.results : (response.data.data as T); }); + + return results; + } + + /** + * Make a POST request to the Listmonk API. + * + * T (type parameter 1) is the type of the data returned by the API call. + * D (type parameter 2) is the type of the data to send to the API. + * + * @param endpoint The endpoint/path to send the POST request to (that is, the URL without the hostname). + * @param data The data to send to the API. + * @returns A promise that resolves to the result of the API call. + */ + async post(endpoint: string, data: D): Promise { + const results = await axios.post,D>(endpoint.startsWith('/') ? this.host + '/api' + endpoint : this.host + '/api/' + endpoint, data, { auth: { username: this.username, password: this.password } }).then(response => response.data); + + return results; + } + + /** + * Make a PUT request to the Listmonk API. + * + * T (type parameter 1) is the type of the data returned by the API call. + * D (type parameter 2) is the type of the data to send to the API. + * + * @param endpoint The endpoint/path to send the PUT request to (that is, the URL without the hostname). + * @param data The data to send to the API. + * @returns A promise that resolves to the result of the API call. + */ + async put(endpoint: string, data: D): Promise { + const results = await axios.put,D>(endpoint.startsWith('/') ? this.host + '/api' + endpoint : this.host + '/api/' + endpoint, data, { auth: { username: this.username, password: this.password } }).then(response => response.data); + + return results; + } + + /** + * Make a DELETE request to the Listmonk API. + * + * T (type parameter 1) is the type of the data returned by the API call. + * + * @param endpoint The endpoint/path to send the DELETE request to (that is, the URL without the hostname). + * @returns A promise that resolves to the result of the API call. + */ + async delete(endpoint: string): Promise { + const results = await axios.delete(endpoint.startsWith('/') ? this.host + '/api' + endpoint : this.host + '/api/' + endpoint, { auth: { username: this.username, password: this.password } }).then(response => response.data); + + return results; + } +} \ No newline at end of file diff --git a/src/APIObject.ts b/src/APIObject.ts new file mode 100644 index 0000000..2384c43 --- /dev/null +++ b/src/APIObject.ts @@ -0,0 +1,29 @@ +import { API, APICredentials } from "./API"; + +export abstract class APIObject { + /** The API instance to use for making requests. */ + protected api: API; + + /** + * Create a new API object. + * + * @param api The API instance to use for making requests. Defaults to a new API instance if not provided. + */ + protected constructor(credentials?: APICredentials) { + this.api = new API(credentials); + } + + /** + * Convert the object to a JSON object. + * + * This is mostly used for API call. But also sometimes for code reuse purposes. + */ + abstract toJSON(): any; + + /** + * Allows the object to be created from data. + * + * @param data The data to create the object from. + */ + abstract fromData(data: T): APIObject | Promise; +} \ No newline at end of file diff --git a/src/Campaign.ts b/src/Campaign.ts new file mode 100644 index 0000000..57fe1b4 --- /dev/null +++ b/src/Campaign.ts @@ -0,0 +1,529 @@ +import { APIObject } from './APIObject'; +import { API, APICredentials } from './API'; +import { List, ListData } from './List'; +import { Template } from './Template'; + +type CampaignData = { + id?: number, + uuid?: string, + name: string, + subject: string, + lists: number[] | ListData[], + type: "regular" | "optin", + content_type: 'richtext' | 'html' | 'markdown' | 'plain', + body: string, + from_email?: string, + alt_body?: string, + send_at?: string, + messenger?: string, + template_id?: number, + tags?: string[], + headers?: { [key: string]: string }, + status?: "draft" | "scheduled" | "running" | "paused" | "cancelled", + created_at?: string, + updated_at?: string +} + +/** + * Represents a campaign in Listmonk. + */ +export class Campaign extends APIObject { + /** Identifier for the Campaign */ + private id?: number; + /** Universal Unique Identifier (UUID) for the Campaign */ + private uuid?: string; + /** Campaign name. */ + private name: string; + /** Campaign email subject. */ + private subject: string; + /** Lists to send campaign to. */ + private lists: List[]; + /** Campaign type: 'regular' or 'optin'. */ + private type?: 'regular' | 'optin'; + /** Content type: `richtext`, `html`, `markdown`, `plain` */ + private contentType?: 'richtext' | 'html' | 'markdown' | 'plain'; + /** Content body of campaign. */ + private campaignBody: string; + /** 'From' email in campaign emails. Defaults to value from settings if not provided. */ + private fromEmail?: string; + /** Alternate plain text body for HTML (and richtext) emails. */ + private altBody?: string; + /** Timestamp to schedule campaign. Format: `YYYY-MM-DDTHH:MM:SSZ` */ + private sendAt?: string; + /** `email` or a custom messenger defined in settings. Defaults to `email` if not provided. */ + private messenger?: string; + /** Template to use */ + private template?: Template; + /** Tags to mark campaign. */ + private tags?: string[]; + /** Key-value pairs to send as SMTP headers. Example: `[{"x-custom-header": "value"}]`. */ + private headers?: { [key: string]: string }; + /** + * status for campaign: `draft`, `scheduled`, `running`, `paused`, `cancelled`. + * + * Note: + * - Only `scheduled` campaigns can change status to `draft`. + * - Only `draft` campaigns can change status to `scheduled`. + * - Only `paused` and `draft` campaigns can start (`running` status). + * - Only `running` campaigns can change status to `cancelled` and `paused`. + */ + private status?: 'draft' | 'scheduled' | 'running' | 'paused' | 'cancelled'; + /** Timestamp for when the campaign was created. Format: `YYYY-MM-DDTHH:MM:SSZ` */ + private createdAt?: string; + /** Timestamp for when the campaign was last updated. Format: `YYYY-MM-DDTHH:MM:SSZ` */ + private updatedAt?: string; + + constructor(credentials?: APICredentials, id?: number, uuid?: string, name?: string, subject?: string, lists?: List[], type?: 'regular' | 'optin', contentType?: 'richtext' | 'html' | 'markdown' | 'plain', campaignBody?: string, fromEmail?: string, altBody?: string, sendAt?: string, messenger?: string, template?: Template, tags?: string[], headers?: { [key: string]: string }, status?: 'draft' | 'scheduled' | 'running' | 'paused' | 'cancelled', createdAt?: string, updatedAt?: string) { + super(credentials); + + this.id = id; + this.uuid = uuid; + this.name = typeof name !== 'undefined' ? name : ''; + this.subject = typeof subject !== 'undefined' ? subject : ''; + this.lists = typeof lists !== 'undefined' ? lists : []; + this.type = type; + this.contentType = contentType; + this.campaignBody = typeof campaignBody !== 'undefined' ? campaignBody : ''; + this.fromEmail = fromEmail; + this.altBody = altBody; + this.sendAt = sendAt; + this.messenger = messenger; + this.template = template; + this.tags = typeof tags !== 'undefined' ? tags : []; + this.headers = headers; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * @returns Identifier for the Campaign + */ + getID(): number | undefined { + return this.id; + } + + /** + * @returns Universal Unique Identifier (UUID) for the Campaign + */ + getUUID(): string | undefined { + return this.uuid; + } + + /** + * @returns Campaign name + */ + getName(): string { + return this.name; + } + /** + * Set the name of the campaign + * + * @param name The name to set the Campaign name to + */ + setName(name: string): void { + this.name = name; + } + + /** + * @returns Campaign email subject + */ + getSubject(): string { + return this.subject; + } + /** + * Set the subject of the campaign + * + * @param subject The subject to set the Campaign email subject to + */ + setSubject(subject: string): void { + this.subject = subject; + } + + /** + * @returns Lists to send the campaign to + */ + getLists(): List[] { + return this.lists; + } + /** + * Add a list for the campaign to send to + * + * @param list List to add to the campaign + */ + addList(list: List): void { + this.lists.push(list); + } + /** + * Remove a list from the campaign + * + * @param list List to remove from the campaign + */ + removeList(list: List): void { + this.lists = this.lists.filter((l: List) => l.getID() !== list.getID()); + } + /** + * Check if the campaign includes a list + * + * @param list The list to check if the campaign includes + * @returns A boolean indicating if the campaign includes the list + */ + includesList(list: List): boolean { + return this.lists.some((l: List) => l.getID() === list.getID()); + } + + /** + * @returns Campaign type: 'regular' or 'optin' + */ + getType(): 'regular' | 'optin' | undefined { + return this.type; + } + /** + * Set the type of the campaign + * + * @param type Campaign type: 'regular' or 'optin' + */ + setType(type: 'regular' | 'optin'): void { + this.type = type; + } + + /** + * @returns Content type: `richtext`, `html`, `markdown`, `plain` + */ + getContentType(): 'richtext' | 'html' | 'markdown' | 'plain' | undefined { + return this.contentType; + } + /** + * Set the content type of the campaign + * + * @param contentType Content type: `richtext`, `html`, `markdown`, `plain` + */ + setContentType(contentType: 'richtext' | 'html' | 'markdown' | 'plain'): void { + this.contentType = contentType; + } + + /** + * @returns Content body of the campaign + */ + getCampaignBody(): string { + return this.campaignBody; + } + /** + * Set the content body of the campaign + * + * @param campaignBody The content body to set the campaign body to + */ + setCampaignBody(campaignBody: string): void { + this.campaignBody = campaignBody; + } + + /** + * @returns 'From' email in campaign emails. Defaults to value from settings if not provided. + */ + getFromEmail(): string | undefined { + return this.fromEmail; + } + /** + * Set the 'From' email in campaign emails + * + * @param fromEmail The email to set the 'From' email in campaign emails to + */ + setFromEmail(fromEmail: string): void { + this.fromEmail = fromEmail; + } + + /** + * @returns Alternate plain text body for HTML (and richtext) emails + */ + getAltBody(): string | undefined { + return this.altBody; + } + /** + * Set the alternate plain text body for HTML (and richtext) emails + * + * @param altBody The alternate plain text body to set for HTML (and richtext) emails + */ + setAltBody(altBody: string): void { + this.altBody = altBody; + } + + /** + * @returns Timestamp to schedule the campaign. Format: `YYYY-MM-DDTHH:MM:SSZ` + */ + getSendAt(): string | undefined { + return this.sendAt; + } + /** + * Set the timestamp to schedule the campaign + * + * @param sendAt The timestamp to set to schedule the campaign + */ + setSendAt(sendAt: string): void { + this.sendAt = sendAt; + } + + /** + * @returns `email` or a custom messenger defined in settings. Defaults to `email` if not provided. + */ + getMessenger(): string | undefined { + return this.messenger; + } + /** + * Set the messenger for the campaign + * + * @param messenger The messenger to set for the campaign + */ + setMessenger(messenger: string): void { + this.messenger = messenger; + } + + /** + * Get the ID of the template used for the campaign + * + * @returns ID of the template used for the campaign or -1 if the template isn't set explicitly + */ + getTemplateID(): number { + return this.getTemplate()?.getID() ?? -1; + } + /** + * @returns The template used for the campaign + */ + getTemplate(): Template | undefined { + return this.template; + } + /** + * Set the template for the campaign + * + * @param template The template to set for the campaign + */ + setTemplate(template: Template): void { + this.template = template; + } + /** + * Set the template for the campaign by it's ID + * + * @param templateID The ID of the template to set for the campaign + */ + setTemplateByID(templateID: number): void { + this.template = new Template(this.api.getCredentials(), templateID); + } + + /** + * @returns Tags to mark the campaign + */ + getTags(): string[] | undefined { + return this.tags; + } + /** + * Add a tag to the campaign + * + * @param tag The tag to add to the campaign + */ + addTag(tag: string): void { + if(this.tags === undefined) { + this.tags = []; + } + this.tags.push(tag); + } + /** + * Remove a tag from the campaign + * + * @param tag The tag to remove from the campaign + */ + removeTag(tag: string): void { + if(this.tags === undefined) { + return; + } + this.tags = this.tags.filter((t: string) => t !== tag); + } + + /** + * @returns Key-value pairs to send as SMTP headers + */ + getHeaders(): { [key: string]: string } | undefined { + return this.headers; + } + /** + * Add a header to the campaign + * + * @param key The key of the header to add + * @param value The value of the header to add + */ + addHeader(key: string, value: string): void { + if(this.headers === undefined) { + this.headers = {}; + } + this.headers[key] = value; + } + /** + * Remove a header from the campaign + * + * @param key The key of the header to remove + */ + removeHeader(key: string): void { + if(this.headers === undefined) { + return; + } + delete this.headers[key]; + } + + /** + * @returns Status for the campaign: `draft`, `scheduled`, `running`, `paused`, `cancelled` + */ + getStatus(): 'draft' | 'scheduled' | 'running' | 'paused' | 'cancelled' | undefined { + return this.status; + } + /** + * Set the status for the campaign + * + * Note: + * - Only `scheduled` campaigns can change status to `draft`. + * - Only `draft` campaigns can change status to `scheduled`. + * - Only `paused` and `draft` campaigns can start (`running` status). + * - Only `running` campaigns can change status to `cancelled` and `paused`. + * + * @param status The status to set for the campaign: `draft`, `scheduled`, `running`, `paused`, `cancelled` + */ + setStatus(status: 'draft' | 'scheduled' | 'running' | 'paused' | 'cancelled'): void { + this.status = status; + } + + /** + * @returns Timestamp for when the campaign was created. Format: `YYYY-MM-DDTHH:MM:SSZ` + */ + getCreatedAt(): string | undefined { + return this.createdAt; + } + + /** + * @returns Timestamp for when the campaign was last updated. Format: `YYYY-MM-DDTHH:MM:SSZ` + */ + getUpdatedAt(): string | undefined { + return this.updatedAt; + } + + /** + * Creates or updates the campaign in Listmonk. + */ + async save() { + const data = this.toJSON(); + + // If the UUID is not set, then we assume we need to create the subscriber in Listmonk + if(typeof data.uuid === 'undefined') { + // Technically, this might be unnecessary since the values should be set to undefined anyway, but it's good to be safe + delete data.id; + delete data.uuid; + delete data.created_at; + delete data.updated_at; + delete data.status; + + + // Make the API call to create the subscriber in Listmonk + await this.api.post('/campaigns', data); + } + else { + await this.api.put('/campaigns/' + data.id, data); + } + } + + toJSON(): CampaignData { + return { + id: this.id, + uuid: this.uuid, + name: this.name, + subject: this.subject, + lists: this.lists.map((list: List) => list.getID() as number), + type: typeof this.type !== 'undefined' ? this.type : 'regular', + content_type: typeof this.contentType !== 'undefined' ? this.contentType : 'plain', + body: this.campaignBody, + from_email: this.fromEmail, + alt_body: this.altBody, + send_at: this.sendAt, + messenger: this.messenger, + template_id: this.template?.getID(), + tags: this.tags, + headers: this.headers, + status: typeof this.status !== 'undefined' ? this.status : 'draft', + created_at: this.createdAt, + updated_at: this.updatedAt + }; + } + + async fromData(data: CampaignData): Promise { + const typedData = data as {id?: number, uuid?: string, name: string, subject: string, lists: number[] | ListData[], type: "regular" | "optin", content_type: 'richtext' | 'html' | 'markdown' | 'plain', body: string, from_email?: string, alt_body?: string, send_at?: string, messenger?: string, template_id?: number, tags?: string[], headers?: { [key: string]: string }, status: "draft" | "scheduled" | "running" | "paused" | "cancelled", created_at?: string, updated_at?: string }; + const lists = ( + await Promise.all( + typedData.lists.map( + async (listData: Number | ListData) => { + if(typeof listData === 'number') { + return await List.find((list: List) => list.getID() === listData); + } + else { + return new List().fromData(listData); + } + } + ) + ) + ).filter((list: List | undefined) => typeof list !== 'undefined'); + + const template = typeof typedData.template_id !== 'undefined' ? await Template.find((template: Template) => template.getID() === typedData.template_id) : undefined; + + this.id = typedData.id; + this.uuid = typedData.uuid; + this.setName(typedData.name); + this.setSubject(typedData.subject); + this.lists = lists; + this.setType(typedData.type); + this.setContentType(typedData.content_type); + this.setCampaignBody(typedData.body); + this.fromEmail = typedData.from_email; + this.altBody = typedData.alt_body; + this.sendAt = typedData.send_at; + this.messenger = typedData.messenger; + this.template = template; + this.tags = typedData.tags; + this.headers = typedData.headers; + this.setStatus(typedData.status); + this.createdAt = typedData.created_at; + this.updatedAt = typedData.updated_at; + + return this; + } + + /** + * Create a new Campaign object with the given data. + * + * Note, this doesn't automatically create the campaign in Listmonk. You need to call `save` to do that. + * + * @param name Name of the new campaign. + * @param subject Subject of the new campaign. + * @param lists Lists to send the campaign to. + * @param type Type of the campaign: 'regular' or 'optin'. + * @param contentType Content type: `richtext`, `html`, `markdown`, `plain` + * @param body Raw HTML body of the campaign + * @returns The new campaign object + */ + static async create(name: string, subject: string, lists: List[], type: "regular" | "optin", content_type: 'richtext' | 'html' | 'markdown' | 'plain', body: string, credentials?: APICredentials): Promise { + return await new Campaign(credentials).fromData({ name, subject, lists: lists.map((list: List) => list.toJSON()), type, content_type, body }); + } + + /** + * Find a campaign based on the given predicate. + * + * @param predicate A function that takes a campaign and returns true if it matches the desired template. + * @param credentials Optional credentials to use to authenticate API requests + * @returns The campaign that matches the predicate, or undefined if no campaign matches the predicate. + */ + static async find(predicate: (campaign: Campaign) => boolean, credentials?: APICredentials): Promise { + return await new API(credentials).get('/campaigns') + .then(async (campaigns: CampaignData[]) => { + // Convert each CampaignData JSON object to a Campaign object + const campaignObjs: Campaign[] = await Promise.all(campaigns.map((campaign: CampaignData) => new Campaign(credentials).fromData(campaign))); + + // Use the predicate function provided to find the desired campaign + const campaign = campaignObjs.find(predicate); + + return campaign; + }); + } +} \ No newline at end of file diff --git a/src/List.ts b/src/List.ts new file mode 100644 index 0000000..9ab40e7 --- /dev/null +++ b/src/List.ts @@ -0,0 +1,131 @@ +import { APIObject } from "./APIObject"; +import { API, APICredentials } from "./API"; + +export type ListData = { + id?: number, + uuid?: string, + name: string, + type: 'public' | 'private', + optin?: 'single' | 'double', + tags: string[], + created_at?: string, + updated_at?: string +}; + +export class List extends APIObject { + private id?: number; + private uuid?: string; + private name: string; + private type?: 'public' | 'private'; + private optin?: 'single' | 'double'; + private tags: string[]; + private createdAt?: string; + private updatedAt?: string; + + constructor(credentials?: APICredentials, id?: number, uuid?: string, name?: string, type?: 'public' | 'private', optin?: 'single' | 'double', tags?: string[], createdAt?: string, updatedAt?: string) { + super(credentials); + + this.id = id; + this.uuid = uuid; + this.name = typeof name !== 'undefined' ? name : ''; + this.type = type; + this.optin = optin; + this.tags = typeof tags !== 'undefined' ? tags : []; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + getID(): number | undefined { + return this.id; + } + + getName(): string { + return this.name; + } + + /** + * Creates or updates the list in Listmonk. + */ + async save() { + const data = this.toJSON(); + + // If the UUID is not set, then we assume we need to create the list in Listmonk + if(typeof data.uuid === 'undefined') { + // Technically, this might be unnecessary since the values should be set to undefined anyway, but it's good to be safe + delete data.id; + delete data.created_at; + delete data.updated_at; + delete data.uuid; + + // Make the API call to create the list in Listmonk + await this.api.post('/lists', data); + } else { + // Make the API call to update the list in Listmonk + await this.api.post('/lists/' + data.uuid, data); + } + } + + toJSON(): ListData { + return { + id: this.id, + uuid: this.uuid, + name: this.name, + type: typeof this.type !== 'undefined' ? this.type : 'private', + optin: this.optin, + tags: this.tags, + created_at: this.createdAt, + updated_at: this.updatedAt + }; + } + + fromData(data: ListData): List { + const typedData = data as { id?: number, uuid?: string, name: string, type: 'public' | 'private', optin?: 'single' | 'double', tags: string[], created_at?: string, updated_at?: string }; + + this.id = typedData.id; + this.uuid = typedData.uuid; + this.name = typedData.name; + this.type = typedData.type; + this.optin = typedData.optin; + this.tags = typedData.tags; + this.createdAt = typedData.created_at; + this.updatedAt = typedData.updated_at; + + return this; + } + + /** + * Create a new List object with the given data. + * + * Note, this doesn't automatically create the list in Listmonk. You need to call `save` to do that. + * + * @param name Name of the new list. + * @param type Type of list. Options: private, public. + * @param optin Opt-in type. Options: single, double. + * @param tags Associated tags for a list. + * @param credentials Optional credentials to use for the API call. + * @returns A new List object with the given data. + */ + static async create(name: string, type: 'public' | 'private', optin: 'single' | 'double', tags?: string[], credentials?: APICredentials): Promise { + return await new List(credentials).fromData({ name, type, optin, tags }); + } + + /** + * Find a list based on a predicate function. + * + * @param predicate The function to use to find the desired list. + * @param credentials Optional The credentials to use for the API call. + * @returns The list that matches the predicate, or undefined if no list matches the predicate. + */ + static async find(predicate: (list: List) => boolean, credentials?: APICredentials): Promise { + return await new API(credentials).get('/lists') + .then((lists: ListData[]) => { + // Convert each LIstData JSOn object to a List object + const listObjs: List[] = lists.map((list: ListData) => new List(credentials).fromData(list)); + + // Use the predicate function provided to find the desired list + const list = listObjs.find(predicate); + + return list; + }); + } +} \ No newline at end of file diff --git a/src/ListMembership.ts b/src/ListMembership.ts new file mode 100644 index 0000000..5c4479f --- /dev/null +++ b/src/ListMembership.ts @@ -0,0 +1,47 @@ +import { APIObject } from './APIObject'; +import { APICredentials } from './API'; +import { List, ListData } from './List'; + +export type ListMembershipData = { + subscription_status: 'unconfirmed' | 'confirmed', + [key: string]: any +}; + +export class ListMembership extends APIObject { + private subscriptionStatus?: 'unconfirmed' | 'confirmed'; + private list?: List; + + constructor(credentials?: APICredentials, subscriptionStatus?: 'unconfirmed' | 'confirmed', list?: List) { + super(credentials); + + this.subscriptionStatus = subscriptionStatus; + this.list = list; + } + + getSubscriptionStatus(): 'unconfirmed' | 'confirmed' | undefined { + return this.subscriptionStatus; + } + + getList(): List | undefined { + return this.list; + } + + toJSON(): ListMembershipData { + return { + subscription_status: typeof this.subscriptionStatus !== 'undefined' ? this.subscriptionStatus : 'unconfirmed', + ...this.list?.toJSON() + }; + } + + fromData(data: ListMembershipData): ListMembership { + // NOTE types are weird here because TypeScript was complaining otherwise + const subscriptionStatus = (data as { subscription_status: 'unconfirmed' | 'confirmed', [key: string]: any }).subscription_status; + delete (data as { subscription_status?: 'unconfirmed' | 'confirmed', [key: string]: any }).subscription_status; + const list = new List().fromData(data as ListData); + + this.subscriptionStatus = subscriptionStatus; + this.list = list; + + return this; + } +} \ No newline at end of file diff --git a/src/Subscriber.ts b/src/Subscriber.ts new file mode 100644 index 0000000..154a769 --- /dev/null +++ b/src/Subscriber.ts @@ -0,0 +1,216 @@ +import { APIObject } from "./APIObject"; +import { API, APICredentials } from "./API"; +import { List } from "./List"; +import { ListMembership, ListMembershipData } from "./ListMembership"; + +type SubscriberData = { + id?: number, + uuid?: string, + email: string, + name: string, + attribs: { + [key: string]: any + }, + status: "enabled" | "blocklisted", + lists: number[] | ListMembershipData[], + created_at?: string, + updated_at?: string +}; + +export class Subscriber extends APIObject { + private id?: number; + private uuid?: string; + private email: string; + private name: string; + private attribs: { [key: string]: any }; + private status?: 'enabled' | 'blocklisted'; + private lists: ListMembership[]; + private createdAt?: string; + private updatedAt?: string; + + constructor(credentials?: APICredentials, id?: number, uuid?: string, email?: string, name?: string, attribs?: { [key: string]: any }, status?: 'enabled' | 'blocklisted', lists?: ListMembership[], createdAt?: string, updatedAt?: string) { + super(credentials); + + this.id = id; + this.uuid = uuid; + this.email = typeof email !== 'undefined' ? email : ''; + this.name = typeof name !== 'undefined' ? name : ''; + this.attribs = typeof attribs !== 'undefined' ? attribs : {}; + this.status = status; + this.lists = typeof lists !== 'undefined' ? lists : []; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + getID(): number | undefined { + return this.id; + } + + getUUID(): string | undefined { + return this.uuid; + } + + getEmail(): string { + return this.email; + } + + getName(): string { + return this.name; + } + + getAttributes(): { [key: string]: any } { + return this.attribs; + } + hasAttribute(key: string): boolean { + return typeof this.attribs[key] !== 'undefined'; + } + getAttribute(key: string): any { + return this.hasAttribute(key) ? this.attribs[key] : undefined; + } + setAttribute(key: string, value: any) { + this.attribs[key] = value; + } + removeAttribute(key: string) { + delete this.attribs[key]; + } + + getStatus(): 'enabled' | 'blocklisted' | undefined { + return this.status; + } + + getLists(): ListMembership[] { + return this.lists; + } + addList(list: List) { + this.lists.push(new ListMembership(this.api.getCredentials()).fromData({ subscription_status: 'unconfirmed', ...list.toJSON() })); + } + removeList(list: ListMembership) { + this.lists = this.lists.filter((listMembership: ListMembership) => listMembership !== list); + } + + getCreatedAt(): string | undefined { + return this.createdAt; + } + + getUpdatedAt(): string | undefined { + return this.updatedAt; + } + + /** + * Creates or updates the subscriber in Listmonk. + */ + async save() { + const data = this.toJSON(); + + // If the UUID is not set, then we assume we need to create the subscriber in Listmonk + if(typeof data.uuid === 'undefined') { + // Technically, this might be unnecessary since the values should be set to undefined anyway, but it's good to be safe + delete data.id; + delete data.created_at; + delete data.updated_at; + delete data.uuid; + + // Make the API call to create the subscriber in Listmonk + await this.api.post('/subscribers', data); + } + else { + await this.api.put('/subscribers/' + data.id, data); + } + } + + toJSON(): SubscriberData { + return { + id: this.id, + created_at: this.createdAt, + updated_at: this.updatedAt, + uuid: this.uuid, + email: this.email, + name: this.name, + attribs: this.attribs, + status: this.status as 'enabled' | 'blocklisted', + lists: this.lists.map((listMembership: ListMembership) => (listMembership.getList() as List).getID() as number) + }; + } + + async fromData(data: SubscriberData): Promise { + // NOTE types are weird here because TypeScript was complaining otherwise + const typedData = (data as { id?: number, uuid?: string, email: string, name: string, attribs: { [key: string]: any }, status: "enabled" | "blocklisted", lists: number[] | ListMembershipData[], created_at?: string, updated_at?: string }); + + let listMemberships: ListMembership[] = []; + // We only want to bother processing the lists if there are any + if(typedData.lists.length > 0) { + listMemberships = await Promise.all( + typedData.lists.map( + async (listMembershipData: number | ListMembershipData) => { + if(typeof listMembershipData === 'number') { + // Get the list object based on it's IDs from Listmonk + const listObj = await List.find((list: List) => (list.getID() as number) === listMembershipData); + + // Verify we found a list with the given ID + if(typeof listObj === 'undefined') { + throw new Error('List with ID ' + listMembershipData + ' not found'); + } + + // Default the membership object to unconfirmed + listMembershipData = { + subscription_status: 'unconfirmed', + ...listObj.toJSON() + } + } + + return new ListMembership(this.api.getCredentials()).fromData(listMembershipData); + } + ) + ); + } + + this.id = typedData.id; + this.uuid = typedData.uuid; + this.email = typedData.email; + this.name = typedData.name; + this.attribs = typedData.attribs; + this.status = typedData.status; + this.lists = listMemberships; + this.createdAt = typedData.created_at; + this.updatedAt = typedData.updated_at; + + return this; + } + + /** + * Create a new Subscriber object with the given data. + * + * Note, this doesn't automatically create the subscriber in Listmonk. You need to call `save` to do that. + * + * @param email Subscriber's email address | + * @param name Subscriber's name. | + * @param status Subscriber's status. | + * @param lists List of list IDs to subscribe to. | + * @param attribs Attributes of the new subscriber. | + * @param preconfirm_subscriptions If true, subscriptions are marked as confirmed and no-optin emails are sent for double opt-in lists. | + * @param credentials Optional The credentials to use for the API call. + */ + static async create(email: string, name: string, status: 'enabled' | 'blocklisted', lists?: List[], attribs?: { [key: string]: any }, preconfirm_subscriptions?: boolean, credentials?: APICredentials): Promise { + return await new Subscriber(credentials).fromData({ email, name, status, lists: typeof lists !== 'undefined' ? lists.map((list: List) => list.getID()) : [] , attribs }); + } + + /** + * Find a subscriber based on the given predicate. + * + * @param predicate A function that takes a subscriber and returns true if it matches the desired subscriber. + * @param credentials Optional The credentials to use for the API call. + * @returns The subscriber that matches the predicate, or undefined if no subscriber matches the predicate. + */ + static async find(predicate: (subscriber: Subscriber) => boolean, credentials?: APICredentials): Promise { + return await new API(credentials).get('/subscribers?per_page=all') + .then(async (subscribers: SubscriberData[]) => { + // Convert each SubscriberData JSON object to a Subscriber object + const subscriberObjs: Subscriber[] = await Promise.all(subscribers.map((subscriber: SubscriberData) => new Subscriber(credentials).fromData(subscriber))); + + // Use the predicate function provided to find the desired list + const subscriber = subscriberObjs.find(predicate); + + return subscriber; + }); + } +} \ No newline at end of file diff --git a/src/Template.ts b/src/Template.ts new file mode 100644 index 0000000..98a66d5 --- /dev/null +++ b/src/Template.ts @@ -0,0 +1,238 @@ +import { APIObject } from './APIObject'; +import { API, APICredentials } from './API'; + +type TemplateData = { + id?: number, + name: string, + type: 'campaign' | 'tx', + body: string, + subject?: string, + is_default?: boolean, + created_at?: string, + updated_at?: string +}; + +/** + * A class that represents a template in Listmonk. + */ +export class Template extends APIObject { + private id?: number; + /** Name of the template */ + private name: string; + /** Type of the template (`campaign` or `tx`) */ + private type?: 'campaign' | 'tx'; + /** Raw HTML body of the template */ + private body: string; + /** Subject line for the template (only for tx) */ + private subject?: string; + /** Whether the template is the default template */ + private isDefault?: boolean; + /** Timestamp when the template was created. Format: `YYYY-MM-DDTHH:MM:SSZ` */ + private createdAt?: string; + /** Timestamp when the template was last updated. Format: `YYYY-MM-DDTHH:MM:SSZ` */ + private updatedAt?: string; + + /** + * Create a new template object + * + * @param credentials Optional credentials to use to authenticate API requests + * @param id The identifier for the template + * @param name The name of the template + * @param type The type of the template (`campaign` or `tx`) + * @param body The raw HTML body of the template + * @param isDefault Whether the template is the default template + * @param createdAt When the template was created. Format: `YYYY-MM-DDTHH:MM:SSZ` + * @param updatedAt When the template was last updated. Format: `YYYY-MM-DDTHH:MM:SSZ` + */ + constructor(credentials?: APICredentials, id?: number, name?: string, type?: "campaign" | "tx", body?: string, isDefault?: boolean, createdAt?: string, updatedAt?: string) { + super(credentials); + + this.id = id; + this.name = typeof name !== 'undefined' ? name : ''; + this.body = typeof body !== 'undefined' ? body : ''; + this.type = type; + this.isDefault = isDefault; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * @returns Identifier for the template + */ + getID(): number | undefined { + return this.id; + } + + /** + * @returns The name of the template + */ + getName(): string { + return this.name; + } + /** + * Set the name of the template + * + * @param name Name of the template + */ + setName(name: string): void { + this.name = name; + } + + /** + * @returns Raw HTML body of the template + */ + getBody(): string { + return this.body; + } + /** + * Set the raw HTML body of the template + * + * @param body Raw HTML body of the template + */ + setBody(body: string): void { + this.body = body; + } + + /** + * @returns Type of the template (`campaign` or `tx`) + */ + getType(): 'campaign' | 'tx' | undefined { + return this.type; + } + /** + * Set the type of the template + * + * @param type Type of the template (`campaign` or `tx`) + */ + setType(type: 'campaign' | 'tx'): void { + this.type = type; + } + + /** + * @returns Subject line for the template (only for tx) + */ + getSubject(): string | undefined { + return this.subject; + } + /** + * Set the subject line for the template + * + * NOTE: ONLY relevant for `tx` type templates + * + * @param subject Subject line for the template + */ + setSubject(subject: string): void { + this.subject = subject; + } + + /** + * @returns Whether the template is the default template + */ + getIsDefault(): boolean | undefined { + return this.isDefault; + } + /** + * Set whether the template is the default template + * + * @param isDefault Whether the template is the default template + */ + setIsDefault(isDefault: boolean): void { + this.isDefault = isDefault; + } + + /** + * @returns Timestamp when the template was created. Format: `YYYY-MM-DDTHH:MM:SSZ` + */ + getCreatedAt(): string | undefined { + return this.createdAt; + } + + /** + * @returns Timestamp when the template was last updated. Format: `YYYY-MM-DDTHH:MM:SSZ` + */ + getUpdatedAt(): string | undefined { + return this.updatedAt; + } + + /** + * Creates or updates the template in Listmonk. + */ + async save() { + const data = this.toJSON(); + + // If the UUID is not set, then we assume we need to create the subscriber in Listmonk + if(typeof data.id === 'undefined') { + // Technically, this might be unnecessary since the values should be set to undefined anyway, but it's good to be safe + delete data.id; + delete data.created_at; + delete data.updated_at; + + // Make the API call to create the subscriber in Listmonk + await this.api.post('/templates', data); + } + else { + await this.api.put('/templates/' + data.id, data); + } + } + + toJSON(): TemplateData { + return { + id: this.id, + name: this.name, + body: this.body, + type: typeof this.type !== 'undefined' ? this.type : 'campaign', + is_default: this.isDefault, + created_at: this.createdAt, + updated_at: this.updatedAt + }; + } + + fromData(data: TemplateData): Template { + const typedData = data as { id?: number, name: string, type: 'campaign' | 'tx', body: string, subject?: string, is_default?: boolean, created_at?: string, updated_at?: string}; + + this.id = typedData.id; + this.name = typedData.name + this.type = typedData.type; + this.body = typedData.body; + this.isDefault = typedData.is_default; + this.createdAt = typedData.created_at; + this.updatedAt = typedData.updated_at; + + return this; + } + + /** + * Create a new Template object with the given data. + * + * Note, this doesn't automatically create the template in Listmonk. You need to call `save` to do that. + * + * @param name Name of the new template. + * @param type Type of the template (`campaign` or `tx`) + * @param body Raw HTML body of the template + * @param credentials Optional credentials to use to authenticate API requests + * @returns The new template object + */ + static async create(name: string, type: 'campaign' | 'tx', body: string, credentials?: APICredentials): Promise