diff --git a/README.md b/README.md index cf2fded..c3261ce 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,18 @@ Built with Express 5 + TypeScript. Hosted at [read.atanasov.fi](https://read.ata ## How it works +Two ways to send something to your e-reader: + +**URL → EPUB** 1. You paste an article URL and your e-reader's email address. 2. The server fetches the page, runs Mozilla Readability over it to strip ads/chrome, builds an EPUB, and emails it to the address you gave. 3. The EPUB is deleted from the server immediately after sending. +**Upload a file** +1. Drag (or pick) a file — EPUB, PDF, MOBI, anything your device accepts. +2. The server attaches it to an email and sends it to your e-reader as-is, no conversion. +3. The file is deleted from the server immediately after sending. Max size: 25 MB. + ## Supported devices Any device that accepts files via email works — the app doesn't restrict by domain. @@ -33,7 +41,7 @@ You'll need to add the **sender** address (the Gmail account this app sends from git clone cd send_to_kindle npm install -cp .env.example .env # then fill in GMAIL_USER and GMAIL_APP_PASSWORD +cp .env.example .env # then fill in SMTP_PASSWORD (Resend API key) and MAIL_FROM npm run dev ``` @@ -51,7 +59,7 @@ npm run start | Variable | Required | Default | Notes | |---|---|---|---| | `SMTP_HOST` | no | `smtp.resend.com` | Any SMTP server works | -| `SMTP_PORT` | no | `465` | `465` = SSL, `587` = STARTTLS | +| `SMTP_PORT` | no | `587` | `587` = STARTTLS (preferred — port 465 is blocked by most VPS providers) | | `SMTP_USER` | yes | — | For Resend, literally the string `resend` | | `SMTP_PASSWORD` | yes | — | For Resend, your API key (`re_…`) | | `MAIL_FROM` | yes | — | Sender address, e.g. `read@atanasov.fi`. Must be from a verified domain on your provider. | @@ -73,8 +81,9 @@ For a different provider, just point `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / ` ## API -`POST /send-article` +### `POST /send-article` +Body (JSON): ```json { "url": "https://example.com/article", @@ -83,7 +92,6 @@ For a different provider, just point `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / ` ``` Response on success: - ```json { "success": true, @@ -92,6 +100,25 @@ Response on success: } ``` +### `POST /send-file` + +Body (multipart/form-data): +- `file` — the binary file (max 25 MB) +- `readerEmail` — destination address + +Response on success: +```json +{ + "success": true, + "message": "File sent to your e-reader", + "title": "original-filename.epub" +} +``` + +### Rate limit + +Both endpoints share a limit of **20 sends per IP per hour**. + ## Limitations - Articles must be publicly accessible — no paywalled or login-required pages. @@ -104,7 +131,9 @@ Response on success: - [@mozilla/readability](https://github.com/mozilla/readability) — article extraction - [jsdom](https://github.com/jsdom/jsdom) — DOM for Readability - [@lesjoursfr/html-to-epub](https://github.com/lesjoursfr/html-to-epub) — EPUB generation -- [nodemailer](https://nodemailer.com/) — SMTP via Gmail +- [nodemailer](https://nodemailer.com/) — SMTP client +- [multer](https://github.com/expressjs/multer) — multipart/form-data upload parsing +- [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) — per-IP rate limiting ## License diff --git a/package-lock.json b/package-lock.json index 3465250..9b07974 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.5.2", "jsdom": "^29.1.1", + "multer": "^2.1.1", "nodemailer": "^8.0.7", "sanitize-filename": "^1.6.4", "tsx": "^4.22.3" @@ -22,6 +23,7 @@ "devDependencies": { "@types/express": "^5.0.6", "@types/jsdom": "^28.0.3", + "@types/multer": "^2.1.0", "@types/node": "^25.9.1", "@types/nodemailer": "^8.0.0", "@types/sanitize-filename": "^1.1.28", @@ -798,6 +800,16 @@ "@types/unist": "*" } }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", @@ -947,6 +959,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -1223,6 +1241,23 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1347,6 +1382,35 @@ "node": ">= 14" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -2721,6 +2785,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -3267,6 +3393,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/streamx": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", @@ -3562,6 +3696,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/package.json b/package.json index 6712f89..88f49a9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "send-to-ereader", - "version": "0.1.0", - "description": "Convert web articles to EPUB and email them to your e-reader.", + "version": "0.2.0", + "description": "Convert web articles to EPUB and email them to your e-reader, or upload a file (EPUB, PDF, etc.) directly.", "homepage": "https://atanasov.fi", "main": "app.js", "type": "module", @@ -21,6 +21,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.5.2", "jsdom": "^29.1.1", + "multer": "^2.1.1", "nodemailer": "^8.0.7", "sanitize-filename": "^1.6.4", "tsx": "^4.22.3" @@ -28,6 +29,7 @@ "devDependencies": { "@types/express": "^5.0.6", "@types/jsdom": "^28.0.3", + "@types/multer": "^2.1.0", "@types/node": "^25.9.1", "@types/nodemailer": "^8.0.0", "@types/sanitize-filename": "^1.1.28", diff --git a/public/index.html b/public/index.html index e3ee044..758c190 100644 --- a/public/index.html +++ b/public/index.html @@ -87,6 +87,17 @@ padding: 2px 8px 2px 0; } + .send-section { + border: 1px solid #828282; + padding: 8px; + margin-bottom: 12px; + } + + .send-section h3 { + margin: 0 0 8px 0; + font-size: 14px; + } + .input-group { margin-bottom: 12px; } @@ -106,6 +117,7 @@ font-size: 14px; background: #fff; margin-bottom: 8px; + box-sizing: border-box; } input[type="url"]:focus, @@ -133,6 +145,34 @@ cursor: not-allowed; } + .dropzone { + border: 2px dashed #828282; + background: #fff; + padding: 24px 12px; + text-align: center; + font-size: 14px; + color: #555; + cursor: pointer; + margin-bottom: 8px; + } + + .dropzone.dragover { + border-color: #00b45a; + background: #eaffea; + color: #000; + } + + .dropzone .picked { + display: block; + margin-top: 6px; + font-size: 13px; + color: #000; + } + + .dropzone .picked.empty { + color: #888; + } + .message { margin-top: 12px; padding: 8px; @@ -187,15 +227,19 @@ before you send the first article.
  • - PocketBook: no dashboard step needed. Send the - first article — PocketBook will email you a confirmation with - an "add to whitelist" link. Click it once, and every future - article will be delivered automatically. + PocketBook: no dashboard step needed. Send + the first article — PocketBook will email you a confirmation + with an "add to whitelist" link. Click it once, and every + future article will be delivered automatically.
  • enter your e-reader's email below
  • -
  • paste the article URL and hit send
  • +
  • + either paste an article URL and hit Send Article, + or drop / pick a file (EPUB, PDF, etc.) and hit + Send File +
  • e-reader email formats

    @@ -218,7 +262,9 @@ -
    +
    +
    -
    - +
    +

    send an article URL

    + +
    + + +
    + + +
    + +
    +

    send a file

    +
    +
    +
    drop a file here or click to pick one
    +
    no file selected
    +
    -
    + + + - - - +