├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── TODO.md ├── configuration ├── development.mjs ├── example.mjs ├── kill-the-newsletter.com.mjs └── kill-the-newsletter.service ├── package-lock.json ├── package.json ├── source ├── index.mts └── index.test.mts ├── static ├── apple-touch-icon.png └── favicon.ico └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: leafac 2 | github: leafac 3 | custom: 4 | - https://paypal.me/LeandroFacchinettiEU 5 | - https://btc.com/34KJBgtaFYMtDqpSgMayw9qiKWg2GQXA9M 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | jobs: 3 | build: 4 | strategy: 5 | fail-fast: false 6 | matrix: 7 | os: 8 | - windows 9 | - macos 10 | - ubuntu 11 | runs-on: ${{ matrix.os }}-latest 12 | steps: 13 | - uses: actions/checkout@main 14 | - uses: actions/setup-node@main 15 | with: 16 | node-version: latest 17 | - run: | 18 | npm install-ci-test 19 | npx package 20 | mv ../kill-the-newsletter.${{ matrix.os == 'windows' && 'zip' || 'tar.gz' }} ./kill-the-newsletter--${{ matrix.os }}--${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || github.sha }}.${{ matrix.os == 'windows' && 'zip' || 'tar.gz' }} 21 | - uses: actions/upload-artifact@main 22 | with: 23 | path: ./kill-the-newsletter--${{ matrix.os }}--${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || github.sha }}.${{ matrix.os == 'windows' && 'zip' || 'tar.gz' }} 24 | name: kill-the-newsletter--${{ matrix.os }}--${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || github.sha }}.${{ matrix.os == 'windows' && 'zip' || 'tar.gz' }} 25 | - if: ${{ matrix.os == 'ubuntu' && startsWith(github.ref, 'refs/tags/v') }} 26 | uses: webfactory/ssh-agent@master 27 | with: 28 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} 29 | - if: ${{ matrix.os == 'ubuntu' && startsWith(github.ref, 'refs/tags/v') }} 30 | run: | 31 | cat >> ~/.ssh/known_hosts << "EOF" 32 | $ ssh-keyscan 159.69.13.122 33 | # 159.69.13.122:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5 34 | 159.69.13.122 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCvUeZIIcFYMFzJAxqen3pWi0E4LP/V1vhaqs926jCxn4YqXAdBLdBaJ6cS6pq7cER0UrHqPXdGONQd0gyH3sFh3EJ/FJGaMvmhrWsfjM03vBDUWm84h7DId57TGgZCGClWdb2Mbhvxpl2Rw87Iiq+Wp//DDPfIl6QPTTwPF8b4V8Wx0Qv2pAXIZrO7eRrJpMaQPSUqaj2A94Hui1hVdl9jvfa/iYwvOguP8e4L9wI3b05ItHjg+v70GYbDCuSw045UAu5wfjlOHlJXi+h93gUSU2XmylTxssDW77agGpkIYDLeuxYxjhWAWJNbyGDiBYTjT1lNbFT4RWrJs9fURw0bc7zQCf74UUxkAinnfXllCgAroVlniJxSz9/87dWDvlRsIncz6SryX2g/lTSM8Yd290AqM2q2fvpCGJ0h6om71yNxsH1P5BaIZwJ74XhZwUVy1OK3th+OoZPLFMYwoi309188kdgWJ1CiJLkWQnoKjjiG7CD4rEaREWRThWPqb9k= 35 | # 159.69.13.122:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5 36 | 159.69.13.122 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFMYkjNwmcUiRukJuoAA7JYtTF1xG3HPEa/QzIdGY0pb 37 | # 159.69.13.122:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5 38 | 159.69.13.122 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBL8H/sc/goyCkg77Vj7Dr/Mf89yUUazhunpjiWGUzuLJclWt9R/JFWmRM7o1lQjVaFxE53CCbD8pLQCO4etApSs= 39 | # 159.69.13.122:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5 40 | # 159.69.13.122:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5 41 | EOF 42 | rsync -a ./kill-the-newsletter--ubuntu--${{ github.ref_name }}.tar.gz root@kill-the-newsletter.com:/mnt/kill-the-newsletter/kill-the-newsletter--ubuntu--${{ github.ref_name }}.tar.gz 43 | ssh root@kill-the-newsletter.com << "EOF" 44 | mkdir -p /mnt/kill-the-newsletter/kill-the-newsletter/ 45 | rm -rf /mnt/kill-the-newsletter/kill-the-newsletter--deploy/ 46 | mkdir /mnt/kill-the-newsletter/kill-the-newsletter--deploy/ 47 | mv /mnt/kill-the-newsletter/kill-the-newsletter--ubuntu--${{ github.ref_name }}.tar.gz /mnt/kill-the-newsletter/kill-the-newsletter--deploy/kill-the-newsletter--ubuntu--${{ github.ref_name }}.tar.gz 48 | cd /mnt/kill-the-newsletter/kill-the-newsletter--deploy/ 49 | tar -xzf ./kill-the-newsletter--ubuntu--${{ github.ref_name }}.tar.gz 50 | cp /mnt/kill-the-newsletter/kill-the-newsletter--deploy/kill-the-newsletter/_/configuration/kill-the-newsletter.com.mjs /mnt/kill-the-newsletter/kill-the-newsletter/configuration.mjs 51 | cp /mnt/kill-the-newsletter/kill-the-newsletter--deploy/kill-the-newsletter/_/configuration/kill-the-newsletter.service /etc/systemd/system/kill-the-newsletter.service 52 | systemctl daemon-reload 53 | systemctl stop kill-the-newsletter 54 | mv /mnt/kill-the-newsletter/kill-the-newsletter/kill-the-newsletter/ /mnt/kill-the-newsletter/kill-the-newsletter--deploy/kill-the-newsletter--old/ || true 55 | mv /mnt/kill-the-newsletter/kill-the-newsletter--deploy/kill-the-newsletter/ /mnt/kill-the-newsletter/kill-the-newsletter/kill-the-newsletter/ 56 | systemctl start kill-the-newsletter 57 | systemctl enable kill-the-newsletter 58 | rm -rf /mnt/kill-the-newsletter/kill-the-newsletter--deploy/ 59 | EOF 60 | 61 | release: 62 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 63 | needs: build 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/download-artifact@main 67 | - uses: softprops/action-gh-release@master 68 | with: 69 | files: ./**/*.* 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | /build/ 4 | /data/ 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.0.8 · 2024-08-01 4 | 5 | - Changed the feed size limit from `2 ** 20` to `2 ** 19` to try and reduce server costs 💀 6 | 7 | ## 2.0.7 · 2024-06-21 8 | 9 | - Added a “feed settings” page, which allows for adding a custom icon to a feed (https://github.com/leafac/kill-the-newsletter/issues/92) 10 | - Added more background job workers to WebSub jobs (https://github.com/leafac/kill-the-newsletter/issues/68). 11 | - Changed the route of feed creation via API from `/` to `/feeds`, for example: 12 | 13 | ```console 14 | $ curl --request POST --header "CSRF-Protection: true" --header "Accept: application/json" --data "title=Example of a feed" https://localhost/feeds 15 | ``` 16 | 17 | Also, now that API endpoint responds with `Content-Type: application/json`. 18 | 19 | ## 2.0.6 · 2024-06-06 20 | 21 | - Added support for `` (https://github.com/leafac/kill-the-newsletter/issues/92). 22 | - Added author to entry (https://github.com/leafac/kill-the-newsletter/issues/102). 23 | 24 | ## 2.0.5 · 2024-06-06 25 | 26 | - Added support for attachments (https://github.com/leafac/kill-the-newsletter/issues/66). 27 | 28 | - Added support for API-like use of feed creation (https://github.com/leafac/kill-the-newsletter/issues/43), for example: 29 | 30 | ```console 31 | $ curl --request POST --header "CSRF-Protection: true" --header "Accept: application/json" --data "title=Example of a feed" https://localhost/ 32 | {"feedId":"r4n7siivh4iiho0gtv59","email":"r4n7siivh4iiho0gtv59@localhost","feed":"https://localhost/feeds/r4n7siivh4iiho0gtv59.xml"} 33 | ``` 34 | 35 | ## 2.0.4 · 2024-06-06 36 | 37 | - Added support for WebSub (https://github.com/leafac/kill-the-newsletter/issues/68). 38 | 39 | ## 2.0.3 · 2024-06-05 40 | 41 | - Fixed an issue in which email redirects weren’t coming through (because they often include the `=` character, which we disallowed previously). 42 | - Added a feature to allow deleting a feed. 43 | - Added rate limiting to try and control server costs. 44 | 45 | ## 2.0.2 · 2024-05-14 46 | 47 | - **Breaking Change:** Changed the configuration option from `administratorEmail` to `systemAdministratorEmail`. 48 | - Allowed hotlinked images in alternate HTML. 49 | 50 | ## 2.0.1 · 2024-05-07 51 | 52 | Adapted Kill the Newsletter! to use [Radically Straightforward](https://github.com/radically-straightforward/radically-straightforward). 53 | 54 | This is a breaking change, and to migrate you must do the following: 55 | 56 | 1. Stop your current installation of Kill the Newsletter! and move it into a temporary directory. 57 | 58 | 2. Install Kill the Newsletter! as if it was a fresh install. 59 | 60 | 3. Run a migration from the old database into the new one. Open a terminal multiplexer, for example, tmux or GNU Screen, so that the migration continues to run even if your SSH connection disconnects. Run the following command: 61 | 62 | ```console 63 | $ ./kill-the-newsletter/kill-the-newsletter ./configuration.mjs --migrate ../kill-the-newsletter--old/data/kill-the-newsletter.db >> ./migration.txt 2>&1 64 | ``` 65 | 66 | > **Note:** The `--migrate` option is only available in version 2.0.1. You first have to migrate to it, then to the later versions above. 67 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Leandro Facchinetti (https://leafac.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kill the Newsletter! 2 | 3 | **Convert email newsletters into Atom feeds** 4 | 5 | **** · 6 | **[Deployment](https://github.com/radically-straightforward/radically-straightforward/blob/main/guides/deployment.md)** · 7 | **[Development](https://github.com/radically-straightforward/radically-straightforward/blob/main/guides/development.md)** 8 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - Provide a link that reads “this newsletter already has an Atom feed to which you could subscribe directly instead of using Kill the Newsletter!”. To do that, parse the email coming in with LinkeDOM and look for `` (email from dynomight) 4 | - Use different `publicId`s for the email address and the feed address https://github.com/leafac/kill-the-newsletter/issues/114 5 | - Get the `from` email address from somewhere else? https://github.com/leafac/kill-the-newsletter/issues/102 6 | - Reduce bandwidth 7 | - Enable cache HTTP header. 8 | - Setup Cloudflare. 9 | - Document that you need to setup a MX record in the DNS to receive email. 10 | -------------------------------------------------------------------------------- /configuration/development.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import * as caddy from "@radically-straightforward/caddy"; 3 | 4 | export default { 5 | hostname: process.env.HOSTNAME ?? "localhost", 6 | tls: { 7 | key: path.join( 8 | caddy.dataDirectory(), 9 | `certificates/local/localhost/localhost.key`, 10 | ), 11 | certificate: path.join( 12 | caddy.dataDirectory(), 13 | `certificates/local/localhost/localhost.crt`, 14 | ), 15 | }, 16 | environment: "development", 17 | }; 18 | -------------------------------------------------------------------------------- /configuration/example.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | hostname: "example.com", 3 | systemAdministratorEmail: "administrator@example.com", 4 | // Paths to the key and certificate generated by Caddy, which must be provided here because they’re used for the email server. 5 | tls: { 6 | key: "/root/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/example.com/example.com.key", 7 | certificate: 8 | "/root/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/example.com/example.com.crt", 9 | }, 10 | // The following is the default `dataDirectory`, but you may change it if necessary. 11 | // dataDirectory: "/root/kill-the-newsletter/data/", 12 | // Enable the following if you can. See https://hstspreload.org/. 13 | // hstsPreload: true, 14 | // Add some extra Caddyfile directives, for example, to redirect `www.example.com` to `example.com`. 15 | // extraCaddyfile: ` 16 | // www.example.com { 17 | // redir https://example.com{uri} 18 | // } 19 | // `, 20 | }; 21 | -------------------------------------------------------------------------------- /configuration/kill-the-newsletter.com.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | hostname: "kill-the-newsletter.com", 3 | systemAdministratorEmail: "kill-the-newsletter@leafac.com", 4 | tls: { 5 | key: "/root/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/kill-the-newsletter.com/kill-the-newsletter.com.key", 6 | certificate: 7 | "/root/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/kill-the-newsletter.com/kill-the-newsletter.com.crt", 8 | }, 9 | hstsPreload: true, 10 | extraCaddyfile: ` 11 | www.kill-the-newsletter.com { 12 | redir https://kill-the-newsletter.com{uri} 13 | } 14 | `, 15 | }; 16 | -------------------------------------------------------------------------------- /configuration/kill-the-newsletter.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Kill the Newsletter! 3 | After=network.target 4 | 5 | [Service] 6 | WorkingDirectory=/mnt/kill-the-newsletter/kill-the-newsletter/ 7 | ExecStart=/mnt/kill-the-newsletter/kill-the-newsletter/kill-the-newsletter/kill-the-newsletter /mnt/kill-the-newsletter/kill-the-newsletter/configuration.mjs 8 | User=root 9 | Restart=always 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "prepare": "tsc && build", 4 | "start": "nodemon --watch ./package.json --watch ./tsconfig.json --watch ./source/ --watch ./static/ --watch ./configuration/ --ext \"*\" --exec \"npm run prepare && node ./build/index.mjs ./configuration/development.mjs\"", 5 | "test": "npm run prepare && prettier --check ./README.md ./CHANGELOG.md ./package.json ./tsconfig.json ./source/ ./configuration/", 6 | "backup": "rsync -av --delete --progress root@kill-the-newsletter.com:/mnt/kill-the-newsletter/kill-the-newsletter/ /Volumes/leafac--external-storage/Backups/kill-the-newsletter/" 7 | }, 8 | "dependencies": { 9 | "@radically-straightforward/production": "^1.0.33", 10 | "crypto-random-string": "^5.0.0", 11 | "mailparser": "^3.7.2", 12 | "smtp-server": "^3.13.6" 13 | }, 14 | "devDependencies": { 15 | "@fontsource-variable/roboto-flex": "^5.1.1", 16 | "@radically-straightforward/development": "^1.0.50", 17 | "@types/mailparser": "^3.4.5", 18 | "@types/node": "^22.13.0", 19 | "@types/nodemailer": "^6.4.17", 20 | "@types/smtp-server": "^3.5.10", 21 | "bootstrap-icons": "^1.11.3", 22 | "nodemailer": "^6.10.0", 23 | "nodemon": "^3.1.9", 24 | "prettier": "^3.4.2", 25 | "typescript": "^5.7.3" 26 | }, 27 | "prettier": {} 28 | } 29 | -------------------------------------------------------------------------------- /source/index.mts: -------------------------------------------------------------------------------- 1 | import util from "node:util"; 2 | import path from "node:path"; 3 | import os from "node:os"; 4 | import fs from "node:fs/promises"; 5 | import fsSync from "node:fs"; 6 | import childProcess from "node:child_process"; 7 | import stream from "node:stream/promises"; 8 | import crypto from "node:crypto"; 9 | import server from "@radically-straightforward/server"; 10 | import * as serverTypes from "@radically-straightforward/server"; 11 | import sql, { Database } from "@radically-straightforward/sqlite"; 12 | import html, { HTML } from "@radically-straightforward/html"; 13 | import css from "@radically-straightforward/css"; 14 | import javascript from "@radically-straightforward/javascript"; 15 | import * as utilities from "@radically-straightforward/utilities"; 16 | import * as node from "@radically-straightforward/node"; 17 | import * as caddy from "@radically-straightforward/caddy"; 18 | import cryptoRandomString from "crypto-random-string"; 19 | import { SMTPServer, SMTPServerAddress } from "smtp-server"; 20 | import * as mailParser from "mailparser"; 21 | 22 | export type Application = { 23 | types: { 24 | states: { 25 | Feed: { 26 | feed: { 27 | id: number; 28 | publicId: string; 29 | title: string; 30 | icon: string | null; 31 | emailIcon: string | null; 32 | }; 33 | }; 34 | }; 35 | }; 36 | version: string; 37 | commandLineArguments: { 38 | values: { 39 | type: undefined | "server" | "email" | "backgroundJob"; 40 | port: undefined | string; 41 | }; 42 | positionals: string[]; 43 | }; 44 | configuration: { 45 | hostname: string; 46 | systemAdministratorEmail: string | undefined; 47 | tls: { key: string; certificate: string }; 48 | dataDirectory: string; 49 | environment: "production" | "development"; 50 | hstsPreload?: boolean; 51 | extraCaddyfile?: string; 52 | }; 53 | privateConfiguration: { 54 | ports: number[]; 55 | }; 56 | database: Database; 57 | server: undefined | ReturnType; 58 | layout: ({ 59 | request, 60 | response, 61 | head, 62 | body, 63 | }: { 64 | request: serverTypes.Request<{}, {}, {}, {}, {}>; 65 | response: serverTypes.Response; 66 | head: HTML; 67 | body: HTML; 68 | }) => HTML; 69 | partials: { 70 | feed: ({ 71 | feed, 72 | feedEntries, 73 | }: { 74 | feed: { 75 | publicId: string; 76 | title: string; 77 | icon: string | null; 78 | emailIcon: string | null; 79 | }; 80 | feedEntries: { 81 | id: number; 82 | publicId: string; 83 | createdAt: string; 84 | author: string | null; 85 | title: string; 86 | content: string; 87 | }[]; 88 | }) => HTML; 89 | }; 90 | email: undefined | SMTPServer; 91 | }; 92 | const application = {} as Application; 93 | application.version = "2.0.8"; 94 | application.commandLineArguments = util.parseArgs({ 95 | options: { 96 | type: { type: "string" }, 97 | port: { type: "string" }, 98 | }, 99 | allowPositionals: true, 100 | }) as Application["commandLineArguments"]; 101 | application.configuration = ( 102 | await import(path.resolve(application.commandLineArguments.positionals[0])) 103 | ).default; 104 | application.configuration.dataDirectory ??= path.resolve("./data/"); 105 | await fs.mkdir(application.configuration.dataDirectory, { recursive: true }); 106 | application.configuration.environment ??= "production"; 107 | application.privateConfiguration = {} as Application["privateConfiguration"]; 108 | application.privateConfiguration.ports = Array.from( 109 | { length: os.availableParallelism() }, 110 | (value, index) => 18000 + index, 111 | ); 112 | if (application.commandLineArguments.values.type === "server") 113 | application.server = server({ 114 | port: Number(application.commandLineArguments.values.port), 115 | csrfProtectionExceptionPathname: new RegExp( 116 | "^/feeds/(?[A-Za-z0-9]+)/websub$", 117 | ), 118 | }); 119 | application.partials = {} as Application["partials"]; 120 | 121 | utilities.log( 122 | "KILL THE NEWSLETTER!", 123 | application.version, 124 | "START", 125 | application.commandLineArguments.values.type ?? 126 | `https://${application.configuration.hostname}`, 127 | application.commandLineArguments.values.port ?? "", 128 | ); 129 | process.once("beforeExit", () => { 130 | utilities.log( 131 | "KILL THE NEWSLETTER!", 132 | "STOP", 133 | application.commandLineArguments.values.type ?? 134 | `https://${application.configuration.hostname}`, 135 | application.commandLineArguments.values.port ?? "", 136 | ); 137 | }); 138 | 139 | application.database = await new Database( 140 | path.join(application.configuration.dataDirectory, "kill-the-newsletter.db"), 141 | ).migrate( 142 | sql` 143 | CREATE TABLE "feeds" ( 144 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, 145 | "reference" TEXT NOT NULL UNIQUE, 146 | "title" TEXT NOT NULL 147 | ) STRICT; 148 | CREATE INDEX "feedsReference" ON "feeds" ("reference"); 149 | CREATE TABLE "entries" ( 150 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, 151 | "reference" TEXT NOT NULL UNIQUE, 152 | "createdAt" TEXT NOT NULL, 153 | "feed" INTEGER NOT NULL REFERENCES "feeds" ON DELETE CASCADE, 154 | "title" TEXT NOT NULL, 155 | "content" TEXT NOT NULL 156 | ) STRICT; 157 | CREATE INDEX "entriesReference" ON "entries" ("reference"); 158 | CREATE INDEX "entriesFeed" ON "entries" ("feed"); 159 | `, 160 | 161 | (database) => { 162 | database.execute( 163 | sql` 164 | alter table "feeds" rename to "old_feeds"; 165 | alter table "entries" rename to "old_entries"; 166 | 167 | create table "feeds" ( 168 | "id" integer primary key autoincrement, 169 | "externalId" text not null unique, 170 | "title" text not null 171 | ) strict; 172 | create index "feeds_externalId" on "feeds" ("externalId"); 173 | create table "feedEntries" ( 174 | "id" integer primary key autoincrement, 175 | "externalId" text not null unique, 176 | "feed" integer not null references "feeds", 177 | "createdAt" text not null, 178 | "title" text not null, 179 | "content" text not null 180 | ) strict; 181 | create index "feedEntries_externalId" on "feedEntries" ("externalId"); 182 | create index "feedEntries_feed" on "feedEntries" ("feed"); 183 | 184 | insert into "feeds" ("id", "externalId", "title") 185 | select "id", "reference", "title" from "old_feeds" order by "id" asc; 186 | insert into "feedEntries" ("id", "externalId", "feed", "createdAt", "title", "content") 187 | select "id", "reference", "feed", "createdAt", "title", "content" from "old_entries" order by "id" asc; 188 | 189 | drop table "old_feeds"; 190 | drop table "old_entries"; 191 | `, 192 | ); 193 | 194 | if (application.configuration.environment === "development") { 195 | const feed = database.get<{ id: number }>( 196 | sql` 197 | select * from "feeds" where "id" = ${ 198 | database.run( 199 | sql` 200 | insert into "feeds" ("externalId", "title") 201 | values (${"r5bsqg3w6gqrsv7m59f1"}, ${"Example of a feed"}); 202 | `, 203 | ).lastInsertRowid 204 | }; 205 | `, 206 | )!; 207 | database.run( 208 | sql` 209 | insert into "feedEntries" ("externalId", "feed", "createdAt", "title", "content") 210 | values ( 211 | ${"fjdkqejwpk22"}, 212 | ${feed.id}, 213 | ${new Date().toISOString()}, 214 | ${"Example of a feed entry"}, 215 | ${html`

Hello World

`} 216 | ); 217 | `, 218 | ); 219 | database.run( 220 | sql` 221 | insert into "feedEntries" ("externalId", "feed", "createdAt", "title", "content") 222 | values ( 223 | ${"fjrl1k4j234"}, 224 | ${feed.id}, 225 | ${new Date().toISOString()}, 226 | ${"Another example of a feed entry"}, 227 | ${html`

Hello World

`} 228 | ); 229 | `, 230 | ); 231 | } 232 | }, 233 | 234 | sql` 235 | create table "feedVisualizations" ( 236 | "id" integer primary key autoincrement, 237 | "feed" integer not null references "feeds", 238 | "createdAt" text not null 239 | ) strict; 240 | create index "feedVisualizations_feed" on "feedVisualizations" ("feed"); 241 | create index "feedVisualizations_createdAt" on "feedVisualizations" ("createdAt"); 242 | `, 243 | 244 | sql` 245 | create table "feedWebSubSubscriptions" ( 246 | "id" integer primary key autoincrement, 247 | "feed" integer not null references "feeds", 248 | "createdAt" text not null, 249 | "callback" text not null, 250 | "secret" text null, 251 | unique ("feed", "callback") 252 | ) strict; 253 | create index "feedWebSubSubscriptions_feed" on "feedWebSubSubscriptions" ("feed"); 254 | create index "feedWebSubSubscriptions_createdAt" on "feedWebSubSubscriptions" ("createdAt"); 255 | create index "feedWebSubSubscriptions_callback" on "feedWebSubSubscriptions" ("callback"); 256 | `, 257 | 258 | sql` 259 | create table "feedEntryEnclosures" ( 260 | "id" integer primary key autoincrement, 261 | "externalId" text not null unique, 262 | "type" text not null, 263 | "length" integer not null, 264 | "name" text not null 265 | ) strict; 266 | 267 | create table "feedEntryEnclosureLinks" ( 268 | "id" integer primary key autoincrement, 269 | "feedEntry" integer not null references "feedEntries", 270 | "feedEntryEnclosure" integer not null references "feedEntryEnclosures" 271 | ) strict; 272 | create index "feedEntryEnclosureLinks_feedEntry" on "feedEntryEnclosureLinks" ("feedEntry"); 273 | create index "feedEntryEnclosureLinks_feedEntryEnclosure" on "feedEntryEnclosureLinks" ("feedEntryEnclosure"); 274 | `, 275 | 276 | sql` 277 | alter table "feeds" add column "icon" text null; 278 | alter table "feedEntries" add column "author" text null; 279 | `, 280 | 281 | sql` 282 | alter table "feeds" rename column "icon" to "emailIcon"; 283 | alter table "feeds" add column "icon" text null; 284 | `, 285 | 286 | sql` 287 | alter table "feeds" rename column "externalId" to "publicId"; 288 | alter table "feedEntries" rename column "externalId" to "publicId"; 289 | alter table "feedEntryEnclosures" rename column "externalId" to "publicId"; 290 | 291 | drop index "feeds_externalId"; 292 | drop index "feedEntries_externalId"; 293 | drop index "feedEntries_feed"; 294 | drop index "feedVisualizations_feed"; 295 | drop index "feedVisualizations_createdAt"; 296 | drop index "feedWebSubSubscriptions_feed"; 297 | drop index "feedWebSubSubscriptions_createdAt"; 298 | drop index "feedWebSubSubscriptions_callback"; 299 | drop index "feedEntryEnclosureLinks_feedEntry"; 300 | drop index "feedEntryEnclosureLinks_feedEntryEnclosure"; 301 | 302 | create index "index_feeds_publicId" on "feeds" ("publicId"); 303 | create index "index_feedEntries_publicId" on "feedEntries" ("publicId"); 304 | create index "index_feedEntries_feed" on "feedEntries" ("feed"); 305 | create index "index_feedVisualizations_feed" on "feedVisualizations" ("feed"); 306 | create index "index_feedVisualizations_createdAt" on "feedVisualizations" ("createdAt"); 307 | create index "index_feedWebSubSubscriptions_feed" on "feedWebSubSubscriptions" ("feed"); 308 | create index "index_feedWebSubSubscriptions_createdAt" on "feedWebSubSubscriptions" ("createdAt"); 309 | create index "index_feedWebSubSubscriptions_callback" on "feedWebSubSubscriptions" ("callback"); 310 | create index "index_feedEntryEnclosureLinks_feedEntry" on "feedEntryEnclosureLinks" ("feedEntry"); 311 | create index "index_feedEntryEnclosureLinks_feedEntryEnclosure" on "feedEntryEnclosureLinks" ("feedEntryEnclosure"); 312 | `, 313 | ); 314 | 315 | if (application.commandLineArguments.values.type === "backgroundJob") 316 | node.backgroundJob({ interval: 60 * 60 * 1000 }, async () => { 317 | for (const feedEntryEnclosure of application.database.all<{ 318 | id: number; 319 | publicId: string; 320 | }>( 321 | sql` 322 | select 323 | "feedEntryEnclosures"."id" as "id", 324 | "feedEntryEnclosures"."publicId" as "publicId" 325 | from "feedEntryEnclosures" 326 | left join "feedEntryEnclosureLinks" on "feedEntryEnclosures"."id" = "feedEntryEnclosureLinks"."feedEntryEnclosure" 327 | where "feedEntryEnclosureLinks"."id" is null; 328 | `, 329 | )) { 330 | await fs.rm( 331 | path.join( 332 | application.configuration.dataDirectory, 333 | "files", 334 | feedEntryEnclosure.publicId, 335 | ), 336 | { recursive: true, force: true }, 337 | ); 338 | application.database.run( 339 | sql` 340 | delete from "feedEntryEnclosures" where "id" = ${feedEntryEnclosure.id}; 341 | `, 342 | ); 343 | } 344 | application.database.run( 345 | sql` 346 | delete from "feedVisualizations" where "createdAt" < ${new Date(Date.now() - 60 * 60 * 1000).toISOString()}; 347 | `, 348 | ); 349 | application.database.run( 350 | sql` 351 | delete from "feedWebSubSubscriptions" where "createdAt" < ${new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()}; 352 | `, 353 | ); 354 | }); 355 | 356 | application.layout = ({ request, response, head, body }) => { 357 | css` 358 | @import "@radically-straightforward/javascript/static/index.css"; 359 | @import "@fontsource-variable/roboto-flex/slnt.css"; 360 | @import "bootstrap-icons/font/bootstrap-icons.css"; 361 | 362 | input[type="text"], 363 | button { 364 | background-color: light-dark( 365 | var(--color--slate--50), 366 | var(--color--slate--950) 367 | ); 368 | padding: var(--size--1) var(--size--2); 369 | border: var(--border-width--1) solid 370 | light-dark(var(--color--slate--400), var(--color--slate--600)); 371 | border-radius: var(--border-radius--1); 372 | transition-property: var(--transition-property--colors); 373 | transition-duration: var(--transition-duration--150); 374 | transition-timing-function: var( 375 | --transition-timing-function--ease-in-out 376 | ); 377 | &:focus-within { 378 | border-color: light-dark( 379 | var(--color--blue--500), 380 | var(--color--blue--500) 381 | ); 382 | } 383 | } 384 | 385 | button { 386 | cursor: pointer; 387 | } 388 | 389 | a { 390 | cursor: pointer; 391 | text-decoration: underline; 392 | color: light-dark(var(--color--blue--500), var(--color--blue--500)); 393 | transition-property: var(--transition-property--colors); 394 | transition-duration: var(--transition-duration--150); 395 | transition-timing-function: var( 396 | --transition-timing-function--ease-in-out 397 | ); 398 | &:hover, 399 | &:focus-within { 400 | color: light-dark(var(--color--blue--400), var(--color--blue--400)); 401 | } 402 | &:active { 403 | color: light-dark(var(--color--blue--600), var(--color--blue--600)); 404 | } 405 | } 406 | 407 | h2 { 408 | font-weight: 700; 409 | } 410 | 411 | hr { 412 | border-bottom: var(--border-width--1) solid 413 | light-dark(var(--color--slate--200), var(--color--slate--800)); 414 | } 415 | 416 | small { 417 | font-size: var(--font-size--3); 418 | line-height: var(--font-size--3--line-height); 419 | font-weight: 700; 420 | color: light-dark(var(--color--slate--500), var(--color--slate--500)); 421 | } 422 | `; 423 | 424 | javascript` 425 | import * as javascript from "@radically-straightforward/javascript/static/index.mjs"; 426 | import * as utilities from "@radically-straightforward/utilities"; 427 | `; 428 | 429 | return html` 430 | 431 | 436 | 437 | 441 | 442 | 443 | 444 | 448 | $${head} 449 | 450 | 463 | $${(() => { 464 | const flash = request.getFlash(); 465 | return typeof flash === "string" 466 | ? html` 467 |
504 | $${flash} 505 |
506 | ` 507 | : html``; 508 | })()} 509 |
519 |
520 | 547 |
548 | Convert email newsletters into Atom feeds 549 |
550 |
551 | $${body} 552 |
553 | 554 | 555 | `; 556 | }; 557 | application.partials.feed = ({ feed, feedEntries }) => 558 | html` 559 | 560 | urn:kill-the-newsletter:${feed.publicId} 561 | 566 | 571 | $${typeof feed.icon === "string" || typeof feed.emailIcon === "string" 572 | ? html`${feed.icon ?? 574 | feed.emailIcon ?? 575 | (() => { 576 | throw new Error(); 577 | })()}` 579 | : html``} 580 | ${feedEntries[0]?.createdAt ?? "2000-01-01T00:00:00.000Z"} 583 | ${feed.title} 584 | $${feedEntries.map( 585 | (feedEntry) => html` 586 | 587 | urn:kill-the-newsletter:${feedEntry.publicId} 588 | 594 | $${application.database 595 | .all<{ 596 | publicId: string; 597 | type: string; 598 | length: number; 599 | name: string; 600 | }>( 601 | sql` 602 | select 603 | "feedEntryEnclosures"."publicId" as "publicId", 604 | "feedEntryEnclosures"."type" as "type", 605 | "feedEntryEnclosures"."length" as "length", 606 | "feedEntryEnclosures"."name" as "name" 607 | from "feedEntryEnclosures" 608 | join "feedEntryEnclosureLinks" on 609 | "feedEntryEnclosureLinks"."feedEntry" = ${feedEntry.id} and 610 | "feedEntryEnclosures"."id" = "feedEntryEnclosureLinks"."feedEntryEnclosure" 611 | `, 612 | ) 613 | .map( 614 | (feedEntryEnclosure) => html` 615 | 622 | `, 623 | )} 624 | ${feedEntry.createdAt} 625 | ${feedEntry.createdAt} 626 | 627 | ${feedEntry.author ?? "Kill the Newsletter!"} 628 | ${feedEntry.author ?? "kill-the-newsletter@leafac.com"} 631 | 632 | ${feedEntry.title} 633 | 634 | ${feedEntry.content} 635 | ${html` 636 |
637 |

638 | 639 | Kill the Newsletter! feed settings 644 | 645 |

646 | `} 647 |
648 |
649 | `, 650 | )} 651 |
`; 652 | application.server?.push({ 653 | method: "GET", 654 | pathname: "/", 655 | handler: (request, response) => { 656 | response.end( 657 | application.layout({ 658 | request, 659 | response, 660 | head: html`Kill the Newsletter!`, 661 | body: html` 662 |
675 | 686 |
687 |
688 |

689 | 690 | By Leandro Facchinetti | 691 | Source | 693 | Report Issue | Patreon · 704 | PayPal · 705 | GitHub Sponsors 706 | 707 |

708 |
709 |
710 |

How does Kill the Newsletter! work?

711 |

712 | Create a feed with the form above and Kill the Newsletter! 713 | provides you with an email address and an Atom feed. Emails that 714 | are received at that address are turned into entries in that feed. 715 | Sign up to a newsletter with that address and use your feed reader 716 | to subscribe to that feed. 717 |

718 |
719 |
720 |

How do I confirm my newsletter subscription?

721 |

722 | In most cases when you subscribe to a newsletter the newsletter 723 | publisher sends you an email with a confirmation link. Kill the 724 | Newsletter! converts that email into a feed entry as usual, so it 725 | appears in your feed reader and you may follow the confirmation 726 | link from there. Some newsletter publishers want you to reply to 727 | an email using the address that subscribed to the newsletter. 728 | Unfortunately Kill the Newsletter! doesn’t support this scenario, 729 | but you may contact the newsletter publisher and ask them to 730 | verify you manually. As a workaround, some people have had success 731 | with signing up for the newsletter using their regular email 732 | address and setting up a filter to forward the emails to Kill the 733 | Newsletter! 734 |

735 |
736 |
737 |

738 | Why can’t I subscribe to a newsletter with my Kill the Newsletter! 739 | email? 740 |

741 |

742 | Some newsletter publishers block Kill the Newsletter!. You may 743 | contact them to explain why using Kill the Newsletter! is 744 | important to you and ask them to reconsider their decision, but 745 | ultimately it’s their content and their choice of who has access 746 | to it and by what means. As a workaround, some people have had 747 | success with signing up for the newsletter using their regular 748 | email address and setting up a filter to forward the emails to 749 | Kill the Newsletter! 750 |

751 |
752 |
753 |

How do I share a Kill the Newsletter! feed?

754 |

755 | You don’t. The feed includes the identifier for the email address 756 | and anyone who has access to it may unsubscribe you from your 757 | newsletters, send you spam, and so forth. Instead of sharing a 758 | feed, you may share Kill the Newsletter! itself and let people 759 | create their own Kill the Newsletter! feeds. Kill the Newsletter! 760 | has been designed this way because it plays better with newsletter 761 | publishers, who may, for example, get statistics on the number of 762 | subscribers who use Kill the Newsletter!. Note that Kill the 763 | Newsletter! itself doesn’t track users in any way. 764 |

765 |
766 |
767 |

Why are old entries disappearing?

768 |

769 | When Kill the Newsletter! receives an email it may delete old 770 | entries to keep the feed under a size limit, because some feed 771 | readers don’t support feeds that are too big. 772 |

773 |
774 |
775 |

Why isn’t my feed updating?

776 |

777 | Send an email to the address that corresponds to your Kill the 778 | Newsletter! feed and wait a few minutes. If the email shows up on 779 | your feed reader, then the issue must be with the newsletter 780 | publisher and you should contact them. Otherwise, please 781 | report the issue to us. 792 |

793 |
794 |
795 |

How do I delete my Kill the Newsletter! feed?

796 |

797 | At the end of each feed entry there’s a link to manage the Kill 798 | the Newsletter! feed settings, including deleting it. 799 |

800 |
801 |
802 |

803 | I’m a newsletter publisher and I saw some people subscribing with 804 | Kill the Newsletter!. What is this? 805 |

806 |

807 | Think of Kill the Newsletter! as an email provider like Gmail, but 808 | the emails get delivered through Atom feeds for people who prefer 809 | to read with feed readers instead of email. Also, consider 810 | providing your content through an Atom feed—your readers will 811 | appreciate it. 812 |

813 |
814 | `, 815 | }), 816 | ); 817 | }, 818 | }); 819 | application.server?.push({ 820 | method: "POST", 821 | pathname: "/feeds", 822 | handler: ( 823 | request: serverTypes.Request<{}, {}, {}, { title: string }, {}>, 824 | response, 825 | ) => { 826 | if ( 827 | typeof request.body.title !== "string" || 828 | request.body.title.trim() === "" || 829 | request.body.title.length > 200 830 | ) 831 | throw "validation"; 832 | const feed = application.database.get<{ 833 | publicId: string; 834 | }>( 835 | sql` 836 | select * from "feeds" where "id" = ${ 837 | application.database.run( 838 | sql` 839 | insert into "feeds" ("publicId", "title") 840 | values ( 841 | ${cryptoRandomString({ 842 | length: 20, 843 | characters: "abcdefghijklmnopqrstuvwxyz0123456789", 844 | })}, 845 | ${request.body.title} 846 | ); 847 | `, 848 | ).lastInsertRowid 849 | }; 850 | `, 851 | )!; 852 | if (request.headers.accept === "application/json") 853 | response.setHeader("Content-Type", "application/json").end( 854 | JSON.stringify({ 855 | feedId: feed.publicId, 856 | email: `${feed.publicId}@${application.configuration.hostname}`, 857 | feed: `https://${ 858 | application.configuration.hostname 859 | }/feeds/${feed.publicId}.xml`, 860 | }), 861 | ); 862 | else response.redirect(`/feeds/${feed.publicId}`); 863 | }, 864 | }); 865 | application.server?.push({ 866 | pathname: new RegExp("^/feeds/(?[A-Za-z0-9]+)(?:$|/|\\.xml$)"), 867 | handler: ( 868 | request: serverTypes.Request< 869 | { feedPublicId: string }, 870 | {}, 871 | {}, 872 | {}, 873 | Application["types"]["states"]["Feed"] 874 | >, 875 | response, 876 | ) => { 877 | if (typeof request.pathname.feedPublicId !== "string") return; 878 | request.state.feed = application.database.get<{ 879 | id: number; 880 | publicId: string; 881 | title: string; 882 | icon: string | null; 883 | emailIcon: string | null; 884 | }>( 885 | sql` 886 | select "id", "publicId", "title", "icon", "emailIcon" 887 | from "feeds" 888 | where "publicId" = ${request.pathname.feedPublicId}; 889 | `, 890 | ); 891 | if (request.state.feed === undefined) return; 892 | response.setHeader("X-Robots-Tag", "none"); 893 | }, 894 | }); 895 | application.server?.push({ 896 | method: "GET", 897 | pathname: new RegExp("^/feeds/(?[A-Za-z0-9]+)$"), 898 | handler: ( 899 | request: serverTypes.Request< 900 | {}, 901 | {}, 902 | {}, 903 | {}, 904 | Application["types"]["states"]["Feed"] 905 | >, 906 | response, 907 | ) => { 908 | if (request.state.feed === undefined) return; 909 | response.end( 910 | application.layout({ 911 | request, 912 | response, 913 | head: html` 914 | ${request.state.feed.title} · Kill the Newsletter! 915 | `, 916 | body: html` 917 |
918 |

${request.state.feed.title}

919 |

Subscribe to a newsletter with the following email address:

920 |
929 | 943 |
944 | 958 |
Copied
959 |
960 |
961 |
962 |
963 |

Subscribe on your feed reader to the following Atom feed:

964 |
973 | 987 |
988 | 1004 |
Copied
1005 |
1006 |
1007 |
1008 |

← Create another feed

1009 |
1010 |
1021 |
1022 |

Feed settings

1023 | 1036 |
1037 | 1060 |
1061 |
1062 |
1063 |
1074 |
1075 |

Delete feed

1076 |

1084 | This action is 1085 | irreversible! Your feed and all its entries will be lost! 1086 |

1087 |
1088 |

1089 | Before you proceed, we recommend that you unsubscribe from the 1090 | publisher (typically you do that by following a link in a feed 1091 | entry) and unsubscribe from the feed on the feed reader. 1092 |

1093 | 1110 |
1111 |
1112 | `, 1113 | }), 1114 | ); 1115 | }, 1116 | }); 1117 | application.server?.push({ 1118 | method: "PATCH", 1119 | pathname: new RegExp("^/feeds/(?[A-Za-z0-9]+)$"), 1120 | handler: ( 1121 | request: serverTypes.Request< 1122 | {}, 1123 | {}, 1124 | {}, 1125 | { title: string; icon: string }, 1126 | Application["types"]["states"]["Feed"] 1127 | >, 1128 | response, 1129 | ) => { 1130 | if (request.state.feed === undefined) return; 1131 | if ( 1132 | typeof request.body.title !== "string" || 1133 | request.body.title.trim() === "" || 1134 | request.body.title.length > 200 || 1135 | typeof request.body.icon !== "string" || 1136 | (request.body.icon.trim() !== "" && 1137 | (request.body.icon.length > 200 || 1138 | (() => { 1139 | try { 1140 | new URL(request.body.icon); 1141 | return false; 1142 | } catch { 1143 | return true; 1144 | } 1145 | })())) 1146 | ) 1147 | throw "validation"; 1148 | application.database.run( 1149 | sql` 1150 | update "feeds" 1151 | set 1152 | "title" = ${request.body.title}, 1153 | "icon" = ${request.body.icon.trim() === "" ? null : request.body.icon} 1154 | where "id" = ${request.state.feed.id}; 1155 | `, 1156 | ); 1157 | response.setFlash(html`Feed settings updated successfully.`); 1158 | response.redirect(); 1159 | }, 1160 | }); 1161 | application.server?.push({ 1162 | method: "DELETE", 1163 | pathname: new RegExp("^/feeds/(?[A-Za-z0-9]+)$"), 1164 | handler: ( 1165 | request: serverTypes.Request< 1166 | {}, 1167 | {}, 1168 | {}, 1169 | {}, 1170 | Application["types"]["states"]["Feed"] 1171 | >, 1172 | response, 1173 | ) => { 1174 | if (request.state.feed === undefined) return; 1175 | application.database.executeTransaction(() => { 1176 | application.database.run( 1177 | sql` 1178 | delete from "feedWebSubSubscriptions" where "feed" = ${request.state.feed!.id}; 1179 | `, 1180 | ); 1181 | application.database.run( 1182 | sql` 1183 | delete from "feedVisualizations" where "feed" = ${request.state.feed!.id}; 1184 | `, 1185 | ); 1186 | for (const feedEntry of application.database.all<{ id: number }>( 1187 | sql` 1188 | select "id" from "feedEntries" where "feed" = ${request.state.feed!.id}; 1189 | `, 1190 | )) { 1191 | application.database.run( 1192 | sql` 1193 | delete from "feedEntryEnclosureLinks" where "feedEntry" = ${feedEntry.id}; 1194 | `, 1195 | ); 1196 | application.database.run( 1197 | sql` 1198 | delete from "feedEntries" where "id" = ${feedEntry.id}; 1199 | `, 1200 | ); 1201 | } 1202 | application.database.run( 1203 | sql` 1204 | delete from "feeds" where "id" = ${request.state.feed!.id}; 1205 | `, 1206 | ); 1207 | }); 1208 | response.setFlash(html`Feed deleted successfully.`); 1209 | response.redirect("/"); 1210 | }, 1211 | }); 1212 | application.server?.push({ 1213 | method: "GET", 1214 | pathname: new RegExp("^/feeds/(?[A-Za-z0-9]+)\\.xml$"), 1215 | handler: ( 1216 | request: serverTypes.Request< 1217 | {}, 1218 | {}, 1219 | {}, 1220 | {}, 1221 | Application["types"]["states"]["Feed"] 1222 | >, 1223 | response, 1224 | ) => { 1225 | if (request.state.feed === undefined) return; 1226 | if ( 1227 | application.database.get<{ count: number }>( 1228 | sql` 1229 | select count(*) as "count" 1230 | from "feedVisualizations" 1231 | where 1232 | "feed" = ${request.state.feed.id} and 1233 | ${new Date(Date.now() - 60 * 60 * 1000).toISOString()} < "createdAt"; 1234 | `, 1235 | )!.count > 10 1236 | ) { 1237 | response.statusCode = 429; 1238 | response.end( 1239 | application.layout({ 1240 | request, 1241 | response, 1242 | head: html`Rate limit · Kill the Newsletter!`, 1243 | body: html` 1244 |
1245 |

Rate limit

1246 |

1247 | This feed was visualized too often. Please return in one hour. 1248 |

1249 |
1250 | `, 1251 | }), 1252 | ); 1253 | return; 1254 | } 1255 | application.database.run( 1256 | sql` 1257 | insert into "feedVisualizations" ("feed", "createdAt") 1258 | values (${request.state.feed.id}, ${new Date().toISOString()}); 1259 | `, 1260 | ); 1261 | const feedEntries = application.database.all<{ 1262 | id: number; 1263 | publicId: string; 1264 | createdAt: string; 1265 | author: string | null; 1266 | title: string; 1267 | content: string; 1268 | }>( 1269 | sql` 1270 | select "id", "publicId", "createdAt", "author", "title", "content" 1271 | from "feedEntries" 1272 | where "feed" = ${request.state.feed.id} 1273 | order by "id" desc; 1274 | `, 1275 | ); 1276 | response 1277 | .setHeader("Content-Type", "application/atom+xml; charset=utf-8") 1278 | .end( 1279 | application.partials.feed({ feed: request.state.feed, feedEntries }), 1280 | ); 1281 | }, 1282 | }); 1283 | application.server?.push({ 1284 | method: "GET", 1285 | pathname: new RegExp( 1286 | "^/feeds/(?[A-Za-z0-9]+)/entries/(?[A-Za-z0-9]+)\\.html$", 1287 | ), 1288 | handler: ( 1289 | request: serverTypes.Request< 1290 | { feedEntryPublicId: string }, 1291 | {}, 1292 | {}, 1293 | {}, 1294 | Application["types"]["states"]["Feed"] 1295 | >, 1296 | response, 1297 | ) => { 1298 | if ( 1299 | request.state.feed === undefined || 1300 | typeof request.pathname.feedEntryPublicId !== "string" 1301 | ) 1302 | return; 1303 | const feedEntry = application.database.get<{ 1304 | content: string; 1305 | }>( 1306 | sql` 1307 | select "feedEntries"."content" as "content" 1308 | from "feedEntries" 1309 | where 1310 | "feedEntries"."feed" = ${request.state.feed.id} and 1311 | "feedEntries"."publicId" = ${request.pathname.feedEntryPublicId}; 1312 | `, 1313 | ); 1314 | if (feedEntry === undefined) return; 1315 | response 1316 | .setHeader( 1317 | "Content-Security-Policy", 1318 | "default-src 'self'; img-src *; style-src 'self' 'unsafe-inline'; frame-src 'none'; object-src 'none'; form-action 'self'; frame-ancestors 'none'", 1319 | ) 1320 | .setHeader("Cross-Origin-Embedder-Policy", "unsafe-none") 1321 | .end(feedEntry.content); 1322 | }, 1323 | }); 1324 | application.server?.push({ 1325 | method: "POST", 1326 | pathname: new RegExp("^/feeds/(?[A-Za-z0-9]+)/websub$"), 1327 | handler: async ( 1328 | request: serverTypes.Request< 1329 | {}, 1330 | {}, 1331 | {}, 1332 | { 1333 | "hub.mode": "subscribe" | "unsubscribe"; 1334 | "hub.topic": string; 1335 | "hub.url": string; 1336 | "hub.callback": string; 1337 | "hub.secret": string; 1338 | }, 1339 | Application["types"]["states"]["Feed"] 1340 | >, 1341 | response, 1342 | ) => { 1343 | if (request.state.feed === undefined) return; 1344 | request.body["hub.topic"] ??= request.body["hub.url"]; 1345 | if ( 1346 | (request.body["hub.mode"] !== "subscribe" && 1347 | request.body["hub.mode"] !== "unsubscribe") || 1348 | request.body["hub.topic"] !== 1349 | `https://${ 1350 | application.configuration.hostname 1351 | }/feeds/${request.state.feed.publicId}.xml` || 1352 | typeof request.body["hub.callback"] !== "string" || 1353 | (() => { 1354 | try { 1355 | return new URL(request.body["hub.callback"]).href; 1356 | } catch { 1357 | return undefined; 1358 | } 1359 | })() !== request.body["hub.callback"] || 1360 | (new URL(request.body["hub.callback"]).protocol !== "https:" && 1361 | new URL(request.body["hub.callback"]).protocol !== "http:") || 1362 | new URL(request.body["hub.callback"]).hostname === 1363 | application.configuration.hostname || 1364 | new URL(request.body["hub.callback"]).hostname === "localhost" || 1365 | new URL(request.body["hub.callback"]).hostname === "127.0.0.1" || 1366 | (request.body["hub.secret"] !== undefined && 1367 | typeof request.body["hub.secret"] !== "string") || 1368 | (typeof request.body["hub.secret"] === "string" && 1369 | request.body["hub.secret"].length === 0) || 1370 | (request.body["hub.mode"] === "subscribe" && 1371 | application.database.get<{ count: number }>( 1372 | sql` 1373 | select count(*) as "count" 1374 | from "feedWebSubSubscriptions" 1375 | where 1376 | "feed" = ${request.state.feed.id} and 1377 | ${new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()} < "createdAt" and 1378 | "callback" != ${request.body["hub.callback"]}; 1379 | `, 1380 | )!.count > 10) 1381 | ) 1382 | throw "validation"; 1383 | const feedWebSubSubscription = application.database.get<{ id: number }>( 1384 | sql` 1385 | select "id" 1386 | from "feedWebSubSubscriptions" 1387 | where 1388 | "feed" = ${request.state.feed!.id} and 1389 | "callback" = ${request.body["hub.callback"]}; 1390 | `, 1391 | ); 1392 | if ( 1393 | request.body["hub.mode"] === "unsubscribe" && 1394 | feedWebSubSubscription === undefined 1395 | ) 1396 | return; 1397 | application.database.run( 1398 | sql` 1399 | insert into "_backgroundJobs" ( 1400 | "type", 1401 | "startAt", 1402 | "parameters" 1403 | ) 1404 | values ( 1405 | ${"feedWebSubSubscriptions.verify"}, 1406 | ${new Date().toISOString()}, 1407 | ${JSON.stringify({ 1408 | feedId: request.state.feed.id, 1409 | "hub.mode": request.body["hub.mode"], 1410 | "hub.topic": request.body["hub.topic"], 1411 | "hub.callback": request.body["hub.callback"], 1412 | "hub.secret": request.body["hub.secret"], 1413 | })} 1414 | ); 1415 | `, 1416 | ); 1417 | response.statusCode = 202; 1418 | response.end(); 1419 | }, 1420 | }); 1421 | if (application.commandLineArguments.values.type === "backgroundJob") 1422 | for ( 1423 | let backgroundJobIndex = 0; 1424 | backgroundJobIndex < 32; 1425 | backgroundJobIndex++ 1426 | ) 1427 | application.database.backgroundJob( 1428 | { type: "feedWebSubSubscriptions.verify", timeout: 5 * 1000, retries: 0 }, 1429 | async (job: { 1430 | feedId: number; 1431 | "hub.mode": "subscribe" | "unsubscribe"; 1432 | "hub.topic": string; 1433 | "hub.callback": string; 1434 | "hub.secret": string; 1435 | }) => { 1436 | const feed = application.database.get<{ 1437 | id: number; 1438 | }>( 1439 | sql` 1440 | select "id" 1441 | from "feeds" 1442 | where "id" = ${job.feedId}; 1443 | `, 1444 | ); 1445 | if (feed === undefined) return; 1446 | const feedWebSubSubscription = application.database.get<{ id: number }>( 1447 | sql` 1448 | select "id" 1449 | from "feedWebSubSubscriptions" 1450 | where 1451 | "feed" = ${feed.id} and 1452 | "callback" = ${job["hub.callback"]}; 1453 | `, 1454 | ); 1455 | if ( 1456 | job["hub.mode"] === "unsubscribe" && 1457 | feedWebSubSubscription === undefined 1458 | ) 1459 | return; 1460 | const verificationChallenge = cryptoRandomString({ 1461 | length: 100, 1462 | characters: "abcdefghijklmnopqrstuvwxyz0123456789", 1463 | }); 1464 | const verificationURL = new URL(job["hub.callback"]); 1465 | verificationURL.searchParams.append("hub.mode", job["hub.mode"]); 1466 | verificationURL.searchParams.append("hub.topic", job["hub.topic"]); 1467 | verificationURL.searchParams.append( 1468 | "hub.challenge", 1469 | verificationChallenge, 1470 | ); 1471 | if (job["hub.mode"] === "subscribe") 1472 | verificationURL.searchParams.append( 1473 | "hub.lease_seconds", 1474 | String(24 * 60 * 60), 1475 | ); 1476 | const verificationResponse = await fetch(verificationURL, { 1477 | redirect: "manual", 1478 | }); 1479 | if ( 1480 | !verificationResponse.ok || 1481 | (await verificationResponse.text()) !== verificationChallenge 1482 | ) 1483 | return; 1484 | if (job["hub.mode"] === "subscribe") { 1485 | if (feedWebSubSubscription === undefined) 1486 | application.database.run( 1487 | sql` 1488 | insert into "feedWebSubSubscriptions" ( 1489 | "feed", 1490 | "createdAt", 1491 | "callback", 1492 | "secret" 1493 | ) 1494 | values ( 1495 | ${feed.id}, 1496 | ${new Date().toISOString()}, 1497 | ${job["hub.callback"]}, 1498 | ${job["hub.secret"]} 1499 | ); 1500 | `, 1501 | ); 1502 | else 1503 | application.database.run( 1504 | sql` 1505 | update "feedWebSubSubscriptions" 1506 | set 1507 | "createdAt" = ${new Date().toISOString()}, 1508 | "secret" = ${job["hub.secret"]} 1509 | where "id" = ${feedWebSubSubscription.id}; 1510 | `, 1511 | ); 1512 | } else if (job["hub.mode"] === "unsubscribe") 1513 | application.database.run( 1514 | sql` 1515 | delete from "feedWebSubSubscriptions" where "id" = ${feedWebSubSubscription!.id}; 1516 | `, 1517 | ); 1518 | else throw new Error(); 1519 | }, 1520 | ); 1521 | application.server?.push({ 1522 | handler: (request, response) => { 1523 | response.statusCode = 404; 1524 | response.end( 1525 | application.layout({ 1526 | request, 1527 | response, 1528 | head: html`Not found · Kill the Newsletter!`, 1529 | body: html` 1530 |
1531 |

Not found

1532 |

1533 | If you expected to see the web version of a newsletter entry, you 1534 | may be interested in the answer to the question 1535 | “Why are old entries disappearing?”. 1536 |

1537 |
1538 | `, 1539 | }), 1540 | ); 1541 | }, 1542 | }); 1543 | application.server?.push({ 1544 | error: true, 1545 | handler: (request, response) => { 1546 | response.end( 1547 | application.layout({ 1548 | request, 1549 | response, 1550 | head: html`Server error · Kill the Newsletter!`, 1551 | body: html` 1552 |
1553 |

Server error.

1554 |

1555 | Please report this issue to 1556 | kill-the-newsletter@leafac.com. 1567 |

1568 |
1569 | `, 1570 | }), 1571 | ); 1572 | }, 1573 | }); 1574 | 1575 | if (application.commandLineArguments.values.type === "email") { 1576 | application.email = new SMTPServer({ 1577 | name: application.configuration.hostname, 1578 | size: 2 ** 19, 1579 | disabledCommands: ["AUTH"], 1580 | key: await fs.readFile(application.configuration.tls.key, "utf-8"), 1581 | cert: await fs.readFile(application.configuration.tls.certificate, "utf-8"), 1582 | onData: async (emailStream, session, callback) => { 1583 | try { 1584 | if ( 1585 | session.envelope.mailFrom === false || 1586 | session.envelope.mailFrom.address.match(utilities.emailRegExp) === 1587 | null || 1588 | ["blogtrottr.com", "feedrabbit.com"].some((hostname) => 1589 | (session.envelope.mailFrom as SMTPServerAddress).address.endsWith( 1590 | "@" + hostname, 1591 | ), 1592 | ) 1593 | ) 1594 | throw new Error("Invalid ‘mailFrom’."); 1595 | const feeds = session.envelope.rcptTo.flatMap(({ address }) => { 1596 | if ( 1597 | application.configuration.environment !== "development" && 1598 | address.match(utilities.emailRegExp) === null 1599 | ) 1600 | return []; 1601 | const [feedPublicId, hostname] = address.split("@"); 1602 | if (hostname !== application.configuration.hostname) return []; 1603 | const feed = application.database.get<{ 1604 | id: number; 1605 | publicId: string; 1606 | }>( 1607 | sql` 1608 | select "id", "publicId" from "feeds" where "publicId" = ${feedPublicId}; 1609 | `, 1610 | ); 1611 | if (feed === undefined) return []; 1612 | return [feed]; 1613 | }); 1614 | if (feeds.length === 0) throw new Error("No valid recipients."); 1615 | const email = await mailParser.simpleParser(emailStream); 1616 | if (emailStream.sizeExceeded) throw new Error("Email is too big."); 1617 | const feedEntryEnclosures = new Array<{ id: number }>(); 1618 | for (const attachment of email.attachments) { 1619 | const feedEntryEnclosure = application.database.get<{ 1620 | id: number; 1621 | publicId: string; 1622 | name: string; 1623 | }>( 1624 | sql` 1625 | select * from "feedEntryEnclosures" where "id" = ${ 1626 | application.database.run( 1627 | sql` 1628 | insert into "feedEntryEnclosures" ( 1629 | "publicId", 1630 | "type", 1631 | "length", 1632 | "name" 1633 | ) 1634 | values ( 1635 | ${cryptoRandomString({ 1636 | length: 20, 1637 | characters: "abcdefghijklmnopqrstuvwxyz0123456789", 1638 | })}, 1639 | ${attachment.contentType}, 1640 | ${attachment.size}, 1641 | ${ 1642 | attachment.filename?.replaceAll( 1643 | /[^A-Za-z0-9_.-]/g, 1644 | "-", 1645 | ) ?? "untitled" 1646 | } 1647 | ); 1648 | `, 1649 | ).lastInsertRowid 1650 | }; 1651 | `, 1652 | )!; 1653 | await fs.mkdir( 1654 | path.join( 1655 | application.configuration.dataDirectory, 1656 | "files", 1657 | feedEntryEnclosure.publicId, 1658 | ), 1659 | { recursive: true }, 1660 | ); 1661 | await fs.writeFile( 1662 | path.join( 1663 | application.configuration.dataDirectory, 1664 | "files", 1665 | feedEntryEnclosure.publicId, 1666 | feedEntryEnclosure.name, 1667 | ), 1668 | attachment.content, 1669 | ); 1670 | feedEntryEnclosures.push(feedEntryEnclosure); 1671 | } 1672 | for (const feed of feeds) 1673 | application.database.executeTransaction(() => { 1674 | application.database.run( 1675 | sql` 1676 | update "feeds" 1677 | set "emailIcon" = ${`https://${(session.envelope.mailFrom as SMTPServerAddress).address.split("@")[1]}/favicon.ico`} 1678 | where "id" = ${feed.id}; 1679 | `, 1680 | ); 1681 | const feedEntry = application.database.get<{ 1682 | id: number; 1683 | publicId: string; 1684 | }>( 1685 | sql` 1686 | select * from "feedEntries" where "id" = ${ 1687 | application.database.run( 1688 | sql` 1689 | insert into "feedEntries" ( 1690 | "publicId", 1691 | "feed", 1692 | "createdAt", 1693 | "author", 1694 | "title", 1695 | "content" 1696 | ) 1697 | values ( 1698 | ${cryptoRandomString({ 1699 | length: 20, 1700 | characters: "abcdefghijklmnopqrstuvwxyz0123456789", 1701 | })}, 1702 | ${feed.id}, 1703 | ${new Date().toISOString()}, 1704 | ${(session.envelope.mailFrom as SMTPServerAddress).address}, 1705 | ${email.subject ?? "Untitled"}, 1706 | ${typeof email.html === "string" ? email.html : typeof email.textAsHtml === "string" ? email.textAsHtml : "No content."} 1707 | ); 1708 | `, 1709 | ).lastInsertRowid 1710 | }; 1711 | `, 1712 | )!; 1713 | for (const feedEntryEnclosure of feedEntryEnclosures) 1714 | application.database.run( 1715 | sql` 1716 | insert into "feedEntryEnclosureLinks" ( 1717 | "feedEntry", 1718 | "feedEntryEnclosure" 1719 | ) values ( 1720 | ${feedEntry.id}, 1721 | ${feedEntryEnclosure.id} 1722 | ); 1723 | `, 1724 | ); 1725 | const deletedFeedEntries = application.database.all<{ 1726 | id: number; 1727 | publicId: string; 1728 | title: string; 1729 | content: string; 1730 | }>( 1731 | sql` 1732 | select "id", "publicId", "title", "content" 1733 | from "feedEntries" 1734 | where "feed" = ${feed.id} 1735 | order by "id" asc; 1736 | `, 1737 | ); 1738 | let feedLength = 0; 1739 | while (deletedFeedEntries.length > 0) { 1740 | const feedEntry = deletedFeedEntries.pop()!; 1741 | feedLength += feedEntry.title.length + feedEntry.content.length; 1742 | if (feedLength > 2 ** 19) break; 1743 | } 1744 | for (const deletedFeedEntry of deletedFeedEntries) { 1745 | application.database.run( 1746 | sql` 1747 | delete from "feedEntryEnclosureLinks" where "feedEntry" = ${deletedFeedEntry.id}; 1748 | `, 1749 | ); 1750 | application.database.run( 1751 | sql` 1752 | delete from "feedEntries" where "id" = ${deletedFeedEntry.id}; 1753 | `, 1754 | ); 1755 | } 1756 | for (const feedWebSubSubscription of application.database.all<{ 1757 | id: number; 1758 | }>( 1759 | sql` 1760 | select "id" 1761 | from "feedWebSubSubscriptions" 1762 | where 1763 | "feed" = ${feed.id} and 1764 | ${new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()} < "createdAt"; 1765 | `, 1766 | )) 1767 | application.database.run( 1768 | sql` 1769 | insert into "_backgroundJobs" ( 1770 | "type", 1771 | "startAt", 1772 | "parameters" 1773 | ) 1774 | values ( 1775 | ${"feedWebSubSubscriptions.dispatch"}, 1776 | ${new Date().toISOString()}, 1777 | ${JSON.stringify({ 1778 | feedId: feed.id, 1779 | feedEntryId: feedEntry.id, 1780 | feedWebSubSubscriptionId: feedWebSubSubscription.id, 1781 | })} 1782 | ); 1783 | `, 1784 | ); 1785 | utilities.log( 1786 | "EMAIL", 1787 | "SUCCESS", 1788 | "FEED", 1789 | String(feed.publicId), 1790 | "ENTRY", 1791 | feedEntry.publicId, 1792 | session.envelope.mailFrom === false 1793 | ? "" 1794 | : session.envelope.mailFrom.address, 1795 | "DELETED ENTRIES", 1796 | JSON.stringify( 1797 | deletedFeedEntries.map( 1798 | (deletedFeedEntry) => deletedFeedEntry.publicId, 1799 | ), 1800 | ), 1801 | ); 1802 | }); 1803 | } catch (error) { 1804 | utilities.log( 1805 | "EMAIL", 1806 | "ERROR", 1807 | session.envelope.mailFrom === false 1808 | ? "" 1809 | : session.envelope.mailFrom.address, 1810 | String(error), 1811 | ); 1812 | } finally { 1813 | emailStream.resume(); 1814 | await stream.finished(emailStream); 1815 | callback(); 1816 | } 1817 | }, 1818 | }); 1819 | application.email.listen(25); 1820 | process.once("gracefulTermination", () => { 1821 | application.email!.close(); 1822 | }); 1823 | for (const file of [ 1824 | application.configuration.tls.key, 1825 | application.configuration.tls.certificate, 1826 | ]) 1827 | fsSync 1828 | .watchFile(file, () => { 1829 | node.exit(); 1830 | }) 1831 | .unref(); 1832 | } 1833 | if (application.commandLineArguments.values.type === "backgroundJob") 1834 | for (let backgroundJobIndex = 0; backgroundJobIndex < 8; backgroundJobIndex++) 1835 | application.database.backgroundJob( 1836 | { 1837 | type: "feedWebSubSubscriptions.dispatch", 1838 | timeout: 5 * 1000, 1839 | retries: 0, 1840 | }, 1841 | async (job: { 1842 | feedId: number; 1843 | feedEntryId: number; 1844 | feedWebSubSubscriptionId: number; 1845 | }) => { 1846 | const feed = application.database.get<{ 1847 | publicId: string; 1848 | title: string; 1849 | icon: string | null; 1850 | emailIcon: string | null; 1851 | }>( 1852 | sql` 1853 | select "publicId", "title", "icon", "emailIcon" 1854 | from "feeds" 1855 | where "id" = ${job.feedId}; 1856 | `, 1857 | ); 1858 | if (feed === undefined) return; 1859 | const feedEntry = application.database.get<{ 1860 | id: number; 1861 | publicId: string; 1862 | createdAt: string; 1863 | author: string | null; 1864 | title: string; 1865 | content: string; 1866 | }>( 1867 | sql` 1868 | select "id", "publicId", "createdAt", "author", "title", "content" 1869 | from "feedEntries" 1870 | where "id" = ${job.feedEntryId}; 1871 | `, 1872 | ); 1873 | if (feedEntry === undefined) return; 1874 | const feedWebSubSubscription = application.database.get<{ 1875 | id: number; 1876 | callback: string; 1877 | secret: string | null; 1878 | }>( 1879 | sql` 1880 | select "id", "callback", "secret" 1881 | from "feedWebSubSubscriptions" 1882 | where "id" = ${job.feedWebSubSubscriptionId}; 1883 | `, 1884 | ); 1885 | if (feedWebSubSubscription === undefined) return; 1886 | const body = application.partials.feed({ 1887 | feed, 1888 | feedEntries: [feedEntry], 1889 | }); 1890 | const response = await fetch(feedWebSubSubscription.callback, { 1891 | redirect: "manual", 1892 | method: "POST", 1893 | headers: { 1894 | "Content-Type": "application/atom+xml; charset=utf-8", 1895 | Link: `; rel="self", ; rel="hub"`, 1900 | ...(typeof feedWebSubSubscription.secret === "string" 1901 | ? { 1902 | "X-Hub-Signature": `sha256=${crypto.createHmac("sha256", feedWebSubSubscription.secret).update(body).digest("hex")}`, 1903 | } 1904 | : {}), 1905 | }, 1906 | body, 1907 | }); 1908 | if (response.status === 410) 1909 | application.database.run( 1910 | sql` 1911 | delete from "feedWebSubSubscriptions" where "id" = ${feedWebSubSubscription.id}; 1912 | `, 1913 | ); 1914 | else if (String(response.status).startsWith("4")) 1915 | utilities.log( 1916 | "feedWebSubSubscriptions.dispatch", 1917 | "REQUEST ERROR", 1918 | String(response), 1919 | ); 1920 | else if (!response.ok) throw new Error(`Response: ${String(response)}`); 1921 | }, 1922 | ); 1923 | 1924 | if (application.commandLineArguments.values.type === undefined) { 1925 | for (const port of application.privateConfiguration.ports) { 1926 | node.childProcessKeepAlive(() => 1927 | childProcess.spawn( 1928 | process.argv[0], 1929 | [ 1930 | "--enable-source-maps", 1931 | process.argv[1], 1932 | ...application.commandLineArguments.positionals, 1933 | "--type", 1934 | "server", 1935 | "--port", 1936 | String(port), 1937 | ], 1938 | { 1939 | env: { 1940 | ...process.env, 1941 | NODE_ENV: application.configuration.environment, 1942 | }, 1943 | stdio: "inherit", 1944 | }, 1945 | ), 1946 | ); 1947 | node.childProcessKeepAlive(() => 1948 | childProcess.spawn( 1949 | process.argv[0], 1950 | [ 1951 | "--enable-source-maps", 1952 | process.argv[1], 1953 | ...application.commandLineArguments.positionals, 1954 | "--type", 1955 | "backgroundJob", 1956 | "--port", 1957 | String(port), 1958 | ], 1959 | { 1960 | env: { 1961 | ...process.env, 1962 | NODE_ENV: application.configuration.environment, 1963 | }, 1964 | stdio: "inherit", 1965 | }, 1966 | ), 1967 | ); 1968 | } 1969 | node.childProcessKeepAlive(() => 1970 | childProcess.spawn( 1971 | process.argv[0], 1972 | [ 1973 | "--enable-source-maps", 1974 | process.argv[1], 1975 | ...application.commandLineArguments.positionals, 1976 | "--type", 1977 | "email", 1978 | ], 1979 | { 1980 | env: { 1981 | ...process.env, 1982 | NODE_ENV: application.configuration.environment, 1983 | }, 1984 | stdio: "inherit", 1985 | }, 1986 | ), 1987 | ); 1988 | caddy.start({ 1989 | ...application.configuration, 1990 | ...application.privateConfiguration, 1991 | untrustedStaticFilesRoots: [ 1992 | `/files/* "${application.configuration.dataDirectory}"`, 1993 | ], 1994 | }); 1995 | } 1996 | -------------------------------------------------------------------------------- /source/index.test.mts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import nodemailer from "nodemailer"; 3 | 4 | await nodemailer 5 | .createTransport({ 6 | host: "localhost", 7 | port: 25, 8 | }) 9 | .sendMail({ 10 | from: `"Example of Sender" `, 11 | to: `"Example of Recipient" `, 12 | subject: "Example of a Newsletter Entry", 13 | html: "

Hello World

".repeat(2 ** 0 /* 13 */), 14 | attachments: [ 15 | { path: path.join(import.meta.dirname, "../static/favicon.ico") }, 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leafac/kill-the-newsletter/d867068a72305076a1e0d9883eccc3288b69acca/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leafac/kill-the-newsletter/d867068a72305076a1e0d9883eccc3288b69acca/static/favicon.ico -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@radically-straightforward/typescript", 3 | "compilerOptions": { 4 | "rootDir": "./source/", 5 | "outDir": "./build/" 6 | } 7 | } 8 | --------------------------------------------------------------------------------