Initial commit
This commit is contained in:
commit
01af871145
5 changed files with 615 additions and 0 deletions
172
send_to_kindle.py
Executable file
172
send_to_kindle.py
Executable file
|
|
@ -0,0 +1,172 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue