wallabag2kindle/send_to_kindle.py
2026-05-16 08:05:30 +00:00

172 lines
5.9 KiB
Python
Executable file

#!/usr/bin/env python3
import argparse
import mimetypes
import os
import smtplib
import re
import zipfile
import xml.etree.ElementTree as ET
from email.message import EmailMessage
from pathlib import Path
DEFAULT_CONFIG = Path("./mail.conf")
DEFAULT_OUT = Path("./out")
def load_config(path: Path) -> dict:
"""Load simple KEY=VALUE config file. Lines starting with # are ignored."""
cfg = {}
if not path.is_file():
return cfg
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
cfg[key.strip()] = value.strip().strip('"').strip("'")
return cfg
def get_value(args, cfg, attr, key, env=None, default=None):
val = getattr(args, attr, None)
if val not in (None, ""):
return val
if key in cfg and cfg[key] != "":
return cfg[key]
if env and os.getenv(env):
return os.getenv(env)
return default
def chunks(items, size):
for i in range(0, len(items), size):
yield items[i:i + size]
def epub_title(path: Path) -> str | None:
"""Read dc:title from EPUB metadata, if available."""
try:
with zipfile.ZipFile(path) as z:
container = ET.fromstring(z.read("META-INF/container.xml"))
ns_container = {"c": "urn:oasis:names:tc:opendocument:xmlns:container"}
rootfile = container.find(".//c:rootfile", ns_container)
if rootfile is None:
return None
opf_path = rootfile.attrib["full-path"]
opf = ET.fromstring(z.read(opf_path))
ns = {"dc": "http://purl.org/dc/elements/1.1/"}
title = opf.find(".//dc:title", ns)
if title is not None and title.text:
return " ".join(title.text.replace("_", " ").split())
except Exception:
return None
return None
def attachment_name(path: Path) -> str:
"""Use EPUB metadata title as emailed attachment filename.
Local filenames stay unchanged. Amazon Send-to-Kindle often derives the
displayed document title from the email attachment filename, so use a nice
title-based attachment filename while keeping the .epub extension.
"""
title = epub_title(path)
if not title:
return path.name
name = re.sub(r'[\\/:*?"<>|]+', ' ', title)
name = re.sub(r"\s+", " ", name).strip().rstrip('.')
return (name[:120] or path.stem) + path.suffix.lower()
def send_to_kindle(smtp_host, smtp_port, smtp_user, smtp_pass, sender, kindle_email, file_paths):
file_paths = [Path(p) for p in file_paths]
for file_path in file_paths:
if not file_path.is_file():
raise FileNotFoundError(file_path)
msg = EmailMessage()
msg["From"] = sender
msg["To"] = kindle_email
msg["Subject"] = "Send to Kindle"
display_names = [attachment_name(p) for p in file_paths]
msg.set_content("Attached ebook(s):\n\n" + "\n".join(display_names))
for file_path in file_paths:
ctype, _ = mimetypes.guess_type(file_path)
if ctype is None:
ctype = "application/octet-stream"
maintype, subtype = ctype.split("/", 1)
with file_path.open("rb") as f:
msg.add_attachment(
f.read(),
maintype=maintype,
subtype=subtype,
filename=attachment_name(file_path),
)
with smtplib.SMTP_SSL(smtp_host, int(smtp_port)) as smtp:
smtp.login(smtp_user, smtp_pass)
smtp.send_message(msg)
def find_epubs(out_dir: Path):
return sorted(out_dir.glob("*.epub"))
def main():
p = argparse.ArgumentParser(description="Send ebook(s) to Kindle via email")
p.add_argument("file", nargs="?", help="ebook file, e.g. .epub/.pdf/.mobi. If omitted, sends all .epub files in ./out")
p.add_argument("--config", default=str(DEFAULT_CONFIG), help=f"config file, default: {DEFAULT_CONFIG}")
p.add_argument("--kindle", help="your Kindle email, e.g. name@kindle.com")
p.add_argument("--smtp-host")
p.add_argument("--smtp-port", type=int)
p.add_argument("--smtp-user")
p.add_argument("--smtp-pass")
p.add_argument("--sender")
p.add_argument("--max-attachments", type=int, default=16, help="maximum attachments per email, default: 16")
args = p.parse_args()
cfg = load_config(Path(args.config).expanduser())
settings = {
"smtp_host": get_value(args, cfg, "smtp_host", "SMTP_HOST", "SMTP_HOST"),
"smtp_port": get_value(args, cfg, "smtp_port", "SMTP_PORT", "SMTP_PORT", "465"),
"smtp_user": get_value(args, cfg, "smtp_user", "SMTP_USER", "SMTP_USER"),
"smtp_pass": get_value(args, cfg, "smtp_pass", "SMTP_PASS", "SMTP_PASS"),
"sender": get_value(args, cfg, "sender", "SMTP_SENDER", "SMTP_SENDER"),
"kindle": get_value(args, cfg, "kindle", "KINDLE_EMAIL", "KINDLE_EMAIL"),
}
missing = [k for k, v in settings.items() if not v]
if missing:
raise SystemExit(
"Missing: " + ", ".join(missing) +
f"\nAdd them to {args.config} or pass them as command-line options."
)
files = [Path(args.file).expanduser()] if args.file else find_epubs(DEFAULT_OUT)
if not files:
raise SystemExit(f"No EPUB files found in {DEFAULT_OUT}")
max_attachments = max(1, args.max_attachments)
batches = list(chunks(files, max_attachments))
for idx, batch in enumerate(batches, 1):
send_to_kindle(
settings["smtp_host"],
settings["smtp_port"],
settings["smtp_user"],
settings["smtp_pass"],
settings["sender"],
settings["kindle"],
batch,
)
suffix = f" ({idx}/{len(batches)})" if len(batches) > 1 else ""
print(f"Sent email{suffix}: {len(batch)} attachment(s)")
for file_path in batch:
file_path.unlink()
print(f" - sent and deleted: {file_path}")
if __name__ == "__main__":
main()