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
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 <your-fork>
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

140
package-lock.json generated
View file

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

View file

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

View file

@ -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.
</li>
<li>
<strong>PocketBook</strong>: 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.
<strong>PocketBook</strong>: 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.
</li>
</ul>
</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>
<h3>e-reader email formats</h3>
@ -218,7 +262,9 @@
<ul>
<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>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>
your e-reader email is saved in your browser
<code>localStorage</code> for future use; nothing is stored on the server
@ -226,11 +272,7 @@
</ul>
</section>
<form
id="readerForm"
class="reader-form"
aria-label="Article to e-reader conversion form"
>
<section class="send-section">
<div class="input-group">
<label for="readerEmail">E-Reader Email:</label>
<input
@ -243,7 +285,11 @@
aria-required="true"
/>
</div>
</section>
<section class="send-section">
<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
@ -252,39 +298,75 @@
name="articleUrl"
placeholder="https://example.com/article"
autocomplete="off"
required
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
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
id="message"
class="message"
role="alert"
aria-live="polite"
></div>
</form>
</article>
</main>
<footer>
<p id="credits">
v 0.1.0 •
v 0.2.0 •
<a
href="https://atanasov.fi"
target="_blank"
rel="noopener noreferrer"
>atanasov.fi</a
>
<a
href="https://codeberg.org/atanasoff/send-to-ereader"
target="_blank"
rel="noopener noreferrer"
>source</a
>
</p>
</footer>
<script>
const urlInput = document.getElementById("articleUrl");
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 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
const legacy = localStorage.getItem("kindleEmail");
@ -302,38 +384,37 @@
messageDiv.className = `message ${type}`;
}
async function sendArticle(event) {
if (event) event.preventDefault();
const url = urlInput.value.trim();
function getEmailOrShow() {
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) {
showMessage("please enter a valid url", "error");
return;
}
if (!email) {
showMessage("please enter your e-reader email", "error");
return;
}
try {
sendButton.disabled = true;
sendUrlButton.disabled = true;
showMessage("processing article...", "loading");
localStorage.setItem("readerEmail", email);
const response = await fetch("/send-article", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, readerEmail: email }),
});
const data = await response.json();
if (data.success) {
showMessage(`success! "${data.title}" sent to your e-reader`, "success");
urlInput.value = "";
@ -343,13 +424,113 @@
} catch (error) {
showMessage(error.message || "error sending article", "error");
} finally {
sendButton.disabled = false;
sendUrlButton.disabled = false;
}
}
document
.getElementById("readerForm")
.addEventListener("submit", sendArticle);
function setPickedFile(file) {
if (!file) {
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 type="application/ld+json">
@ -368,7 +549,7 @@
"name": "Stefan Atanasov",
"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",
"browserRequirements": "Requires JavaScript"
}

View file

@ -7,6 +7,7 @@ import fs from "fs";
import sanitizeFilename from "sanitize-filename";
import { EpubOptions, EPub } from "@lesjoursfr/html-to-epub";
import { rateLimit } from "express-rate-limit";
import multer from "multer";
import dotenv from "dotenv";
dotenv.config();
@ -34,7 +35,7 @@ app.set("trust proxy", 1);
app.use(express.json());
app.use(express.static("public"));
const sendArticleLimiter = rateLimit({
const sendLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
limit: 20,
standardHeaders: "draft-7",
@ -42,6 +43,13 @@ const sendArticleLimiter = rateLimit({
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({
host: SMTP_HOST,
port: SMTP_PORT,
@ -178,7 +186,7 @@ const emailShape = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
app.post(
"/send-article",
sendArticleLimiter,
sendLimiter,
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
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) => {
res.status(404).json({ error: "Page doesn't exist" });
});