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 = {
KINDLE_EMAIL: '___YOUR_KINDLE_EMAIL_HERE___',
SENDERS: [
'____NEWSLETTER’S_SENDER_EMAIL___',
'____NEWSLETTER’S_SENDER_EMAIL___',
// '____NEWSLETTER’S_SENDER_EMAIL___',
],
SEARCH_WINDOW_DAYS: 2,
PROCESSED_RETENTION_DAYS: 7,
TRIGGER_INTERVAL_MINUTES: 15,
};
// Main job — runs on the time-based trigger.
function main() {
const processed = loadProcessedIds_();
const threads = GmailApp.search(buildSearchQuery_());
threads.forEach(function (thread) {
thread.getMessages().forEach(function (message) {
const id = message.getId();
if (processed[id]) return; // already sent
if (!isTargetSender_(message.getFrom())) return;
try {
sendToKindle_(message);
processed[id] = Date.now();
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);
}
// Send one email to Kindle as an .html attachment.
function sendToKindle_(message) {
const subject = message.getSubject() || '(no subject)';
const html = buildDocumentHtml_(message, subject);
const filename = sanitizeFilename_(subject) + '.html';
const blob = Utilities.newBlob('', 'text/html', filename)
.setDataFromString(html, 'UTF-8');
GmailApp.sendEmail(CONFIG.KINDLE_EMAIL, subject, 'newsletter-to-kindle', {
attachments: [blob],
name: 'Newsletter to Kindle',
});
}
// Plain-text newsletters need their line breaks converted,
// otherwise Kindle collapses them into one giant paragraph.
function buildDocumentHtml_(message, subject) {
let body = message.getBody();
if (!body || !looksLikeHtml_(body)) {
body = plainTextToHtml_(message.getPlainBody() || '');
}
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>'
);
}
function looksLikeHtml_(body) {
return /<\s*(html|body|div|p|br|table|tr|td|img|span|a|blockquote|h[1-6])[\s>\/]/i.test(body);
}
function plainTextToHtml_(text) {
return escapeHtml_(text)
.split(/\r?\n\s*\r?\n+/)
.map(function (p) { return '<p>' + p.replace(/\r?\n/g, '<br>') + '</p>'; })
.join('\n');
}
function buildSearchQuery_() {
const fromClause = CONFIG.SENDERS.map(function (s) { return '"' + s + '"'; }).join(' OR ');
return 'from:(' + fromClause + ') newer_than:' + CONFIG.SEARCH_WINDOW_DAYS + 'd';
}
function isTargetSender_(fromHeader) {
const lower = fromHeader.toLowerCase();
return CONFIG.SENDERS.some(function (s) { return lower.indexOf(s.toLowerCase()) !== -1; });
}
function loadProcessedIds_() {
const raw = PropertiesService.getScriptProperties().getProperty('processedIds');
return raw ? JSON.parse(raw) : {};
}
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));
}
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, '"');
}
// Run once: creates the recurring trigger.
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: every ' + CONFIG.TRIGGER_INTERVAL_MINUTES + ' minutes.');
}
// Test: sends your most recent matching newsletter right now.
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.');
}