This commit is contained in:
Leandro Facchinetti 2022-11-07 19:07:13 +00:00
parent 4662cb5438
commit bb32b33d99
6 changed files with 556 additions and 157 deletions

View File

@ -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`<title>A newsletter</title>`);
// 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 <updated> field will be updated
await emailClient.sendMail({
from: "publisher@example.com",
to: `${feedReference}@${emailHostname}`,
subject: "Test email with HTML",
html: html`<p>Some HTML</p>`,
});
const feedWithHTMLEntry = (await webClient.get(`feeds/${feedReference}.xml`))
.body;
expect(feedWithHTMLEntry.match(/<updated>(.+?)<\/updated>/)![1]).not.toBe(
feedOriginal.body.match(/<updated>(.+?)<\/updated>/)![1]
);
expect(feedWithHTMLEntry).toMatch(
html`<author><name>publisher@example.com</name></author>`
);
expect(feedWithHTMLEntry).toMatch(html`<title>Test email with HTML</title>`);
expect(feedWithHTMLEntry).toMatch(
// prettier-ignore
html`<content type="html">${`<p>Some HTML</p>`}\n</content>`
);
// 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`<content type="html">${`<p>A link: <a href="https://kill-the-newsletter.com">https://kill-the-newsletter.com</a></p>`}</content>`
);
// Test email missing fields
await emailClient.sendMail({
to: `${feedReference}@${emailHostname}`,
});
const feedMissingFields = (await webClient.get(`feeds/${feedReference}.xml`))
.body;
expect(feedMissingFields).toMatch(html`<author><name></name></author>`);
expect(feedMissingFields).toMatch(html`<title></title>`);
expect(feedMissingFields).toMatch(html`<content type="html"></content>`);
// 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 thats too long
await emailClient.sendMail({
from: "publisher@example.com",
to: `${feedReference}@${emailHostname}`,
subject: "Test email thats 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`<title>Test email after truncation</title>`
);
// Stop servers
webServer.close();
emailServer.close();
});

14
package-lock.json generated
View File

@ -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",

View File

@ -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"
},

View File

@ -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`<title>A newsletter</title>`);
// // 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 <updated> field will be updated
// await emailClient.sendMail({
// from: "publisher@example.com",
// to: `${feedReference}@${emailHostname}`,
// subject: "Test email with HTML",
// html: html`<p>Some HTML</p>`,
// });
// const feedWithHTMLEntry = (await webClient.get(`feeds/${feedReference}.xml`))
// .body;
// expect(feedWithHTMLEntry.match(/<updated>(.+?)<\/updated>/)![1]).not.toBe(
// feedOriginal.body.match(/<updated>(.+?)<\/updated>/)![1]
// );
// expect(feedWithHTMLEntry).toMatch(
// html`<author><name>publisher@example.com</name></author>`
// );
// expect(feedWithHTMLEntry).toMatch(html`<title>Test email with HTML</title>`);
// expect(feedWithHTMLEntry).toMatch(
// // prettier-ignore
// html`<content type="html">${`<p>Some HTML</p>`}\n</content>`
// );
// // 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`<content type="html">${`<p>A link: <a href="https://kill-the-newsletter.com">https://kill-the-newsletter.com</a></p>`}</content>`
// );
// // Test email missing fields
// await emailClient.sendMail({
// to: `${feedReference}@${emailHostname}`,
// });
// const feedMissingFields = (await webClient.get(`feeds/${feedReference}.xml`))
// .body;
// expect(feedMissingFields).toMatch(html`<author><name></name></author>`);
// expect(feedMissingFields).toMatch(html`<title></title>`);
// expect(feedMissingFields).toMatch(html`<content type="html"></content>`);
// // 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 thats too long
// await emailClient.sendMail({
// from: "publisher@example.com",
// to: `${feedReference}@${emailHostname}`,
// subject: "Test email thats 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`<title>Test email after truncation</title>`
// );
// // 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 <process-type>")
.default("main")
.hideHelp()
)
.addOption(
new commander.Option("--process-number <process-number>").hideHelp()
)
.argument(
"[configuration]",
"Path to configuration file. If you dont 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<void>((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<ExecaChildProcess>();
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();

View File

@ -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",

View File

@ -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",