├── output └── .gitkeep ├── .npmignore ├── functions └── rss-to-email │ ├── now.json │ ├── package.json │ ├── readme.md │ └── index.js ├── .gitignore ├── fixtures └── config.test.json ├── .github └── workflows │ └── test-and-lint.yml ├── .eslintrc.json ├── config.example.json ├── cli.js ├── package.json ├── src ├── Email.js ├── Feed.js ├── templates │ └── default.mjml ├── RssToEmail.js └── Config.js ├── tests ├── unit │ ├── Email.test.js │ ├── Config.test.js │ └── Feed.test.js └── integration │ └── RssToEmail.test.js ├── changelog.md ├── contributing.md └── readme.md /output/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | functions/ 3 | -------------------------------------------------------------------------------- /functions/rss-to-email/now.json: -------------------------------------------------------------------------------- 1 | { 2 | "builds": [{ "src": "index.js", "use": "@now/node" }] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | npm-debug.log* 3 | node_modules/ 4 | 5 | config.*.json 6 | config.json 7 | !config.example.json 8 | !fixtures/config.test.json 9 | output/* 10 | !.gitkeep 11 | coverage/ 12 | package-lock.json 13 | docs/_site 14 | -------------------------------------------------------------------------------- /functions/rss-to-email/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rss-to-email-function", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "dependencies": { 6 | "rss-to-email": "^0.10.0" 7 | }, 8 | "devDependencies": { 9 | "@now/node": "^1.0.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /fixtures/config.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "accentColor": "black", 3 | "feeds": [ 4 | { 5 | "description": "A short custom feed description", 6 | "title": "A custom feed title", 7 | "url": "http://www.feedforall.com/sample.xml" 8 | } 9 | ], 10 | "greeting": "Hey friend,", 11 | "header": { 12 | "banner": "http://www.example.com/image2.png", 13 | "link": "http://www.example.com/", 14 | "title": "Another Test Header" 15 | }, 16 | "outro": "Thanks for reading.", 17 | "signature": "Michelle Wilson, CEO at Example Co." 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/test-and-lint.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | node-version: [ 10.x, 12.x, 14.x, 15.x ] 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Use Node.js ${{ matrix.node-version }} 12 | uses: actions/setup-node@v2 13 | with: 14 | node-version: ${{ matrix.node-version }} 15 | - run: npm install 16 | - run: npm run test:unit 17 | - run: npm run test:integration 18 | - run: npm run lint 19 | - name: Upload coverage to Codecov 20 | uses: codecov/codecov-action@v1 21 | with: 22 | token: ${{ secrets.CODECOV_TOKEN }} 23 | -------------------------------------------------------------------------------- /functions/rss-to-email/readme.md: -------------------------------------------------------------------------------- 1 | # RSS To Email 2 | 3 | > Serverless deployment with Zeit Now 4 | 5 | Because this service currently won't work in the browser, I deployed it to [Ziet Now](https://zeit.co/) to make it easy to deploy your own microservice. Just sign up for an account, download the [Now CLI](https://zeit.co/docs#install-now-cli), and run the webtask from this subdirectory: 6 | 7 | ``` 8 | # Local dev 9 | now dev 10 | 11 | # Public endpoint 12 | now 13 | ``` 14 | 15 | Your endpoint will now be available at the URL returned by the Now CLI. You can now make a `POST` request to that URL to create an email from the RSS feeds you pass in. 16 | 17 | The body of your `POST` should pass in a [configuration object](../../readme.md#Configuration) as JSON, and may also include a `format` parameter with the value `html` (default) or `mjml`. 18 | 19 | The response will contain the entire email with a `Content-Type` of `text/html`. 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "rules": { 4 | "no-unused-expressions": [ 5 | "error", 6 | { 7 | "allowShortCircuit": true 8 | } 9 | ], 10 | "no-console": [ 11 | "error", 12 | { 13 | "allow": [ 14 | "warn", 15 | "error" 16 | ] 17 | } 18 | ], 19 | "require-jsdoc": [ 20 | "error", 21 | { 22 | "require": { 23 | "FunctionDeclaration": true, 24 | "MethodDefinition": true, 25 | "ClassDeclaration": false, 26 | "FunctionExpression": true 27 | } 28 | } 29 | ], 30 | "valid-jsdoc": [ 31 | "error", 32 | { 33 | "requireParamDescription": false, 34 | "requireReturnDescription": false 35 | } 36 | ], 37 | "padding-line-between-statements": [ 38 | "error", 39 | { 40 | "blankLine": "always", 41 | "prev": "*", 42 | "next": "return" 43 | } 44 | ] 45 | } 46 | } -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "accentColor": "red", 3 | "filename": "example", 4 | "header": { 5 | "banner": "http://www.example.com/image.png", 6 | "link": "http://www.example.com/", 7 | "title": "Example Header" 8 | }, 9 | "intro": "Hey there,

Thanks for opening the email! Here are some links I want you to check out:", 10 | "feeds": [ 11 | { 12 | "description": "A short custom feed description", 13 | "title": "A custom feed title", 14 | "url": "http://www.feedforall.com/sample.xml", 15 | "publishedSince": "2018-01-01" 16 | }, 17 | { 18 | "description": "A second short custom feed description", 19 | "title": "Another custom feed title", 20 | "url": "http://www.feedforall.com/sample.xml", 21 | "publishedSince": null 22 | } 23 | ], 24 | "outro": "Thanks for reading. We'll be back next week with more!

John Smith, CMO at Example Co.", 25 | "templateUrl": "https://raw.githubusercontent.com/portable-cto/rss-to-email/master/src/templates/default.mjml" 26 | } 27 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const RssToEmail = require('./src/RssToEmail'); 3 | const fs = require('fs'); 4 | 5 | /** 6 | * Run the RSS to Email script 7 | * @param config 8 | * @return {Promise} 9 | */ 10 | async function init(config) { 11 | const rssToEmail = RssToEmail(config); 12 | 13 | // Get files 14 | const html = await rssToEmail.getEmail(); 15 | const mjml = await rssToEmail.getEmail('mjml'); 16 | 17 | return {html, mjml}; 18 | } 19 | 20 | const [,, ...args] = process.argv; 21 | 22 | if (args[0] && args[1]) { 23 | console.log(`Using config file '${args[0]}'`); 24 | 25 | // Get config object from file path 26 | const config = JSON.parse(fs.readFileSync(args[0], 'utf8')); 27 | 28 | // Run the script 29 | init(config).then((results) => { 30 | fs.writeFileSync(`${args[1]}/${config.filename}.html`, results.html); 31 | fs.writeFileSync(`${args[1]}/${config.filename}.mjml`, results.mjml); 32 | 33 | console.log('Process complete'); 34 | }); 35 | } else { 36 | console.error('Error: config file and output directory should be specified.'); 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rss-to-email", 3 | "description": "Generate HTML email from your RSS feeds", 4 | "homepage": "https://github.com/rsslove/rss-to-email", 5 | "version": "0.11.2", 6 | "main": "src/RssToEmail.js", 7 | "scripts": { 8 | "build": "webpack", 9 | "cli": "node cli.js", 10 | "lint": "eslint src/", 11 | "test": "jest", 12 | "test:integration": "jest tests/integration", 13 | "test:unit": "jest tests/unit --coverage", 14 | "test:watch": "jest --watchAll" 15 | }, 16 | "license": "Apache-2.0", 17 | "dependencies": { 18 | "handlebars": "^4.7.7", 19 | "handlebars-helpers": "^0.10.0", 20 | "mjml": "4.9.3", 21 | "node-fetch": "^2.6.1", 22 | "rss-parser": "^3.12.0", 23 | "stampit": "^4.3.2" 24 | }, 25 | "devDependencies": { 26 | "eslint": "^7.30.0", 27 | "eslint-config-airbnb-base": "^14.2.1", 28 | "eslint-plugin-import": "^2.23.4", 29 | "file-exists": "^5.0.1", 30 | "jest": "^27.0.6" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/rsslove/rss-to-email" 35 | }, 36 | "bin": { 37 | "rss-to-email": "cli.js" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /functions/rss-to-email/index.js: -------------------------------------------------------------------------------- 1 | const RssToEmail = require('rss-to-email'); 2 | 3 | module.exports = (request, response) => { 4 | const headers = {}; 5 | headers['Access-Control-Allow-Origin'] = '*'; 6 | headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, DELETE, OPTIONS'; 7 | headers['Access-Control-Allow-Headers'] = 'X-Requested-With, X-HTTP-Method-Override, Content-Type, Accept'; 8 | 9 | if (request.method === 'OPTIONS') { 10 | response.writeHead(204, headers); 11 | response.end(); 12 | } else { 13 | const config = request.body || {}; 14 | const rssToEmail = RssToEmail(config); 15 | 16 | rssToEmail.getEmail(config.format || 'html') 17 | .then(email => { 18 | response.writeHead(200, { 19 | ...headers, 20 | 'Content-Type': 'text/html ', 21 | }); 22 | response.end(email); 23 | }) 24 | .catch(e => { 25 | console.error(e); 26 | response.writeHead(500, { 27 | ...headers, 28 | 'Content-Type': 'text/html ', 29 | }); 30 | response.end('Something went wrong. Please check the server logs for more info.') 31 | }); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/Email.js: -------------------------------------------------------------------------------- 1 | const mjmlLib = require('mjml'); 2 | const stampit = require('stampit'); 3 | const fetch = require('node-fetch'); 4 | const handlebars = require('handlebars'); 5 | require('handlebars-helpers')({ handlebars }); 6 | 7 | /** 8 | * Fetch the template file from a URL 9 | * @param {string} url 10 | * @return {Promise} 11 | */ 12 | function getTemplateFile(url) { 13 | return fetch(url).then((res) => res.text()).then((res) => res); 14 | } 15 | 16 | const Email = stampit({ 17 | props: { 18 | config: undefined, 19 | feeds: undefined, 20 | mjmlContent: undefined, 21 | }, 22 | 23 | /** 24 | * Instantiate a new email object 25 | * @param {{object, array}} config, feeds 26 | * @return {void} 27 | */ 28 | init({ config, feeds }) { 29 | this.config = config; 30 | this.feeds = feeds; 31 | }, 32 | 33 | methods: { 34 | /** 35 | * Generate mjmlContent from the config and feeds set in the constructor 36 | * @returns {Email} 37 | */ 38 | async generate() { 39 | const source = await getTemplateFile(this.config.templateUrl); 40 | 41 | const template = handlebars.compile(source); 42 | 43 | this.mjmlContent = template({ ...this.config, feeds: this.feeds }); 44 | 45 | return this; 46 | }, 47 | 48 | /** 49 | * Get the MJML email as a string. 50 | * @return {string} 51 | */ 52 | async getMjml() { 53 | this.mjmlContent || await this.generate(); 54 | 55 | return this.mjmlContent; 56 | }, 57 | 58 | /** 59 | * Get the HTML email as a string. 60 | * @return {string} 61 | */ 62 | async getHtml() { 63 | return mjmlLib(await this.getMjml()).html; 64 | }, 65 | }, 66 | }); 67 | 68 | module.exports = Email; 69 | -------------------------------------------------------------------------------- /tests/unit/Email.test.js: -------------------------------------------------------------------------------- 1 | const Email = require('../../src/Email'); 2 | const mjmlLib = require('mjml'); 3 | const fetch = require('node-fetch'); 4 | const handlebars = require('handlebars'); 5 | jest.mock('mjml'); 6 | jest.mock('node-fetch'); 7 | jest.mock('handlebars'); 8 | 9 | describe('Email', () => { 10 | let email; 11 | let config; 12 | let feeds; 13 | 14 | beforeEach(() => { 15 | mjmlLib.mockClear(); 16 | fetch.mockClear(); 17 | 18 | config = {templateUrl: 'http://www.example.com'}; 19 | feeds = [{items: [{title: 'test title'}]}]; 20 | email = new Email({config, feeds}); 21 | }); 22 | 23 | test('creates Email object from config and feeds', () => { 24 | expect(email.config).toEqual(config); 25 | expect(email.feeds).toEqual(feeds); 26 | }); 27 | 28 | test('get mjml when mjml content is set', async() => { 29 | email.mjmlContent = ''; 30 | 31 | const result = await email.getMjml(); 32 | 33 | expect(result).toEqual(''); 34 | }); 35 | 36 | test('get mjml when mjml content is not set', async() => { 37 | const mjmlContent = ''; 38 | fetch.mockImplementation(() => Promise.resolve({text: () => Promise.resolve(mjmlContent)})); 39 | handlebars.compile.mockImplementation(() => () => mjmlContent); 40 | 41 | const result = await email.getMjml(); 42 | 43 | expect(fetch).toHaveBeenCalledWith(config.templateUrl); 44 | expect(result).toContain(''); 45 | expect(result).toContain(''); 46 | }); 47 | 48 | test('get html when mjml content is set', async() => { 49 | const html = ''; 50 | const mjmlContent = ''; 51 | email.mjmlContent = mjmlContent; 52 | mjmlLib.mockImplementation(() => ({html})); 53 | 54 | const result = await email.getHtml(); 55 | 56 | expect(result).toEqual(html); 57 | expect(mjmlLib).toHaveBeenCalledWith(mjmlContent); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/unit/Config.test.js: -------------------------------------------------------------------------------- 1 | const Config = require('../../src/Config'); 2 | 3 | describe('Config', () => { 4 | let config; 5 | 6 | test('throws exception when no inputs given', () => { 7 | expect(() => Config()).toThrow('No configuration object included.'); 8 | }); 9 | 10 | test('creates config object when object input given', () => { 11 | const input = { 12 | accentColor: '#2568ba', 13 | header: { 14 | link: 'http://www.feedforall.com/', 15 | title: 'Test Example Input Header', 16 | }, 17 | feeds: [ 18 | { 19 | description: 'A test feed description', 20 | title: 'A test feed title', 21 | url: 'http://www.feedforall.com/test.xml', 22 | } 23 | ] 24 | }; 25 | 26 | config = Config(input); 27 | 28 | expect(config.accentColor).toEqual(input.accentColor); 29 | expect(config.header).toEqual(input.header); 30 | expect(config.feeds).toEqual(input.feeds); 31 | expect(config.outro).toEqual("Thanks for reading. We'll be back next week with more!

John Smith, CMO at Example Co."); 32 | }); 33 | 34 | test('config does not replace empty strings', () => { 35 | const input = { 36 | accentColor: '#2568ba', 37 | header: { 38 | link: 'http://www.feedforall.com/', 39 | title: 'Test Example Input Header', 40 | }, 41 | feeds: [ 42 | { 43 | description: 'A test feed description', 44 | title: 'A test feed title', 45 | url: 'http://www.feedforall.com/test.xml', 46 | } 47 | ], 48 | outro: '', 49 | }; 50 | 51 | config = Config(input); 52 | 53 | expect(config.outro).toEqual(input.outro); 54 | }); 55 | 56 | test('creates config object with default header and feeds when fields not given', () => { 57 | const input = {accentColor: '#2568ba'}; 58 | 59 | config = Config(input); 60 | 61 | expect(config.accentColor).toEqual(input.accentColor); 62 | expect(config.header).toBeDefined(); 63 | expect(config.feeds).toBeDefined(); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/Feed.js: -------------------------------------------------------------------------------- 1 | const Parser = require('rss-parser'); 2 | const stampit = require('stampit'); 3 | 4 | /** 5 | * Cleans an item 6 | * @param {Object} item 7 | * @return {Object} 8 | */ 9 | function cleanItem(item) { 10 | const cleanedItem = { ...item }; 11 | cleanedItem.title = item.title.replace(/\bhttps?:\/\/\S+/gi, ''); 12 | 13 | return cleanedItem; 14 | } 15 | 16 | const Feed = stampit({ 17 | props: { 18 | config: undefined, 19 | parser: undefined, 20 | items: undefined, 21 | url: undefined, 22 | title: undefined, 23 | description: undefined, 24 | }, 25 | 26 | /** 27 | * Creates a new Feed from a feed configuration object 28 | * @param { Object } Feed configuration object 29 | * @return { void } 30 | */ 31 | init({ feedConfig }) { 32 | this.config = feedConfig; 33 | this.parser = new Parser(feedConfig.parserOptions || {}); 34 | }, 35 | 36 | methods: { 37 | /** 38 | * Promises a feed with items embedded 39 | * @return { Promise } 40 | */ 41 | async resolve() { 42 | const feedObject = await this.parser.parseURL(this.config.url); 43 | 44 | this.items = feedObject.items.map((item) => cleanItem(item)); 45 | this.applyFilters(); 46 | this.title = this.config.title || feedObject.title; 47 | this.description = this.config.description || feedObject.description; 48 | this.url = this.config.url || feedObject.feedUrl; 49 | 50 | return this; 51 | }, 52 | 53 | /** 54 | * Apply filters to the items in this object 55 | * @return {void} 56 | */ 57 | applyFilters() { 58 | if (this.items) { 59 | // Filter by published since 60 | if (this.config.publishedSince) { 61 | this.items = this.items 62 | .filter((item) => new Date(item.isoDate) >= new Date(this.config.publishedSince)); 63 | } 64 | 65 | // Apply limit 66 | if (this.config.limit) { 67 | this.items = this.items.slice(0, this.config.limit); 68 | } 69 | } 70 | }, 71 | 72 | }, 73 | }); 74 | 75 | module.exports = Feed; 76 | -------------------------------------------------------------------------------- /tests/integration/RssToEmail.test.js: -------------------------------------------------------------------------------- 1 | const RssToEmail = require('../../src/RssToEmail'); 2 | 3 | let config; 4 | let subject; 5 | 6 | describe('RssToEmail - Integration', () => { 7 | 8 | beforeEach(() => { 9 | config = { 10 | accentColor: 'blue', 11 | feeds: [ 12 | { 13 | description: 'A feed with custom namespace fields', 14 | title: 'A custom field test', 15 | url: 'https://abcnews.go.com/abcnews/topstories', 16 | parserOptions: {customFields: {item: ['media:keywords']}}, 17 | }, 18 | ], 19 | }; 20 | subject = RssToEmail(config); 21 | }); 22 | 23 | test('can generate email when feeds resolve', async () => { 24 | const result = await subject.generateEmail(); 25 | 26 | expect(result.email).toBeDefined(); 27 | expect(result.email.config.accentColor).toEqual(config.accentColor); 28 | expect(result.email.config.feeds).toEqual(config.feeds); 29 | }); 30 | 31 | test('can get mjml email when email already set', async () => { 32 | const email = { 33 | getMjml: function () { 34 | return '' 35 | } 36 | }; 37 | subject.email = email; 38 | 39 | const result = await subject.getEmail('mjml'); 40 | 41 | expect(result).toEqual(email.getMjml()); 42 | }); 43 | 44 | test('can get html email when email already set', async () => { 45 | const email = { 46 | getHtml: function () { 47 | return '' 48 | } 49 | }; 50 | subject.email = email; 51 | 52 | const result = await subject.getEmail('html'); 53 | 54 | expect(result).toEqual(email.getHtml()); 55 | }); 56 | 57 | test('can get email in default format when email set', async () => { 58 | const email = { 59 | getHtml: function () { 60 | return '' 61 | } 62 | }; 63 | subject.email = email; 64 | 65 | const result = await subject.getEmail(); 66 | 67 | expect(result).toEqual(email.getHtml()); 68 | }); 69 | 70 | test('can generate and get email in default format when email not yet set', async () => { 71 | const result = await subject.getEmail('html'); 72 | 73 | expect(result).toBeDefined(); 74 | }); 75 | }); -------------------------------------------------------------------------------- /src/templates/default.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | a { 12 | color: {{accentColor}}; 13 | text-decoration: underline; 14 | } 15 | 16 | 17 | 18 | 19 | 20 | 21 | {{#if header.banner}} 22 | 23 | {{else}} 24 | 25 | {{header.title}} 26 | 27 | {{/if}} 28 | 29 | 30 | 31 | 32 | 33 | 34 | {{{intro}}} 35 | 36 | 37 | 38 | {{#each feeds}} 39 | 40 | 41 | 42 | {{this.title}} 43 | {{this.description}} 44 | 45 | 46 | 47 | {{#each items}} 48 | 49 | 50 | 51 | 52 | {{this.title}} 53 | 54 | {{{this.content}}} 55 | 56 | 57 | 58 | {{/each}} 59 | 60 | {{/each}} 61 | 62 | 63 | 64 | 65 | {{{outro}}} 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/RssToEmail.js: -------------------------------------------------------------------------------- 1 | const stampit = require('stampit'); 2 | const Config = require('./Config'); 3 | const Feed = require('./Feed'); 4 | const Email = require('./Email'); 5 | 6 | /** 7 | * Capitalize the first letter in a string 8 | * 9 | * @param {string} str 10 | * @return {string} 11 | */ 12 | function capitalizeFirstLetter(str) { 13 | return str.charAt(0).toUpperCase() + str.slice(1); 14 | } 15 | 16 | /** 17 | * Convert an email format to a method name 18 | * 19 | * @param {string} format 20 | * @return {string} 21 | */ 22 | function emailFormatToMethodName(format) { 23 | return `get${capitalizeFirstLetter(format)}`; 24 | } 25 | 26 | const RssToEmail = stampit({ 27 | props: { 28 | config: undefined, 29 | resolvedFeeds: undefined, 30 | email: undefined, 31 | defaultFormat: 'html', 32 | }, 33 | /** 34 | * Initialize 35 | * 36 | * @param {object} options 37 | * @return {void} 38 | */ 39 | init(options) { 40 | this.config = Config(options); 41 | }, 42 | methods: { 43 | /** 44 | * Resolving feeds and set email object 45 | * 46 | * @return {Promise} 47 | */ 48 | async generateEmail() { 49 | // Create an array of resolved feeds 50 | this.resolvedFeeds = await this.resolveFeeds(this.config.feeds); 51 | 52 | // Generate Email 53 | this.email = Email({ config: this.config, feeds: this.resolvedFeeds }); 54 | 55 | return this; 56 | }, 57 | 58 | /** 59 | * Generate (if necessary) and return the email string 60 | * 61 | * @param {string} format - 'html' and 'mjml' are supported 62 | * @return {Promise} 63 | */ 64 | async getEmail(format) { 65 | this.email || await this.generateEmail(); 66 | const methodName = emailFormatToMethodName(format || this.defaultFormat); 67 | 68 | return this.email[methodName](); 69 | }, 70 | 71 | /** 72 | * Resolve an array of feeds, returning a promise 73 | * 74 | * @param {[FeedConfig]} feeds 75 | * @return {Promise<[Feed]>} 76 | */ 77 | resolveFeeds(feeds) { 78 | return Promise.all(feeds.map(this.resolveFeed)); 79 | }, 80 | 81 | /** 82 | * Resolve a single feed, returning a promise 83 | * 84 | * @param {FeedConfig} feedConfig 85 | * @return {Promise} 86 | */ 87 | resolveFeed(feedConfig) { 88 | return Feed({ feedConfig }).resolve(); 89 | }, 90 | }, 91 | }); 92 | 93 | module.exports = RssToEmail; 94 | -------------------------------------------------------------------------------- /src/Config.js: -------------------------------------------------------------------------------- 1 | const stampit = require('stampit'); 2 | 3 | const DEFAULT_CONFIG_WARNING_MESSAGE = 'No configuration object included.'; 4 | const DEFAULT_TEMPLATE_URL = 'https://raw.githubusercontent.com/portable-cto/rss-to-email/master/src/templates/default.mjml'; 5 | 6 | const HeaderConfig = stampit({ 7 | props: { 8 | banner: undefined, 9 | link: undefined, 10 | title: undefined, 11 | }, 12 | /** 13 | * Initialize 14 | * @param {object} options 15 | * @return {void} 16 | */ 17 | init(options) { 18 | this.banner = options.banner; 19 | this.link = options.link; 20 | this.title = options.title; 21 | }, 22 | }); 23 | 24 | const FeedConfig = stampit({ 25 | props: { 26 | description: undefined, 27 | title: undefined, 28 | url: undefined, 29 | }, 30 | /** 31 | * Initialize 32 | * @param {object} options 33 | * @return {void} 34 | */ 35 | init(options) { 36 | this.description = options.description; 37 | this.title = options.title; 38 | this.url = options.url; 39 | }, 40 | }); 41 | 42 | const DEFAULT_CONFIG_OBJECT = { 43 | accentColor: 'red', 44 | filename: 'example', 45 | header: HeaderConfig({ 46 | description: 'A short custom feed description', 47 | title: 'A custom feed title', 48 | url: 'http://www.feedforall.com/sample.xml', 49 | }), 50 | intro: 'Hey there,

Thanks for opening the email! Here are some links I want you to check out:', 51 | feeds: [FeedConfig({ 52 | publishedSince: undefined, 53 | description: 'A short custom feed description', 54 | title: 'A custom feed title', 55 | url: 'http://www.feedforall.com/sample.xml', 56 | })], 57 | outro: "Thanks for reading. We'll be back next week with more!

John Smith, CMO at Example Co.", 58 | templateUrl: DEFAULT_TEMPLATE_URL, 59 | }; 60 | 61 | /** 62 | * Determine whether or not a value is null or undefined 63 | * @param {any} val 64 | * @return {boolean} 65 | */ 66 | function isNullOrUndefined(val) { 67 | return val === undefined || val === null; 68 | } 69 | 70 | const Config = stampit({ 71 | props: DEFAULT_CONFIG_OBJECT, 72 | /** 73 | * Initialize 74 | * @param {object} options 75 | * @return {void} 76 | */ 77 | init(options) { 78 | if (Object.keys(options).length === 0 && options.constructor === Object) { 79 | throw DEFAULT_CONFIG_WARNING_MESSAGE; 80 | } 81 | 82 | Object.keys(this).forEach((property) => { 83 | this[property] = !isNullOrUndefined(options[property]) 84 | ? options[property] 85 | : DEFAULT_CONFIG_OBJECT[property]; 86 | }); 87 | }, 88 | }); 89 | 90 | module.exports = Config; 91 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ### Added 10 | - Nothing yet 11 | 12 | ### Fixed 13 | - Nothing yet 14 | 15 | ### Removed 16 | - Nothing yet 17 | 18 | ## 0.9.0 - 2019-03-17 19 | 20 | ### Added 21 | - Template support for all [handlebars-helpers](https://github.com/helpers/handlebars-helpers). 22 | - Documentation for templates. 23 | 24 | ### Fixed 25 | - Updated NPM dependencies. 26 | 27 | ## 0.8.0 - 2018-12-23 28 | 29 | ### Added 30 | - Optional `limit` paramter to truncate number of items in each feed. 31 | 32 | ## 0.7.0 - 2018-12-23 33 | 34 | ### Added 35 | - Optional `parserOptions` configuration field for each feed to allow custom fields in templates. 36 | - Docs folder with frontend demo using Jekyll. 37 | 38 | ### Fixed 39 | - Allowing HTML in content for each item in rss template. 40 | 41 | ### Removed 42 | - `/webtask` directory from NPM package. 43 | 44 | ## 0.6.0 - 2018-05-26 45 | 46 | ### Added 47 | - Custom templates using Handlebars/MJML. 48 | 49 | ### Changed 50 | - Node versions supported to 8, 9, and 10. No longer supporting Node <7. 51 | 52 | ### Removed 53 | - `signature` and `greeting` fields. Should now consolidate and use `intro` and `outro` only. 54 | 55 | ## 0.5.0 - 2018-04-25 56 | 57 | ### Added 58 | - [Webtask](https://webtask.io/) to allow deployment as a serverless endpoint. 59 | - `publishedSince` option on each RSS feed. 60 | 61 | ### Changed 62 | - Updated documentation. 63 | - Improved error message when no config passed in. 64 | 65 | ## 0.4.0 - 2018-04-16 66 | 67 | ### Changed 68 | - Improvements to templates. 69 | - Upgraded to mjml v4. 70 | - Refactored objects/classes with [Stampit](https://github.com/stampit-org/stampit). 71 | - Removing `fs` and `path` modules from core library. 72 | - Reimplemented and documented CLI. 73 | 74 | ### Removed 75 | - Webpack/build for frontend as [mjml v4 no longer supports it](https://github.com/mjmlio/mjml/issues/438#issuecomment-302712905). 76 | 77 | ## 0.3.0 - 2018-04-13 78 | 79 | ### Added 80 | - Infrastructure to build as browser-ready JS project. 81 | - Example `index.html` file to demonstrate browser use. 82 | - Docs about importing as node module, contributing, releasing new versions, etc. 83 | - More fun badges! 84 | - Improvements to eslint (docblocks and blank line before `return`). 85 | 86 | ## 0.2.0 - 2018-04-09 87 | 88 | ### Added 89 | - Added unit and integratin tests with Jest 90 | - Broke main functions out into classes/objects 91 | - Added eslint 92 | - Allowing multiple RSS feeds (with optional headers/descriptions between each) 93 | - Add CI via Travis 94 | 95 | ## 0.0.1 - 2018-03-29 96 | 97 | ### Added 98 | - Working prototype using configuration file 99 | - Readme with instructions, license, etc. 100 | -------------------------------------------------------------------------------- /tests/unit/Feed.test.js: -------------------------------------------------------------------------------- 1 | const Feed = require('../../src/Feed'); 2 | const Parser = require('rss-parser'); 3 | jest.mock('rss-parser'); 4 | 5 | describe('Feed', () => { 6 | let feed; 7 | let feedConfig; 8 | 9 | beforeEach(() => { 10 | Parser.mockClear(); 11 | 12 | feedConfig = { 13 | description: 'This is a description', 14 | title: 'This is a title', 15 | url: 'http://www.examplefeed.com/rss', 16 | }; 17 | }); 18 | 19 | test('creates feed from configuration', () => { 20 | feed = Feed({feedConfig}); 21 | 22 | expect(feed.config).toEqual(feedConfig); 23 | expect(Parser).toHaveBeenCalledTimes(1); 24 | }); 25 | 26 | test('resolves feed with valid url', async () => { 27 | const items = [{ title: 'test', content: 'test 2' }]; 28 | Parser.mockImplementation(() => ({ 29 | parseURL: () => ({ title: 'mock title', items }), 30 | })); 31 | 32 | const result = await Feed({feedConfig}).resolve(); 33 | 34 | expect(result.description).toEqual(feedConfig.description); 35 | expect(result.title).toEqual(feedConfig.title); 36 | expect(result.items).toEqual(items); 37 | }); 38 | 39 | test('it removes urls from titles', async () => { 40 | const items = [{ title: 'test http://www.example.com/', content: 'test more content' }]; 41 | Parser.mockImplementation(() => ({ 42 | parseURL: () => ({ title: 'mock title', items }), 43 | })); 44 | 45 | const result = await Feed({feedConfig}).resolve(); 46 | 47 | expect(result.items[0].title).toEqual('test '); 48 | }); 49 | 50 | test('it filters by publishedSince if set', async () => { 51 | feedConfig.publishedSince = '2018-01-01'; 52 | const items = [ 53 | { 54 | title: 'This was published before the cutoff', 55 | content: 'test more content', 56 | isoDate: '2017-11-12T21:16:39.000Z', 57 | }, 58 | { 59 | title: 'This was published after the cutoff', 60 | content: 'test more content', 61 | isoDate: '2018-02-02T21:16:39.000Z', 62 | }, 63 | ]; 64 | Parser.mockImplementation(() => ({ 65 | parseURL: () => ({ title: 'mock title', items }), 66 | })); 67 | 68 | const result = await Feed({feedConfig}).resolve(); 69 | 70 | expect(result.items[0].title).toEqual('This was published after the cutoff'); 71 | }); 72 | 73 | test('it does not filter by publishedSince if not set', async () => { 74 | feedConfig.publishedSince = undefined; 75 | const items = [ 76 | { 77 | title: 'This was published before the cutoff', 78 | content: 'test more content', 79 | isoDate: '2017-11-12T21:16:39.000Z', 80 | }, 81 | { 82 | title: 'This was published after the cutoff', 83 | content: 'test more content', 84 | isoDate: '2018-02-02T21:16:39.000Z', 85 | }, 86 | ]; 87 | Parser.mockImplementation(() => ({ 88 | parseURL: () => ({ title: 'mock title', items }), 89 | })); 90 | 91 | const result = await Feed({feedConfig}).resolve(); 92 | 93 | expect(result.items[1].title).toEqual('This was published after the cutoff'); 94 | expect(result.items[0].title).toEqual('This was published before the cutoff'); 95 | }); 96 | 97 | test('it limits feed items when limit specified', async () => { 98 | feedConfig.limit = 3; 99 | const items = [ 100 | { title: 'test http://www.example.com/', content: 'test more content' }, 101 | { title: 'test http://www.example.com/', content: 'test more content' }, 102 | { title: 'test http://www.example.com/', content: 'test more content' }, 103 | { title: 'test http://www.example.com/', content: 'test more content' }, 104 | ]; 105 | Parser.mockImplementation(() => ({ 106 | parseURL: () => ({ title: 'mock title', items }), 107 | })); 108 | 109 | const result = await Feed({feedConfig}).resolve(); 110 | 111 | expect(result.items.length).toEqual(feedConfig.limit); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via the [issues on Github](https://github.com/portable-cto/rss-to-email/issues). 4 | 5 | Please note we have a code of conduct, please follow it in all your interactions with the project. 6 | 7 | ## Local Development 8 | 9 | ### Prerequisites 10 | 11 | In order to do local development on this project, you'll need to be running: 12 | 13 | - [Node 8.0+](https://nodejs.org/) 14 | - [npm 5.0+](https://www.npmjs.com/) 15 | 16 | ### Installation 17 | 18 | Clone this repository: 19 | 20 | ``` 21 | $ git clone https://github.com/portable-cto/rss-to-email.git 22 | ``` 23 | 24 | Install the dependencies: 25 | 26 | ``` 27 | $ npm install 28 | ``` 29 | 30 | ### Testing and Linting 31 | 32 | Tests are written using [Jest](https://facebook.github.io/jest/), and are kept in the `tests/` directory. We use integration tests to ensure that the whole library works as intended, and unit tests to evaluate individual functions and classes in isolation. 33 | 34 | To run the whole test suite once: 35 | 36 | ``` 37 | npm test 38 | ``` 39 | 40 | Or to run it and watch for updates: 41 | 42 | ``` 43 | npm run test:watch 44 | ``` 45 | 46 | We use [eslint](https://eslint.org/) to standardize code styles and [JSDoc](http://usejsdoc.org/index.html) style docblocks are enforced. Run the linter with: 47 | 48 | ``` 49 | npm run lint 50 | ``` 51 | 52 | PRs will not be evaluated until the tests and linting passes. 53 | 54 | ## Pull Request Process 55 | 56 | 1. Make sure tests are running and linting passes before you submit a PR. 57 | 2. Update any relevant parts of the documentation in the `readme.md` file. 58 | 3. Update the `changelog.md` file with any new updates, breaking changes, or important notes. 59 | 3. Run the build process: `npm run build`. 60 | 4. Include a link to any relevant issues in the PR on Github. If there are problems with your PR, we will discuss them in Github before merging. 61 | 62 | ## Releases 63 | 64 | This library uses [semantic versioning](https://semver.org/) to inform users of breaking and non-breaking changes. When a new release is ready, the following steps will be taken: 65 | 66 | - Make sure tests still pass: `npm run test`. 67 | - Run the release script: `npm version && npm publish && git push --tags` with the release number you want to use. 68 | 69 | This will create a new Tag in Github and a new release on NPM. 70 | 71 | ## Code of Conduct 72 | 73 | ### Our Pledge 74 | 75 | In the interest of fostering an open and welcoming environment, we as 76 | contributors and maintainers pledge to making participation in our project and 77 | our community a harassment-free experience for everyone, regardless of age, body 78 | size, disability, ethnicity, gender identity and expression, level of experience, 79 | nationality, personal appearance, race, religion, or sexual identity and 80 | orientation. 81 | 82 | ### Our Standards 83 | 84 | Examples of behavior that contributes to creating a positive environment 85 | include: 86 | 87 | * Using welcoming and inclusive language 88 | * Being respectful of differing viewpoints and experiences 89 | * Gracefully accepting constructive criticism 90 | * Focusing on what is best for the community 91 | * Showing empathy towards other community members 92 | 93 | Examples of unacceptable behavior by participants include: 94 | 95 | * The use of sexualized language or imagery and unwelcome sexual attention or 96 | advances 97 | * Trolling, insulting/derogatory comments, and personal or political attacks 98 | * Public or private harassment 99 | * Publishing others' private information, such as a physical or electronic 100 | address, without explicit permission 101 | * Other conduct which could reasonably be considered inappropriate in a 102 | professional setting 103 | 104 | ### Our Responsibilities 105 | 106 | Project maintainers are responsible for clarifying the standards of acceptable 107 | behavior and are expected to take appropriate and fair corrective action in 108 | response to any instances of unacceptable behavior. 109 | 110 | Project maintainers have the right and responsibility to remove, edit, or 111 | reject comments, commits, code, wiki edits, issues, and other contributions 112 | that are not aligned to this Code of Conduct, or to ban temporarily or 113 | permanently any contributor for other behaviors that they deem inappropriate, 114 | threatening, offensive, or harmful. 115 | 116 | ### Scope 117 | 118 | This Code of Conduct applies both within project spaces and in public spaces 119 | when an individual is representing the project or its community. Examples of 120 | representing a project or community include using an official project e-mail 121 | address, posting via an official social media account, or acting as an appointed 122 | representative at an online or offline event. Representation of a project may be 123 | further defined and clarified by project maintainers. 124 | 125 | ### Enforcement 126 | 127 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 128 | reported by contacting the project team at [karl@portablecto.com]. All 129 | complaints will be reviewed and investigated and will result in a response that 130 | is deemed necessary and appropriate to the circumstances. The project team is 131 | obligated to maintain confidentiality with regard to the reporter of an incident. 132 | Further details of specific enforcement policies may be posted separately. 133 | 134 | Project maintainers who do not follow or enforce the Code of Conduct in good 135 | faith may face temporary or permanent repercussions as determined by other 136 | members of the project's leadership. 137 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # rss-to-email 2 | 3 | Generate HTML emails and [mjml](https://mjml.io/) templates from one or more RSS feeds. 4 | 5 | > Note: This project is pre-version 1.0.0, so breaking changes may occur. Use at your own risk or lock down to a specific version using NPM. 6 | 7 | [![GitHub workflow](https://github.com/rsslove/rss-to-email/actions/workflows/test-and-lint.yml/badge.svg)](https://github.com/rsslove/rss-to-email/actions) 8 | [![codecov](https://codecov.io/gh/rsslove/rss-to-email/branch/master/graph/badge.svg?token=I2752VL3TQ)](https://codecov.io/gh/rsslove/rss-to-email) 9 | [![npm](https://img.shields.io/npm/v/rss-to-email.svg)](https://www.npmjs.com/package/rss-to-email) 10 | [![GitHub stars](https://img.shields.io/github/stars/portable-cto/rss-to-email.svg?style=social&label=Stars)](https://github.com/portable-cto/rss-to-email) 11 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 12 | 13 | ``` 14 | _ _ _ 15 | _ __ ___ ___ | |_ ___ ___ _ __ ___ __ _(_) | 16 | | '__/ __/ __|_____| __/ _ \ _____ / _ \ '_ ` _ \ / _` | | | 17 | | | \__ \__ \_____| || (_) |_____| __/ | | | | | (_| | | | 18 | |_| |___/___/ \__\___/ \___|_| |_| |_|\__,_|_|_| 19 | 20 | 21 | ``` 22 | 23 | [![rss-to-email example](http://g.recordit.co/KKSAZBRdsT.gif)](http://g.recordit.co/KKSAZBRdsT.gif) 24 | 25 | 26 | ## Table of Contents 27 | 28 | - [Usage](#usage) 29 | - [Node](#node) 30 | - [Command Line](#command-line) 31 | - [Browser](#browser) 32 | - [Configuration](#configuration) 33 | - [Templates](#templates) 34 | - [Contributing](#contributing) 35 | - [License](#license) 36 | 37 | 38 | ## Usage 39 | 40 | ### Node 41 | 42 | The recommended way to use this package is as [an npm package](https://www.npmjs.com/package/rss-to-email). To install and save it to your project's dependencies, run: 43 | 44 | ``` 45 | npm install rss-to-email --save 46 | ``` 47 | 48 | After installing, call the `RssToEmail` factory with [a config object](#configuration). Use the resulting `rssToEmail` object to get emails in `mjml` or `html` formats: 49 | 50 | ``` 51 | const RssToEmail = require('rss-to-email'); 52 | const config = { 53 | // See #Configuration section of the docs below 54 | }; 55 | const rssToEmail = RssToEmail(config); 56 | 57 | rssToEmail.getEmail('html').then((email) => { 58 | console.log(email); // The HTML version of your email 59 | }); 60 | 61 | rssToEmail.getEmail('mjml').then((email) => { 62 | console.log(email); // The MJML version of your email 63 | }); 64 | ``` 65 | 66 | ### Command Line 67 | 68 | You can install this package globally and run it as a command line tool as well. First install it: 69 | 70 | ``` 71 | npm install -g rss-to-email 72 | ``` 73 | 74 | Then run the tool: 75 | 76 | ``` 77 | rss-to-email 78 | ``` 79 | 80 | The path should be relative to your current directory. For example, if your config file is at `./config.json` and you want to output the resulting files to a directory `./output`, you would run: 81 | 82 | ``` 83 | rss-to-email ./config.json ./output 84 | ``` 85 | 86 | ### Configuration 87 | 88 | For an example config file, see `config.example.json`. 89 | 90 | - `accentColor`: A hex or html-safe color code. 91 | - `filename`: The base name of the output files to generate (do not include extensions). 92 | - `header`: Configuration for the header section of the email: 93 | - `banner`: (optional) An image url for the banner at the top of the email. 94 | - `link`: A link for the header image or text. 95 | - `title`: Shown if the banner is not set or as an `alt` tag on the image. 96 | - `intro`: The first line of the email. Can use HTML or plain text. 97 | - `feeds`: An array of RSS feeds you'd like to include. Only `url` is required: 98 | - `url`: The url to the RSS feed. 99 | - `title`: (optional) A custom feed title. Will use the RSS feed's embedded one by default. 100 | - `description`: (optional) A short custom feed description. Will use the RSS feed's embedded one by default. 101 | - `limit`: (optional) Truncate items greater than a given limit. 102 | - `publishedSince`: (optional) Filter out posts published before this date. 103 | - `parserOptions`: (optional) Custom RSS parser options outlined in the Node [rss-parser](https://www.npmjs.com/package/rss-parser#xml-options) documentation. 104 | - `outro`: The last line of the email. Can use HTML or plain text. 105 | - `templateUrl`: (optional) A handlebars/mjml template. For more details, see [Templates](#templates) section. 106 | 107 | ### Templates 108 | In order to compose custom emails, you can build your own [MJML templates](https://mjml.io/) with [Handlebars](). If you don't specify a template URL, the library defaults to [this file](https://raw.githubusercontent.com/portable-cto/rss-to-email/master/src/templates/default.mjml). 109 | 110 | Many of the config file's variables are exposed in the templates including: 111 | 112 | - `header` 113 | - `intro` 114 | - `outro` 115 | 116 | The `feeds` variable contains an array of all of the feeds with an array of all of the items in each. For example, the following is a basic template that will loop through all the RSS feeds and items, displaying the title and content of each: 117 | 118 | ```html 119 | {{#each feeds}} 120 | 121 | 122 | 123 | {{this.title}} 124 | {{this.description}} 125 | 126 | 127 | {{#each items}} 128 | 129 | 130 | 131 | 132 | {{this.title}} 133 | 134 | {{{this.content}}} 135 | 136 | 137 | {{/each}} 138 | {{/each}} 139 | ``` 140 | 141 | You can also use any helper in the [handlebars-helpers](https://github.com/helpers/handlebars-helpers) library: 142 | 143 | ```html 144 | {{#is intro "A certain intro"}} 145 |

A certain intro was used.

146 | {{/is}} 147 | ``` 148 | 149 | ## Contributing 150 | 151 | All patches, fixes, and ideas welcome! Please read [contributing.md](contributing.md) for furthers details. 152 | 153 | 154 | ## License 155 | 156 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 157 | 158 | Copyright 2021, Hughes Domains, LLC. 159 | --------------------------------------------------------------------------------