#!/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()