provide exectuable for flake and add readme

This commit is contained in:
Valentin Boettcher 2024-06-08 13:24:31 -04:00
parent a759dfb98d
commit 62408b2126
No known key found for this signature in database
GPG key ID: E034E12B7AF56ACE
4 changed files with 89 additions and 20 deletions

35
README.md Normal file
View file

@ -0,0 +1,35 @@
# Kindle (Scribe) Email Fetch Hack
This is a quick-and-dirty python script to log into an IMAP server,
monitor incoming messages for the ones that contain the links to the
PDFs that you sent from the Kindle scribe. Once such an email is found
the pdf linked therein is downloaded to a local directory and the
email is deleted. The latest downloaded file is also copied to a
preset filename to make it easier to find it. I'm always running
`zathura ~/kindle_dump/latest.pdf` to have the latest kindle pdf
visible.
## Installation / Usage
Either clone this repo and use `poerty install` and the like or run the nix flake with `nix run github:vale981/kindle_fetch -- [args]`.
```
usage: kindle_fetch [-h] [--outdir OUTDIR] [--current_file CURRENT_FILE] [--imap_folder IMAP_FOLDER]
server user pass_command
Monitors you Email and automatically downloads the notes sent to it.
positional arguments:
server the IMAP server to connect to
user the IMAP username
pass_command a shell command that returns the password to the server
options:
-h, --help show this help message and exit
--outdir OUTDIR the directory to dump the note PDFs in
--current_file CURRENT_FILE
the path to the file that will contain the the most currently downloaded pdf relative to
`outdir`
--imap_folder IMAP_FOLDER
the folder to monitor for new messages
```

View file

@ -15,16 +15,23 @@
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
poetry2nix = inputs.poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }; poetry2nix = inputs.poetry2nix.lib.mkPoetry2Nix { inherit pkgs; };
kindleFetch = poetry2nix.mkPoetryApplication {
projectDir = self;
preferWheels = true;
};
in in
{ {
packages = { packages = {
kindleFetch = poetry2nix.mkPoetryApplication { kindleFetch = kindleFetch;
projectDir = self;
preferWheels = true;
};
default = self.packages.${system}.kindleFetch; default = self.packages.${system}.kindleFetch;
}; };
apps.default = {
type = "app";
program = "${kindleFetch}/bin/kindle_fetch";
};
# Shell for app dependencies. # Shell for app dependencies.
# #
# nix develop # nix develop

View file

@ -1,9 +1,6 @@
#! /usr/bin/env python #! /usr/bin/env python
import glob
import os
from pathlib import Path from pathlib import Path
import re import re
import time
import shutil import shutil
import urllib.request import urllib.request
import asyncio import asyncio
@ -12,12 +9,9 @@ import subprocess
from aioimaplib import aioimaplib from aioimaplib import aioimaplib
from collections import namedtuple from collections import namedtuple
import re import re
from asyncio import run, wait_for from asyncio import wait_for
from collections import namedtuple from collections import namedtuple
from email.message import Message
from email.parser import BytesHeaderParser, BytesParser from email.parser import BytesHeaderParser, BytesParser
from typing import Collection
from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
@ -46,6 +40,7 @@ class Options:
def get_document_title(header_string): def get_document_title(header_string):
"""Get the title of the document from the email header."""
m = re.search(r'"(.*?)" from your Kindle', header_string) m = re.search(r'"(.*?)" from your Kindle', header_string)
if not m: if not m:
@ -55,6 +50,10 @@ def get_document_title(header_string):
def get_download_link(text): def get_download_link(text):
"""
Get the download link and whether the file is the full document or
just `page` pages from the email body.
"""
m = re.search(r"\[Download PDF\]\((.*?)\)", text) m = re.search(r"\[Download PDF\]\((.*?)\)", text)
if not m: if not m:
@ -75,6 +74,12 @@ MessageAttributes = namedtuple("MessageAttributes", "uid flags sequence_number")
async def fetch_messages_headers(imap_client: aioimaplib.IMAP4_SSL, max_uid: int): async def fetch_messages_headers(imap_client: aioimaplib.IMAP4_SSL, max_uid: int):
"""
Fetch the headers of the messages in the mailbox.
Pretty much stolen from the `aioimaplib` examples.
"""
response = await imap_client.uid( response = await imap_client.uid(
"fetch", "fetch",
"%d:*" % (max_uid + 1), "%d:*" % (max_uid + 1),
@ -112,22 +117,31 @@ async def fetch_messages_headers(imap_client: aioimaplib.IMAP4_SSL, max_uid: int
async def fetch_message_body(imap_client: aioimaplib.IMAP4_SSL, uid: int): async def fetch_message_body(imap_client: aioimaplib.IMAP4_SSL, uid: int):
"""Fetch the message body of the message with the given ``uid``."""
dwnld_resp = await imap_client.uid("fetch", str(uid), "BODY.PEEK[]") dwnld_resp = await imap_client.uid("fetch", str(uid), "BODY.PEEK[]")
return BytesParser().parsebytes(dwnld_resp.lines[1]) return BytesParser().parsebytes(dwnld_resp.lines[1])
async def remove_message(imap_client: aioimaplib.IMAP4_SSL, uid: int): async def remove_message(imap_client: aioimaplib.IMAP4_SSL, uid: int):
"""Mark the message with the given ``uid`` as deleted and expunge it."""
await imap_client.uid("store", str(uid), "+FLAGS (\Deleted \Seen)") await imap_client.uid("store", str(uid), "+FLAGS (\Deleted \Seen)")
return await imap_client.expunge() return await imap_client.expunge()
async def wait_for_new_message(imap_client, options: Options): async def wait_for_new_message(imap_client, options: Options):
"""
Wait for a new message to arrive in the mailbox, detect Kindle
messages and download the PDF linked in if possible.
"""
persistent_max_uid = 1 persistent_max_uid = 1
persistent_max_uid, head = await fetch_messages_headers( persistent_max_uid, head = await fetch_messages_headers(
imap_client, persistent_max_uid imap_client, persistent_max_uid
) )
while True: while True:
print("Waiting for new message") print("Waiting for new message")
idle_task = await imap_client.idle_start(timeout=60) idle_task = await imap_client.idle_start(timeout=60)
msg = await imap_client.wait_server_push() msg = await imap_client.wait_server_push()
imap_client.idle_done() imap_client.idle_done()
@ -169,11 +183,16 @@ async def wait_for_new_message(imap_client, options: Options):
await remove_message(imap_client, persistent_max_uid) await remove_message(imap_client, persistent_max_uid)
# await asyncio.wait_for(idle_task, timeout=5)
# print("ending idle")
async def make_client(host, user, password, folder): async def make_client(host, user, password, folder):
"""Connect to the IMAP server and login.
:param host: the IMAP server to connect to
:param user: the IMAP username
:param password: the password to the server
:param folder: the folder to monitor for new messages
"""
imap_client = aioimaplib.IMAP4_SSL(host=host) imap_client = aioimaplib.IMAP4_SSL(host=host)
await imap_client.wait_hello_from_server() await imap_client.wait_hello_from_server()
await imap_client.login(user, password) await imap_client.login(user, password)
@ -185,7 +204,7 @@ async def make_client(host, user, password, folder):
def parse_args(): def parse_args():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="Kindle Fetcher", prog="kindle_fetch",
description="Monitors you Email and automatically downloads the notes sent to it.", description="Monitors you Email and automatically downloads the notes sent to it.",
) )
@ -232,13 +251,17 @@ def parse_args():
) )
if __name__ == "__main__": def main():
options = parse_args() options = parse_args()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
client = loop.run_until_complete( try:
make_client(options.server, options.user, options.password, options.mailbox) client = loop.run_until_complete(
) make_client(options.server, options.user, options.password, options.mailbox)
)
except Exception as e:
print(f"Failed to connect to the server: {e}")
exit(1)
loop.run_until_complete(wait_for_new_message(client, options)) loop.run_until_complete(wait_for_new_message(client, options))
loop.run_until_complete(client.logout()) loop.run_until_complete(client.logout())

View file

@ -1,5 +1,5 @@
[tool.poetry] [tool.poetry]
name = "kindlefetch" name = "kindle_fetch"
version = "0.1.0" version = "0.1.0"
description = "" description = ""
authors = ["Valentin Boettcher <hiro@protagon.space>"] authors = ["Valentin Boettcher <hiro@protagon.space>"]
@ -14,3 +14,7 @@ aioimaplib = "^1.0.1"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
kindle_fetch = 'kindle_fetch.kindle_fetch:main'