Prevents the form being abused as an open email relay to arbitrary addresses (which is the realistic abuse vector — disk fill, server compromise, and device exploitation are all bounded already). Default allowlist: kindle.com, pbsync.com. Suffix matching so subdomains (e.g. free.kindle.com) are also accepted. Configurable via ALLOWED_READER_DOMAINS env var so the list can be extended without code changes.
563 lines
16 KiB
HTML
563 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Send to E-Reader — Web Articles to EPUB</title>
|
|
|
|
<!-- Primary Meta Tags -->
|
|
<meta
|
|
name="description"
|
|
content="Convert web articles to EPUB and email them straight to your e-reader (Kindle, PocketBook, or any device that accepts emailed files). No ads, no tracking, open source."
|
|
/>
|
|
<meta
|
|
name="keywords"
|
|
content="send to ereader, send to kindle, send to pocketbook, article to epub, web to ereader, web article epub"
|
|
/>
|
|
<meta name="author" content="Stefan Atanasov" />
|
|
|
|
<!-- Open Graph / Facebook -->
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:url" content="https://read.atanasov.fi" />
|
|
<meta
|
|
property="og:title"
|
|
content="Send to E-Reader — Web Articles to EPUB"
|
|
/>
|
|
<meta
|
|
property="og:description"
|
|
content="Convert web articles to EPUB and email them to your e-reader in one click."
|
|
/>
|
|
|
|
<!-- Twitter -->
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:url" content="https://read.atanasov.fi" />
|
|
<meta
|
|
name="twitter:title"
|
|
content="Send to E-Reader — Web Articles to EPUB"
|
|
/>
|
|
<meta
|
|
name="twitter:description"
|
|
content="Convert web articles to EPUB and email them to your e-reader in one click."
|
|
/>
|
|
|
|
<!-- Canonical URL -->
|
|
<link rel="canonical" href="https://read.atanasov.fi" />
|
|
|
|
<style>
|
|
body {
|
|
font-family: "Courier New", Courier, monospace;
|
|
line-height: 1.4;
|
|
max-width: 800px;
|
|
margin: 8px auto;
|
|
padding: 8px;
|
|
background: #f6f6ef;
|
|
}
|
|
|
|
.container {
|
|
background: #f6f6ef;
|
|
}
|
|
|
|
h1 {
|
|
color: #000;
|
|
margin: 0 0 16px 0;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.setup-info {
|
|
border: 1px solid #828282;
|
|
padding: 8px;
|
|
margin-bottom: 16px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.setup-info code {
|
|
background: #fff;
|
|
padding: 2px 4px;
|
|
}
|
|
|
|
.setup-info table {
|
|
border-collapse: collapse;
|
|
margin: 8px 0;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.setup-info table th,
|
|
.setup-info table td {
|
|
text-align: left;
|
|
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;
|
|
}
|
|
|
|
.input-group label {
|
|
display: block;
|
|
margin-bottom: 4px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
input[type="url"],
|
|
input[type="email"] {
|
|
width: 100%;
|
|
padding: 4px;
|
|
border: 1px solid #828282;
|
|
font-family: "Courier New", Courier, monospace;
|
|
font-size: 14px;
|
|
background: #fff;
|
|
margin-bottom: 8px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
input[type="url"]:focus,
|
|
input[type="email"]:focus {
|
|
outline: none;
|
|
border-color: #000;
|
|
}
|
|
|
|
button {
|
|
background: #1ce783;
|
|
color: #000;
|
|
border: 1px solid #828282;
|
|
padding: 4px 8px;
|
|
font-family: "Courier New", Courier, monospace;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
button:hover {
|
|
background: #00b45a;
|
|
}
|
|
|
|
button:disabled {
|
|
background: #cccccc;
|
|
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;
|
|
font-size: 14px;
|
|
border: 1px solid #828282;
|
|
display: none;
|
|
}
|
|
|
|
.success {
|
|
background: #dfd;
|
|
display: block;
|
|
}
|
|
|
|
.error {
|
|
background: #fdd;
|
|
display: block;
|
|
}
|
|
|
|
.loading {
|
|
background: #ffffd1;
|
|
display: block;
|
|
}
|
|
|
|
#credits {
|
|
font-size: 12px;
|
|
margin-top: 16px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Send to E-Reader</h1>
|
|
</header>
|
|
|
|
<main>
|
|
<article class="container">
|
|
<section class="setup-info">
|
|
<h2>guide</h2>
|
|
<ol>
|
|
<li>
|
|
whitelist <code>send@read.atanasov.fi</code> on your device — one-time setup:
|
|
<ul>
|
|
<li>
|
|
<strong>Kindle</strong>: add it in
|
|
<a
|
|
href="https://www.amazon.com/hz/mycd/myx#/home/settings/payment"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>Amazon → Manage Your Content and Devices → Preferences →
|
|
Personal Document Settings → Approved Personal Document E-mail List</a
|
|
>
|
|
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.
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
<li>enter your e-reader's email below</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>
|
|
<table>
|
|
<tr>
|
|
<th>device</th>
|
|
<th>email pattern</th>
|
|
</tr>
|
|
<tr>
|
|
<td>Amazon Kindle</td>
|
|
<td><code>*@kindle.com</code></td>
|
|
</tr>
|
|
<tr>
|
|
<td>PocketBook</td>
|
|
<td><code>*@pbsync.com</code></td>
|
|
</tr>
|
|
</table>
|
|
|
|
<h3>notes:</h3>
|
|
<ul>
|
|
<li>
|
|
only <code>@kindle.com</code> and <code>@pbsync.com</code>
|
|
destination addresses are accepted (subdomains too, e.g.
|
|
<code>free.kindle.com</code>). This is to prevent the form being
|
|
abused as an open email relay.
|
|
</li>
|
|
<li>article should be publicly accessible (no paywalls / login required)</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
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section class="send-section">
|
|
<div class="input-group">
|
|
<label for="readerEmail">E-Reader Email:</label>
|
|
<input
|
|
type="email"
|
|
id="readerEmail"
|
|
name="readerEmail"
|
|
placeholder="your-device@kindle.com"
|
|
autocomplete="email"
|
|
required
|
|
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
|
|
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
|
|
type="file"
|
|
id="fileInput"
|
|
name="file"
|
|
hidden
|
|
/>
|
|
<button type="submit" id="sendFileButton">Send File</button>
|
|
</form>
|
|
</section>
|
|
|
|
<div
|
|
id="message"
|
|
class="message"
|
|
role="alert"
|
|
aria-live="polite"
|
|
></div>
|
|
</article>
|
|
</main>
|
|
|
|
<footer>
|
|
<p id="credits">
|
|
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 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");
|
|
if (legacy && !localStorage.getItem("readerEmail")) {
|
|
localStorage.setItem("readerEmail", legacy);
|
|
localStorage.removeItem("kindleEmail");
|
|
}
|
|
|
|
if (localStorage.getItem("readerEmail")) {
|
|
emailInput.value = localStorage.getItem("readerEmail");
|
|
}
|
|
|
|
function showMessage(text, type) {
|
|
messageDiv.textContent = text;
|
|
messageDiv.className = `message ${type}`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
try {
|
|
sendUrlButton.disabled = true;
|
|
showMessage("processing article...", "loading");
|
|
|
|
const response = await fetch("/send-article", {
|
|
method: "POST",
|
|
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 = "";
|
|
} else {
|
|
throw new Error(data.error || data.message || "failed to send article");
|
|
}
|
|
} catch (error) {
|
|
showMessage(error.message || "error sending article", "error");
|
|
} finally {
|
|
sendUrlButton.disabled = false;
|
|
}
|
|
}
|
|
|
|
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">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "WebApplication",
|
|
"name": "Send to E-Reader",
|
|
"applicationCategory": "UtilityApplication",
|
|
"offers": {
|
|
"@type": "Offer",
|
|
"price": "0",
|
|
"priceCurrency": "EUR"
|
|
},
|
|
"creator": {
|
|
"@type": "Person",
|
|
"name": "Stefan Atanasov",
|
|
"url": "https://atanasov.fi"
|
|
},
|
|
"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"
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|