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:
Stefan 2026-05-21 23:43:17 +03:00
parent 356e2c8ee6
commit 39292bfee9
5 changed files with 489 additions and 61 deletions

View file

@ -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
View file

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

View file

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

View file

@ -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,48 +285,88 @@
aria-required="true" aria-required="true"
/> />
</div> </div>
</section>
<div class="input-group"> <section class="send-section">
<label for="articleUrl">Article URL:</label> <h3>send an article URL</h3>
<form id="urlForm" aria-label="Send article URL">
<div class="input-group">
<label for="articleUrl">Article URL:</label>
<input
type="url"
id="articleUrl"
name="articleUrl"
placeholder="https://example.com/article"
autocomplete="off"
aria-required="true"
/>
</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 <input
type="url" type="file"
id="articleUrl" id="fileInput"
name="articleUrl" name="file"
placeholder="https://example.com/article" hidden
autocomplete="off"
required
aria-required="true"
/> />
</div> <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"
} }

View file

@ -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" });
}); });