Skip to content

Commit 1318b23

Browse files
committed
[minor] Adding e-mail based synchronization.
1 parent ff52d85 commit 1318b23

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+50671
-104
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
- name: Set up Go
3434
uses: actions/setup-go@v5
3535
with:
36-
go-version: '1.23.5'
36+
go-version: '1.23.6'
3737

3838
- name: Build everything
3939
run: task release

cmd/s2k/main.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ func beforeAppRun(ctx *cli.Context) (err error) {
3131
if env.Rpt, err = env.Cfg.Reporting.Prepare(); err != nil {
3232
return fmt.Errorf("unable to prepare debug reporter: %w", err)
3333
}
34-
// save external configuration file if any
34+
// save complete processed configuration if external configuration was provided
3535
if len(configFile) > 0 {
36-
env.Rpt.Store(fmt.Sprintf("config/%s", filepath.Base(configFile)), configFile)
36+
// we do not want any of your secrets!
37+
if data, err := config.Dump(env.Cfg); err == nil {
38+
env.Rpt.StoreData(fmt.Sprintf("config/%s", filepath.Base(configFile)), data)
39+
}
3740
}
3841
}
3942
if env.Log, err = env.Cfg.Logging.Prepare(env.Rpt); err != nil {
@@ -123,6 +126,23 @@ to storage and will fail if something still have device opened, on Linux it requ
123126
unmount filesystem after mount seases to be busy, etc. Since this is command line tool this flag mostly makes sense
124127
on Windows, where standard way of unmounting USB media from the command line has been missing for years. On Linux
125128
you could simply use 'eject' or 'udisksctl' commands.
129+
`, cli.CommandHelpTemplate),
130+
},
131+
{
132+
Name: "mail",
133+
Usage: "Synchronizes books between local source and target device using kindle e-mail",
134+
Before: beforeCmdRun,
135+
Flags: []cli.Flag{
136+
&cli.BoolFlag{Name: "dry-run", Usage: "do not perform any actual changes"},
137+
},
138+
Action: sync.RunMail,
139+
CustomHelpTemplate: fmt.Sprintf(`%s
140+
Using Amazon e-mail delivery syncronizes books between 'source' local directory and 'target' device.
141+
Both could be specified in configuration file, otherwise 'source' is current working directory and 'target' has no default.
142+
In this case have no way of accessing device content, so all desisions are made base on local files and history.
143+
144+
Proper configuration is expected for succesful operation, including working smtp server auth and authorized e-mail address
145+
(amazon account settings).
126146
`, cli.CommandHelpTemplate),
127147
},
128148
{
@@ -177,6 +197,10 @@ To see actual "active" configuration use dry-run mode.
177197
if env.Cfg != nil && len(env.Cfg.Thumbnails.Dir) > 0 {
178198
os.RemoveAll(env.Cfg.Thumbnails.Dir)
179199
}
200+
// cleanup temporary directory with mails if any
201+
if env.Cfg != nil && len(env.Cfg.Smtp.Dir) > 0 {
202+
os.RemoveAll(env.Cfg.Smtp.Dir)
203+
}
180204
if err != nil {
181205
os.Exit(1)
182206
}

common/devices.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package common
22

3+
import (
4+
"maps"
5+
"strings"
6+
)
7+
38
const (
49
ThumbnailFolder = "system/thumbnails"
510
)
@@ -9,6 +14,7 @@ type SupportedProtocols int
914
const (
1015
ProtocolUSB SupportedProtocols = iota
1116
ProtocolMTP
17+
ProtocolMail
1218
)
1319

1420
func (p SupportedProtocols) String() string {
@@ -17,11 +23,45 @@ func (p SupportedProtocols) String() string {
1723
return "USB"
1824
case ProtocolMTP:
1925
return "MTP"
26+
case ProtocolMail:
27+
return "e-Mail"
2028
default:
2129
return "Unknown"
2230
}
2331
}
2432

33+
var supportedFileFormatsForEMail = map[string]string{
34+
".DOC": "application/msword",
35+
".DOCX": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
36+
".HTML": "text/html",
37+
".HTM": "text/html",
38+
".RTF": "application/rtf",
39+
".TXT": "text/plain",
40+
".JPEG": "image/jpeg",
41+
".JPG": "image/jpeg",
42+
".GIF": "image/gif",
43+
".PNG": "image/png",
44+
".BMP": "image/bmp",
45+
".PDF": "application/pdf",
46+
".EPUB": "application/epub+zip",
47+
}
48+
49+
func IsSupportedEMailFormat(ext string) bool {
50+
for v := range maps.Keys(supportedFileFormatsForEMail) {
51+
if strings.EqualFold(v, ext) {
52+
return true
53+
}
54+
}
55+
return false
56+
}
57+
58+
func GetEMailContentType(ext string) string {
59+
if v, ok := supportedFileFormatsForEMail[ext]; ok {
60+
return v
61+
}
62+
return "application/octet-stream"
63+
}
64+
2565
var supportedDevices = []struct {
2666
vid, pid int
2767
protocol SupportedProtocols

config/cfg.go

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,69 @@ import (
55
_ "embed"
66
"fmt"
77
"os"
8+
"strings"
89

10+
validator "github.com/go-playground/validator/v10"
911
yaml "gopkg.in/yaml.v3"
1012

1113
"github.com/rupor-github/gencfg"
12-
13-
"sync2kindle/thumbs"
1414
)
1515

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

19-
type Config struct {
20-
SourcePath string `yaml:"source" sanitize:"path_abs,path_toslash" validate:"required,dir"`
21-
TargetPath string `yaml:"target" sanitize:"path_clean,path_toslash" validate:"required,gt=0"`
22-
HistoryPath string `yaml:"history" sanitize:"path_clean,assure_dir_exists" validate:"required,dir"`
23-
DeviceSerial string `yaml:"device_serial" validate:"omitempty,gt=0"`
19+
type (
20+
ThumbnailsConfig struct {
21+
Width int `yaml:"width" validate:"required,gt=0"`
22+
Height int `yaml:"height" validate:"required,gt=0"`
23+
24+
Dir string `yaml:"-"` // internal use only
25+
}
26+
27+
SmtpConfig struct {
28+
From string `yaml:"from" validate:"omitempty,email"`
29+
Server string `yaml:"server" validate:"hostname|ip"`
30+
Port int `yaml:"port" validate:"gt=0,lt=65536"`
31+
User string `yaml:"user" validate:"omitempty"`
32+
Password SecretString `yaml:"password" validate:"omitempty"`
33+
34+
Dir string `yaml:"-"` // internal use only (storing mails for debugging)
35+
}
36+
37+
Config struct {
38+
SourcePath string `yaml:"source" sanitize:"path_abs,path_toslash" validate:"required,dir"`
39+
TargetPath string `yaml:"target" sanitize:"path_clean,path_toslash" validate:"required,filepath|email"`
40+
HistoryPath string `yaml:"history" sanitize:"path_clean,assure_dir_exists" validate:"required,dir"`
41+
DeviceSerial string `yaml:"device_serial" validate:"omitempty,gt=0"`
42+
43+
BookExtensions []string `yaml:"book_extensions" validate:"required,gt=0"`
44+
ThumbExtensions []string `yaml:"thumb_extensions" validate:"required,gt=0"`
2445

25-
BookExtensions []string `yaml:"book_extensions" validate:"required,gt=0"`
26-
ThumbExtensions []string `yaml:"thumb_extensions" validate:"required,gt=0"`
46+
Smtp SmtpConfig `yaml:"smtp"`
47+
Thumbnails ThumbnailsConfig `yaml:"thumbnails"`
2748

28-
Thumbnails thumbs.ThumbnailsConfig `yaml:"thumbnails"`
49+
Logging LoggingConfig `yaml:"logging"`
50+
Reporting ReporterConfig `yaml:"reporting"`
51+
}
52+
)
2953

30-
Logging LoggingConfig `yaml:"logging"`
31-
Reporting ReporterConfig `yaml:"reporting"`
54+
func checks(sl validator.StructLevel) {
55+
c := sl.Current().Interface().(Config)
56+
57+
if strings.Contains(c.TargetPath, "@") {
58+
if len(c.Smtp.From) == 0 {
59+
sl.ReportError(c.Smtp.From, "From", "", "when \"target\" is e-mail sender address cannot be empty", "")
60+
}
61+
if len(c.Smtp.Server) == 0 {
62+
sl.ReportError(c.Smtp.Server, "Server", "", "when \"target\" is e-mail server address cannot be empty", "")
63+
}
64+
if c.Smtp.Port == 0 {
65+
sl.ReportError(c.Smtp.Port, "Port", "", "when \"target\" is e-mail server port cannot be empty", "")
66+
}
67+
if len(c.Smtp.User) == 0 {
68+
sl.ReportError(c.Smtp.User, "User", "", "when \"target\" is e-mail user cannot be empty", "")
69+
}
70+
}
3271
}
3372

3473
func unmarshalConfig(data []byte, cfg *Config, process bool) (*Config, error) {
@@ -43,7 +82,7 @@ func unmarshalConfig(data []byte, cfg *Config, process bool) (*Config, error) {
4382
if err := gencfg.Sanitize(cfg); err != nil {
4483
return nil, err
4584
}
46-
if err := gencfg.Validate(cfg); err != nil {
85+
if err := gencfg.Validate(cfg, gencfg.WithAdditionalChecks(checks)); err != nil {
4786
return nil, err
4887
}
4988
}
@@ -52,10 +91,10 @@ func unmarshalConfig(data []byte, cfg *Config, process bool) (*Config, error) {
5291

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

58-
data, err := gencfg.Process(ConfigTmpl)
97+
data, err := gencfg.Process(ConfigTmpl, options...)
5998
if err != nil {
6099
return nil, fmt.Errorf("failed to process configuration template: %w", err)
61100
}

config/config.yaml.tmpl

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,40 @@
11
#---- default configuration
2-
# directory with books for syncronization (path can be relative to current directory or absolute)
2+
# directory with books for synchronization (path can be relative to current directory or absolute)
33
source: .
4-
#---- target directory for books on the device (always relative, proper mount will be determined automatically)
4+
#---- either target directory for books on the device (always relative, proper mount will be
5+
#---- determined automatically, cannot contain "@") or email of your kindle device ("smtp" fields
6+
#---- have to be properly set in this case)
57
target: documents/mybooks
68

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

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

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

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

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

30+
#---- only used for e-mail delivery
31+
smtp:
32+
# from: "sender address authorized by your Amazon account"
33+
server: smtp.gmail.com
34+
port: 587
35+
# user: "smtp server user"
36+
# password: "smtp server password"
37+
2638
logging:
2739
#---- controls terminal (stdout, stderr) output
2840
console:

config/reporter.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type entry struct {
3434
original string
3535
actual string
3636
stamp time.Time
37+
data []byte
3738
}
3839

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

94+
// StoreData saves binary data to be put in the final archive later as a file under requested name.
95+
func (r *Report) StoreData(name string, data []byte) {
96+
if r == nil {
97+
// Ignore uninitialized cases to avoid checking in many places. This means no report has been requested.
98+
return
99+
}
100+
101+
if _, exists := r.entries[name]; exists {
102+
// Somewhere I do not know what I am doing.
103+
panic(fmt.Sprintf("Attempt to overwrite data in the report for [%s]", name))
104+
}
105+
106+
e := entry{
107+
data: data,
108+
stamp: time.Now(),
109+
}
110+
r.entries[name] = e
111+
}
112+
93113
// 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.
94114
// names are versioned with timestamps to avoid collisions, so it is safe to put the same content into report multiple times.
95115
func (r *Report) StoreCopy(name, path string) error {
@@ -219,6 +239,13 @@ func (r *Report) finalize() error {
219239

220240
// in the same order as in manifest
221241
for _, name := range names {
242+
if len(r.entries[name].data) > 0 {
243+
if err := saveFile(arc, name, r.entries[name].stamp, bytes.NewReader(r.entries[name].data)); err != nil {
244+
return err
245+
}
246+
continue
247+
}
248+
222249
path := r.entries[name].actual
223250
// ignoring absent files
224251
if info, err := os.Stat(path); err == nil {

config/secretstring.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package config
2+
3+
// SecretStringValue must be exported - used in tests.
4+
const SecretStringValue = "<secret>"
5+
6+
// SecretString is a type that should be used for fields that should not be visible in logs.
7+
type SecretString string
8+
9+
// MarshalJSON marshals SecretString to JSON making sure that actual value is not visible.
10+
func (s SecretString) MarshalJSON() ([]byte, error) {
11+
if len(s) == 0 {
12+
return nil, nil
13+
}
14+
return []byte("\"" + SecretStringValue + "\""), nil
15+
}
16+
17+
// MarshalYAML marshals SecretString to YAML making sure that actual value is not visible.
18+
func (s SecretString) MarshalYAML() (interface{}, error) {
19+
if len(s) == 0 {
20+
return nil, nil
21+
}
22+
return SecretStringValue, nil
23+
}

0 commit comments

Comments
 (0)