#!/usr/bin/env hy (import subprocess [Popen run PIPE DEVNULL]) (import email.parser :as parser) (import shutil) (import csv) (import sys) (import os) (defclass MailInbox [] (setv maildir-path None) (defn __init__ [self maildir-path] (setv self.maildir-path maildir-path)) (defn get-new-messages [self folder] (let [target-dir (+ self.maildir-path "/" folder "/new")] (lfor file (os.listdir target-dir) (MailMessage.from-file self (+ target-dir "/" file))))) (defn find-by-uid [self uid [exclude-msgs None]] (let [files (str.splitlines (. (run ["rg" "-Fl" uid self.maildir-path] :stdout PIPE :text True) stdout)) output #{}] (when (is-not exclude-msgs None) (for [excluded exclude-msgs] (files.remove (os.path.abspath (excluded.get-full-path))))) (for [file files] (let [msg (MailMessage.from-file self file)] (when (= msg.uid uid) (output.add msg)))) output)) (defn mark-all-read [self uid [exclude-msgs None]] (let [msgs (self.find-by-uid uid exclude-msgs) num 0] (for [msg msgs] (+= num 1) (msg.mark-read)) num)) (defn mark-all-unread [self uid [exclude-msgs None]] (let [msgs (self.find-by-uid uid exclude-msgs) num 0] (for [msg msgs] (+= num 1) (msg.mark-unread)) num))) (defclass MailMessage [] (setv inbox None uid None file None folder None sender None subject None flags None read? False attachment? False new? False) (defn __init__ [self inbox uid path sender subject attachment?] (let [dir-path (os.path.dirname path)] (setv self.inbox inbox self.uid uid self.file (os.path.basename path) self.folder (os.path.relpath (os.path.dirname dir-path) inbox.maildir-path) self.sender sender self.subject subject self.flags (MailMessage.get-path-flags self.file) self.read? (in "S" self.flags) self.attachment? attachment? self.new? (= (os.path.basename dir-path) "new")))) (defn get-dir-path [self] (+ self.inbox.maildir-path "/" self.folder "/" (if self.new? "new" "cur"))) (defn get-full-path [self] (+ (self.get-dir-path) "/" self.file)) (defn move [self new-folder] (let [clean-new-folder (MailMessage.-clean-folder new-folder)] (when (!= self.folder clean-new-folder) (shutil.move (self.get-full-path) (+ self.inbox.maildir-path "/" clean-new-folder "/" (if self.new? "new" "cur") "/" self.file)) (setv self.folder clean-new-folder)))) (defn process [self] (when self.new? (shutil.move (self.get-full-path) (+ self.inbox.maildir-path "/" self.folder "/cur/" self.file)) (setv self.new? False))) (defn mark-read [self] (when (not self.read?) (self.flags.add "S") (let [base-name (get self.file (slice (+ (self.file.rindex ",") 1))) new-name (+ base-name (str.join "" self.flags)) dir-path (self.get-dir-path)] (shutil.move (+ dir-path "/" self.file) (+ dir-path "/" new-name)) (setv self.file new-name self.read? True)))) (defn mark-unread [self] (when self.read? (self.flags.remove "S") (let [base-name (get self.file (slice (+ (self.file.rindex ",") 1))) new-name (+ base-name (str.join "" self.flags)) dir-path (self.get-dir-path)] (shutil.move (+ dir-path "/" self.file) (+ dir-path "/" new-name)) (setv self.file new-name self.read? False)))) (defn -parse-from-address [header] (try (let [index (str.index header "<")] (get header (slice 1 (- index 2)))) (except [ValueError] header))) (defn -clean-folder [folder] (when (str.startswith folder "/") (setv folder (get folder (slice 1)))) (when (str.endswith folder "/") (setv folder (get folder (slice None -1)))) folder) (defn -message-has-attachment [mail-obj] (when (mail-obj.is_multipart) (for [part (mail-obj.walk)] (when (str.startswith (part.get "Content-Disposition") "attachment") (return True)))) False) (defn get-path-flags [path] (set (get path (slice (+ (path.rindex ",") 1) None)))) (defn from-file [inbox path] (with [file-obj (open path "r")] (let [parse (parser.Parser) mail-obj (parse.parse file-obj :headersonly True)] (MailMessage inbox (mail-obj.get "Message-Id") path (MailMessage.-parse-from-address (mail-obj.get "From")) (mail-obj.get "Subject") (MailMessage.-message-has-attachment mail-obj)))))) (defclass INotifyEvent [] (setv path None flags #{} name None) (defn __init__ [self path flags name] (setv self.path (if (path.endswith "/") (get path (slice 0 -1)) path) self.flags (set (flags.split ",")) self.name name)) (defn get-full-path [self] (+ self.path "/" self.name)) (defn dir? [self] (in "ISDIR" self.flags)) (defn mail-file? [self] (and (not (self.dir?)) (in (os.path.basename self.path) ["new" "cur"]))) (defn __str__ [self] (+ "Event(" self.path "," (str self.flags) "," self.name ")"))) (defn handle-mail-move [inbox from-event to-event] (let [from-flags (MailMessage.get-path-flags from-event.name) to-flags (MailMessage.get-path-flags to-event.name)] (cond (and (in "S" from-flags) (not-in "S" to-flags)) (let [to-msg (MailMessage.from-file inbox (to-event.get-full-path))] (inbox.mark-all-unread to-msg.uid :exclude-msgs [to-msg])) (and (in "S" to-flags) (not-in "S" from-flags)) (let [to-msg (MailMessage.from-file inbox (to-event.get-full-path))] (inbox.mark-all-read to-msg.uid :exclude-msgs [to-msg])) True 0))) (when (< (len sys.argv) 2) (print "usage: mail-reader-daemon.hy " :file sys.stderr) (sys.exit 1)) (when (= (get sys.argv 1) "-h") (print "usage: mail-reader-daemon.hy ") (sys.exit 0)) (with [process (Popen ["inotifywait" "-mrce" "MOVED_FROM,MOVED_TO" (get sys.argv 1)] :stdout PIPE :stderr DEVNULL :text True)] (let [reader (csv.reader process.stdout) inbox (MailInbox (get sys.argv 1)) skip-count 0 from-event None] (for [csv-line reader] (let [event (INotifyEvent #* csv-line)] (setv from-event (cond (> skip-count 0) (do (-= skip-count 1) None) (is from-event None) event True (do (when (from-event.mail-file?) (setv skip-count (* 2 (handle-mail-move inbox from-event event)))) None)))))))