diff --git a/TODO/index.test.ts b/TODO/index.test.ts deleted file mode 100644 index 0f2479c..0000000 --- a/TODO/index.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { jest, test, expect } from "@jest/globals"; -import os from "os"; -import path from "path"; -import fs from "fs"; -import * as got from "got"; -import nodemailer from "nodemailer"; -import html from "@leafac/html"; -import killTheNewsletter from "."; - -jest.setTimeout(300_000); - -test("Kill the Newsletter!", async () => { - // Start servers - const rootDirectory = fs.mkdtempSync( - path.join(os.tmpdir(), "kill-the-newsletter--test--") - ); - const { webApplication, emailApplication } = killTheNewsletter(rootDirectory); - const webServer = webApplication.listen( - new URL(webApplication.get("url")).port - ); - const emailServer = emailApplication.listen( - new URL(webApplication.get("email")).port - ); - const webClient = got.default.extend({ - prefixUrl: webApplication.get("url"), - }); - const emailClient = nodemailer.createTransport(webApplication.get("email")); - const emailHostname = new URL(webApplication.get("url")).hostname; - - // Create feed - const create = (await webClient.post("", { form: { name: "A newsletter" } })) - .body; - expect(create).toMatch(`“A newsletter” inbox created`); - const feedReference = create.match(/\/feeds\/([a-z0-9]{16})\.xml/)![1]; - - // Test feed properties - const feedOriginal = await webClient.get(`feeds/${feedReference}.xml`); - expect(feedOriginal.headers["content-type"]).toMatch("application/atom+xml"); - expect(feedOriginal.headers["x-robots-tag"]).toBe("noindex"); - expect(feedOriginal.body).toMatch(html`A newsletter`); - - // Test alternate - const alternateReference = feedOriginal.body.match( - /\/alternates\/([a-z0-9]{16})\.html/ - )![1]; - const alternate = await webClient.get( - `alternates/${alternateReference}.html` - ); - expect(alternate.headers["content-type"]).toMatch("text/html"); - expect(alternate.headers["x-robots-tag"]).toBe("noindex"); - expect(alternate.body).toMatch(`Enjoy your readings!`); - - // Test email with HTML - await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for a second to test that the ‘’ field will be updated - await emailClient.sendMail({ - from: "publisher@example.com", - to: `${feedReference}@${emailHostname}`, - subject: "Test email with HTML", - html: html`

Some HTML

`, - }); - const feedWithHTMLEntry = (await webClient.get(`feeds/${feedReference}.xml`)) - .body; - expect(feedWithHTMLEntry.match(/(.+?)<\/updated>/)![1]).not.toBe( - feedOriginal.body.match(/(.+?)<\/updated>/)![1] - ); - expect(feedWithHTMLEntry).toMatch( - html`publisher@example.com` - ); - expect(feedWithHTMLEntry).toMatch(html`Test email with HTML`); - expect(feedWithHTMLEntry).toMatch( - // prettier-ignore - html`${`

Some HTML

`}\n
` - ); - - // Test email with text - await emailClient.sendMail({ - from: "publisher@example.com", - to: `${feedReference}@${emailHostname}`, - subject: "Test email with text", - text: "A link: https://kill-the-newsletter.com", - }); - expect((await webClient.get(`feeds/${feedReference}.xml`)).body).toMatch( - // prettier-ignore - html`${`

A link: https://kill-the-newsletter.com

`}
` - ); - - // Test email missing fields - await emailClient.sendMail({ - to: `${feedReference}@${emailHostname}`, - }); - const feedMissingFields = (await webClient.get(`feeds/${feedReference}.xml`)) - .body; - expect(feedMissingFields).toMatch(html``); - expect(feedMissingFields).toMatch(html``); - expect(feedMissingFields).toMatch(html``); - - // Test email to nonexistent ‘to’ (gets ignored) - await emailClient.sendMail({ - from: "publisher@example.com", - to: `nonexistent@${emailHostname}`, - subject: "Test email to nonexistent ‘to’ (gets ignored)", - text: "A link: https://kill-the-newsletter.com", - }); - expect((await webClient.get(`feeds/${feedReference}.xml`)).body).not.toMatch( - "Test email to nonexistent ‘to’ (gets ignored)" - ); - - // Test truncation - for (let index = 1; index <= 5; index++) - await emailClient.sendMail({ - from: "publisher@example.com", - to: `${feedReference}@${emailHostname}`, - subject: `Test truncation: ${index}`, - text: `TRUNCATION ${index} `.repeat(10_000), - }); - const feedTruncated = (await webClient.get(`feeds/${feedReference}.xml`)) - .body; - expect(feedTruncated).toMatch("TRUNCATION 5"); - expect(feedTruncated).not.toMatch("TRUNCATION 1"); - - // Test email that’s too long - await emailClient.sendMail({ - from: "publisher@example.com", - to: `${feedReference}@${emailHostname}`, - subject: "Test email that’s too long", - text: `TOO LONG `.repeat(100_000), - }); - const feedEvenMoreTruncated = ( - await webClient.get(`feeds/${feedReference}.xml`) - ).body; - expect(feedEvenMoreTruncated).not.toMatch("TOO LONG"); - expect(feedEvenMoreTruncated).not.toMatch("TRUNCATION 5"); - - // Test email after truncation - await emailClient.sendMail({ - from: "publisher@example.com", - to: `${feedReference}@${emailHostname}`, - subject: "Test email after truncation", - text: "A link: https://kill-the-newsletter.com", - }); - expect((await webClient.get(`feeds/${feedReference}.xml`)).body).toMatch( - // prettier-ignore - html`Test email after truncation` - ); - - // Stop servers - webServer.close(); - emailServer.close(); -}); diff --git a/package-lock.json b/package-lock.json index 34b1d33..1ec9ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,9 @@ "@leafac/sqlite": "^3.2.2", "commander": "^9.4.1", "crypto-random-string": "^5.0.0", + "dedent": "^0.7.0", "express": "^4.18.2", + "lodash": "^4.17.21", "mailparser": "^3.5.0", "smtp-server": "^3.11.0" }, @@ -1773,8 +1775,7 @@ "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", - "dev": true + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==" }, "node_modules/deep-extend": { "version": "0.6.0", @@ -3627,8 +3628,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.defaults": { "version": "4.2.0", @@ -8284,8 +8284,7 @@ "dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", - "dev": true + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==" }, "deep-extend": { "version": "0.6.0", @@ -9701,8 +9700,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.defaults": { "version": "4.2.0", diff --git a/package.json b/package.json index 468571d..8a97b7d 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,9 @@ "@leafac/sqlite": "^3.2.2", "commander": "^9.4.1", "crypto-random-string": "^5.0.0", + "dedent": "^0.7.0", "express": "^4.18.2", + "lodash": "^4.17.21", "mailparser": "^3.5.0", "smtp-server": "^3.11.0" }, diff --git a/server/index.mts b/server/index.mts index 55f14e3..242e4d8 100644 --- a/server/index.mts +++ b/server/index.mts @@ -1,5 +1,525 @@ +#!/usr/bin/env node + import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import url from "node:url"; +import timers from "node:timers/promises"; +import os from "node:os"; +import * as commander from "commander"; +import express from "express"; +import nodemailer from "nodemailer"; +import lodash from "lodash"; +import { execa, ExecaChildProcess } from "execa"; +import caddyfile from "dedent"; +import dedent from "dedent"; if (process.env.TEST === "kill-the-newsletter") { + delete process.env.TEST; + + // import { jest, test, expect } from "@jest/globals"; + // import os from "os"; + // import path from "path"; + // import fs from "fs"; + // import * as got from "got"; + // import nodemailer from "nodemailer"; + // import html from "@leafac/html"; + // import killTheNewsletter from "."; + + // jest.setTimeout(300_000); + + // test("Kill the Newsletter!", async () => { + // // Start servers + // const rootDirectory = fs.mkdtempSync( + // path.join(os.tmpdir(), "kill-the-newsletter--test--") + // ); + // const { webApplication, emailApplication } = killTheNewsletter(rootDirectory); + // const webServer = webApplication.listen( + // new URL(webApplication.get("url")).port + // ); + // const emailServer = emailApplication.listen( + // new URL(webApplication.get("email")).port + // ); + // const webClient = got.default.extend({ + // prefixUrl: webApplication.get("url"), + // }); + // const emailClient = nodemailer.createTransport(webApplication.get("email")); + // const emailHostname = new URL(webApplication.get("url")).hostname; + + // // Create feed + // const create = (await webClient.post("", { form: { name: "A newsletter" } })) + // .body; + // expect(create).toMatch(`“A newsletter” inbox created`); + // const feedReference = create.match(/\/feeds\/([a-z0-9]{16})\.xml/)![1]; + + // // Test feed properties + // const feedOriginal = await webClient.get(`feeds/${feedReference}.xml`); + // expect(feedOriginal.headers["content-type"]).toMatch("application/atom+xml"); + // expect(feedOriginal.headers["x-robots-tag"]).toBe("noindex"); + // expect(feedOriginal.body).toMatch(html`A newsletter`); + + // // Test alternate + // const alternateReference = feedOriginal.body.match( + // /\/alternates\/([a-z0-9]{16})\.html/ + // )![1]; + // const alternate = await webClient.get( + // `alternates/${alternateReference}.html` + // ); + // expect(alternate.headers["content-type"]).toMatch("text/html"); + // expect(alternate.headers["x-robots-tag"]).toBe("noindex"); + // expect(alternate.body).toMatch(`Enjoy your readings!`); + + // // Test email with HTML + // await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for a second to test that the ‘’ field will be updated + // await emailClient.sendMail({ + // from: "publisher@example.com", + // to: `${feedReference}@${emailHostname}`, + // subject: "Test email with HTML", + // html: html`

Some HTML

`, + // }); + // const feedWithHTMLEntry = (await webClient.get(`feeds/${feedReference}.xml`)) + // .body; + // expect(feedWithHTMLEntry.match(/(.+?)<\/updated>/)![1]).not.toBe( + // feedOriginal.body.match(/(.+?)<\/updated>/)![1] + // ); + // expect(feedWithHTMLEntry).toMatch( + // html`publisher@example.com` + // ); + // expect(feedWithHTMLEntry).toMatch(html`Test email with HTML`); + // expect(feedWithHTMLEntry).toMatch( + // // prettier-ignore + // html`${`

Some HTML

`}\n
` + // ); + + // // Test email with text + // await emailClient.sendMail({ + // from: "publisher@example.com", + // to: `${feedReference}@${emailHostname}`, + // subject: "Test email with text", + // text: "A link: https://kill-the-newsletter.com", + // }); + // expect((await webClient.get(`feeds/${feedReference}.xml`)).body).toMatch( + // // prettier-ignore + // html`${`

A link: https://kill-the-newsletter.com

`}
` + // ); + + // // Test email missing fields + // await emailClient.sendMail({ + // to: `${feedReference}@${emailHostname}`, + // }); + // const feedMissingFields = (await webClient.get(`feeds/${feedReference}.xml`)) + // .body; + // expect(feedMissingFields).toMatch(html``); + // expect(feedMissingFields).toMatch(html``); + // expect(feedMissingFields).toMatch(html``); + + // // Test email to nonexistent ‘to’ (gets ignored) + // await emailClient.sendMail({ + // from: "publisher@example.com", + // to: `nonexistent@${emailHostname}`, + // subject: "Test email to nonexistent ‘to’ (gets ignored)", + // text: "A link: https://kill-the-newsletter.com", + // }); + // expect((await webClient.get(`feeds/${feedReference}.xml`)).body).not.toMatch( + // "Test email to nonexistent ‘to’ (gets ignored)" + // ); + + // // Test truncation + // for (let index = 1; index <= 5; index++) + // await emailClient.sendMail({ + // from: "publisher@example.com", + // to: `${feedReference}@${emailHostname}`, + // subject: `Test truncation: ${index}`, + // text: `TRUNCATION ${index} `.repeat(10_000), + // }); + // const feedTruncated = (await webClient.get(`feeds/${feedReference}.xml`)) + // .body; + // expect(feedTruncated).toMatch("TRUNCATION 5"); + // expect(feedTruncated).not.toMatch("TRUNCATION 1"); + + // // Test email that’s too long + // await emailClient.sendMail({ + // from: "publisher@example.com", + // to: `${feedReference}@${emailHostname}`, + // subject: "Test email that’s too long", + // text: `TOO LONG `.repeat(100_000), + // }); + // const feedEvenMoreTruncated = ( + // await webClient.get(`feeds/${feedReference}.xml`) + // ).body; + // expect(feedEvenMoreTruncated).not.toMatch("TOO LONG"); + // expect(feedEvenMoreTruncated).not.toMatch("TRUNCATION 5"); + + // // Test email after truncation + // await emailClient.sendMail({ + // from: "publisher@example.com", + // to: `${feedReference}@${emailHostname}`, + // subject: "Test email after truncation", + // text: "A link: https://kill-the-newsletter.com", + // }); + // expect((await webClient.get(`feeds/${feedReference}.xml`)).body).toMatch( + // // prettier-ignore + // html`Test email after truncation` + // ); + + // // Stop servers + // webServer.close(); + // emailServer.close(); + // }); + assert.equal(1 + 1, 2); + + process.exit(0); } + +const version = JSON.parse( + await fs.readFile(new URL("../../package.json", import.meta.url), "utf8") +).version; + +await commander.program + .name("kill-the-newsletter") + .description("Convert email newsletters into Atom feeds") + .addOption( + new commander.Option("--process-type ") + .default("main") + .hideHelp() + ) + .addOption( + new commander.Option("--process-number ").hideHelp() + ) + .argument( + "[configuration]", + "Path to configuration file. If you don’t provide a configuration file, the application runs in demonstration mode.", + url.fileURLToPath( + new URL("../../configuration/default.mjs", import.meta.url) + ) + ) + .version(version) + .addHelpText( + "after", + "\n" + + dedent` + Configuration: + See ‘https://github.com/courselore/courselore/blob/main/documentation/self-hosting.md’ for instructions, and ‘https://github.com/courselore/courselore/blob/main/configuration/example.mjs’ for an example. + ` + ) + .allowExcessArguments(false) + .showHelpAfterError() + .action( + async ( + configuration: string, + { + processType, + processNumber, + }: { + processType: "main" | "server" | "worker"; + processNumber: string; + } + ) => { + const stop = new Promise((resolve) => { + const processKeepAlive = new AbortController(); + timers + .setInterval(1 << 30, undefined, { + signal: processKeepAlive.signal, + }) + [Symbol.asyncIterator]() + .next() + .catch(() => {}); + for (const event of [ + "exit", + "SIGHUP", + "SIGINT", + "SIGQUIT", + "SIGTERM", + "SIGUSR2", + "SIGBREAK", + ]) + process.on(event, () => { + processKeepAlive.abort(); + resolve(); + }); + }); + + const application = { + name: "courselore", + version, + process: { + id: Math.random().toString(36).slice(2), + type: processType, + number: + typeof processNumber === "string" + ? Number(processNumber) + : undefined, + }, + configuration: (await import(url.pathToFileURL(configuration).href)) + .default, + static: JSON.parse( + await fs.readFile( + new URL("../static/paths.json", import.meta.url), + "utf8" + ) + ), + ports: { + server: lodash.times( + os.cpus().length, + (processNumber) => 6000 + processNumber + ), + serverEvents: lodash.times( + os.cpus().length, + (processNumber) => 7000 + processNumber + ), + workerEvents: lodash.times( + os.cpus().length, + (processNumber) => 8000 + processNumber + ), + }, + addresses: { + canonicalHostname: "courselore.org", + metaCourseloreInvitation: "https://meta.courselore.org", + tryHostname: "try.courselore.org", + }, + server: express() as any, + serverEvents: express() as any, + workerEvents: express() as any, + } as Application; + + application.configuration.environment ??= "production"; + application.configuration.demonstration ??= + application.configuration.environment !== "production"; + application.configuration.tunnel ??= false; + application.configuration.alternativeHostnames ??= []; + application.configuration.hstsPreload ??= false; + application.configuration.caddy ??= caddyfile``; + + // application.server.locals.middleware = {} as any; + // application.server.locals.helpers = {} as any; + + await logging(application); + await database(application); + await healthChecks(application); + await base(application); + // await liveUpdates(application); + // await authentication(application); + // await layouts(application); + // await about(application); + // await administration(application); + // await user(application); + // await course(application); + // await conversation(application); + // await message(application); + // await content(application); + // await email(application); + // await demonstration(application); + // await error(application); + // await helpers(application); + + switch (application.process.type) { + case "main": { + const childProcesses = new Set(); + let restartChildProcesses = true; + for (const execaArguments of [ + ...["server", "worker"].flatMap((processType) => + lodash.times(os.cpus().length, (processNumber) => ({ + file: process.argv[0], + arguments: [ + process.argv[1], + "--process-type", + processType, + "--process-number", + processNumber, + configuration, + ], + options: { + preferLocal: true, + stdio: "inherit", + ...(application.configuration.environment === "production" + ? { env: { NODE_ENV: "production" } } + : {}), + }, + })) + ), + { + file: "caddy", + arguments: ["run", "--config", "-", "--adapter", "caddyfile"], + options: { + preferLocal: true, + stdout: "ignore", + stderr: "ignore", + input: caddyfile` + { + admin off + ${ + application.configuration.environment === "production" + ? `email ${application.configuration.administratorEmail}` + : `local_certs` + } + } + + (common) { + header Cache-Control no-store + header Content-Security-Policy "default-src https://${ + application.configuration.hostname + }/ 'unsafe-inline' 'unsafe-eval'; frame-ancestors 'none'; object-src 'none'" + header Cross-Origin-Embedder-Policy require-corp + header Cross-Origin-Opener-Policy same-origin + header Cross-Origin-Resource-Policy same-origin + header Referrer-Policy no-referrer + header Strict-Transport-Security "max-age=31536000; includeSubDomains${ + application.configuration.hstsPreload ? `; preload` : `` + }" + header X-Content-Type-Options nosniff + header Origin-Agent-Cluster "?1" + header X-DNS-Prefetch-Control off + header X-Frame-Options DENY + header X-Permitted-Cross-Domain-Policies none + header -Server + header -X-Powered-By + header X-XSS-Protection 0 + header Permissions-Policy "interest-cohort=()" + encode zstd gzip + } + + ${[ + application.configuration.tunnel + ? [] + : [application.configuration.hostname], + ...application.configuration.alternativeHostnames, + ] + .map((hostname) => `http://${hostname}`) + .join(", ")} { + import common + redir https://{host}{uri} 308 + handle_errors { + import common + } + } + + ${ + application.configuration.alternativeHostnames.length > 0 + ? caddyfile` + ${application.configuration.alternativeHostnames + .map((hostname) => `https://${hostname}`) + .join(", ")} { + import common + redir https://${ + application.configuration.hostname + }{uri} 307 + handle_errors { + import common + } + } + ` + : `` + } + + http${application.configuration.tunnel ? `` : `s`}://${ + application.configuration.hostname + } { + route { + import common + route { + root * ${JSON.stringify( + url.fileURLToPath( + new URL("../static/", import.meta.url) + ) + )} + @file_exists file + route @file_exists { + header Cache-Control "public, max-age=31536000, immutable" + file_server + } + } + route /files/* { + root * ${JSON.stringify( + path.resolve(application.configuration.dataDirectory) + )} + @file_exists file + route @file_exists { + header Cache-Control "private, max-age=31536000, immutable" + @must_be_downloaded not path *.png *.jpg *.jpeg *.gif *.mp3 *.mp4 *.m4v *.ogg *.mov *.mpeg *.avi *.pdf *.txt + header @must_be_downloaded Content-Disposition attachment + @may_be_embedded_in_other_sites path *.png *.jpg *.jpeg *.gif *.mp3 *.mp4 *.m4v *.ogg *.mov *.mpeg *.avi *.pdf + header @may_be_embedded_in_other_sites Cross-Origin-Resource-Policy cross-origin + file_server + } + } + reverse_proxy ${application.ports.server + .map((port) => `127.0.0.1:${port}`) + .join(" ")} + } + handle_errors { + import common + } + } + + ${application.configuration.caddy} + `, + }, + }, + ]) + (async () => { + while (restartChildProcesses) { + const childProcess = execa( + execaArguments.file, + execaArguments.arguments as any, + { + ...execaArguments.options, + reject: false, + cleanup: false, + } as any + ); + childProcesses.add(childProcess); + const childProcessResult = await childProcess; + application.log( + "CHILD PROCESS RESULT", + JSON.stringify(childProcessResult, undefined, 2) + ); + childProcesses.delete(childProcess); + } + })(); + await stop; + restartChildProcesses = false; + for (const childProcess of childProcesses) childProcess.cancel(); + break; + } + + case "server": { + const serverApplication = application.server; + const eventsApplication = application.serverEvents; + serverApplication.emit("start"); + eventsApplication.emit("start"); + const server = serverApplication.listen( + application.ports.server[application.process.number], + "127.0.0.1" + ); + const events = eventsApplication.listen( + application.ports.serverEvents[application.process.number], + "127.0.0.1" + ); + await stop; + server.close(); + events.close(); + serverApplication.emit("stop"); + eventsApplication.emit("stop"); + break; + } + + case "worker": { + const eventsApplication = application.workerEvents; + eventsApplication.emit("start"); + const events = eventsApplication.listen( + application.ports.workerEvents[application.process.number], + "127.0.0.1" + ); + await stop; + events.close(); + eventsApplication.emit("stop"); + break; + } + } + + await timers.setTimeout(10 * 1000, undefined, { ref: false }); + process.exit(1); + } + ) + .parseAsync(); diff --git a/server/package-lock.json b/server/package-lock.json index be8bd38..ccb4fac 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -5,7 +5,9 @@ "packages": { "": { "devDependencies": { + "@types/dedent": "^0.7.0", "@types/express": "^4.17.14", + "@types/lodash": "^4.14.188", "@types/mailparser": "^3.4.0", "@types/node": "^18.11.9", "@types/nodemailer": "^6.4.6", @@ -32,6 +34,12 @@ "@types/node": "*" } }, + "node_modules/@types/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@types/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A==", + "dev": true + }, "node_modules/@types/express": { "version": "4.17.14", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", @@ -55,6 +63,12 @@ "@types/range-parser": "*" } }, + "node_modules/@types/lodash": { + "version": "4.14.188", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.188.tgz", + "integrity": "sha512-zmEmF5OIM3rb7SbLCFYoQhO4dGt2FRM9AMkxvA3LaADOF1n8in/zGJlWji9fmafLoNyz+FoL6FE0SLtGIArD7w==", + "dev": true + }, "node_modules/@types/mailparser": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.0.tgz", @@ -170,6 +184,12 @@ "@types/node": "*" } }, + "@types/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@types/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A==", + "dev": true + }, "@types/express": { "version": "4.17.14", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", @@ -193,6 +213,12 @@ "@types/range-parser": "*" } }, + "@types/lodash": { + "version": "4.14.188", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.188.tgz", + "integrity": "sha512-zmEmF5OIM3rb7SbLCFYoQhO4dGt2FRM9AMkxvA3LaADOF1n8in/zGJlWji9fmafLoNyz+FoL6FE0SLtGIArD7w==", + "dev": true + }, "@types/mailparser": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.0.tgz", diff --git a/server/package.json b/server/package.json index 23494c4..c148714 100644 --- a/server/package.json +++ b/server/package.json @@ -3,7 +3,9 @@ "prepare": "tsc" }, "devDependencies": { + "@types/dedent": "^0.7.0", "@types/express": "^4.17.14", + "@types/lodash": "^4.14.188", "@types/mailparser": "^3.4.0", "@types/node": "^18.11.9", "@types/nodemailer": "^6.4.6",