├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── demo-sources.png └── demo.gif ├── eslint.config.js ├── examples ├── nunjucks-contacts.csv ├── nunjucks-template.md ├── outreach-template.md └── sample-contacts.csv ├── package-lock.json ├── package.json ├── server ├── Dockerfile ├── README.md ├── buildAndPush.sh ├── package.json ├── server.cjs └── startLocal.sh ├── src ├── cli │ ├── cmd.compose.ts │ ├── cmd.dev.ts │ ├── cmd.renderers.ts │ ├── cmd.send.ts │ ├── cmd.setup.ts │ ├── help.ts │ ├── index.ts │ ├── preview.ts │ ├── prompt.ts │ └── serializer.ts └── lib │ ├── config.ts │ ├── gmail.ts │ ├── google-auth.ts │ ├── ollama.ts │ ├── openai.ts │ ├── renderers │ ├── base.ts │ ├── index.ts │ ├── mock.ts │ ├── nunjucks.ts │ ├── ollama.ts │ └── openai.ts │ ├── types.ts │ ├── utils.ts │ └── versioning.ts └── tsconfig.json /.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 | 132 | # Secrets 133 | credentials.json -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Our Project 2 | 3 | We welcome contributions from everyone. To contribute, please follow these steps: 4 | 5 | 1. **Fork the repository** and create your branch from `main`. 6 | Try running locally 7 | 8 | ``` 9 | npm i 10 | 11 | npm run start -- compose --contacts ./examples/sample-contacts.csv ./examples/outreach-template.md 12 | ``` 13 | 14 | 2. **Open a Pull Request (PR)** with a clear description of your changes. 15 | 3. **Tag** either `@charlesyu108` or `@ryanhuang519` in the PR for review. 16 | 17 | Thank you for your contributions! 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024, mailmerge-js (charlesyu108 & ryanhuang519) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MailMerge-JS ⚡ 2 | 3 | MailMerge-JS is a next-generation Gmail automation tool supercharged by AI. Effortlessly draft and send highly personalized templated emails without worrying about email templating and data massaging, all from your Gmail inbox. 4 | 5 | 6 | 7 | 8 | 9 | ## How it Works 10 | 11 | MailMerge-JS leverages the power of GenAI and the Gmail API to streamline the email drafting process. Write your templates in any format you prefer (HTML/Markdown/Text/Jinja) and loosely express variables and directives in pseudocode using double curly braces `{{ }}`. The AI will then generate the actual email content for you, synthesized against any data file format you provide. 12 | 13 | ## Features 14 | 15 | - **Bring Your Own Keys and Credentials**: Maintain control over your API keys and credentials. 16 | - **Scale**: Draft and send emails at scale, perfect for large outreach campaigns. 17 | - **Flexibility**: Understands loose or missing data requirements, making it adaptable to various data sources. 18 | - **Free and Open-Source**: Completely free to use and modify. 19 | - **OpenAI Integration**: Enhances the email drafting process with AI-powered content generation. 20 | 21 | ## Installation 22 | 23 | To install MailMerge-JS, use the following commands: 24 | 25 | ```bash 26 | npm install -g mailmerge-js 27 | mailmerge setup 28 | ``` 29 | 30 | Setup will guide you through the process of setting up your MailMerge-JS environment. 31 | To get the most out of this tool, you will need an OpenAI API key and Google App credentials. 32 | 33 | ### Setting up Google App Credentials 34 | 35 | This tool requires Google App credentials to draft and send emails. Here is how you can obtain those credentials: 36 | 37 | 1. Go to the [Google Developer Console](https://console.developers.google.com/). 38 | 2. Create a new project. 39 | 3. Enable the Gmail API for that project. 40 | 4. Add the following scopes to the project: 41 | - `https://www.googleapis.com/auth/gmail.send` 42 | - `https://www.googleapis.com/auth/gmail.compose` 43 | 5. Create credentials for a desktop application. 44 | 6. Download the JSON file 45 | 46 | **NOTICE (5/22/2024) - We used to provide a simple way to authorize via a hosted web server. This is no longer supported due to difficulties with getting Google App Approval. You will need to provide your own application credentials** 47 | 48 | ### Setting up OpenAI API Key 49 | 50 | To use OpenAI features you will need an OpenAI developer API key (read more to see how you can use our tool with local LLMs) 51 | 52 | You can sign up on [OpenAI's website](https://platform.openai.com/signup/). 53 | Get your API key from the [OpenAI API Keys page](https://platform.openai.com/api-keys). 54 | 55 | ### Setting up Local LLM with Ollama 56 | 57 | We also support using this tool with local language models thru Ollama. For example, to compose emails using llama3, you will feed the following 58 | flag to the `compose` command: `--renderer llama3` 59 | 60 | Install Ollama by following the instructions [here](https://ollama.com/download). 61 | 62 | ### Nunjucks (Non-AI Template Engine) 63 | 64 | We also support a non-AI version of this tool via Nunjucks. You need to modify your contact and template files according to the Nunjucks example provided in the `examples` folder. To compose emails using Nunjucks, use the following flag with the `compose` command: `--renderer nunjucks`. 65 | 66 | ## Quickstart 67 | 68 | ### Draft personalized emails 69 | 70 | ``` 71 | mailmerge compose --contacts ./examples/sample-contacts.csv ./examples/outreach-template.md 72 | ``` 73 | 74 | #### Template: `examples/outreach-template.md` 75 | 76 | ```markdown 77 | # Subject 78 | 79 | {{ "Insert some subject related to connecting via their company or title, whichever more appropriate" }} 80 | 81 | # Body 82 | 83 | Hi {{first name}}, 84 | 85 | I hope this message finds you well. I'm Bob from MailMerge-JS, a startup that's building a tool to automate email outreach. 86 | I came across your profile and was impressed by your track record in {{ industry in company }} and wanted to show 87 | you how our tool can help you automate {{ insert reason to use the outreach tool based on their title }} 88 | 89 | Would you be open to a quick chat next week? 90 | 91 | Best, 92 | Bob @ MailMerge-JS 93 | [https://mailmerge-js.dev](https://mailmerge-js.dev) 94 | ``` 95 | 96 | #### Contact Data: `examples/sample-contacts.csv` 97 | 98 | ```csv 99 | name,email,company,position 100 | John Doe,john.doe@example.com,Crunch Fitness,Fitness Instructor 101 | Jane Smith,jane.smith@example.com,Coca-Cola,CTO 102 | Alice Johnson,alice.johnson@example.com,Microsoft,Product Manager 103 | Bob Brown,bob.brown@example.com,Bank of America,Marketing Director 104 | ``` 105 | 106 | ## Contributing 107 | 108 | We welcome contributions! See [CONTRIBUTING.md](https://github.com/WarmSaluters/mailmerge-js/blob/main/CONTRIBUTING.md) for details. 109 | 110 | ## License 111 | 112 | Licensed under the MIT License. See [LICENSE](https://github.com/WarmSaluters/mailmerge-js/blob/main/LICENSE) for details. 113 | 114 | ## Privacy 115 | 116 | Our hosted server endpoint on the `mailmerge-js.dev` domain is used solely for authorizing our app against Google APIs. We do not store any data; all tokens are stored on the user side. We do not collect or store any data. The server code can be inspected in this repository. 117 | 118 | _Note: This privacy policy only applies if you are using the hosted server._ 119 | 120 | ## Terms of Service 121 | 122 | The server exists to make the app more accessible for users setting up the CLI. Its purpose is to obfuscate our own credentials from abuse. We do not collect or store any data. 123 | 124 | _Note: These terms of service only apply if you are using the hosted server._ 125 | 126 | --- 127 | 128 | Made with ❤️ by charlesyu108 & ryanhuang519 129 | -------------------------------------------------------------------------------- /assets/demo-sources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WarmSaluters/mailmerge-js/75e7c756be11d1d14c2b9ab467c3d65b2f2dd4f6/assets/demo-sources.png -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WarmSaluters/mailmerge-js/75e7c756be11d1d14c2b9ab467c3d65b2f2dd4f6/assets/demo.gif -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | 6 | export default [ 7 | {languageOptions: { globals: globals.browser }}, 8 | pluginJs.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | { 11 | ignores: ["dist", "server"], 12 | }, 13 | { 14 | rules: { 15 | "@typescript-eslint/no-explicit-any": "off", 16 | "@typescript-eslint/no-unused-vars": "off", 17 | }, 18 | }, 19 | ]; 20 | 21 | -------------------------------------------------------------------------------- /examples/nunjucks-contacts.csv: -------------------------------------------------------------------------------- 1 | first_name,name,email,company,position,subject 2 | John,John Doe,john.doe@example.com,Crunch Fitness,Fitness Instructor,Reaching out in regards of automation mailing tool 3 | Jane,Jane Smith,jane.smith@example.com,Coca-Cola,CTO,Reaching out in regards of automation mailing tool 4 | Alice,Alice Johnson,alice.johnson@example.com,Microsoft,Product Manager,Reaching out in regards of automation mailing tool 5 | Bob,Bob Brown,bob.brown@example.com,Bank of America,Marketing Director,Reaching out in regards of automation mailing tool -------------------------------------------------------------------------------- /examples/nunjucks-template.md: -------------------------------------------------------------------------------- 1 | Hi {{ name }}, 2 | 3 | I hope this message finds you well. I'm Bob from MailMerge-JS, a startup that's building a tool to automate email outreach. 4 | I came across your profile and was impressed by your track record in {{ company }} and wanted to show 5 | you how our tool can help you automate {{ position }} Job . 6 | 7 | Would you be open to a quick chat next week? 8 | 9 | Best, 10 | Bob @ MailMerge-JS 11 | [https://mailmerge-js.dev](https://mailmerge-js.dev) 12 | -------------------------------------------------------------------------------- /examples/outreach-template.md: -------------------------------------------------------------------------------- 1 | # Subject 2 | 3 | {{ Insert some subject related to connecting via their company or title, whichever more appropriate }} 4 | 5 | # Body 6 | 7 | Hi {{ first name}}, 8 | 9 | I hope this message finds you well. I'm Bob from MailMerge-JS, a startup that's building a tool to automate email outreach. 10 | I came across your profile and was impressed by your track record in {{ industry of company }} and wanted to show 11 | you how our tool can help you automate {{ insert reason to use the outreach tool based on their title }} 12 | 13 | Would you be open to a quick chat next week? 14 | 15 | Best, 16 | Bob @ MailMerge-JS 17 | [https://mailmerge-js.dev](https://mailmerge-js.dev) 18 | -------------------------------------------------------------------------------- /examples/sample-contacts.csv: -------------------------------------------------------------------------------- 1 | name,email,company,position 2 | John Doe,john.doe@example.com,Crunch Fitness,Fitness Instructor 3 | Jane Smith,jane.smith@example.com,Coca-Cola,CTO 4 | Alice Johnson,alice.johnson@example.com,Microsoft,Product Manager 5 | Bob Brown,bob.brown@example.com,Bank of America,Marketing Director -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mailmerge-js", 3 | "version": "1.0.24", 4 | "description": "A powerful CLI for gmail automation, supercharged by AI ⚡", 5 | "exports": "./dist/src/cli/index.js", 6 | "bin": { 7 | "mailmerge": "./dist/src/cli/index.js" 8 | }, 9 | "engines": { 10 | "node": ">=18" 11 | }, 12 | "postinstall": "echo '⚡ ZAP! Run mailmerge setup to get started. ⚡'", 13 | "scripts": { 14 | "start": "npm run build && ./dist/src/cli/index.js", 15 | "release": "npm run build && npm version patch && npm run build && npm publish && git push", 16 | "build": "tsc", 17 | "test": "echo \"Error: no test specified\" && exit 1", 18 | "lint": "eslint .", 19 | "lint:fix": "eslint . --fix" 20 | }, 21 | "type": "module", 22 | "author": "", 23 | "license": "ISC", 24 | "dependencies": { 25 | "axios": "^1.6.8", 26 | "chalk": "^4.1.2", 27 | "cli-spinners": "^3.0.0", 28 | "commander": "^8.3.0", 29 | "csv-parse": "^5.5.6", 30 | "express": "^4.19.2", 31 | "google-auth-library": "^9.10.0", 32 | "googleapis": "^137.1.0", 33 | "inquirer": "^9.2.22", 34 | "marked": "^12.0.2", 35 | "marked-terminal": "^7.0.0", 36 | "nunjucks": "^3.2.4", 37 | "open": "^10.1.0", 38 | "openai": "^4.47.1", 39 | "ora": "5.4.1", 40 | "semver": "^7.6.2", 41 | "showdown": "^2.1.0" 42 | }, 43 | "devDependencies": { 44 | "@eslint/js": "^9.4.0", 45 | "@types/eslint__js": "^8.42.3", 46 | "@types/express": "^4.17.21", 47 | "@types/inquirer": "^9.0.7", 48 | "@types/marked-terminal": "^6.1.1", 49 | "@types/node": "^20.12.12", 50 | "@types/nunjucks": "^3.2.6", 51 | "@types/semver": "^7.5.8", 52 | "@types/showdown": "^2.0.6", 53 | "@types/uuid": "^9.0.8", 54 | "eslint": "^8.57.0", 55 | "globals": "^15.3.0", 56 | "ts-node": "^10.9.2", 57 | "typescript": "^4.4.4", 58 | "typescript-eslint": "^7.11.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Node.js image. 2 | FROM node:14 3 | 4 | # Create and change to the app directory. 5 | WORKDIR /usr/src/app 6 | 7 | # Copy application dependency manifests to the container image. 8 | COPY package*.json ./ 9 | 10 | # Install dependencies. 11 | RUN npm install 12 | 13 | # Copy the local code to the container image. 14 | COPY . . 15 | 16 | # Expose the port the app runs on. 17 | EXPOSE 3000 18 | 19 | # Run the web service on container startup. 20 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Server 2 | This server is only necessary for providing remote auth to the Google API. YOU DO NOT NEED THIS IF YOU WISH TO USE YOUR OWN GOOGLE APP CREDENTIALS. 3 | Mailmerge-js hosts this for easy setup. -------------------------------------------------------------------------------- /server/buildAndPush.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | # Use buildx to build 3 | docker buildx build --platform linux/amd64 --tag warmsaluters/mailmerge-js-auth . --push 4 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth-server", 3 | "version": "1.0.0", 4 | "description": "A simple authentication server using Google OAuth2", 5 | "main": "server.cjs", 6 | "scripts": { 7 | "start": "node server.cjs" 8 | }, 9 | "dependencies": { 10 | "express": "^4.17.1", 11 | "googleapis": "^39.2.0" 12 | } 13 | } -------------------------------------------------------------------------------- /server/server.cjs: -------------------------------------------------------------------------------- 1 | // NOTE: This is only required if you want to serve your own auth server. Not needed for personal cli use 2 | 3 | const express = require('express'); 4 | const { google } = require('googleapis'); 5 | 6 | const app = express(); 7 | const PORT = process.env.PORT || 3000; 8 | 9 | const oAuth2Client = new google.auth.OAuth2( 10 | process.env.CLIENT_ID, 11 | process.env.CLIENT_SECRET, 12 | process.env.REDIRECT_URI 13 | ); 14 | 15 | app.get('/auth', (req, res) => { 16 | const authUrl = oAuth2Client.generateAuthUrl({ 17 | access_type: 'offline', 18 | scope: req.query.scopes.split(','), 19 | }); 20 | res.json({ authUrl }); 21 | }); 22 | 23 | app.get('/oauth2callback', async (req, res) => { 24 | const code = req.query.code; 25 | try { 26 | const { tokens } = await oAuth2Client.getToken(code); 27 | res.json(tokens); 28 | } catch (error) { 29 | res.status(500).send('Error during authentication'); 30 | } 31 | }); 32 | 33 | app.get('/refresh', async (req, res) => { 34 | const storedTokens = JSON.parse(req.query.token); 35 | oAuth2Client.setCredentials(storedTokens); 36 | oAuth2Client.forceRefreshOnFailure = true; 37 | await oAuth2Client.getAccessToken(); 38 | const tokens = oAuth2Client.credentials; 39 | res.json({tokens}); 40 | }); 41 | 42 | app.listen(PORT, () => { 43 | console.log(`Server is running on port ${PORT}`); 44 | }); -------------------------------------------------------------------------------- /server/startLocal.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | 3 | # set the path to the credentials.json 4 | path=../credentials.json 5 | 6 | # set the redirect url to http://localhost:7278/oauth2callback 7 | redirectUrl="http://localhost:7278/oauth2callback" 8 | 9 | # Parse client id, client secret out of the credentials.json 10 | clientId=$(jq -r '.installed.client_id' $path) 11 | clientSecret=$(jq -r '.installed.client_secret' $path) 12 | 13 | # start docker with the env vars and the path to the credentials.json 14 | docker build -t warmsaluters/mailmerge-js-auth . 15 | docker run -t -p 3000:3000 -e CLIENT_ID="$clientId" -e CLIENT_SECRET="$clientSecret" -e REDIRECT_URI="$redirectUrl" warmsaluters/mailmerge-js-auth -------------------------------------------------------------------------------- /src/cli/cmd.compose.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { Command } from "commander"; 3 | import { marked } from "marked"; 4 | import { markedTerminal } from "marked-terminal"; 5 | import ora from "ora"; 6 | import showdown from "showdown"; 7 | import { createDraft, sendEmail } from "../lib/gmail.js"; 8 | import { authorize } from "../lib/google-auth.js"; 9 | import { getFileContents } from "../lib/utils.js"; 10 | import { continueOrSkip } from "./prompt.js"; 11 | import { EmailPreviewer } from "./preview.js"; 12 | import inquirer from "inquirer"; 13 | import { EmailSerializer } from "./serializer.js"; 14 | import Config from "../lib/config.js"; 15 | import { Renderer, getRenderer } from "../lib/renderers/index.js"; 16 | import { exit } from "process"; 17 | import { OllamaMissingModelError, OllamaNotFoundError } from "../lib/ollama.js"; 18 | import fs from "node:fs"; 19 | 20 | export default function DraftAndSendCommand(program: Command) { 21 | //@ts-expect-error not typed correctly 22 | marked.use(markedTerminal()); 23 | 24 | program 25 | .command("compose") 26 | .description(`Compose mail using AI and save to drafts or send via Gmail.`) 27 | .argument("template", "email template to use") 28 | .requiredOption( 29 | "-c, --contacts ", 30 | "contacts file to use for mail merge" + chalk.cyan.bold(" (required)") 31 | ) 32 | .option( 33 | "-r, --renderer ", 34 | "renderer to use for drafting " + 35 | chalk.yellow.bold("(see 'mailmerge renderers list' for options)"), 36 | "gpt-4o" 37 | ) 38 | .option("-l, --limit ", "number of emails to draft") 39 | .option("--outDir ", "If provided, save drafts to this directory.") 40 | .option("--no-preview", "Don't show a preview of the emails.") 41 | .action(async (template, options) => { 42 | // Drafting mail 43 | const spinner = ora("⚡ Composing emails...").start(); 44 | const renderer = await getRenderer(options.renderer); 45 | const templateContents = await getFileContents(template); 46 | const contactsContents = await getFileContents(options.contacts); 47 | const { emails, warnings } = await tryRenderEmails( 48 | renderer, 49 | templateContents, 50 | contactsContents, 51 | options 52 | ); 53 | spinner.stop(); 54 | 55 | if (warnings.length > 0) { 56 | console.log(chalk.yellow("[!] Completed with warnings:")); 57 | for (const warning of warnings) { 58 | console.log(chalk.magenta(`[!] ${warning}`)); 59 | } 60 | } 61 | console.log(chalk.green(`✅ Drafted ${emails.length} emails.`)); 62 | 63 | if (options.noPreview === undefined || options.noPreview === false) { 64 | const showPreviews = await continueOrSkip("Show previews?").prompt(); 65 | if (showPreviews) { 66 | const previewer = new EmailPreviewer(emails); 67 | await previewer.show(); 68 | } 69 | } else { 70 | console.log("=> Skipping previews..."); 71 | } 72 | 73 | if (options.outDir) { 74 | const spinner = ora( 75 | `⚡ Saving drafts to ${chalk.cyan(options.outDir)}...` 76 | ).start(); 77 | const serializer = new EmailSerializer(options.outDir); 78 | await serializer.serialize(emails); 79 | spinner.stop(); 80 | console.log( 81 | chalk.green( 82 | `✅ Saved ${emails.length} drafts to ${chalk.cyan(options.outDir)}` 83 | ) 84 | ); 85 | return; 86 | } 87 | 88 | console.log(chalk.cyan(` [Current Mailbox]: ${Config.currentMailbox}`)); 89 | 90 | // Specify how to save 91 | const { nextOption } = await inquirer.prompt([ 92 | { 93 | type: "list", 94 | name: "nextOption", 95 | message: "What would you like to do with your drafts?\n", 96 | choices: [ 97 | "Save to Gmail Drafts", 98 | "Save to Local Directory", 99 | "Send via Gmail", 100 | "Exit", 101 | ], 102 | }, 103 | ]); 104 | 105 | if (nextOption === "Exit") { 106 | console.log("Exiting..."); 107 | return; 108 | } 109 | 110 | if (nextOption === "Save to Local Directory") { 111 | const { outDir } = await inquirer.prompt([ 112 | { 113 | type: "input", 114 | name: "outDir", 115 | message: "Enter the directory to save the drafts to:", 116 | }, 117 | ]); 118 | if (!fs.existsSync(outDir)) { 119 | console.error( 120 | chalk.red( 121 | `❌ Error saving drafts to ${chalk.cyan( 122 | outDir 123 | )}: path does not exist` 124 | ) 125 | ); 126 | process.exit(1); 127 | } 128 | const spinner = ora( 129 | `⚡ Saving drafts to ${chalk.cyan(outDir)}...` 130 | ).start(); 131 | const serializer = new EmailSerializer(outDir); 132 | await serializer.serialize(emails); 133 | spinner.stop(); 134 | console.log( 135 | chalk.green( 136 | `✅ Saved ${emails.length} drafts to ${chalk.cyan(outDir)}` 137 | ) 138 | ); 139 | return; 140 | } 141 | 142 | const converter = new showdown.Converter({ simpleLineBreaks: true }); 143 | const auth = await authorize(); 144 | 145 | if (nextOption === "Save to Gmail Drafts") { 146 | for (const email of emails) { 147 | await createDraft( 148 | auth, 149 | email.to, 150 | email.subject, 151 | converter.makeHtml(email.body) 152 | ); 153 | } 154 | console.log( 155 | chalk.green( 156 | `✅ Created ${emails.length} drafts for all emails.\n You will need to go into Gmail to send them.` 157 | ) 158 | ); 159 | } 160 | 161 | if (nextOption === "Send via Gmail") { 162 | for (const email of emails) { 163 | await sendEmail( 164 | auth, 165 | email.to, 166 | email.subject, 167 | converter.makeHtml(email.body) 168 | ); 169 | } 170 | console.log(chalk.green(`✅ Sent ${emails.length} emails.`)); 171 | } 172 | }) 173 | .addHelpText( 174 | "after", 175 | ` 176 | ${chalk.reset.bold("Addendum:")} 177 | ${chalk.cyan.bold( 178 | "NOTE: By default, this command only drafts emails. It will NOT send unless prompted.\n" 179 | )} 180 | ${chalk.yellowBright.bold("~~~ ABOUT TEMPLATES ~~~")} 181 | 182 | ${"Templates can be expressed in any markup language you like."} 183 | ${"As a best practice, you should clearly indicate the subject and body of the email."} 184 | ${`Use the delimiters ${chalk.bold.cyan( 185 | "{{ }}" 186 | )} to indicate variables or directives you want the AI to take.`} 187 | 188 | ${chalk.yellowBright.bold("~~~ ABOUT CONTACTS ~~~")} 189 | 190 | ${`Contacts can be provided in any format but must have at least an "email" field.`} 191 | ` 192 | ); 193 | } 194 | 195 | const tryRenderEmails = async ( 196 | renderer: Renderer, 197 | templateContents: string, 198 | contactsContents: string, 199 | options: any 200 | ) => { 201 | try { 202 | const { emails, warnings } = await renderer.render( 203 | templateContents, 204 | contactsContents, 205 | options 206 | ); 207 | return { emails, warnings }; 208 | } catch (error) { 209 | console.log(); 210 | 211 | if (error instanceof OllamaNotFoundError) { 212 | console.error( 213 | chalk.bold.red( 214 | "\n[!] Error: Ollama is not running or not installed! You need this to run local models." 215 | ) 216 | ); 217 | console.log( 218 | chalk.reset( 219 | "To install Ollama, download it from https://ollama.com/downloads\n" 220 | ) 221 | ); 222 | exit(1); 223 | } 224 | 225 | if (error instanceof OllamaMissingModelError) { 226 | console.error(chalk.bold.red("\n[!] Error: " + error.message)); 227 | const missingModel = error.message.split("'", 3)[1]; 228 | console.log( 229 | chalk.reset( 230 | `\nTo proceed, you can pull the model by running: \n\n\t${chalk.yellow( 231 | "ollama pull " + missingModel 232 | )}\n` 233 | ) 234 | ); 235 | exit(1); 236 | } 237 | 238 | console.error(chalk.bold.red("\n[!] Error: " + error)); 239 | exit(1); 240 | } 241 | }; 242 | -------------------------------------------------------------------------------- /src/cli/cmd.dev.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { getCurrentMailbox } from "../lib/gmail.js"; 3 | import { authorize } from "../lib/google-auth.js"; 4 | 5 | export default function _DevCommand(program: Command) { 6 | const root = program 7 | .command("dev", { hidden: true}) 8 | .description("Misc dev tools") 9 | 10 | root.command('test-auth') 11 | .description('Test the tokens') 12 | .action(testAuth); 13 | } 14 | 15 | 16 | const testAuth = async () => { 17 | const auth = await authorize('local'); 18 | const mailbox = await getCurrentMailbox(auth); 19 | console.log(mailbox); 20 | } -------------------------------------------------------------------------------- /src/cli/cmd.renderers.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { supportedRenderers } from "../lib/renderers/index.js"; 3 | 4 | export default function RenderersCommand(program: Command) { 5 | const root = program 6 | .command("renderers") 7 | .description("Manage renderers - These are the engines you can select from that synthesize your emails from templates and contacts.") 8 | 9 | root.command("list") 10 | .description("List all renderers.") 11 | .action(displayRenderers) 12 | } 13 | 14 | const displayRenderers = async () => { 15 | console.table(Object.entries(supportedRenderers).map(([tag, info]) => ({ 16 | TAG: tag, 17 | NAME: info.name, 18 | DESCRIPTION: info.description.split("\n")[0], 19 | ALIASES: [tag, ...(info.aliases ?? [])].join(", ") 20 | }))); 21 | } 22 | 23 | -------------------------------------------------------------------------------- /src/cli/cmd.send.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { sendEmail } from "../lib/gmail.js"; 3 | import { authorize } from "../lib/google-auth.js"; 4 | import chalk from "chalk"; 5 | import { EmailSerializer } from "./serializer.js"; 6 | import { Email } from "../lib/types.js"; 7 | import { continueOrSkip } from "./prompt.js"; 8 | import Config from "../lib/config.js"; 9 | 10 | export default function SendCommand(program: Command) { 11 | program 12 | .command("send") 13 | .description("Send email drafts.") 14 | .argument("", "path to the draft file or directory to send.") 15 | .option("--confirm", "sends without prompting for confirmation") 16 | .action(async (draftPath, options) => { 17 | const emails = await new EmailSerializer(draftPath).deserialize(); 18 | 19 | if (options.confirm) { 20 | await sendEmails(emails); 21 | } 22 | const shouldSend = await continueOrSkip(`Send ${emails.length} emails? ` + chalk.cyan(`\n [Current Mailbox]: ${Config.currentMailbox}`)).prompt(); 23 | if (shouldSend) { 24 | await sendEmails(emails); 25 | } 26 | }) 27 | .addHelpText('after', ` 28 | 29 | ${chalk.bold("Drafts must be JSON files with the following format:")} 30 | ${chalk.cyan(` 31 | { 32 | "to": "", 33 | "subject": "", 34 | "message": "" 35 | } 36 | `)} 37 | `) 38 | } 39 | 40 | 41 | const sendEmails = async (emails: Email[]) => { 42 | const auth = await authorize(); 43 | for (const email of emails) { 44 | await sendEmail(auth, email.to, email.subject, email.body); 45 | } 46 | } 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/cli/cmd.setup.ts: -------------------------------------------------------------------------------- 1 | import { Argument, Command, Option } from "commander"; 2 | import { Config, deleteConfigFile, updateConfigFile } from '../lib/config.js'; 3 | import { question, continueOrSkip } from "./prompt.js"; 4 | import chalk from "chalk"; 5 | import { exit } from "process"; 6 | import { getCurrentMailbox, listLabels } from "../lib/gmail.js"; 7 | import { LocalAuthorizer, MailmergeServerAuthorizer, authorize } from "../lib/google-auth.js"; 8 | import fs from 'fs'; 9 | 10 | export default function SetupCommand(program: Command) { 11 | const root = program.command('setup') 12 | .description('Configure mailmerge-js.') 13 | .action(async () => { 14 | await setupOpenAI(); 15 | await setupGmail(); 16 | }) 17 | .addHelpText('after', 18 | `${chalk.reset.bold("Addendum:")} 19 | You will need an OpenAI API key and Google App credentials to fully configure this app. 20 | Full instructions can be found here: 21 | ${chalk.cyan.bold("https://github.com/WarmSaluters/mailmerge-js/blob/main/README.md#Installation")} 22 | `) 23 | root.command('openai').description('Set up OpenAI API key.').action(setupOpenAI); 24 | root.command('gmail').description('Set up Gmail.').action(setupGmail); 25 | root.command('reset').description('Reset the setup.').action(resetSetup) 26 | } 27 | 28 | const setupGmail = async () => { 29 | 30 | const configureGmail = await continueOrSkip( 31 | "Set up Gmail?\n"+ 32 | " You will need to create a google app if you have not done so already. \n" + 33 | chalk.green(" Instructions: https://github.com/WarmSaluters/mailmerge-js/blob/main/README.md#setting-up-google-app-credentials \n") + 34 | chalk.cyan("\n [Current Mailbox]: ", Config.currentMailbox ?? chalk.red('')) + " ", 35 | { default: Config.currentMailbox ? 'n' : 'y' } 36 | ).prompt(); 37 | 38 | if (configureGmail) { 39 | // Ask for path to credentials.json. Load the file and add to Config 40 | const credentialsPath = await question("> Enter the path to your credentials.json: ").prompt(); 41 | if (!fs.existsSync(credentialsPath)) { 42 | console.log(chalk.red("The provided path does not exist. Please try again.")); 43 | exit(1); 44 | } 45 | const credentials = fs.readFileSync(credentialsPath, 'utf8'); 46 | Config.googleCredentialsJSON = credentials; 47 | updateConfigFile(Config); 48 | await LocalAuthorizer.promptConsent(); 49 | 50 | console.log(chalk.green("Gmail credentials set up successfully.")); 51 | const auth = await authorize(); 52 | const mailbox = await getCurrentMailbox(auth); 53 | Config.currentMailbox = mailbox ?? undefined; 54 | updateConfigFile(Config); 55 | console.log(chalk.green("Set mailbox to: ", Config.currentMailbox)); 56 | } 57 | } 58 | 59 | const setupOpenAI = async () => { 60 | const displayKey = chalk.blue("\n[Current API Key]: ", formatSensitiveData(Config.openaiAPIKey)); 61 | const configureOpenAI = await continueOrSkip("Set up OpenAI API key? " + displayKey + " ", { default: Config.openaiAPIKey ? 'n' : 'y' }).prompt(); 62 | 63 | if (configureOpenAI) { 64 | await question("> Enter your OpenAI API key: ") 65 | .onInput(async (input) => { 66 | if (input.trim() === '') { 67 | console.log(chalk.red("No API key provided. Please try again.")); 68 | exit(1); 69 | } 70 | Config.openaiAPIKey = input; 71 | updateConfigFile(Config); 72 | console.log(chalk.green("OpenAI API key set up successfully.")); 73 | }) 74 | .prompt(); 75 | } 76 | } 77 | 78 | const resetSetup = async () => { 79 | const reset = await continueOrSkip("Are you sure you want to reset the setup?" + chalk.bold.red("\nTHIS ACTION CANNOT BE UNDONE"), { default: 'n' }).prompt(); 80 | if (reset) { 81 | deleteConfigFile(); 82 | console.log(chalk.green("Setup reset successfully.")); 83 | } 84 | } 85 | 86 | const formatSensitiveData = (data: string | undefined) => { 87 | if (!data) { 88 | return chalk.red(''); 89 | } 90 | // Show first 4 characters, then replace with **** 91 | return data.slice(0, 4) + '****'; 92 | } 93 | 94 | -------------------------------------------------------------------------------- /src/cli/help.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { Command } from "commander"; 3 | 4 | export default function ConfigureHelp(program: Command) { 5 | program.configureHelp({ 6 | formatHelp: (cmd, helper) => { 7 | const termWidth = helper.padWidth(cmd, helper); 8 | const formatItem = (term: string, description: string) => { 9 | return `\t${chalk.blue(term.padEnd(termWidth))}\t${chalk.green(description)}`; 10 | }; 11 | 12 | const helpTextParts : string[] = []; 13 | 14 | if (cmd.parent === null) { 15 | const titleText = `⚡ mailmerge-js`; 16 | const title = chalk.bold.magentaBright(`\n${titleText}\n`); 17 | helpTextParts.push(title); 18 | } 19 | 20 | const usage = helper.commandUsage(cmd); 21 | if (cmd.parent && usage) { 22 | helpTextParts.push(`${chalk.bold('Usage:')}\n ${usage}\n`); 23 | } 24 | 25 | const description = helper.commandDescription(cmd); 26 | if (description) { 27 | helpTextParts.push(`${chalk.bold('Description:')}\n ${description}\n`); 28 | } 29 | 30 | const commands = helper.visibleCommands(cmd); 31 | if (commands.length > 0) { 32 | helpTextParts.push(`${chalk.bold('Commands:')}\n` + commands.map(subCmd => { 33 | return formatItem(subCmd.name(), subCmd.description()); 34 | }).join('\n') + '\n'); 35 | } 36 | 37 | 38 | const options = helper.visibleOptions(cmd); 39 | if (options.length > 0) { 40 | helpTextParts.push(`${chalk.bold('Options:')}\n` + options.map(option => { 41 | return formatItem(helper.optionTerm(option), helper.optionDescription(option)); 42 | }).join('\n') + '\n\n'); 43 | } 44 | 45 | return helpTextParts.join('\n'); 46 | } 47 | }); 48 | }; 49 | 50 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --no-warnings 2 | import { Command } from "commander"; 3 | import ComposeCommand from "./cmd.compose.js"; 4 | import SendCommand from "./cmd.send.js"; 5 | import SetupCommand from "./cmd.setup.js"; 6 | import ConfigureHelp from "./help.js"; 7 | import _DevCommand from "./cmd.dev.js"; 8 | import { readFileSync } from 'fs'; 9 | import { join, dirname } from 'path'; 10 | import { fileURLToPath } from 'url'; 11 | 12 | const __dirname = dirname(fileURLToPath(import.meta.url)); 13 | const packageJSON = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf-8')); 14 | 15 | import RenderersCommand from "./cmd.renderers.js"; 16 | 17 | const program = new Command(); 18 | program 19 | .name(packageJSON.name) 20 | .version(packageJSON.version) 21 | .description(packageJSON.description) 22 | .showHelpAfterError() 23 | .action(() => { 24 | program.help(); 25 | }) 26 | .addHelpCommand(false) 27 | .enablePositionalOptions() 28 | .passThroughOptions(); 29 | 30 | // ------------- Add things that mutate the program ---------- 31 | ConfigureHelp(program); 32 | SendCommand(program); 33 | ComposeCommand(program); 34 | RenderersCommand(program); 35 | SetupCommand(program); 36 | _DevCommand(program); 37 | 38 | // ------------- Execute the program ---------- 39 | program.parse(process.argv); -------------------------------------------------------------------------------- /src/cli/preview.ts: -------------------------------------------------------------------------------- 1 | import { Email } from "../lib/types"; 2 | import chalk from "chalk"; 3 | import { marked } from "marked"; 4 | import readline from "readline"; 5 | 6 | 7 | export class EmailPreviewer { 8 | private index: number; 9 | private linesWritten: number = 0; 10 | 11 | constructor(private emails: Email[]) { 12 | this.emails = emails; 13 | this.index = 0; 14 | } 15 | 16 | get current() { 17 | return this.emails[this.index]; 18 | } 19 | 20 | show() { 21 | return new Promise((resolve, reject) => { 22 | this.render(); 23 | 24 | // Create readline interface 25 | const rl = readline.createInterface({ 26 | input: process.stdin, 27 | output: process.stdout, 28 | }); 29 | 30 | // Function to handle key press 31 | // @ts-expect-error typescript wonkiness 32 | rl.input.on("keypress", (str: string, key: any) => { 33 | // You can add your logic here to handle the key press 34 | if (key.sequence === "\u0003") { 35 | // Ctrl+C to exit 36 | rl.close(); 37 | resolve(); 38 | } 39 | 40 | if (key.name === "q") { 41 | rl.close(); 42 | resolve(); 43 | } 44 | 45 | if (key.name === "left") { 46 | this.index--; 47 | if (this.index < 0) { 48 | this.index = 0; 49 | } 50 | this.render(); 51 | } 52 | 53 | if (key.name === "right") { 54 | this.index++; 55 | if (this.index >= this.emails.length) { 56 | this.index = this.emails.length - 1; 57 | } 58 | this.render(); 59 | } 60 | }); 61 | 62 | // Set the stdin to raw mode to detect key press immediately 63 | process.stdin.setRawMode(true); 64 | process.stdin.resume(); 65 | }); 66 | } 67 | 68 | render = () => { 69 | this.clearLines(this.linesWritten); 70 | const { content, lines } = this.renderEmailPreviewContent( 71 | this.current, 72 | this.index, 73 | this.emails.length 74 | ); 75 | console.log(content); 76 | console.log( 77 | chalk.bold('Use "<-" to go back, "->" to go forward, "q" to exit.') 78 | ); 79 | this.linesWritten = lines + 2; 80 | }; 81 | 82 | renderEmailPreviewContent = (email: Email, index: number, total: number) => { 83 | const content = "\n" + 84 | `${chalk.bold(`-`.repeat(process.stdout.columns))}` + 85 | "\n" + 86 | chalk.bold(`Displaying email ${index + 1} of ${total}`) + 87 | "\n" + 88 | `${chalk.bold(`-`.repeat(process.stdout.columns))}` + 89 | "\n" + 90 | `${chalk.bold.cyan(`Subject: ${chalk.yellowBright(email.subject)}`)}\n` + 91 | `${chalk.bold.cyan(`To: ${chalk.yellowBright(email.to)}`)}\n\n` + 92 | marked.parse(email.body); 93 | 94 | const lines = content.split("\n").length; 95 | return { content, lines }; 96 | }; 97 | 98 | clearLines = (n: number) => { 99 | for (let i = 0; i < n; i++) { 100 | //first clear the current line, then clear the previous line 101 | const y = i === 0 ? null : -1; 102 | process.stdout.moveCursor(0, y as number); 103 | process.stdout.clearLine(1); 104 | } 105 | process.stdout.cursorTo(0); 106 | }; 107 | } 108 | -------------------------------------------------------------------------------- /src/cli/prompt.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import inquirer from 'inquirer'; 3 | 4 | export class InputPrompt { 5 | private callback: ((input: string) => any) | null = (input) => input; 6 | 7 | constructor(private readonly promptString: string) { 8 | this.promptString = promptString; 9 | } 10 | 11 | onInput(callback: (input: string) => any) { 12 | this.callback = callback; 13 | return this; 14 | } 15 | 16 | async prompt () { 17 | const result = await inquirer.prompt([{ 18 | type: 'input', 19 | name: 'result', 20 | message: this.promptString, 21 | }]); 22 | 23 | if (this.callback) { 24 | return this.callback(result.result); 25 | } 26 | return result.result; 27 | } 28 | } 29 | 30 | export const question = (promptString: string) => { 31 | return new InputPrompt(chalk.bold(promptString)); 32 | } 33 | 34 | export const continueOrSkip = (promptString: string, opts?: { default: 'y' | 'n' }) => { 35 | const _default = opts?.default ?? 'y'; 36 | const suffix = _default === 'y' ? '[Y/n]' : '[y/N]'; 37 | 38 | return new InputPrompt(chalk.bold(promptString) + suffix + " ") 39 | .onInput((input) => { 40 | 41 | let shouldContinue = _default === 'y' ? true : false; 42 | 43 | if (input.toLowerCase() === 'y') { 44 | shouldContinue = true 45 | } 46 | else if (input.toLowerCase() === 'n') { 47 | shouldContinue = false; 48 | } 49 | 50 | return shouldContinue; 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /src/cli/serializer.ts: -------------------------------------------------------------------------------- 1 | import { Email } from "../lib/types.js"; 2 | import { writeJSON } from "../lib/utils.js"; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import fs from "node:fs"; 5 | 6 | export class EmailSerializer { 7 | 8 | constructor(private readonly draftsPath: string) { 9 | this.draftsPath = draftsPath; 10 | } 11 | 12 | async serialize(emails: Email[]) { 13 | fs.mkdirSync(this.draftsPath, { recursive: true }); 14 | for (const email of emails) { 15 | const uniqueId = 'draft-' + uuidv4(); 16 | writeJSON(`${this.draftsPath}/${uniqueId}.json`, email); 17 | } 18 | } 19 | 20 | async deserialize() : Promise { 21 | const isDir = fs.lstatSync(this.draftsPath).isDirectory(); 22 | if (isDir) { 23 | const files = fs.readdirSync(this.draftsPath); 24 | const emails = files.map(file => { 25 | const email = fs.readFileSync(`${this.draftsPath}/${file}`, 'utf8'); 26 | return JSON.parse(email); 27 | }); 28 | return emails; 29 | } else { 30 | const email = fs.readFileSync(this.draftsPath, 'utf8'); 31 | return [JSON.parse(email)]; 32 | } 33 | } 34 | 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | type IConfig = { 5 | openaiAPIKey?: string; 6 | gmailToken?: string; 7 | googleCredentialsJSON?: string; 8 | currentMailbox?: string; 9 | latestKnownVersion?: string; 10 | }; 11 | 12 | const DEFAULT_CONFIG_FILE = "~/.mailmerge/config.json".replace( 13 | "~", 14 | process.env.HOME ?? "" 15 | ); 16 | 17 | export const deleteConfigFile = () => { 18 | fs.unlinkSync(DEFAULT_CONFIG_FILE); 19 | }; 20 | 21 | export const updateConfigFile = ( 22 | config: IConfig, 23 | file: string = DEFAULT_CONFIG_FILE 24 | ) => { 25 | // Create if not exist 26 | fs.mkdirSync(path.dirname(file), { recursive: true }); 27 | fs.writeFileSync(file, JSON.stringify(config)); 28 | }; 29 | 30 | export const readConfigFile = (file: string = DEFAULT_CONFIG_FILE) => { 31 | try { 32 | return JSON.parse(fs.readFileSync(file, "utf8")); 33 | } catch (error) { 34 | return {}; 35 | } 36 | }; 37 | 38 | export const loadConfig = () => { 39 | const config = readConfigFile(); 40 | 41 | const loaded = { 42 | openaiAPIKey: config.openaiAPIKey, 43 | gmailToken: config.gmailToken, 44 | googleCredentialsJSON: config.googleCredentialsJSON, 45 | currentMailbox: config.currentMailbox, 46 | latestKnownVersion: config.latestKnownVersion, 47 | } as IConfig; 48 | 49 | // Repair broken token 50 | if (loaded.gmailToken && !loaded.gmailToken.includes("refresh")) { 51 | loaded.gmailToken = undefined 52 | } 53 | 54 | return loaded; 55 | }; 56 | 57 | export const Config = loadConfig(); 58 | export default Config; 59 | -------------------------------------------------------------------------------- /src/lib/gmail.ts: -------------------------------------------------------------------------------- 1 | import { Auth, google } from "googleapis"; 2 | 3 | // Functions 4 | export async function getCurrentMailbox(auth: Auth.OAuth2Client) { 5 | const gmail = google.gmail({ version: "v1", auth: auth }); 6 | const res = await gmail.users.getProfile({ userId: "me" }); 7 | return res.data.emailAddress; 8 | } 9 | 10 | export async function listLabels(auth: Auth.OAuth2Client) { 11 | const gmail = google.gmail({ version: "v1", auth: auth }); 12 | const res = await gmail.users.labels.list({ userId: "me" }); 13 | const labels = res.data.labels; 14 | if (labels && labels.length) { 15 | console.log("Labels:"); 16 | labels.forEach((label) => console.log(`- ${label.name}`)); 17 | } else { 18 | console.log("No labels found."); 19 | } 20 | } 21 | 22 | export async function createDraft( 23 | auth: Auth.OAuth2Client, 24 | to: string, 25 | subject: string, 26 | body: string 27 | ) { 28 | const gmail = google.gmail({ version: "v1", auth: auth }); 29 | const raw = makeBody(to, "me", subject, body); 30 | const draft = await gmail.users.drafts.create({ 31 | userId: "me", 32 | requestBody: { 33 | message: { 34 | raw: raw, 35 | }, 36 | }, 37 | }); 38 | 39 | return draft.data.id; 40 | } 41 | 42 | export async function sendEmail( 43 | auth: Auth.OAuth2Client, 44 | to: string, 45 | subject: string, 46 | body: string 47 | ) { 48 | const gmail = google.gmail({ version: "v1", auth: auth }); 49 | const draftId = await createDraft(auth, to, subject, body); 50 | console.log("Draft Id: ", draftId); 51 | 52 | const send = await gmail.users.drafts.send({ 53 | userId: "me", 54 | requestBody: { 55 | id: draftId, 56 | }, 57 | }); 58 | return send.data.id; 59 | } 60 | 61 | function makeBody(to: string, from: string, subject: string, message: string) { 62 | // Build the full email content with proper line endings 63 | const fullEmail = [ 64 | 'Content-Type: text/html; charset="UTF-8"\r\n', 65 | "MIME-Version: 1.0\r\n", 66 | "Content-Transfer-Encoding: 7bit\r\n", 67 | "To: ", 68 | to, 69 | "\r\n", 70 | "From: ", 71 | from, 72 | "\r\n", 73 | "Subject: ", 74 | subject, 75 | "\r\n\r\n", 76 | message, 77 | ].join(""); 78 | 79 | // Base64 encode the entire email content 80 | const encodedEmail = Buffer.from(fullEmail) 81 | .toString("base64") 82 | .replace(/\+/g, "-") // Replace + with - to make base64 URL-friendly 83 | .replace(/\//g, "_"); // Replace / with _ to make base64 URL-friendly 84 | 85 | return encodedEmail; 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/google-auth.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import express from "express"; 3 | import { Auth, google } from "googleapis"; 4 | import http from "http"; 5 | import open from "open"; 6 | import Config, { updateConfigFile } from "./config.js"; 7 | 8 | const MAILMERGE_AUTH_SERVER_URL = 9 | process.env.AUTH_SERVER_URL ?? "https://auth.mailmerge-js.dev"; 10 | 11 | // Callback expected at: http://localhost:7278/oauth2callback, used to receive the auth code from Google 12 | const LOCAL_REDIRECT_PORT = 7278; 13 | const LOCAL_REDIRECT_URL = 14 | "http://localhost:" + LOCAL_REDIRECT_PORT + "/oauth2callback"; 15 | 16 | const SCOPES = [ 17 | "https://www.googleapis.com/auth/gmail.readonly", 18 | "https://www.googleapis.com/auth/gmail.compose", 19 | ]; 20 | 21 | // Return an authorized client, initiating consent if needed 22 | export async function authorize( 23 | strategy?: "local" | "server" 24 | ): Promise { 25 | let authorizer: Authorizer; 26 | if (strategy === "server") { 27 | authorizer = MailmergeServerAuthorizer; 28 | } else if (strategy === "local") { 29 | authorizer = LocalAuthorizer; 30 | } else { 31 | // If no explicit strategy is provided, assume existence of local credentials means use local server 32 | authorizer = Config.googleCredentialsJSON 33 | ? LocalAuthorizer 34 | : MailmergeServerAuthorizer; 35 | } 36 | 37 | if (!Config.gmailToken) { 38 | await authorizer.promptConsent(); 39 | } 40 | return authorizer.getAuthorizedClient(); 41 | } 42 | 43 | export interface Authorizer { 44 | promptConsent: () => Promise; 45 | getAuthorizedClient: () => Promise; 46 | } 47 | 48 | export const LocalAuthorizer: Authorizer = { 49 | promptConsent: async () => { 50 | try { 51 | const credentials = JSON.parse(Config.googleCredentialsJSON ?? "null"); 52 | const { client_id, client_secret } = credentials.installed; 53 | 54 | const oAuth2Client = new google.auth.OAuth2( 55 | client_id, 56 | client_secret, 57 | LOCAL_REDIRECT_URL 58 | ); 59 | const authUrl = oAuth2Client.generateAuthUrl({ 60 | access_type: "offline", 61 | scope: SCOPES, 62 | }); 63 | 64 | const callbackServer = openListeningCallback(async (req, res, next) => { 65 | const code = req.query.code as string; 66 | const tokenResponse = await oAuth2Client.getToken(code); 67 | oAuth2Client.setCredentials(tokenResponse.tokens); 68 | Config.gmailToken = JSON.stringify(tokenResponse.tokens); 69 | updateConfigFile(Config); 70 | res.send( 71 | "mailmerge-js authentication successful! You may now close this tab." 72 | ); 73 | next(); 74 | }); 75 | 76 | console.log("Opening browser for authentication..."); 77 | await open(authUrl); 78 | await callbackServer; 79 | } catch (error) { 80 | console.error( 81 | "Error during authentication with user credentials:", 82 | error 83 | ); 84 | } 85 | }, 86 | getAuthorizedClient: async () => { 87 | const credentials = JSON.parse(Config.googleCredentialsJSON ?? "null"); 88 | const { client_id, client_secret } = credentials.installed; 89 | 90 | const oAuth2Client = new google.auth.OAuth2( 91 | client_id, 92 | client_secret, 93 | LOCAL_REDIRECT_URL 94 | ); 95 | oAuth2Client.setCredentials(JSON.parse(Config.gmailToken!)); 96 | oAuth2Client.forceRefreshOnFailure = true; 97 | oAuth2Client.getAccessToken(); 98 | return oAuth2Client; 99 | }, 100 | }; 101 | 102 | export const MailmergeServerAuthorizer: Authorizer = { 103 | promptConsent: async () => { 104 | try { 105 | const response = await axios.get( 106 | `${MAILMERGE_AUTH_SERVER_URL}/auth?scopes=${SCOPES.join(",")}` 107 | ); 108 | const authUrl = response.data.authUrl; 109 | 110 | const callbackServer = openListeningCallback(async (req, res, next) => { 111 | const code = req.query.code as string; 112 | const tokenResponse = await axios.get( 113 | `${MAILMERGE_AUTH_SERVER_URL}/oauth2callback?code=${code}` 114 | ); 115 | const token = tokenResponse.data; 116 | Config.gmailToken = JSON.stringify(token); 117 | updateConfigFile(Config); 118 | res.send( 119 | "mailmerge-js authentication successful! You may now close this tab." 120 | ); 121 | next(); 122 | }); 123 | 124 | console.log("Opening browser for authentication..."); 125 | await open(authUrl); 126 | await callbackServer; 127 | } catch (error) { 128 | console.error( 129 | "Error during authentication with mailmerge-js server:", 130 | error 131 | ); 132 | } 133 | }, 134 | getAuthorizedClient: async () => { 135 | const oAuth2Client = new google.auth.OAuth2(); 136 | 137 | const token = JSON.parse(Config.gmailToken!); 138 | 139 | if (isTokenExpired(token)) { 140 | // Fire refresh token request to server 141 | const tokenResponse = await axios.get( 142 | `${MAILMERGE_AUTH_SERVER_URL}/refresh?token=${Config.gmailToken}` 143 | ); 144 | const tokens = tokenResponse.data?.tokens; 145 | // Write back to config and save 146 | Config.gmailToken = JSON.stringify(tokens); 147 | updateConfigFile(Config); 148 | } 149 | 150 | oAuth2Client.setCredentials(JSON.parse(Config.gmailToken!)); 151 | return oAuth2Client; 152 | }, 153 | }; 154 | 155 | async function openListeningCallback( 156 | callbackHandler: express.Handler, 157 | listeningPort: number = LOCAL_REDIRECT_PORT 158 | ) { 159 | const app = express(); 160 | let server: http.Server | null = null; 161 | let backupTimeout: NodeJS.Timeout | null = null; 162 | 163 | try { 164 | app.get("/oauth2callback", (req, res, next) => { 165 | callbackHandler(req, res, () => { 166 | // Close server after handling callback 167 | server?.close(); 168 | backupTimeout = setTimeout(() => { 169 | server?.close(); 170 | }, 3000); 171 | next(); 172 | }); 173 | }); 174 | 175 | server = app.listen(listeningPort, () => { 176 | console.log( 177 | `Listening for OAuth callback on http://localhost:${listeningPort}/oauth2callback` 178 | ); 179 | }); 180 | 181 | // Wait for the server to close (i.e., after receiving the callback) 182 | await new Promise((resolve) => { 183 | server?.on("close", resolve); 184 | }); 185 | } catch (error) { 186 | console.error("Error during authentication:", error); 187 | server?.close(); 188 | } 189 | } 190 | 191 | const isTokenExpired = ( 192 | token: { expiry_date: number }, 193 | toleranceMS: number = 10000 194 | ) => { 195 | return token.expiry_date < new Date().getTime() - toleranceMS; 196 | }; 197 | -------------------------------------------------------------------------------- /src/lib/ollama.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from "axios"; 2 | 3 | const OLLAMA_SERVER = "http://localhost:11434/api/chat" 4 | 5 | export class OllamaClient { 6 | 7 | async requestLLM(messages: { role: string, content: string }[], options?: { model: string }) { 8 | try { 9 | const response = await axios.post(OLLAMA_SERVER, { 10 | model: options?.model || "llama3", 11 | messages: messages, 12 | stream: false 13 | }) 14 | 15 | if (response.data.error) { 16 | throw new Error(response.data.error); 17 | } 18 | 19 | return response.data; 20 | } catch (error) { 21 | if (error instanceof AxiosError) { 22 | if (error.response?.data?.error) { 23 | throw new OllamaMissingModelError(error.response.data.error); 24 | } 25 | if (error.code === "ECONNREFUSED") { 26 | throw new OllamaNotFoundError("Ollama not found"); 27 | } 28 | } 29 | throw new Error("Unexpected error: " + error); 30 | } 31 | } 32 | } 33 | 34 | 35 | export class OllamaMissingModelError extends Error { 36 | constructor(message: string) { 37 | super(message); 38 | this.name = "OllamaMissingModelError"; 39 | } 40 | } 41 | 42 | export class OllamaNotFoundError extends Error { 43 | constructor(message: string) { 44 | super(message); 45 | this.name = "OllamaNotFoundError"; 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /src/lib/openai.ts: -------------------------------------------------------------------------------- 1 | import { OpenAI } from "openai"; 2 | import Config from "./config.js"; 3 | 4 | export const requestLLM = async (messages: { role: string, content: string }[], options?: {model: string}) => { 5 | const openai = new OpenAI({ 6 | apiKey: Config.openaiAPIKey 7 | }); 8 | const response = await openai.chat.completions.create({ 9 | model: options?.model || "gpt-4o", 10 | // @ts-expect-error types definition wonkiness 11 | messages, 12 | response_format: { type: "json_object" }, 13 | }); 14 | return response.choices[0].message.content; 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/renderers/base.ts: -------------------------------------------------------------------------------- 1 | import { Email } from "../types.js"; 2 | 3 | export type RenderInputOptions = { 4 | limit: number; 5 | } 6 | 7 | export type RenderResponse = { 8 | emails: Email[]; 9 | warnings: string[]; 10 | } 11 | 12 | export interface Renderer { 13 | render (templateContents: string, contactsContents: string, options: RenderInputOptions): Promise; 14 | } 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/lib/renderers/index.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from "./base.js"; 2 | import { MockRenderer } from "./mock.js"; 3 | import { OllamaRenderer } from "./ollama.js"; 4 | import { OpenAIChatRenderer } from "./openai.js"; 5 | import { NunjucksRenderer } from "./nunjucks.js"; 6 | 7 | export * from "./base.js"; 8 | export * from "./openai.js"; 9 | export * from "./mock.js"; 10 | export * from "./ollama.js"; 11 | 12 | type RendererInfo = { 13 | name: string; 14 | description: string; 15 | aliases?: string[]; 16 | }; 17 | 18 | type RendererTag = 19 | | "mock" 20 | | "openai/gpt-4o" 21 | | "openai/gpt-4-turbo" 22 | | "openai/gpt-3.5-turbo" 23 | | "openai/gpt-4-turbo-preview" 24 | | "ollama/llama3" 25 | | "ollama/llama3-70b" 26 | | "engine/nunjucks"; 27 | 28 | export const supportedRenderers: Record = { 29 | mock: { 30 | name: "Mock", 31 | description: "Mock renderer", 32 | }, 33 | "openai/gpt-4o": { 34 | name: "OpenAI GPT-4o", 35 | description: "OpenAI GPT-4o renderer", 36 | aliases: ["gpt-4o"], 37 | }, 38 | "openai/gpt-4-turbo": { 39 | name: "OpenAI GPT-4-Turbo", 40 | description: "OpenAI GPT-4-Turbo renderer", 41 | aliases: ["gpt-4-turbo"], 42 | }, 43 | "openai/gpt-3.5-turbo": { 44 | name: "OpenAI GPT-3.5-Turbo", 45 | description: "OpenAI GPT-3.5-Turbo renderer", 46 | aliases: ["gpt-3.5-turbo"], 47 | }, 48 | "openai/gpt-4-turbo-preview": { 49 | name: "OpenAI GPT-4-Turbo-Preview", 50 | description: "OpenAI GPT-4-Turbo-Preview renderer", 51 | aliases: ["gpt-4-turbo-preview"], 52 | }, 53 | "ollama/llama3": { 54 | name: "Llama3", 55 | description: "Llama3 8B locally served by Ollama", 56 | aliases: ["llama3", "llama3-8b"], 57 | }, 58 | "ollama/llama3-70b": { 59 | name: "Llama3-70b", 60 | description: "Llama3 70B locally served by Ollama", 61 | aliases: ["llama3-70b"], 62 | }, 63 | "engine/nunjucks": { 64 | name: "nunjucks", 65 | description: "Nunjucks is a non-ai template engine inspired from Jina2", 66 | aliases: ["nunjucks"], 67 | }, 68 | }; 69 | 70 | export const getRenderer = (tagOrAlias: string): Renderer => { 71 | const resolved = Object.keys(supportedRenderers).find( 72 | (tag) => 73 | tag === tagOrAlias || 74 | supportedRenderers[tag as RendererTag].aliases?.includes(tagOrAlias) 75 | ); 76 | if (!resolved) { 77 | throw new Error(`Renderer not found: ${tagOrAlias}`); 78 | } 79 | 80 | switch (resolved as RendererTag) { 81 | case "mock": 82 | return new MockRenderer(); 83 | case "openai/gpt-4o": 84 | return new OpenAIChatRenderer("gpt-4o"); 85 | case "openai/gpt-4-turbo": 86 | return new OpenAIChatRenderer("gpt-4-turbo"); 87 | case "openai/gpt-3.5-turbo": 88 | return new OpenAIChatRenderer("gpt-3.5-turbo"); 89 | case "ollama/llama3": 90 | return new OllamaRenderer("llama3"); 91 | case "ollama/llama3-70b": 92 | return new OllamaRenderer("llama3-70b"); 93 | case "engine/nunjucks": 94 | return new NunjucksRenderer("llama3"); 95 | default: 96 | throw new Error(`Renderer not found: ${tagOrAlias}`); 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /src/lib/renderers/mock.ts: -------------------------------------------------------------------------------- 1 | import { Email } from "../types.js"; 2 | import { Renderer, RenderInputOptions } from "./base.js"; 3 | 4 | export class MockRenderer implements Renderer { 5 | async render(template: string, contacts: string, options: RenderInputOptions) { 6 | return getMockEmails(); 7 | } 8 | } 9 | 10 | const mockResponse = `{ 11 | "emails": [ 12 | { 13 | "to": "test@gmail.com", 14 | "subject": "Hello", 15 | "body": "Hello, I found your email test@gmail.com. \\n Thanks." 16 | }, 17 | { 18 | "to": "example@exmaple.com", 19 | "subject": "Hi", 20 | "body": "Hi, I found your email example@example.com. \\n Thanks." 21 | } 22 | ], 23 | "warnings": ["This is a mock response"] 24 | } 25 | `; 26 | 27 | export const getMockEmails = (): { emails: Email[]; warnings: string[]; } => { 28 | const responseJSON = JSON.parse(mockResponse); 29 | return { 30 | emails: responseJSON.emails ?? [], 31 | warnings: responseJSON.warnings ?? [], 32 | }; 33 | }; 34 | 35 | -------------------------------------------------------------------------------- /src/lib/renderers/nunjucks.ts: -------------------------------------------------------------------------------- 1 | import { Renderer, RenderInputOptions } from "./base.js"; 2 | import nunjucks from "nunjucks"; 3 | import { parse } from "csv-parse/sync"; 4 | 5 | export class NunjucksRenderer implements Renderer { 6 | template: string; 7 | 8 | constructor(template: string) { 9 | this.template = template; 10 | } 11 | 12 | async render( 13 | template: string, 14 | contacts: string, 15 | options: RenderInputOptions 16 | ) { 17 | const contactsArray = parseCSV(contacts); 18 | return mailMergeNunjucks(template, contactsArray, options); 19 | } 20 | } 21 | 22 | const parseCSV = (csvContent: string) => { 23 | return parse(csvContent, { 24 | columns: true, 25 | skip_empty_lines: true, 26 | }); 27 | }; 28 | 29 | const mailMergeNunjucks = ( 30 | template: string, 31 | contactsArray: any[], 32 | options?: { limit?: number } 33 | ) => { 34 | const limit = options?.limit ?? contactsArray.length; 35 | const emails = []; 36 | 37 | for (let i = 0; i < limit; i++) { 38 | const contact = contactsArray[i]; 39 | const email = nunjucks.renderString(template, contact); 40 | emails.push({ 41 | to: contact.email, 42 | subject: nunjucks.renderString("{{ subject }}", contact), 43 | body: email, 44 | }); 45 | } 46 | 47 | return { 48 | emails, 49 | warnings: [], 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /src/lib/renderers/ollama.ts: -------------------------------------------------------------------------------- 1 | import { Renderer, RenderInputOptions } from "./base.js"; 2 | import { OllamaClient } from "../ollama.js"; 3 | import chalk from "chalk"; 4 | 5 | export class OllamaRenderer implements Renderer { 6 | model: string; 7 | 8 | constructor(model: string) { 9 | this.model = model; 10 | } 11 | 12 | async render(template: string, contacts: string, options: RenderInputOptions) { 13 | return mailMergeAIBulk(template, contacts, this.model, options); 14 | } 15 | } 16 | 17 | const mailMergeAIBulk = async ( 18 | template: string, 19 | contacts: string, 20 | model: string, 21 | options?: { limit?: number; } 22 | ) => { 23 | const formatted = gptPrompt 24 | .replace(/!TEMPLATE!/g, template) 25 | .replace(/!CONTACTS!/g, contacts) 26 | .replace(/!LIMIT!/g, options?.limit?.toString() ?? "None"); 27 | const messages = [ 28 | { 29 | role: "system", 30 | content: "You are an intelligent email drafting tool for performing mail merges. You are given a list of contacts and an email template. You are asked to generate a list of emails. ONLY RETURN IN JSON", 31 | }, 32 | { role: "user", content: formatted }, 33 | ]; 34 | 35 | const response = await new OllamaClient().requestLLM(messages, { model }); 36 | 37 | try { 38 | const content = JSON.parse(response?.message?.content ?? ""); 39 | return content; 40 | } catch (error) { 41 | console.log(chalk.red("[!] Error parsing response:"), error); 42 | } 43 | }; 44 | 45 | const gptPrompt = ` 46 | You are an intelligent email drafting tool for performing mail merges. You are given a list of contacts and an email template. You are asked to generate a list of emails. 47 | 48 | The contacts list may include fields like name, email, phone, etc. 49 | 50 | The email template may include placeholders for the contact's name, email, phone, etc. Sometimes this may contain directives from the user. 51 | Placeholders in the email template are indicated by {{ }} delimters. 52 | 53 | NOTE: The mail template variables may not match the contact fields exactly and you may need to perform some data mapping. Additionally if there are gaps that cannot be filled 54 | by the contact data you may need to tweak the message to accommodate for missing data. You should also smooth out any discrepancies in grammar introduced by plugging the fields in. 55 | 56 | IMPORTANT: Unless explicitly given direction to do so or only for fixing grammar, DO NOT change the user's content or you will be penalized. 57 | 58 | Return ONLY your answer in the following JSON format (the body should be formatted as markdown regardless of the template). 59 | { 60 | "emails": [ 61 | { 62 | "to": "email@example.com", 63 | "subject": "Hello, {{ contact.name }}", 64 | "body": "Hello, {{ contact.name }}. This is a test email." 65 | } 66 | ], 67 | "warnings": 68 | } 69 | 70 | 71 | # TEMPLATE 72 | !TEMPLATE! 73 | 74 | 75 | # CONTACTS 76 | !CONTACTS! 77 | 78 | # LIMIT 79 | !LIMIT! 80 | `; 81 | -------------------------------------------------------------------------------- /src/lib/renderers/openai.ts: -------------------------------------------------------------------------------- 1 | import { requestLLM } from "../openai.js"; 2 | import { Renderer, RenderInputOptions, RenderResponse } from "./base.js"; 3 | 4 | export class OpenAIChatRenderer implements Renderer { 5 | model: string; 6 | 7 | constructor (model: string) { 8 | this.model = model; 9 | } 10 | 11 | async render (templateContents: string, contactsContents: string, options: RenderInputOptions): Promise { 12 | const response = await mailMergeAIBulk( 13 | templateContents, 14 | contactsContents, 15 | this.model, 16 | { 17 | limit: options.limit, 18 | } 19 | ); 20 | const responseJSON = JSON.parse(response ?? "{}"); 21 | return { 22 | emails: responseJSON.emails ?? [], 23 | warnings: responseJSON.warnings ?? [], 24 | }; 25 | } 26 | } 27 | 28 | const mailMergeAIBulk = async ( 29 | template: string, 30 | contacts: string, 31 | model: string, 32 | options?: { limit?: number; } 33 | ) => { 34 | const formatted = gptPrompt 35 | .replace(/!TEMPLATE!/g, template) 36 | .replace(/!CONTACTS!/g, contacts) 37 | .replace(/!LIMIT!/g, options?.limit?.toString() ?? "None"); 38 | const messages = [ 39 | { 40 | role: "system", 41 | content: "You are an intelligent email drafting tool for performing mail merges. You are given a list of contacts and an email template. You are asked to generate a list of emails.", 42 | }, 43 | { role: "user", content: formatted }, 44 | ]; 45 | 46 | const response = await requestLLM(messages, { model }); 47 | return response; 48 | }; 49 | 50 | const gptPrompt = ` 51 | You are an intelligent email drafting tool for performing mail merges. You are given a list of contacts and an email template. You are asked to generate a list of emails. 52 | 53 | The contacts list may include fields like name, email, phone, etc. 54 | 55 | The email template may include placeholders for the contact's name, email, phone, etc. Sometimes this may contain directives from the user. 56 | Placeholders in the email template are indicated by {{ }} delimters. 57 | 58 | NOTE: The mail template variables may not match the contact fields exactly and you may need to perform some data mapping. Additionally if there are gaps that cannot be filled 59 | by the contact data you may need to tweak the message to accommodate for missing data. You should also smooth out any discrepancies in grammar introduced by plugging the fields in. 60 | 61 | IMPORTANT: Unless explicitly given direction to do so or only for fixing grammar, DO NOT change the user's content or you will be penalized. 62 | 63 | Return your answer in the following JSON format (the body should be formatted as markdown regardless of the template). 64 | { 65 | "emails": [ 66 | { 67 | "to": "email@example.com", 68 | "subject": "Hello, {{ contact.name }}", 69 | "body": "Hello, {{ contact.name }}. This is a test email." 70 | } 71 | ], 72 | "warnings": 73 | } 74 | 75 | 76 | # TEMPLATE 77 | !TEMPLATE! 78 | 79 | 80 | # CONTACTS 81 | !CONTACTS! 82 | 83 | # LIMIT 84 | !LIMIT! 85 | `; 86 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type Email = { 2 | to: string; 3 | subject: string; 4 | body: string; 5 | }; 6 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | export const getFileContents = async (file: string) => { 4 | return fs.readFileSync(file, "utf8"); 5 | }; 6 | 7 | export const writeJSON = (file: string, data: any) => { 8 | fs.writeFileSync(file, JSON.stringify(data, null, 2)); 9 | }; -------------------------------------------------------------------------------- /src/lib/versioning.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import semver from 'semver'; 3 | import Config from './config.js'; 4 | 5 | // TODO: Need to implement this into cli gracefully. 6 | 7 | export const backgroundCheckForNewVersion = async () => { 8 | try { 9 | const { data } = await axios.get(`https://registry.npmjs.org/mailmerge-js`); 10 | const latestVersion = data['dist-tags'].latest; 11 | 12 | if (semver.gt(latestVersion, Config.latestKnownVersion ?? '0.0.0')) { 13 | // This will be launched async so hope something else writes back 14 | Config.latestKnownVersion = latestVersion; 15 | } 16 | } catch (error) { 17 | // Unknown but ok 18 | } 19 | }; 20 | 21 | export const isCurrentVersionLatest = (currentVersion: string) => { 22 | console.log('isCurrentVersionLatest', currentVersion, Config.latestKnownVersion); 23 | return semver.gt(currentVersion, Config.latestKnownVersion ?? '0.0.0'); 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "outDir": "./dist", 7 | "rootDir": ".", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "resolveJsonModule": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "baseUrl": "./src", 14 | "paths": { 15 | "@/*": ["*"] 16 | } 17 | }, 18 | "include": ["src/**/*", "./package.json", "./README.md"] 19 | } 20 | 21 | --------------------------------------------------------------------------------