First commit
This commit is contained in:
commit
8ad5bb2eb7
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
*.env
|
||||||
|
books
|
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@ -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"]
|
13
go.mod
Normal file
13
go.mod
Normal file
@ -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
|
||||||
|
)
|
14
go.sum
Normal file
14
go.sum
Normal file
@ -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=
|
180
main.go
Normal file
180
main.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user