172 lines
5.9 KiB
Python
Executable file
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()
|