From 8ad5bb2eb78115d6339dde1b27a0d692a29803a0 Mon Sep 17 00:00:00 2001 From: "R. Alex Matevish" Date: Tue, 27 May 2025 19:05:48 -0700 Subject: [PATCH] First commit --- .gitignore | 3 + Dockerfile | 30 +++++++++ go.mod | 13 ++++ go.sum | 14 +++++ main.go | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c00ecf --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.env +books \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bef1a95 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Stage 1: Build +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# Install dependencies for CGO + SQLite +RUN apk add --no-cache gcc musl-dev sqlite-dev + +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download + +COPY . ./ +RUN go build -o kindle-sender + +# Stage 2: Run +FROM alpine:latest + +WORKDIR /app + +COPY --from=builder /app/kindle-sender . + +# Required for sending mail (ca-certificates) +RUN apk add --no-cache ca-certificates + +ENV WATCH_DIR=/books + +VOLUME ["/books"] + +CMD ["./kindle-sender"] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a9bb465 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/ramatevish/kindle-sender + +go 1.24.3 + +require ( + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-sqlite3 v1.14.28 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.20.0 // indirect + gorm.io/driver/sqlite v1.5.7 // indirect + gorm.io/gorm v1.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b90bbc7 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= +gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9644a59 --- /dev/null +++ b/main.go @@ -0,0 +1,180 @@ +package main + +import ( + "bytes" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "log" + "mime/multipart" + "mime/quotedprintable" + "net/smtp" + "os" + "path/filepath" + "strings" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var ( + smtpHost = os.Getenv("SMTP_HOST") + smtpPort = os.Getenv("SMTP_PORT") + smtpUser = os.Getenv("SMTP_USER") + smtpPassword = os.Getenv("SMTP_PASSWORD") + senderEmail = os.Getenv("SENDER_EMAIL") + recipient = os.Getenv("RECIPIENT_EMAIL") + watchDir = os.Getenv("WATCH_DIR") + db *gorm.DB +) + +type SentFile struct { + ID uint `gorm:"primaryKey"` + Path string + Hash string `gorm:"uniqueIndex"` + SentAt time.Time +} + +func isEbookFile(name string) bool { + lower := strings.ToLower(name) + return strings.HasSuffix(lower, ".mobi") || strings.HasSuffix(lower, ".epub") || + strings.HasSuffix(lower, ".pdf") || strings.HasSuffix(lower, ".azw3") +} + +func sendEmailWithAttachment(filePath string) error { + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + header := map[string]string{ + "From": senderEmail, + "To": recipient, + "Subject": "Ebook Delivery", + "MIME-Version": "1.0", + "Content-Type": "multipart/mixed; boundary=\"" + writer.Boundary() + "\"", + } + + for k, v := range header { + fmt.Fprintf(&buf, "%s: %s\r\n", k, v) + } + fmt.Fprint(&buf, "\r\n") + + textPart, _ := writer.CreatePart(map[string][]string{ + "Content-Type": {"text/plain; charset=utf-8"}, + "Content-Transfer-Encoding": {"quoted-printable"}, + }) + qp := quotedprintable.NewWriter(textPart) + qp.Write([]byte("Attached is your ebook.")) + qp.Close() + + attachmentHeader := map[string][]string{ + "Content-Type": {"application/octet-stream"}, + "Content-Transfer-Encoding": {"base64"}, + "Content-Disposition": {fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(filePath))}, + } + attachmentPart, _ := writer.CreatePart(attachmentHeader) + encoded := make([]byte, base64.StdEncoding.EncodedLen(len(data))) + base64.StdEncoding.Encode(encoded, data) + attachmentPart.Write(encoded) + + writer.Close() + + auth := smtp.PlainAuth("", smtpUser, smtpPassword, smtpHost) + return smtp.SendMail(smtpHost+":"+smtpPort, auth, senderEmail, []string{recipient}, buf.Bytes()) +} + +func fileHash(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := md5.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func hasBeenSent(hash string) bool { + var file SentFile + err := db.Where("hash = ?", hash).First(&file).Error + return err == nil +} + +func markAsSent(filePath, hash string) { + err := db.Create(&SentFile{ + Path: filePath, + Hash: hash, + SentAt: time.Now().UTC(), + }).Error + if err != nil { + log.Printf("DB error marking file as sent: %v", err) + } +} + +func setupDatabase() error { + dbPath := filepath.Join(watchDir, "sent_files.db") + database, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + return err + } + db = database + return db.AutoMigrate(&SentFile{}) +} + +func scanAndSendEbooks() { + err := filepath.Walk(watchDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || !isEbookFile(info.Name()) { + return nil + } + hash, err := fileHash(path) + if err != nil { + log.Printf("Error hashing file %s: %v", path, err) + return nil + } + if hasBeenSent(hash) { + log.Printf("Already sent (hash match): %s\n", path) + return nil + } + log.Printf("Detected ebook file: %s\n", path) + time.Sleep(1 * time.Second) + if err := sendEmailWithAttachment(path); err != nil { + log.Printf("Error sending email: %v\n", err) + } else { + markAsSent(path, hash) + log.Printf("Sent %s to %s\n", path, recipient) + } + return nil + }) + if err != nil { + log.Printf("Error walking directory: %v\n", err) + } +} + +func main() { + if watchDir == "" { + log.Fatal("WATCH_DIR environment variable is required") + } + + if err := setupDatabase(); err != nil { + log.Fatalf("Failed to setup database: %v", err) + } + + log.Printf("Scanning folder: %s\n", watchDir) + for { + scanAndSendEbooks() + time.Sleep(10 * time.Second) + } +}