How to Send your Newsletters to Kindle for FREE with Google Apps Script
This is how to send your favorite newsletters to your Kindle automatically using Google Apps Script. The script checks your newsletter every 15 minutes.
- 1. Open script.google.com
- 2. Get your Send-to-Kindle email address
- 3. Add your Gmail to the Approved E-mail List
- 4. Edit the two values at the top of the code below
- 5. Copy & paste the code at script.google.com
- 6. Select
testSendLatestin the toolbar and Run - 7. Check your Kindle and your newsletter is there
- 8. Select
setupTriggerand click Run. That's it
» Click to copy all the code below:
const CONFIG = {
// Your Send to Kindle address (find it in Amazon's "Manage Your Content and Devices")
KINDLE_EMAIL: '___YOUR_KINDLE_EMAIL_HERE___',
// Sender addresses of the newsletters you want on your Kindle
// (just add or remove lines here to manage them)
SENDERS: [
'____NEWSLETTER’S_SENDER_EMAIL___',
'____NEWSLETTER’S_SENDER_EMAIL___',
// '____NEWSLETTER’S_SENDER_EMAIL___',
],
// How many days back to search (fine as long as it's comfortably longer than the trigger interval)
SEARCH_WINDOW_DAYS: 2,
// How many days to remember processed message IDs (must be longer than SEARCH_WINDOW_DAYS)
PROCESSED_RETENTION_DAYS: 7,
// Interval in minutes between automatic runs. One of 1, 5, 10, 15, 30
TRIGGER_INTERVAL_MINUTES: 15,
};
/**
* Main job. Runs periodically on the time-based trigger.
*/
function main() {
if (CONFIG.SENDERS.length === 0) {
console.warn('CONFIG.SENDERS is empty. Add the senders of the newsletters you want to forward.');
return;
}
if (CONFIG.KINDLE_EMAIL.indexOf('@kindle.com') === -1) {
console.warn('CONFIG.KINDLE_EMAIL is not a Send to Kindle address: ' + CONFIG.KINDLE_EMAIL);
return;
}
const processed = loadProcessedIds_();
const query = buildSearchQuery_();
const threads = GmailApp.search(query);
let sentCount = 0;
threads.forEach(function (thread) {
thread.getMessages().forEach(function (message) {
const id = message.getId();
if (processed[id]) return; // already sent
if (!isTargetSender_(message.getFrom())) return; // skip unrelated messages in the same thread
try {
sendToKindle_(message);
processed[id] = Date.now();
sentCount++;
console.log('Sent to Kindle: ' + message.getSubject());
} catch (e) {
// Not marked as processed, so it retries on the next run
console.error('Failed: ' + message.getSubject() + ' — ' + e);
}
});
});
saveProcessedIds_(processed);
console.log('Check complete. Newly sent: ' + sentCount);
}
/**
* Send one email to the Kindle address as an .html attachment.
* Amazon's conversion engine doesn't fetch remote images referenced in
* the HTML, so we download each image ourselves and inline it into the
* HTML as a base64 data URI.
* (Bundling images in a ZIP doesn't work: Amazon converts each file
* inside the ZIP as a separate document.)
*/
function sendToKindle_(message) {
const subject = message.getSubject() || '(no subject)';
let html = buildDocumentHtml_(message, subject);
const filename = sanitizeFilename_(subject) + '.html';
// Download remote images and embed them into the HTML.
// Images we can't fetch (unsupported format or error) would only show
// up as broken-image icons on the Kindle, so drop the whole tag.
let embeddedCount = 0;
let embeddedBytes = 0;
html = html.replace(/<img src=("|')(https?:\/\/[^"']+)\1([^>]*)>/g, function (match, quote, url, rest) {
if (embeddedCount >= 30 || embeddedBytes > 20 * 1024 * 1024) return ''; // safety limits
const dataUri = fetchImageAsDataUri_(url);
if (!dataUri) return '';
embeddedCount++;
embeddedBytes += dataUri.length;
return '<img src="' + dataUri + '"' + rest + '>';
});
const blob = Utilities.newBlob('', 'text/html', filename)
.setDataFromString(html, 'UTF-8');
GmailApp.sendEmail(CONFIG.KINDLE_EMAIL, subject, 'Sent by auto-send-newsletter-to-kindle', {
attachments: [blob],
name: 'Newsletter to Kindle',
});
}
/**
* Download an image URL and return it as a 'data:image/...;base64,...' string.
* Returns null for formats Kindle can't handle (webp/svg etc.) or on fetch failure.
*/
function fetchImageAsDataUri_(url) {
const SUPPORTED_TYPES = {
'image/jpeg': true,
'image/jpg': true,
'image/png': true,
'image/gif': true,
'image/bmp': true,
};
try {
const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
if (resp.getResponseCode() !== 200) return null;
const blob = resp.getBlob();
const type = (blob.getContentType() || '').toLowerCase().split(';')[0];
if (!SUPPORTED_TYPES[type]) return null;
const bytes = blob.getBytes();
if (bytes.length > 5 * 1024 * 1024) return null; // max 5MB per image
return 'data:' + type + ';base64,' + Utilities.base64Encode(bytes);
} catch (e) {
return null;
}
}
/**
* Build the HTML document for Kindle conversion from the email body.
* Plain-text newsletters get their line breaks converted to <br> and
* blank lines to paragraph breaks before being wrapped in HTML.
*/
function buildDocumentHtml_(message, subject) {
let body = message.getBody(); // HTML body
if (!body || !looksLikeHtml_(body)) {
body = plainTextToHtml_(message.getPlainBody() || '');
} else {
body = cleanEmailHtml_(body);
}
const from = escapeHtml_(message.getFrom());
const date = Utilities.formatDate(message.getDate(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm');
return (
'<!DOCTYPE html>' +
'<html>' +
'<head>' +
'<meta charset="UTF-8">' +
'<title>' + escapeHtml_(subject) + '</title>' +
'</head>' +
'<body>' +
'<p style="color:#666;font-size:0.85em;">' + from + '<br>' + date + '</p>' +
'<hr>' +
body +
'</body>' +
'</html>'
);
}
/**
* Clean up email HTML for Kindle conversion.
* Newsletter HTML from Substack and similar services contains deeply
* nested tables, piles of inline styles, and conditional comments.
* Amazon's conversion engine can misread these and drop most of the
* body (the symptom: only the opening and the footer survive).
* The fix: keep only the tags that carry the content (paragraphs,
* headings, links, images, ...) via a whitelist, strip every layout
* tag and attribute, and rebuild a simple HTML document.
*/
const KEEP_TAGS_ = /^(?:p|br|hr|h1|h2|h3|h4|h5|h6|a|img|ul|ol|li|blockquote|strong|em|b|i|u|s|small|sub|sup|pre|code)$/;
function cleanEmailHtml_(html) {
let cleaned = html;
// Strip HTML comments (including Outlook conditional comments <!--[if mso]> ...)
cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, '');
// Strip the DOCTYPE declaration (the whitelist pass below only handles tags, so remove it separately)
cleaned = cleaned.replace(/<!DOCTYPE[^>]*>/gi, '');
// Strip <head> (with its <meta> and <style>), plus standalone <style>/<script> blocks
cleaned = cleaned.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head\s*>/gi, '');
cleaned = cleaned.replace(/<style(\s[^>]*)?>[\s\S]*?<\/style\s*>/gi, '');
cleaned = cleaned.replace(/<script(\s[^>]*)?>[\s\S]*?<\/script\s*>/gi, '');
// Rewrite Substack CDN transform URLs back to the original image URL
// (Amazon can't fetch the CDN ones, which breaks the images)
cleaned = cleaned.replace(
/https:\/\/substackcdn\.com\/image\/fetch\/[^"'\s>]*\/(https%3A%2F%2F[^"'\s>]+)/gi,
function (match, encodedUrl) {
try {
return decodeURIComponent(encodedUrl);
} catch (e) {
return match;
}
}
);
// Strip open-tracking pixels (1px images, Substack's /o/ beacons)
cleaned = cleaned.replace(/<img[^>]*\s(?:width|height)=["']?1["']?[^>]*>/gi, '');
cleaned = cleaned.replace(/<img[^>]*substack\.com\/o\/[^>]*>/gi, '');
// Whitelist the tags: anything that isn't a content tag (table/div/span etc.)
// is removed, keeping only its inner text. Kept tags lose their attributes
// too (only <a> href and <img> src/alt survive).
cleaned = cleaned.replace(
/<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g,
function (match, slash, tag, attrs) {
tag = tag.toLowerCase();
if (!KEEP_TAGS_.test(tag)) return ' ';
if (slash) return '</' + tag + '>';
if (tag === 'a') {
const href = (attrs.match(/\shref\s*=\s*("[^"]*"|'[^']*')/i) || [])[1];
return href ? '<a href=' + href + '>' : '<a>';
}
if (tag === 'img') {
const src = (attrs.match(/\ssrc\s*=\s*("[^"]*"|'[^']*')/i) || [])[1];
if (!src) return '';
const alt = (attrs.match(/\salt\s*=\s*("[^"]*"|'[^']*')/i) || [])[1] || '""';
return '<img src=' + src + ' alt=' + alt + ' style="max-width:100%">';
}
return '<' + tag + '>';
}
);
// Strip the invisible preview-text characters Substack stuffs at the top
// of the email. They appear both as raw characters and as numeric
// character references (͏ etc.), so remove both forms.
cleaned = cleaned.replace(/[\u034F\u00AD\u200B-\u200D\uFEFF\u2007]/g, '');
cleaned = cleaned.replace(/&#(?:847|173|8199|820[3-5]|65279);/g, '');
cleaned = cleaned.replace(/&#x(?:0*34f|0*ad|200[b-d]|feff|2007);/gi, '');
// Collapse the runs of whitespace left behind (prevents blank pages on the Kindle)
cleaned = cleaned.replace(/(?: |[ \t]){3,}/g, ' ');
return cleaned;
}
/** Build the Gmail search query from the sender list. */
function buildSearchQuery_() {
const fromClause = CONFIG.SENDERS.map(function (s) {
return '"' + s + '"';
}).join(' OR ');
return 'from:(' + fromClause + ') newer_than:' + CONFIG.SEARCH_WINDOW_DAYS + 'd';
}
/** Does the From header (e.g. "Foo News <news@example.com>") match the sender list? */
function isTargetSender_(fromHeader) {
const lower = fromHeader.toLowerCase();
return CONFIG.SENDERS.some(function (s) {
return lower.indexOf(s.toLowerCase()) !== -1;
});
}
/** Load the map of processed message IDs {messageId: sentTimeMs}. */
function loadProcessedIds_() {
const raw = PropertiesService.getScriptProperties().getProperty('processedIds');
return raw ? JSON.parse(raw) : {};
}
/** Save the processed IDs, dropping entries older than the retention window. */
function saveProcessedIds_(processed) {
const cutoff = Date.now() - CONFIG.PROCESSED_RETENTION_DAYS * 24 * 60 * 60 * 1000;
const pruned = {};
Object.keys(processed).forEach(function (id) {
if (processed[id] >= cutoff) pruned[id] = processed[id];
});
PropertiesService.getScriptProperties().setProperty('processedIds', JSON.stringify(pruned));
}
/**
* Is the body an HTML email?
* getBody() returns content even for plain-text emails, so check
* whether actual HTML structural tags are present.
*/
function looksLikeHtml_(body) {
return /<\s*(html|body|div|p|br|table|tr|td|img|span|a|blockquote|h[1-6])[\s>\/]/i.test(body);
}
/**
* Convert plain text to Kindle-friendly HTML.
* Blank lines become paragraph breaks; newlines within a paragraph become <br>.
*/
function plainTextToHtml_(text) {
const escaped = escapeHtml_(text);
return escaped
.split(/\r?\n\s*\r?\n+/) // split into paragraphs on blank lines
.map(function (paragraph) {
return '<p>' + paragraph.replace(/\r?\n/g, '<br>') + '</p>';
})
.join('\n');
}
function sanitizeFilename_(name) {
return name.replace(/[\\\/:*?"<>|\r\n]+/g, ' ').trim().slice(0, 80) || 'newsletter';
}
function escapeHtml_(text) {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// ─────────────────────────────────────────────
// Setup functions below — run each manually, once
// ─────────────────────────────────────────────
/**
* Set up the recurring trigger. Run this once from the editor.
* Any existing trigger is recreated, so it's also safe to re-run
* after changing the interval.
*/
function setupTrigger() {
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'main') ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('main')
.timeBased()
.everyMinutes(CONFIG.TRIGGER_INTERVAL_MINUTES)
.create();
console.log('Trigger set: runs every ' + CONFIG.TRIGGER_INTERVAL_MINUTES + ' minutes.');
}
/**
* Test: actually sends your most recent matching newsletter to the Kindle.
* The sent email is recorded as processed, so the automatic runs won't
* send it again.
*/
function testSendLatest() {
const threads = GmailApp.search(buildSearchQuery_());
for (let i = 0; i < threads.length; i++) {
const messages = threads[i].getMessages();
for (let j = messages.length - 1; j >= 0; j--) {
const message = messages[j];
if (isTargetSender_(message.getFrom())) {
sendToKindle_(message);
const processed = loadProcessedIds_();
processed[message.getId()] = Date.now();
saveProcessedIds_(processed);
console.log('Test sent: ' + message.getSubject());
return;
}
}
}
console.warn('No matching email found (search window: last ' + CONFIG.SEARCH_WINDOW_DAYS + ' days).');
}
/**
* Diagnostic: logs the length and the tail of the body GAS could fetch
* for the most recent matching email. Use this to narrow things down
* when the document gets cut off on the Kindle: if the newsletter's
* footer (unsubscribe link etc.) is visible at the tail, the fetch was
* complete and the truncation happened on Amazon's side.
*/
function debugLatestBody() {
const threads = GmailApp.search(buildSearchQuery_());
for (let i = 0; i < threads.length; i++) {
const messages = threads[i].getMessages();
for (let j = messages.length - 1; j >= 0; j--) {
const message = messages[j];
if (!isTargetSender_(message.getFrom())) continue;
const html = message.getBody() || '';
const plain = message.getPlainBody() || '';
console.log('Subject: ' + message.getSubject());
console.log('getBody() length: ' + html.length + ' chars');
console.log('getPlainBody() length: ' + plain.length + ' chars');
console.log('--- last 500 chars of getBody() ---');
console.log(html.slice(-500));
return;
}
}
console.warn('No matching email found.');
}
/** Reset all processed records (only needed for re-send testing). */
function resetProcessedIds() {
PropertiesService.getScriptProperties().deleteProperty('processedIds');
console.log('Processed records have been reset.');
}