Skip to content

Commit

Permalink
[minor] Adding e-mail based synchronization.
Browse files Browse the repository at this point in the history
  • Loading branch information
rupor-github committed Feb 6, 2025
1 parent ff52d85 commit 1318b23
Show file tree
Hide file tree
Showing 61 changed files with 50,671 additions and 104 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23.5'
go-version: '1.23.6'

- name: Build everything
run: task release
Expand Down
28 changes: 26 additions & 2 deletions cmd/s2k/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ func beforeAppRun(ctx *cli.Context) (err error) {
if env.Rpt, err = env.Cfg.Reporting.Prepare(); err != nil {
return fmt.Errorf("unable to prepare debug reporter: %w", err)
}
// save external configuration file if any
// save complete processed configuration if external configuration was provided
if len(configFile) > 0 {
env.Rpt.Store(fmt.Sprintf("config/%s", filepath.Base(configFile)), configFile)
// we do not want any of your secrets!
if data, err := config.Dump(env.Cfg); err == nil {
env.Rpt.StoreData(fmt.Sprintf("config/%s", filepath.Base(configFile)), data)
}
}
}
if env.Log, err = env.Cfg.Logging.Prepare(env.Rpt); err != nil {
Expand Down Expand Up @@ -123,6 +126,23 @@ to storage and will fail if something still have device opened, on Linux it requ
unmount filesystem after mount seases to be busy, etc. Since this is command line tool this flag mostly makes sense
on Windows, where standard way of unmounting USB media from the command line has been missing for years. On Linux
you could simply use 'eject' or 'udisksctl' commands.
`, cli.CommandHelpTemplate),
},
{
Name: "mail",
Usage: "Synchronizes books between local source and target device using kindle e-mail",
Before: beforeCmdRun,
Flags: []cli.Flag{
&cli.BoolFlag{Name: "dry-run", Usage: "do not perform any actual changes"},
},
Action: sync.RunMail,
CustomHelpTemplate: fmt.Sprintf(`%s
Using Amazon e-mail delivery syncronizes books between 'source' local directory and 'target' device.
Both could be specified in configuration file, otherwise 'source' is current working directory and 'target' has no default.
In this case have no way of accessing device content, so all desisions are made base on local files and history.
Proper configuration is expected for succesful operation, including working smtp server auth and authorized e-mail address
(amazon account settings).
`, cli.CommandHelpTemplate),
},
{
Expand Down Expand Up @@ -177,6 +197,10 @@ To see actual "active" configuration use dry-run mode.
if env.Cfg != nil && len(env.Cfg.Thumbnails.Dir) > 0 {
os.RemoveAll(env.Cfg.Thumbnails.Dir)
}
// cleanup temporary directory with mails if any
if env.Cfg != nil && len(env.Cfg.Smtp.Dir) > 0 {
os.RemoveAll(env.Cfg.Smtp.Dir)
}
if err != nil {
os.Exit(1)
}
Expand Down
40 changes: 40 additions & 0 deletions common/devices.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package common

import (
"maps"
"strings"
)

const (
ThumbnailFolder = "system/thumbnails"
)
Expand All @@ -9,6 +14,7 @@ type SupportedProtocols int
const (
ProtocolUSB SupportedProtocols = iota
ProtocolMTP
ProtocolMail
)

func (p SupportedProtocols) String() string {
Expand All @@ -17,11 +23,45 @@ func (p SupportedProtocols) String() string {
return "USB"
case ProtocolMTP:
return "MTP"
case ProtocolMail:
return "e-Mail"
default:
return "Unknown"
}
}

var supportedFileFormatsForEMail = map[string]string{
".DOC": "application/msword",
".DOCX": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".HTML": "text/html",
".HTM": "text/html",
".RTF": "application/rtf",
".TXT": "text/plain",
".JPEG": "image/jpeg",
".JPG": "image/jpeg",
".GIF": "image/gif",
".PNG": "image/png",
".BMP": "image/bmp",
".PDF": "application/pdf",
".EPUB": "application/epub+zip",
}

func IsSupportedEMailFormat(ext string) bool {
for v := range maps.Keys(supportedFileFormatsForEMail) {
if strings.EqualFold(v, ext) {
return true
}
}
return false
}

func GetEMailContentType(ext string) string {
if v, ok := supportedFileFormatsForEMail[ext]; ok {
return v
}
return "application/octet-stream"
}

var supportedDevices = []struct {
vid, pid int
protocol SupportedProtocols
Expand Down
69 changes: 54 additions & 15 deletions config/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,69 @@ import (
_ "embed"
"fmt"
"os"
"strings"

validator "github.com/go-playground/validator/v10"
yaml "gopkg.in/yaml.v3"

"github.com/rupor-github/gencfg"

"sync2kindle/thumbs"
)

//go:embed config.yaml.tmpl
var ConfigTmpl []byte

type Config struct {
SourcePath string `yaml:"source" sanitize:"path_abs,path_toslash" validate:"required,dir"`
TargetPath string `yaml:"target" sanitize:"path_clean,path_toslash" validate:"required,gt=0"`
HistoryPath string `yaml:"history" sanitize:"path_clean,assure_dir_exists" validate:"required,dir"`
DeviceSerial string `yaml:"device_serial" validate:"omitempty,gt=0"`
type (
ThumbnailsConfig struct {
Width int `yaml:"width" validate:"required,gt=0"`
Height int `yaml:"height" validate:"required,gt=0"`

Dir string `yaml:"-"` // internal use only
}

SmtpConfig struct {
From string `yaml:"from" validate:"omitempty,email"`
Server string `yaml:"server" validate:"hostname|ip"`
Port int `yaml:"port" validate:"gt=0,lt=65536"`
User string `yaml:"user" validate:"omitempty"`
Password SecretString `yaml:"password" validate:"omitempty"`

Dir string `yaml:"-"` // internal use only (storing mails for debugging)
}

Config struct {
SourcePath string `yaml:"source" sanitize:"path_abs,path_toslash" validate:"required,dir"`
TargetPath string `yaml:"target" sanitize:"path_clean,path_toslash" validate:"required,filepath|email"`
HistoryPath string `yaml:"history" sanitize:"path_clean,assure_dir_exists" validate:"required,dir"`
DeviceSerial string `yaml:"device_serial" validate:"omitempty,gt=0"`

BookExtensions []string `yaml:"book_extensions" validate:"required,gt=0"`
ThumbExtensions []string `yaml:"thumb_extensions" validate:"required,gt=0"`

BookExtensions []string `yaml:"book_extensions" validate:"required,gt=0"`
ThumbExtensions []string `yaml:"thumb_extensions" validate:"required,gt=0"`
Smtp SmtpConfig `yaml:"smtp"`
Thumbnails ThumbnailsConfig `yaml:"thumbnails"`

Thumbnails thumbs.ThumbnailsConfig `yaml:"thumbnails"`
Logging LoggingConfig `yaml:"logging"`
Reporting ReporterConfig `yaml:"reporting"`
}
)

Logging LoggingConfig `yaml:"logging"`
Reporting ReporterConfig `yaml:"reporting"`
func checks(sl validator.StructLevel) {
c := sl.Current().Interface().(Config)

if strings.Contains(c.TargetPath, "@") {
if len(c.Smtp.From) == 0 {
sl.ReportError(c.Smtp.From, "From", "", "when \"target\" is e-mail sender address cannot be empty", "")
}
if len(c.Smtp.Server) == 0 {
sl.ReportError(c.Smtp.Server, "Server", "", "when \"target\" is e-mail server address cannot be empty", "")
}
if c.Smtp.Port == 0 {
sl.ReportError(c.Smtp.Port, "Port", "", "when \"target\" is e-mail server port cannot be empty", "")
}
if len(c.Smtp.User) == 0 {
sl.ReportError(c.Smtp.User, "User", "", "when \"target\" is e-mail user cannot be empty", "")
}
}
}

func unmarshalConfig(data []byte, cfg *Config, process bool) (*Config, error) {
Expand All @@ -43,7 +82,7 @@ func unmarshalConfig(data []byte, cfg *Config, process bool) (*Config, error) {
if err := gencfg.Sanitize(cfg); err != nil {
return nil, err
}
if err := gencfg.Validate(cfg); err != nil {
if err := gencfg.Validate(cfg, gencfg.WithAdditionalChecks(checks)); err != nil {
return nil, err
}
}
Expand All @@ -52,10 +91,10 @@ func unmarshalConfig(data []byte, cfg *Config, process bool) (*Config, error) {

// LoadConfiguration reads the configuration from the file at the given path, superimposes its values on
// top of expanded configuration tamplate to provide sane defaults and performs validation.
func LoadConfiguration(path string) (*Config, error) {
func LoadConfiguration(path string, options ...func(*gencfg.ProcessingOptions)) (*Config, error) {
haveFile := len(path) > 0

data, err := gencfg.Process(ConfigTmpl)
data, err := gencfg.Process(ConfigTmpl, options...)
if err != nil {
return nil, fmt.Errorf("failed to process configuration template: %w", err)
}
Expand Down
18 changes: 15 additions & 3 deletions config/config.yaml.tmpl
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
#---- default configuration
# directory with books for syncronization (path can be relative to current directory or absolute)
# directory with books for synchronization (path can be relative to current directory or absolute)
source: .
#---- target directory for books on the device (always relative, proper mount will be determined automatically)
#---- either target directory for books on the device (always relative, proper mount will be
#---- determined automatically, cannot contain "@") or email of your kindle device ("smtp" fields
#---- have to be properly set in this case)
target: documents/mybooks

#---- directory to keep history databases for each source/target pair
history: '{{ternary (joinPath (env "HOMEDRIVE") (env "HOMEPATH") ".s2k" "history") (joinPath (env "HOME") ".s2k" "history") (eq .OS "windows")}}'

#---- to select particular connected device, this makes sure that only specific device will be used with this
#---- configuration, usually not necessary - first connected supported device is selected automatically
#---- this is ignored for e-mail delivery
# device_serial: "DEVICE_SN"

#---- Source files with following extensions are books to synchronize
book_extensions: [.mobi, .azw3, .kfx, .pdf]

#---- Recognize thumbnails with following extensions (used when looking for thumbnails at the device)
#---- Recognize thumbnails with following extensions (used when looking for thumbnails on target device)
thumb_extensions: [.jpg]

#---- When e-book is processed (not a personal document, aka PDOC) thumbnails are extracted and synchronized
#---- ignored if thumbnails are not accessible on device or if e-mail delivery is requested
thumbnails:
#---- when thumbnail is prepared it will be scaled to following dimensions
width: 330
height: 470

#---- only used for e-mail delivery
smtp:
# from: "sender address authorized by your Amazon account"
server: smtp.gmail.com
port: 587
# user: "smtp server user"
# password: "smtp server password"

logging:
#---- controls terminal (stdout, stderr) output
console:
Expand Down
27 changes: 27 additions & 0 deletions config/reporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type entry struct {
original string
actual string
stamp time.Time
data []byte
}

// Reporter accumulates information necessary to prepare full debug report.
Expand Down Expand Up @@ -90,6 +91,25 @@ func (r *Report) Store(name, path string) {
r.entries[name] = e
}

// StoreData saves binary data to be put in the final archive later as a file under requested name.
func (r *Report) StoreData(name string, data []byte) {
if r == nil {
// Ignore uninitialized cases to avoid checking in many places. This means no report has been requested.
return
}

if _, exists := r.entries[name]; exists {
// Somewhere I do not know what I am doing.
panic(fmt.Sprintf("Attempt to overwrite data in the report for [%s]", name))
}

e := entry{
data: data,
stamp: time.Now(),
}
r.entries[name] = e
}

// StoreCopy makes a copy (at the time of a call) of the file or directory into temporary location to be put in the final archive later.
// names are versioned with timestamps to avoid collisions, so it is safe to put the same content into report multiple times.
func (r *Report) StoreCopy(name, path string) error {
Expand Down Expand Up @@ -219,6 +239,13 @@ func (r *Report) finalize() error {

// in the same order as in manifest
for _, name := range names {
if len(r.entries[name].data) > 0 {
if err := saveFile(arc, name, r.entries[name].stamp, bytes.NewReader(r.entries[name].data)); err != nil {
return err
}
continue
}

path := r.entries[name].actual
// ignoring absent files
if info, err := os.Stat(path); err == nil {
Expand Down
23 changes: 23 additions & 0 deletions config/secretstring.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package config

// SecretStringValue must be exported - used in tests.
const SecretStringValue = "<secret>"

// SecretString is a type that should be used for fields that should not be visible in logs.
type SecretString string

// MarshalJSON marshals SecretString to JSON making sure that actual value is not visible.
func (s SecretString) MarshalJSON() ([]byte, error) {
if len(s) == 0 {
return nil, nil
}
return []byte("\"" + SecretStringValue + "\""), nil
}

// MarshalYAML marshals SecretString to YAML making sure that actual value is not visible.
func (s SecretString) MarshalYAML() (interface{}, error) {
if len(s) == 0 {
return nil, nil
}
return SecretStringValue, nil
}
Loading

0 comments on commit 1318b23

Please sign in to comment.