├── .yarnrc.yml ├── src ├── index.ts ├── utils.ts ├── sequence.ts ├── types.ts ├── scheduler.ts └── scheduler.test.ts ├── jest.config.ts ├── tsconfig.json ├── .github └── workflows │ └── ci.yml ├── package.json ├── examples └── onboarding-sequence.ts ├── README.md └── .gitignore /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableImmutableInstalls: false 2 | enableTelemetry: false 3 | nodeLinker: node-modules 4 | yarnPath: .yarn/releases/yarn-3.2.3.cjs 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { SchedulerParams, ExecParams } from "./types"; 2 | export { Scheduler } from "./scheduler"; 3 | export { Sequence } from "./sequence"; 4 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 2 | 3 | export const removeFromArray = (a: Array, b: Array) => { 4 | return a.filter((item) => !b.includes(item)); 5 | }; 6 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest"; 2 | 3 | const config: Config = { 4 | preset: "ts-jest", 5 | roots: ["/src"], 6 | moduleFileExtensions: ["js", "json", "ts"], 7 | forceExit: true, 8 | verbose: false, 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["ESNext", "ESNext.AsyncIterable"], 5 | "module": "CommonJS", 6 | "forceConsistentCasingInFileNames": true, 7 | "rootDir": "src", 8 | "outDir": "dist", 9 | "moduleResolution": "node", 10 | "types": ["node", "jest"], 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "removeComments": true, 14 | "isolatedModules": true 15 | }, 16 | "exclude": ["dist", "node_modules", "examples"], 17 | "include": ["./src/**/*.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | test: 12 | name: "Test" 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [16.x] 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Install dependencies 27 | run: yarn install 28 | 29 | - name: Run tests 30 | run: yarn test 31 | 32 | - name: Code coverage 33 | run: yarn test --coverage 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dakiya", 3 | "version": "0.2.1", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "repository": "https://github.com/arn4v/dakiya", 7 | "license": "MIT", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "build": "rimraf dist && tsup src/index.ts --dts", 13 | "prepare": "yarn build", 14 | "test": "jest", 15 | "test:coverage": "yarn test --coverage", 16 | "prepublish": "yarn prepare" 17 | }, 18 | "dependencies": { 19 | "date-fns": "^2.29.2", 20 | "mongodb": "^4.9.0", 21 | "mongodb-memory-server": "^8.9.1", 22 | "ms": "canary", 23 | "node-cron": "^3.0.2", 24 | "nodemailer": "^6.7.8", 25 | "tsup": "^6.2.3", 26 | "underscore": "^1.13.4", 27 | "zod": "^3.18.0" 28 | }, 29 | "devDependencies": { 30 | "@shelf/jest-mongodb": "^4.1.0", 31 | "@types/jest": "^29.0.0", 32 | "@types/node": "^18.7.14", 33 | "@types/node-cron": "^3.0.2", 34 | "@types/nodemailer": "^6.4.5", 35 | "@types/underscore": "^1.11.4", 36 | "jest": "^28", 37 | "jest-smtp": "^0.0.2", 38 | "rimraf": "^3.0.2", 39 | "ts-jest": "^28.0.8", 40 | "ts-node": "^10.9.1", 41 | "typescript": "^4.8.2" 42 | }, 43 | "packageManager": "yarn@3.2.3" 44 | } 45 | -------------------------------------------------------------------------------- /src/sequence.ts: -------------------------------------------------------------------------------- 1 | import { StringValue } from "ms"; 2 | import { z } from "zod"; 3 | import { 4 | SequenceAction, 5 | SequenceActionType, 6 | EmailSubjectOrHtmlGenerator, 7 | } from "./types"; 8 | 9 | export class Sequence< 10 | Key extends string, 11 | VariablesSchema extends z.ZodObject<{}> 12 | > { 13 | public emails: Record< 14 | string, 15 | { 16 | key: string; 17 | subject: string | EmailSubjectOrHtmlGenerator; 18 | html: string | EmailSubjectOrHtmlGenerator; 19 | } 20 | > = {}; 21 | private emailCount: number = 0; 22 | public steps: SequenceAction[] = []; 23 | 24 | constructor(public key: Key, public variableSchema: VariablesSchema) {} 25 | 26 | waitFor(value: StringValue) { 27 | const action: SequenceAction = { type: SequenceActionType.WAIT_FOR, value }; 28 | 29 | this.steps.push(action); 30 | 31 | return this; 32 | } 33 | 34 | sendMail({ 35 | key, 36 | subject: getSubject, 37 | html: getHtml, 38 | }: { 39 | key?: string; 40 | subject: string | EmailSubjectOrHtmlGenerator; 41 | html: string | EmailSubjectOrHtmlGenerator; 42 | }) { 43 | const action: SequenceAction = { 44 | type: SequenceActionType.SEND_MAIL, 45 | value: key ?? String(++this.emailCount), 46 | }; 47 | 48 | this.emails[action.value] = { 49 | key: action.value, 50 | html: getHtml, 51 | subject: getSubject, 52 | }; 53 | this.steps.push(action); 54 | 55 | return this; 56 | } 57 | } 58 | 59 | export type UnknownSequence = Sequence; 60 | -------------------------------------------------------------------------------- /examples/onboarding-sequence.ts: -------------------------------------------------------------------------------- 1 | import { Scheduler, Sequence } from "../src"; 2 | import { z } from "zod"; 3 | 4 | const onboardingSequenceVarsSchema = z.object({ 5 | name: z.string(), 6 | verificationUrl: z.string(), 7 | }); 8 | 9 | const onboardingSequence = new Sequence( 10 | "onboarding", 11 | onboardingSequenceVarsSchema 12 | ) 13 | .waitFor("5m") 14 | .sendMail({ 15 | key: "Welcome", 16 | subject({ name }) { 17 | // Return subject string 18 | return `Welcome to App, ${name}`; 19 | }, 20 | html({ name, verificationUrl }) { 21 | // Return Email HTML 22 | return ""; 23 | }, 24 | }); 25 | // .waitFor("1d") 26 | // .mail(/** */); 27 | 28 | const scheduler = new Scheduler([onboardingSequence], { 29 | mongoUri: process.env.MONGODB_URI!, 30 | transportOpts: {}, 31 | }); 32 | 33 | const getVerificationUrl = (token: string) => 34 | `https://myapp.com/verify?token=${token}`; 35 | 36 | interface CreateUserDto { 37 | name: string; 38 | email: string; 39 | password: string; 40 | } 41 | 42 | const createUser = async ({ name, email }: CreateUserDto) => { 43 | //> Save to db 44 | // const user = await usersCollection.insertOne(/* ... */); 45 | // const token = createVerificationToken({}); 46 | const url = getVerificationUrl("myToken"); 47 | 48 | //> Create verification link 49 | 50 | await scheduler.start( 51 | "onboarding", 52 | { 53 | name, 54 | verificationUrl: url, 55 | }, 56 | { to: email, from: "support@myapp.com" } 57 | ); 58 | }; 59 | 60 | const run = async () => { 61 | await scheduler.initialize(); 62 | 63 | await createUser({ 64 | email: "email@email.com", 65 | name: "Arnav", 66 | password: "securepassword123", 67 | }); 68 | }; 69 | 70 | void run(); 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dakiya 2 | 3 | ![CI](https://github.com/arn4v/dakiya/actions/workflows/ci.yml/badge.svg) 4 | 5 | _Simple_ email automation for Node.js _made easy_. 6 | 7 | ## Features 8 | 9 | - **Zero config management**: Use simple, chainable code to create email sequences. 10 | - **Email platform agnostic**: Only SMTP credentials required. 11 | 12 | ## Roadmap 13 | 14 | 15 | 1. [ ] Compliance Features (Unsubscribing) 16 | 2. [ ] Custom conditionals support 17 | 3. [ ] Tracking Opens 18 | 4. [ ] Self-hostable web interface 19 | 20 | ## Example Ussage 21 | 22 | ```typescript 23 | import { Sequence, Scheduler } from "dakiya"; 24 | import { z } from "zod"; 25 | 26 | const welcomeVariablesSchema = z.object({ 27 | name: z.string(), 28 | verificationUrl: z.string(), 29 | }); 30 | 31 | enum EmailSequence { 32 | Onboarding = "onboarding", 33 | } 34 | 35 | export const onboarding = new Sequence( 36 | EmailSequence.Onboarding, 37 | welcomeVariablesSchema 38 | ) 39 | .waitFor("5m") 40 | .sendMail({ 41 | subject: "Welcome to {Product Name}!", 42 | html({ name }) { 43 | return `Hi ${name}, Welcome to {Product Name}`; // Email HTML 44 | }, 45 | }) 46 | .sendMail({ 47 | subject: "Verify Your Email", 48 | html({ verificationUrl }) { 49 | return ""; 50 | }, 51 | }) 52 | .waitFor("1d") 53 | .sendMail({ 54 | subject: "Access {Product Name} On The Go!", 55 | html({ name }) { 56 | const downloadUrl = ""; 57 | return `Hi ${name}, Access {Product Name} on the go using our mobile app. Download for iOS: ${downloadUrl}` 58 | } 59 | }); 60 | 61 | export const scheduler = new Scheduler([onboarding], { 62 | mongoUri: "", // mongodb connections string 63 | transportOpts: {}, // nodemailer transport options 64 | waitMode: "stack" 65 | }); 66 | 67 | await scheduler.initialize(); 68 | await scheduler.schedule( 69 | EmailSequence.Onboarding, 70 | { name: "", verificationUrl: "" }, 71 | // Nodemailer SendMailOptions 72 | { 73 | to: "", 74 | from: "", 75 | } 76 | ); 77 | ``` 78 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient, ObjectId } from "mongodb"; 2 | import { StringValue } from "ms"; 3 | import { Transporter, SendMailOptions, createTransport } from "nodemailer"; 4 | import SMTPTransport from "nodemailer/lib/smtp-transport"; 5 | import { z } from "zod"; 6 | import { UnknownSequence } from "./sequence"; 7 | 8 | type MongoClientOrUrl = 9 | | { 10 | mongo: MongoClient; 11 | } 12 | | { 13 | mongoUrl: string; 14 | }; 15 | 16 | type TransporterOrOptions = 17 | | { 18 | transporterOpts: SMTPTransport.Options; 19 | } 20 | | { 21 | transporter: Transporter; 22 | }; 23 | 24 | export type SchedulerParams = { 25 | waitMode?: "stack" | "individual"; 26 | cronStringOverride?: string; 27 | } & TransporterOrOptions & 28 | MongoClientOrUrl; 29 | 30 | export type ExecParams = Pick< 31 | SendMailOptions, 32 | "cc" | "bcc" | "to" | "from" | "replyTo" | "subject" 33 | >; 34 | 35 | export interface SequenceMetadataDocument { 36 | _id: ObjectId; 37 | name: string; 38 | variables: Object; 39 | jobs: ObjectId[]; 40 | completedJobs: ObjectId[]; 41 | sendParams: ExecParams; 42 | } 43 | 44 | export interface ScheduledJobDocument { 45 | _id: ObjectId; 46 | sequenceId: ObjectId; 47 | key: string; 48 | scheduledFor: number; 49 | canceled: boolean; 50 | sentAt: number | null; 51 | createdAt: number; 52 | } 53 | 54 | export type InternalSequencesMap< 55 | Sequences extends Readonly 56 | > = { 57 | [key in Sequences[number]["key"]]: Sequences extends Array 58 | ? U 59 | : UnknownSequence; 60 | }; 61 | 62 | export enum SequenceActionType { 63 | WAIT_FOR = "waitFor", 64 | SEND_MAIL = "sendMail", 65 | } 66 | 67 | export type SequenceAction = 68 | | { 69 | type: SequenceActionType.WAIT_FOR; 70 | value: StringValue; 71 | } 72 | | { 73 | type: SequenceActionType.SEND_MAIL; 74 | value: string; 75 | }; 76 | 77 | export type EmailSubjectOrHtmlGenerator< 78 | VariablesSchema extends z.ZodObject<{}> 79 | > = (vars: z.infer) => string; 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /src/scheduler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnyBulkWriteOperation, 3 | Collection, 4 | Db, 5 | MongoClient, 6 | ObjectId, 7 | } from "mongodb"; 8 | import ms from "ms"; 9 | import * as cron from "node-cron"; 10 | import { createTransport, Transporter } from "nodemailer"; 11 | import { z, ZodError } from "zod"; 12 | import { UnknownSequence } from "./sequence"; 13 | import { 14 | ExecParams, 15 | InternalSequencesMap, 16 | ScheduledJobDocument, 17 | SchedulerParams, 18 | SequenceActionType, 19 | SequenceMetadataDocument, 20 | } from "./types"; 21 | import { removeFromArray } from "./utils"; 22 | 23 | export class Scheduler< 24 | Sequences extends Readonly, 25 | SequenceMap extends InternalSequencesMap = InternalSequencesMap, 26 | SequenceKeys extends keyof SequenceMap = keyof SequenceMap 27 | > { 28 | private sequences: Readonly; 29 | mongo: MongoClient; 30 | db: Db | undefined; 31 | sequenceCollection: Collection | undefined; 32 | jobsCollection: Collection | undefined; 33 | transporter: Transporter; 34 | initialized: boolean = false; 35 | 36 | constructor(_sequences: Sequences, private params: SchedulerParams) { 37 | this.transporter = 38 | "transporter" in params 39 | ? params.transporter 40 | : createTransport(params.transporterOpts); 41 | 42 | this.sequences = _sequences.reduce((map, sequence) => { 43 | const key = sequence.key as unknown as SequenceKeys; 44 | map[key] = sequence as SequenceMap[SequenceKeys]; 45 | return map; 46 | }, {} as SequenceMap) as Readonly; 47 | 48 | this.mongo = 49 | "mongo" in params ? params.mongo : new MongoClient(params.mongoUrl); 50 | 51 | this.sendPendingEmails = this.sendPendingEmails.bind(this); 52 | 53 | if (!params.waitMode) params.waitMode = "stack"; 54 | } 55 | 56 | async initialize() { 57 | try { 58 | await this.mongo.connect(); 59 | 60 | this.db = this.mongo.db("dakiya"); 61 | this.sequenceCollection = this.db.collection("sequences"); 62 | this.jobsCollection = this.db.collection("jobs"); 63 | 64 | console.log("Dakiya: Scheduler.initialize: Connected to MongoDB"); 65 | } catch (err) { 66 | console.error( 67 | "Dakiya: Scheduler.initialize: Unable to connect to MongoDB" 68 | ); 69 | throw err; 70 | } 71 | 72 | this.startCron(); 73 | console.log("Dakiya: Cronjob started"); 74 | } 75 | 76 | private startCron() { 77 | cron.schedule( 78 | this.params.cronStringOverride ?? "* * * * *", 79 | this.sendPendingEmails 80 | ); 81 | } 82 | 83 | async getScheduledSequence(_id: ObjectId) { 84 | const sequence = await this.sequenceCollection?.findOne({ 85 | _id, 86 | }); 87 | 88 | if (!sequence) { 89 | throw new Error("Sequence not found"); 90 | } 91 | 92 | return sequence; 93 | } 94 | 95 | async getScheduledJobs() { 96 | return await this.jobsCollection 97 | ?.find({ 98 | sentAt: null, 99 | canceled: false, 100 | scheduledFor: { 101 | $lte: new Date().getTime(), 102 | }, 103 | }) 104 | .toArray(); 105 | } 106 | 107 | async sendPendingEmails() { 108 | const jobs = (await this.getScheduledJobs()) || []; 109 | 110 | for (const { _id, key, sequenceId } of jobs) { 111 | try { 112 | const scheduledSequence = await this.getScheduledSequence(sequenceId); 113 | 114 | const sequenceObject = 115 | this.sequences[scheduledSequence.name as unknown as SequenceKeys]; 116 | const template = sequenceObject.emails[key]; 117 | const variables = scheduledSequence?.variables as z.infer< 118 | typeof sequenceObject["variableSchema"] 119 | >; 120 | 121 | await this.transporter.sendMail({ 122 | subject: 123 | typeof template.subject == "function" 124 | ? template.subject(variables) 125 | : template.subject, 126 | html: 127 | typeof template.html == "function" 128 | ? template.html(variables) 129 | : template.html, 130 | }); 131 | 132 | await this.jobsCollection?.updateOne( 133 | { 134 | _id, 135 | }, 136 | { 137 | sentAt: new Date().getTime(), 138 | } 139 | ); 140 | 141 | await this.sequenceCollection?.updateOne( 142 | { 143 | _id: sequenceId, 144 | }, 145 | { 146 | $push: { 147 | completedJobs: _id, 148 | }, 149 | } 150 | ); 151 | } catch (e) { 152 | if (e instanceof Error) { 153 | console.error( 154 | `sendPendingEmails: Failed to send email ${key} of workflow id ${sequenceId}. Reason: ${e.message}` 155 | ); 156 | } 157 | continue; 158 | } 159 | } 160 | } 161 | 162 | async cancel(_id: ObjectId) { 163 | const sequence = await this.getScheduledSequence(_id); 164 | 165 | await this.jobsCollection?.updateMany( 166 | { 167 | _id: { 168 | $in: removeFromArray(sequence?.jobs, sequence?.completedJobs), 169 | }, 170 | }, 171 | { 172 | $set: { 173 | canceled: true, 174 | }, 175 | } 176 | ); 177 | 178 | await this.sequenceCollection?.deleteOne({ 179 | _id, 180 | }); 181 | } 182 | 183 | async schedule( 184 | name: Name, 185 | variables: z.infer, 186 | sendParams: ExecParams 187 | ) { 188 | const sequence = this.sequences[name]; 189 | 190 | if (!sequence) throw new Error("Sequence not found."); 191 | 192 | try { 193 | sequence.variableSchema?.parse(variables); 194 | } catch (e) { 195 | if (e instanceof ZodError) { 196 | console.error( 197 | `Dakiya: Scheduler.schedule: Variables provided for sequence ${String( 198 | name 199 | )} do not match schema.`, 200 | e.issues 201 | ); 202 | throw e; 203 | } 204 | } 205 | 206 | const { jobIds, ops, scheduledSequenceId } = 207 | this.getScheduledJobsOpsObject(sequence); 208 | 209 | await this.sequenceCollection?.insertOne({ 210 | _id: scheduledSequenceId, 211 | name: sequence.key, 212 | variables, 213 | jobs: jobIds, 214 | completedJobs: [], 215 | sendParams: sendParams, 216 | }); 217 | await this.jobsCollection?.bulkWrite(ops); 218 | 219 | return scheduledSequenceId; 220 | } 221 | 222 | getScheduledJobsOpsObject( 223 | sequence: Sequence 224 | ) { 225 | let waitFor: number = 0; 226 | 227 | const ops: AnyBulkWriteOperation[] = []; 228 | 229 | const jobIds: ObjectId[] = []; 230 | 231 | const scheduledSequenceId = new ObjectId(); 232 | 233 | const startTime = new Date().getTime(); 234 | 235 | for (const action of sequence.steps) { 236 | if (action.type == SequenceActionType.WAIT_FOR) { 237 | if (this.params.waitMode == "individual") { 238 | waitFor = ms(action.value); 239 | } else { 240 | waitFor = waitFor + ms(action.value); 241 | } 242 | } else if (action.type == SequenceActionType.SEND_MAIL) { 243 | const jobId = new ObjectId(); 244 | 245 | jobIds.push(jobId); 246 | 247 | ops.push({ 248 | insertOne: { 249 | document: { 250 | _id: jobId, 251 | key: sequence.emails[action.value].key, 252 | sequenceId: scheduledSequenceId, 253 | scheduledFor: startTime + waitFor, 254 | canceled: false, 255 | sentAt: null, 256 | createdAt: new Date().getTime(), 257 | }, 258 | }, 259 | }); 260 | 261 | if (this.params.waitMode == "individual") { 262 | waitFor = 0; 263 | } 264 | } 265 | } 266 | 267 | return { 268 | ops, 269 | jobIds, 270 | scheduledSequenceId, 271 | startTime, 272 | }; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/scheduler.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, test } from "@jest/globals"; 2 | import { MongoClient } from "mongodb"; 3 | import { MongoMemoryServer } from "mongodb-memory-server"; 4 | import ms from "ms"; 5 | import cron from "node-cron"; 6 | import { 7 | createTestAccount, 8 | createTransport, 9 | TestAccount, 10 | Transporter, 11 | } from "nodemailer"; 12 | import { z, ZodError } from "zod"; 13 | import { Scheduler } from "./scheduler"; 14 | import { Sequence } from "./sequence"; 15 | import { sleep } from "./utils"; 16 | 17 | const testSequence = new Sequence( 18 | "test", 19 | z.object({ 20 | name: z.string(), 21 | }) 22 | ) 23 | .sendMail({ 24 | html: "1", 25 | subject: "", 26 | }) 27 | .sendMail({ 28 | html: "2", 29 | subject: "", 30 | }); 31 | 32 | describe("Scheduler", () => { 33 | let mongod: MongoMemoryServer; 34 | let mongo: MongoClient; 35 | let transporter: Transporter; 36 | let testAccount: TestAccount; 37 | 38 | beforeAll(async () => { 39 | mongod = await MongoMemoryServer.create({ 40 | binary: { 41 | checkMD5: false, 42 | arch: "x86_64", 43 | }, 44 | instance: {}, 45 | }); 46 | 47 | mongo = await MongoClient.connect(mongod.getUri()); 48 | 49 | testAccount = await createTestAccount(); 50 | 51 | transporter = createTransport({ 52 | ...testAccount.smtp, 53 | auth: { 54 | user: testAccount.user, 55 | pass: testAccount.pass, 56 | }, 57 | }); 58 | }); 59 | 60 | afterAll(() => mongod.stop()); 61 | 62 | beforeEach(async () => { 63 | const db = mongo.db("dakiya"); 64 | 65 | for (const collection of await db.listCollections().toArray()) { 66 | await db.collection(collection.name).drop(); 67 | } 68 | }); 69 | 70 | it("Should connect to MongoDB & schedule Cron on initialize", async () => { 71 | const cronSpy = jest.spyOn(cron, "schedule"); 72 | const mongoSpy = jest.spyOn(mongo, "connect"); 73 | 74 | const scheduler = new Scheduler([testSequence], { 75 | mongo, 76 | transporter, 77 | }); 78 | 79 | await scheduler.initialize(); 80 | 81 | expect(mongoSpy).toBeCalled(); 82 | expect(cronSpy).toBeCalled(); 83 | }); 84 | 85 | it( 86 | "Should send emails on cron.schedule call", 87 | async () => { 88 | const scheduler = new Scheduler([testSequence], { 89 | mongo, 90 | transporter, 91 | }); 92 | 93 | const sendPendingSpy = jest.spyOn(scheduler, "sendPendingEmails"); 94 | const cronSpy = jest.spyOn(cron, "schedule").mockImplementationOnce( 95 | // @ts-ignore 96 | async (_, __, ___) => await scheduler.sendPendingEmails() 97 | ); 98 | 99 | await scheduler.initialize(); 100 | await scheduler.schedule("test", { name: "" }, { from: "", to: "" }); 101 | 102 | expect(cronSpy).toBeCalled(); 103 | // await sleep(60 * 1000); 104 | expect(sendPendingSpy).toBeCalled(); 105 | }, 106 | 70 * 1000 107 | ); 108 | 109 | it("Should throw an exception if variables don't match zod spec", async () => { 110 | const scheduler = new Scheduler([testSequence], { 111 | mongo, 112 | transporter, 113 | }); 114 | 115 | await scheduler.initialize(); 116 | 117 | // @ts-ignore 118 | expect(() => scheduler.schedule("test", {})).rejects.toBeInstanceOf( 119 | ZodError 120 | ); 121 | }); 122 | 123 | it("scheduler.schedule() adds jobs to scheduled jobs collection", async () => { 124 | const scheduler = new Scheduler([testSequence], { 125 | mongo, 126 | transporter, 127 | }); 128 | 129 | await scheduler.initialize(); 130 | 131 | await scheduler.schedule( 132 | "test", 133 | { 134 | name: "Test", 135 | }, 136 | { 137 | to: "test@test.com", 138 | from: "me@me.com", 139 | subject: "Test ", 140 | } 141 | ); 142 | 143 | expect(await scheduler.getScheduledJobs()).toHaveLength(2); 144 | expect(await scheduler.sequenceCollection?.find().toArray()).toHaveLength( 145 | 1 146 | ); 147 | }); 148 | 149 | it("Should cancel all jobs in a sequence", async () => { 150 | const scheduler = new Scheduler([testSequence], { 151 | mongo, 152 | transporter, 153 | }); 154 | 155 | await scheduler.initialize(); 156 | 157 | const scheduledSequenceId = await scheduler.schedule( 158 | "test", 159 | { 160 | name: "Test", 161 | }, 162 | { 163 | to: "test@test.com", 164 | from: "me@me.com", 165 | subject: "Test ", 166 | } 167 | ); 168 | 169 | expect(await scheduler.getScheduledJobs()).toHaveLength(2); 170 | 171 | await scheduler.cancel(scheduledSequenceId); 172 | 173 | expect(await scheduler.getScheduledJobs()).toHaveLength(0); 174 | }); 175 | 176 | it("Should stack waitFor delays", async () => { 177 | const sequence = new Sequence( 178 | "test", 179 | z.object({ 180 | name: z.string(), 181 | }) 182 | ) 183 | .waitFor("1m") 184 | .sendMail({ 185 | html: "1", 186 | subject: "", 187 | }) 188 | .waitFor("2m") 189 | .sendMail({ 190 | html: "2", 191 | subject: "", 192 | }); 193 | 194 | const scheduler = new Scheduler([sequence], { 195 | mongo, 196 | transporter, 197 | waitMode: "stack", 198 | }); 199 | 200 | const { ops } = scheduler.getScheduledJobsOpsObject(sequence); 201 | 202 | // @ts-ignore 203 | const jobs = ops.map((item) => item.insertOne.document); 204 | 205 | expect(ops).toHaveLength(2); 206 | 207 | expect(jobs?.[1].scheduledFor! - jobs?.[0].scheduledFor!).toBe(ms("2m")); 208 | }); 209 | 210 | it("Should not stack waitFor delays (individual mode)", async () => { 211 | const sequence = new Sequence( 212 | "test", 213 | z.object({ 214 | name: z.string(), 215 | }) 216 | ) 217 | .waitFor("1m") 218 | .sendMail({ 219 | html: "1", 220 | subject: "", 221 | }) 222 | .waitFor("2m") 223 | .sendMail({ 224 | html: "2", 225 | subject: "", 226 | }); 227 | 228 | const scheduler = new Scheduler([sequence], { 229 | mongo, 230 | transporter, 231 | waitMode: "individual", 232 | }); 233 | 234 | const { ops } = scheduler.getScheduledJobsOpsObject(sequence); 235 | 236 | // @ts-ignore 237 | const jobs = ops.map((item) => item.insertOne.document); 238 | 239 | expect(ops).toHaveLength(2); 240 | 241 | expect(jobs?.[1].scheduledFor! - jobs?.[0].scheduledFor!).toBe(ms("1m")); 242 | }); 243 | 244 | test("getScheduledSequence should throw error if invalid _id is passed", async () => { 245 | const sequence = new Sequence( 246 | "test", 247 | z.object({ 248 | name: z.string(), 249 | }) 250 | ) 251 | .waitFor("1m") 252 | .sendMail({ 253 | html: "1", 254 | subject: "", 255 | }) 256 | .waitFor("2m") 257 | .sendMail({ 258 | html: "2", 259 | subject: "", 260 | }); 261 | 262 | const scheduler = new Scheduler([sequence], { 263 | mongo, 264 | transporter, 265 | waitMode: "individual", 266 | }); 267 | 268 | await scheduler.initialize(); 269 | 270 | // @ts-ignore 271 | expect(() => scheduler.getScheduledSequence("invalidId")).rejects.toThrow(); 272 | }); 273 | 274 | it("Should send all pending emails", async () => { 275 | const sequence = new Sequence( 276 | "test", 277 | z.object({ 278 | name: z.string(), 279 | }) 280 | ) 281 | .sendMail({ 282 | html: "1", 283 | subject: "", 284 | }) 285 | .sendMail({ 286 | html: "2", 287 | subject: "", 288 | }); 289 | 290 | const sendMailSpy = jest 291 | .spyOn(transporter, "sendMail") 292 | .mockImplementation(jest.fn()); 293 | 294 | const scheduler = new Scheduler([sequence], { 295 | mongo, 296 | transporter, 297 | }); 298 | 299 | await scheduler.initialize(); 300 | await scheduler.schedule("test", { name: "" }, { from: "", to: "" }); 301 | 302 | expect(await scheduler.getScheduledJobs()).toHaveLength(2); 303 | 304 | await scheduler.sendPendingEmails(); 305 | 306 | expect(sendMailSpy).toBeCalledTimes(2); 307 | }); 308 | }); 309 | --------------------------------------------------------------------------------