Add file upload (POST /send-file) and drag-and-drop UI
Adds a second flow alongside the URL-to-EPUB pipeline: - New POST /send-file endpoint accepts multipart/form-data with a `file` and `readerEmail`, attaches the file to an email, and ships it to the reader as-is. No conversion — the device decides what it accepts (EPUB, PDF, MOBI, FB2, ...). - 25 MB cap enforced both client-side and server-side (multer). - Shares the same 20/hour rate limit as /send-article (now sendLimiter). - UI: dropzone with drag/drop + click-to-pick + visible filename feedback. - Shared e-reader email input across both flows. - Codeberg source link added to the footer. - Version bumped 0.1.0 → 0.2.0.
This commit is contained in:
parent
356e2c8ee6
commit
39292bfee9
5 changed files with 489 additions and 61 deletions
39
README.md
39
README.md
|
|
@ -6,10 +6,18 @@ Built with Express 5 + TypeScript. Hosted at [read.atanasov.fi](https://read.ata
|
||||||
|
|
||||||
## How it works
|
## 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.
|
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.
|
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.
|
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
|
## Supported devices
|
||||||
|
|
||||||
Any device that accepts files via email works — the app doesn't restrict by domain.
|
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 <your-fork>
|
git clone <your-fork>
|
||||||
cd send_to_kindle
|
cd send_to_kindle
|
||||||
npm install
|
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
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -51,7 +59,7 @@ npm run start
|
||||||
| Variable | Required | Default | Notes |
|
| Variable | Required | Default | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `SMTP_HOST` | no | `smtp.resend.com` | Any SMTP server works |
|
| `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_USER` | yes | — | For Resend, literally the string `resend` |
|
||||||
| `SMTP_PASSWORD` | yes | — | For Resend, your API key (`re_…`) |
|
| `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. |
|
| `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
|
## API
|
||||||
|
|
||||||
`POST /send-article`
|
### `POST /send-article`
|
||||||
|
|
||||||
|
Body (JSON):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"url": "https://example.com/article",
|
"url": "https://example.com/article",
|
||||||
|
|
@ -83,7 +92,6 @@ For a different provider, just point `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `
|
||||||
```
|
```
|
||||||
|
|
||||||
Response on success:
|
Response on success:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"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
|
## Limitations
|
||||||
|
|
||||||
- Articles must be publicly accessible — no paywalled or login-required pages.
|
- 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
|
- [@mozilla/readability](https://github.com/mozilla/readability) — article extraction
|
||||||
- [jsdom](https://github.com/jsdom/jsdom) — DOM for Readability
|
- [jsdom](https://github.com/jsdom/jsdom) — DOM for Readability
|
||||||
- [@lesjoursfr/html-to-epub](https://github.com/lesjoursfr/html-to-epub) — EPUB generation
|
- [@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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
140
package-lock.json
generated
140
package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"nodemailer": "^8.0.7",
|
"nodemailer": "^8.0.7",
|
||||||
"sanitize-filename": "^1.6.4",
|
"sanitize-filename": "^1.6.4",
|
||||||
"tsx": "^4.22.3"
|
"tsx": "^4.22.3"
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jsdom": "^28.0.3",
|
"@types/jsdom": "^28.0.3",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/sanitize-filename": "^1.1.28",
|
"@types/sanitize-filename": "^1.1.28",
|
||||||
|
|
@ -798,6 +800,16 @@
|
||||||
"@types/unist": "*"
|
"@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": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.9.1",
|
"version": "25.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
"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"
|
"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": {
|
"node_modules/archiver": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
||||||
|
|
@ -1223,6 +1241,23 @@
|
||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
|
@ -1347,6 +1382,35 @@
|
||||||
"node": ">= 14"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
|
|
@ -2721,6 +2785,68 @@
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/negotiator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
|
|
@ -3267,6 +3393,14 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/streamx": {
|
||||||
"version": "2.25.0",
|
"version": "2.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
|
||||||
|
|
@ -3562,6 +3696,12 @@
|
||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "send-to-ereader",
|
"name": "send-to-ereader",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Convert web articles to EPUB and email them to your e-reader.",
|
"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",
|
"homepage": "https://atanasov.fi",
|
||||||
"main": "app.js",
|
"main": "app.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"nodemailer": "^8.0.7",
|
"nodemailer": "^8.0.7",
|
||||||
"sanitize-filename": "^1.6.4",
|
"sanitize-filename": "^1.6.4",
|
||||||
"tsx": "^4.22.3"
|
"tsx": "^4.22.3"
|
||||||
|
|
@ -28,6 +29,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jsdom": "^28.0.3",
|
"@types/jsdom": "^28.0.3",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/sanitize-filename": "^1.1.28",
|
"@types/sanitize-filename": "^1.1.28",
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,17 @@
|
||||||
padding: 2px 8px 2px 0;
|
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 {
|
.input-group {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +117,7 @@
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="url"]:focus,
|
input[type="url"]:focus,
|
||||||
|
|
@ -133,6 +145,34 @@
|
||||||
cursor: not-allowed;
|
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 {
|
.message {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|
@ -187,15 +227,19 @@
|
||||||
before you send the first article.
|
before you send the first article.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>PocketBook</strong>: no dashboard step needed. Send the
|
<strong>PocketBook</strong>: no dashboard step needed. Send
|
||||||
first article — PocketBook will email you a confirmation with
|
the first article — PocketBook will email you a confirmation
|
||||||
an "add to whitelist" link. Click it once, and every future
|
with an "add to whitelist" link. Click it once, and every
|
||||||
article will be delivered automatically.
|
future article will be delivered automatically.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>enter your e-reader's email below</li>
|
<li>enter your e-reader's email below</li>
|
||||||
<li>paste the article URL and hit send</li>
|
<li>
|
||||||
|
<strong>either</strong> paste an article URL and hit Send Article,
|
||||||
|
<strong>or</strong> drop / pick a file (EPUB, PDF, etc.) and hit
|
||||||
|
Send File
|
||||||
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h3>e-reader email formats</h3>
|
<h3>e-reader email formats</h3>
|
||||||
|
|
@ -218,7 +262,9 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li>any email works — domain isn't checked, use whatever your device gave you</li>
|
<li>any email works — domain isn't checked, use whatever your device gave you</li>
|
||||||
<li>article should be publicly accessible (no paywalls / login required)</li>
|
<li>article should be publicly accessible (no paywalls / login required)</li>
|
||||||
<li>EPUB is generated server-side and deleted immediately after sending</li>
|
<li>uploaded files are passed through as-is — your device decides what formats it accepts (EPUB, PDF, MOBI, etc.)</li>
|
||||||
|
<li>max file size: 25 MB</li>
|
||||||
|
<li>articles are converted to EPUB and files are stored on the server only until they're emailed, then deleted immediately</li>
|
||||||
<li>
|
<li>
|
||||||
your e-reader email is saved in your browser
|
your e-reader email is saved in your browser
|
||||||
<code>localStorage</code> for future use; nothing is stored on the server
|
<code>localStorage</code> for future use; nothing is stored on the server
|
||||||
|
|
@ -226,11 +272,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form
|
<section class="send-section">
|
||||||
id="readerForm"
|
|
||||||
class="reader-form"
|
|
||||||
aria-label="Article to e-reader conversion form"
|
|
||||||
>
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="readerEmail">E-Reader Email:</label>
|
<label for="readerEmail">E-Reader Email:</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -243,7 +285,11 @@
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="send-section">
|
||||||
|
<h3>send an article URL</h3>
|
||||||
|
<form id="urlForm" aria-label="Send article URL">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="articleUrl">Article URL:</label>
|
<label for="articleUrl">Article URL:</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -252,39 +298,75 @@
|
||||||
name="articleUrl"
|
name="articleUrl"
|
||||||
placeholder="https://example.com/article"
|
placeholder="https://example.com/article"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
required
|
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="submit" id="sendUrlButton">Send Article</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="send-section">
|
||||||
|
<h3>send a file</h3>
|
||||||
|
<form id="fileForm" aria-label="Send file">
|
||||||
|
<div
|
||||||
|
id="dropzone"
|
||||||
|
class="dropzone"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="Drop a file here, or click to pick one"
|
||||||
|
>
|
||||||
|
<div>drop a file here or click to pick one</div>
|
||||||
|
<div id="picked" class="picked empty">no file selected</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="fileInput"
|
||||||
|
name="file"
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
<button type="submit" id="sendFileButton">Send File</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button type="submit" id="sendButton">Send</button>
|
|
||||||
<div
|
<div
|
||||||
id="message"
|
id="message"
|
||||||
class="message"
|
class="message"
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
></div>
|
></div>
|
||||||
</form>
|
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p id="credits">
|
<p id="credits">
|
||||||
v 0.1.0 •
|
v 0.2.0 •
|
||||||
<a
|
<a
|
||||||
href="https://atanasov.fi"
|
href="https://atanasov.fi"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>atanasov.fi</a
|
>atanasov.fi</a
|
||||||
>
|
>
|
||||||
|
•
|
||||||
|
<a
|
||||||
|
href="https://codeberg.org/atanasoff/send-to-ereader"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>source</a
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const urlInput = document.getElementById("articleUrl");
|
const urlInput = document.getElementById("articleUrl");
|
||||||
const emailInput = document.getElementById("readerEmail");
|
const emailInput = document.getElementById("readerEmail");
|
||||||
const sendButton = document.getElementById("sendButton");
|
const sendUrlButton = document.getElementById("sendUrlButton");
|
||||||
|
const sendFileButton = document.getElementById("sendFileButton");
|
||||||
const messageDiv = document.getElementById("message");
|
const messageDiv = document.getElementById("message");
|
||||||
|
const fileInput = document.getElementById("fileInput");
|
||||||
|
const dropzone = document.getElementById("dropzone");
|
||||||
|
const picked = document.getElementById("picked");
|
||||||
|
|
||||||
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
// Migrate any previously-saved key from the old version
|
// Migrate any previously-saved key from the old version
|
||||||
const legacy = localStorage.getItem("kindleEmail");
|
const legacy = localStorage.getItem("kindleEmail");
|
||||||
|
|
@ -302,38 +384,37 @@
|
||||||
messageDiv.className = `message ${type}`;
|
messageDiv.className = `message ${type}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendArticle(event) {
|
function getEmailOrShow() {
|
||||||
if (event) event.preventDefault();
|
|
||||||
|
|
||||||
const url = urlInput.value.trim();
|
|
||||||
const email = emailInput.value.trim();
|
const email = emailInput.value.trim();
|
||||||
|
if (!email) {
|
||||||
|
showMessage("please enter your e-reader email", "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
localStorage.setItem("readerEmail", email);
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendArticle(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const url = urlInput.value.trim();
|
||||||
|
const email = getEmailOrShow();
|
||||||
|
if (!email) return;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showMessage("please enter a valid url", "error");
|
showMessage("please enter a valid url", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
showMessage("please enter your e-reader email", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sendButton.disabled = true;
|
sendUrlButton.disabled = true;
|
||||||
showMessage("processing article...", "loading");
|
showMessage("processing article...", "loading");
|
||||||
|
|
||||||
localStorage.setItem("readerEmail", email);
|
|
||||||
|
|
||||||
const response = await fetch("/send-article", {
|
const response = await fetch("/send-article", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ url, readerEmail: email }),
|
body: JSON.stringify({ url, readerEmail: email }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showMessage(`success! "${data.title}" sent to your e-reader`, "success");
|
showMessage(`success! "${data.title}" sent to your e-reader`, "success");
|
||||||
urlInput.value = "";
|
urlInput.value = "";
|
||||||
|
|
@ -343,13 +424,113 @@
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showMessage(error.message || "error sending article", "error");
|
showMessage(error.message || "error sending article", "error");
|
||||||
} finally {
|
} finally {
|
||||||
sendButton.disabled = false;
|
sendUrlButton.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document
|
function setPickedFile(file) {
|
||||||
.getElementById("readerForm")
|
if (!file) {
|
||||||
.addEventListener("submit", sendArticle);
|
picked.textContent = "no file selected";
|
||||||
|
picked.classList.add("empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_UPLOAD_BYTES) {
|
||||||
|
showMessage(`file too large (${(file.size / 1024 / 1024).toFixed(1)} MB — max 25 MB)`, "error");
|
||||||
|
fileInput.value = "";
|
||||||
|
picked.textContent = "no file selected";
|
||||||
|
picked.classList.add("empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
picked.textContent = `${file.name} (${(file.size / 1024).toFixed(0)} KB)`;
|
||||||
|
picked.classList.remove("empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFile(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const email = getEmailOrShow();
|
||||||
|
if (!email) return;
|
||||||
|
const file = fileInput.files && fileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
showMessage("please pick or drop a file first", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendFileButton.disabled = true;
|
||||||
|
showMessage(`sending "${file.name}"...`, "loading");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("readerEmail", email);
|
||||||
|
|
||||||
|
const response = await fetch("/send-file", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showMessage(`success! "${data.title}" sent to your e-reader`, "success");
|
||||||
|
fileInput.value = "";
|
||||||
|
setPickedFile(null);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || data.message || "failed to send file");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(error.message || "error sending file", "error");
|
||||||
|
} finally {
|
||||||
|
sendFileButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL form
|
||||||
|
document.getElementById("urlForm").addEventListener("submit", sendArticle);
|
||||||
|
|
||||||
|
// File form
|
||||||
|
document.getElementById("fileForm").addEventListener("submit", sendFile);
|
||||||
|
|
||||||
|
// Dropzone click → trigger file picker
|
||||||
|
dropzone.addEventListener("click", () => fileInput.click());
|
||||||
|
dropzone.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
setPickedFile(fileInput.files && fileInput.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag-and-drop
|
||||||
|
["dragenter", "dragover"].forEach((ev) => {
|
||||||
|
dropzone.addEventListener(ev, (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dropzone.classList.add("dragover");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
["dragleave", "drop"].forEach((ev) => {
|
||||||
|
dropzone.addEventListener(ev, (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dropzone.classList.remove("dragover");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener("drop", (e) => {
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
if (!dt || !dt.files || dt.files.length === 0) return;
|
||||||
|
// Assign dropped file to the input so form submission works uniformly
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
setPickedFile(dt.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent the browser from navigating away if a file is dropped outside the dropzone
|
||||||
|
["dragover", "drop"].forEach((ev) => {
|
||||||
|
window.addEventListener(ev, (e) => e.preventDefault());
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
|
|
@ -368,7 +549,7 @@
|
||||||
"name": "Stefan Atanasov",
|
"name": "Stefan Atanasov",
|
||||||
"url": "https://atanasov.fi"
|
"url": "https://atanasov.fi"
|
||||||
},
|
},
|
||||||
"description": "Convert web articles to EPUB and email them to your e-reader.",
|
"description": "Convert web articles to EPUB and email them to your e-reader, or upload any file (EPUB, PDF) directly.",
|
||||||
"operatingSystem": "All",
|
"operatingSystem": "All",
|
||||||
"browserRequirements": "Requires JavaScript"
|
"browserRequirements": "Requires JavaScript"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
src/app.ts
80
src/app.ts
|
|
@ -7,6 +7,7 @@ import fs from "fs";
|
||||||
import sanitizeFilename from "sanitize-filename";
|
import sanitizeFilename from "sanitize-filename";
|
||||||
import { EpubOptions, EPub } from "@lesjoursfr/html-to-epub";
|
import { EpubOptions, EPub } from "@lesjoursfr/html-to-epub";
|
||||||
import { rateLimit } from "express-rate-limit";
|
import { rateLimit } from "express-rate-limit";
|
||||||
|
import multer from "multer";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
@ -34,7 +35,7 @@ app.set("trust proxy", 1);
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static("public"));
|
app.use(express.static("public"));
|
||||||
|
|
||||||
const sendArticleLimiter = rateLimit({
|
const sendLimiter = rateLimit({
|
||||||
windowMs: 60 * 60 * 1000, // 1 hour
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
limit: 20,
|
limit: 20,
|
||||||
standardHeaders: "draft-7",
|
standardHeaders: "draft-7",
|
||||||
|
|
@ -42,6 +43,13 @@ const sendArticleLimiter = rateLimit({
|
||||||
message: { error: "Too many send requests. Try again in an hour." },
|
message: { error: "Too many send requests. Try again in an hour." },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; // 25 MB
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
dest: "uploads/",
|
||||||
|
limits: { fileSize: MAX_UPLOAD_BYTES },
|
||||||
|
});
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: SMTP_HOST,
|
host: SMTP_HOST,
|
||||||
port: SMTP_PORT,
|
port: SMTP_PORT,
|
||||||
|
|
@ -178,7 +186,7 @@ const emailShape = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
"/send-article",
|
"/send-article",
|
||||||
sendArticleLimiter,
|
sendLimiter,
|
||||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
const { url, readerEmail } = req.body;
|
const { url, readerEmail } = req.body;
|
||||||
|
|
||||||
|
|
@ -213,6 +221,74 @@ app.post(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function sendFileToReader(
|
||||||
|
filePath: string,
|
||||||
|
filename: string,
|
||||||
|
readerEmail: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const safeName = sanitizeFilename(filename) || "document";
|
||||||
|
const mailOptions = {
|
||||||
|
from: MAIL_FROM,
|
||||||
|
to: readerEmail,
|
||||||
|
subject: safeName,
|
||||||
|
text: `File: ${safeName}\n\nSent to your e-reader`,
|
||||||
|
attachments: [{ filename: safeName, path: filePath }],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail(mailOptions);
|
||||||
|
} finally {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/send-file",
|
||||||
|
sendLimiter,
|
||||||
|
upload.single("file"),
|
||||||
|
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
const file = req.file;
|
||||||
|
const { readerEmail } = req.body;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (file && fs.existsSync(file.path)) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
res.status(400).json({ error: "No file provided" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!readerEmail || typeof readerEmail !== "string") {
|
||||||
|
cleanup();
|
||||||
|
res.status(400).json({ error: "No e-reader email provided" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!emailShape.test(readerEmail)) {
|
||||||
|
cleanup();
|
||||||
|
res.status(400).json({ error: "Invalid e-reader email address" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendFileToReader(file.path, file.originalname, readerEmail);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "File sent to your e-reader",
|
||||||
|
title: file.originalname,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending file:", error);
|
||||||
|
cleanup();
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.use((req: Request, res: Response) => {
|
app.use((req: Request, res: Response) => {
|
||||||
res.status(404).json({ error: "Page doesn't exist" });
|
res.status(404).json({ error: "Page doesn't exist" });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue