this repo has no description

add draft smtp server

Changed files
+100 -63
.vscode
app
cmd
examples
worker
+2
.vscode/launch.json
··· 7 "program": "${workspaceFolder}/main.go", 8 "args": [ 9 "up", 10 "--ssh-addr=:2222", 11 ], 12 "env": { 13 "SMALLWEB_DIR": "${workspaceFolder}/examples"
··· 7 "program": "${workspaceFolder}/main.go", 8 "args": [ 9 "up", 10 + "--http-addr=:8080", 11 "--ssh-addr=:2222", 12 + "--smtp-addr=:2525", 13 ], 14 "env": { 15 "SMALLWEB_DIR": "${workspaceFolder}/examples"
+1 -1
.vscode/tasks.json
··· 4 { 5 "label": "Go: Generate", 6 "type": "shell", 7 - "command": "go generate ./...", 8 } 9 ] 10 }
··· 4 { 5 "label": "Go: Generate", 6 "type": "shell", 7 + "command": "go generate ./..." 8 } 9 ] 10 }
+20 -16
app/app.go
··· 31 } 32 33 type App struct { 34 - Admin bool `json:"admin"` 35 - Name string `json:"name"` 36 - Dir string `json:"dir,omitempty"` 37 - Domain string `json:"-"` 38 - URL string `json:"url"` 39 - Env map[string]string `json:"-"` 40 - Config AppConfig `json:"-"` 41 } 42 43 - func (me *App) Root() string { 44 dir := me.Dir 45 if fi, err := os.Lstat(dir); err == nil && fi.Mode()&os.ModeSymlink != 0 { 46 if root, err := os.Readlink(dir); err == nil { ··· 99 } 100 101 app := App{ 102 - Name: appname, 103 - Admin: isAdmin, 104 - Dir: filepath.Join(rootDir, appname), 105 - Domain: fmt.Sprintf("%s.%s", appname, domain), 106 - URL: fmt.Sprintf("https://%s.%s/", appname, domain), 107 - Env: make(map[string]string), 108 } 109 110 for _, dotenvPath := range []string{ ··· 202 } 203 204 if me.Config.Entrypoint != "" { 205 - return filepath.Join(me.Root(), me.Config.Entrypoint) 206 } 207 208 for _, candidate := range []string{"main.js", "main.ts", "main.jsx", "main.tsx"} { 209 - path := filepath.Join(me.Root(), candidate) 210 if utils.FileExists(path) { 211 return path 212 }
··· 31 } 32 33 type App struct { 34 + Admin bool `json:"admin"` 35 + Name string `json:"name"` 36 + RootDir string `json:"-"` 37 + RootDomain string `json:"-"` 38 + Dir string `json:"dir,omitempty"` 39 + Domain string `json:"-"` 40 + URL string `json:"url"` 41 + Env map[string]string `json:"-"` 42 + Config AppConfig `json:"-"` 43 } 44 45 + func (me *App) DataDir() string { 46 dir := me.Dir 47 if fi, err := os.Lstat(dir); err == nil && fi.Mode()&os.ModeSymlink != 0 { 48 if root, err := os.Readlink(dir); err == nil { ··· 101 } 102 103 app := App{ 104 + Name: appname, 105 + Admin: isAdmin, 106 + RootDir: rootDir, 107 + RootDomain: domain, 108 + Dir: filepath.Join(rootDir, appname), 109 + Domain: fmt.Sprintf("%s.%s", appname, domain), 110 + URL: fmt.Sprintf("https://%s.%s/", appname, domain), 111 + Env: make(map[string]string), 112 } 113 114 for _, dotenvPath := range []string{ ··· 206 } 207 208 if me.Config.Entrypoint != "" { 209 + return filepath.Join(me.DataDir(), me.Config.Entrypoint) 210 } 211 212 for _, candidate := range []string{"main.js", "main.ts", "main.jsx", "main.tsx"} { 213 + path := filepath.Join(me.DataDir(), candidate) 214 if utils.FileExists(path) { 215 return path 216 }
+1 -1
cmd/crons.go
··· 166 continue 167 } 168 169 - wk := worker.NewWorker(a, k.String("dir"), k.String("domain")) 170 171 command, err := wk.Command(context.Background(), job.Args...) 172 if err != nil {
··· 166 continue 167 } 168 169 + wk := worker.NewWorker(a) 170 171 command, err := wk.Command(context.Background(), job.Args...) 172 if err != nil {
+1 -1
cmd/email.go
··· 84 return fmt.Errorf("failed to load app: %w", err) 85 } 86 87 - wk := worker.NewWorker(a, k.String("dir"), k.String("domain")) 88 89 if err := wk.SendEmail(cmd.Context(), strings.NewReader(message.String())); err != nil { 90 return fmt.Errorf("failed to send email: %w", err)
··· 84 return fmt.Errorf("failed to load app: %w", err) 85 } 86 87 + wk := worker.NewWorker(a) 88 89 if err := wk.SendEmail(cmd.Context(), strings.NewReader(message.String())); err != nil { 90 return fmt.Errorf("failed to send email: %w", err)
+1 -1
cmd/fetch.go
··· 57 return fmt.Errorf("failed to load app: %w", err) 58 } 59 60 - wk := worker.NewWorker(a, k.String("dir"), k.String("domain")) 61 if err := wk.Start(); err != nil { 62 return fmt.Errorf("failed to start worker: %w", err) 63 }
··· 57 return fmt.Errorf("failed to load app: %w", err) 58 } 59 60 + wk := worker.NewWorker(a) 61 if err := wk.Start(); err != nil { 62 return fmt.Errorf("failed to start worker: %w", err) 63 }
+1 -1
cmd/run.go
··· 29 return fmt.Errorf("failed to load app: %w", err) 30 } 31 32 - wk := worker.NewWorker(a, k.String("dir"), k.String("domain")) 33 command, err := wk.Command(cmd.Context(), args[1:]...) 34 if err != nil { 35 return fmt.Errorf("failed to create command: %w", err)
··· 29 return fmt.Errorf("failed to load app: %w", err) 30 } 31 32 + wk := worker.NewWorker(a) 33 command, err := wk.Command(cmd.Context(), args[1:]...) 34 if err != nil { 35 return fmt.Errorf("failed to create command: %w", err)
+43 -23
cmd/up.go
··· 25 "github.com/caddyserver/certmagic" 26 "github.com/charmbracelet/ssh" 27 "github.com/creack/pty" 28 "github.com/pkg/sftp" 29 "github.com/pomdtr/smallweb/app" 30 "github.com/pomdtr/smallweb/watcher" ··· 39 func NewCmdUp() *cobra.Command { 40 var flags struct { 41 cron bool 42 - addr string 43 sshAddr string 44 sshHostKey string 45 onDemandTLS bool ··· 110 fmt.Fprintf(os.Stderr, "Serving *.%s from %s with on-demand TLS...\n", k.String("domain"), utils.AddTilde(k.String("dir"))) 111 go certmagic.HTTPS(nil, handler) 112 } else { 113 - addr := flags.addr 114 if addr == "" { 115 if flags.cert != "" || flags.key != "" { 116 addr = "0.0.0.0:443" ··· 166 }, 167 SubsystemHandlers: map[string]ssh.SubsystemHandler{ 168 "sftp": func(sess ssh.Session) { 169 - var workDir string 170 - if sess.User() == "_" { 171 - workDir = k.String("dir") 172 - } else { 173 - app, err := app.LoadApp(sess.User(), k.String("dir"), k.String("domain"), slices.Contains(k.Strings("adminApps"), sess.User())) 174 - if err != nil { 175 - fmt.Fprintln(sess, "failed to load app:", err) 176 - return 177 - } 178 - 179 - workDir = filepath.Join(app.Root(), "data") 180 - if _, err := os.Stat(workDir); err != nil { 181 - fmt.Fprintln(sess, "failed to get app data directory") 182 - return 183 - } 184 } 185 186 server, err := sftp.NewServer( 187 sess, 188 - sftp.WithServerWorkingDirectory(workDir), 189 ) 190 191 if err != nil { ··· 211 cmd = exec.Command(execPath, sess.Command()...) 212 cmd.Env = os.Environ() 213 } else { 214 - app, err := app.LoadApp(sess.User(), k.String("dir"), k.String("domain"), slices.Contains(k.Strings("adminApps"), sess.User())) 215 if err != nil { 216 fmt.Fprintf(sess, "failed to load app: %v\n", err) 217 return 218 } 219 220 - wk := worker.NewWorker(app, k.String("dir"), k.String("domain")) 221 command, err := wk.Command(sess.Context(), sess.Command()...) 222 if err != nil { 223 fmt.Fprintf(sess, "failed to get command: %v\n", err) ··· 289 defer server.Close() 290 } 291 292 // sigint handling 293 sigint := make(chan os.Signal, 1) 294 signal.Notify(sigint, os.Interrupt) ··· 298 }, 299 } 300 301 - cmd.Flags().StringVar(&flags.addr, "addr", "", "address to listen on") 302 cmd.Flags().StringVar(&flags.sshAddr, "ssh-addr", "", "address to listen on for ssh/sftp") 303 cmd.Flags().StringVar(&flags.sshHostKey, "ssh-host-key", "~/.ssh/id_ed25519", "ssh host key") 304 cmd.Flags().BoolVar(&flags.onDemandTLS, "on-demand-tls", false, "enable on-demand TLS") 305 cmd.Flags().StringVar(&flags.cert, "cert", "", "tls certificate file") ··· 308 309 cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "cert") 310 cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "key") 311 - cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "addr") 312 313 return cmd 314 } ··· 442 return nil, fmt.Errorf("failed to load app: %w", err) 443 } 444 445 - wk := worker.NewWorker(a, k.String("dir"), domain) 446 447 wk.Logger = me.logger 448 if err := wk.Start(); err != nil {
··· 25 "github.com/caddyserver/certmagic" 26 "github.com/charmbracelet/ssh" 27 "github.com/creack/pty" 28 + "github.com/mhale/smtpd" 29 "github.com/pkg/sftp" 30 "github.com/pomdtr/smallweb/app" 31 "github.com/pomdtr/smallweb/watcher" ··· 40 func NewCmdUp() *cobra.Command { 41 var flags struct { 42 cron bool 43 + httpAddr string 44 + smtpAddr string 45 sshAddr string 46 sshHostKey string 47 onDemandTLS bool ··· 112 fmt.Fprintf(os.Stderr, "Serving *.%s from %s with on-demand TLS...\n", k.String("domain"), utils.AddTilde(k.String("dir"))) 113 go certmagic.HTTPS(nil, handler) 114 } else { 115 + addr := flags.httpAddr 116 if addr == "" { 117 if flags.cert != "" || flags.key != "" { 118 addr = "0.0.0.0:443" ··· 168 }, 169 SubsystemHandlers: map[string]ssh.SubsystemHandler{ 170 "sftp": func(sess ssh.Session) { 171 + if sess.User() != "_" { 172 + fmt.Fprintln(sess, "sftp is only allowed for the _ user") 173 + return 174 } 175 176 server, err := sftp.NewServer( 177 sess, 178 + sftp.WithServerWorkingDirectory(utils.AddTilde(k.String("dir"))), 179 ) 180 181 if err != nil { ··· 201 cmd = exec.Command(execPath, sess.Command()...) 202 cmd.Env = os.Environ() 203 } else { 204 + a, err := app.LoadApp(sess.User(), k.String("dir"), k.String("domain"), slices.Contains(k.Strings("adminApps"), sess.User())) 205 if err != nil { 206 fmt.Fprintf(sess, "failed to load app: %v\n", err) 207 return 208 } 209 210 + wk := worker.NewWorker(a) 211 command, err := wk.Command(sess.Context(), sess.Command()...) 212 if err != nil { 213 fmt.Fprintf(sess, "failed to get command: %v\n", err) ··· 279 defer server.Close() 280 } 281 282 + if flags.smtpAddr != "" { 283 + fmt.Fprintf(os.Stderr, "Starting SFTP server on %s...\n", flags.smtpAddr) 284 + go smtpd.ListenAndServe(flags.smtpAddr, func(remoteAddr net.Addr, from string, to []string, data []byte) error { 285 + for _, recipient := range to { 286 + parts := strings.SplitN(recipient, "@", 2) 287 + appname, domain := parts[0], parts[1] 288 + if domain != k.String("domain") { 289 + continue 290 + } 291 + 292 + a, err := app.LoadApp(appname, k.String("dir"), k.String("domain"), slices.Contains(k.Strings("adminApps"), appname)) 293 + if err != nil { 294 + if errors.Is(err, app.ErrAppNotFound) { 295 + continue 296 + } 297 + 298 + return fmt.Errorf("failed to load app: %v", err) 299 + } 300 + 301 + wk := worker.NewWorker(a) 302 + if err := wk.SendEmail(context.Background(), strings.NewReader(string(data))); err != nil { 303 + return fmt.Errorf("failed to send email: %v", err) 304 + } 305 + } 306 + 307 + return nil 308 + }, "Smallweb", k.String("domain")) 309 + } 310 + 311 // sigint handling 312 sigint := make(chan os.Signal, 1) 313 signal.Notify(sigint, os.Interrupt) ··· 317 }, 318 } 319 320 + cmd.Flags().StringVar(&flags.httpAddr, "http-addr", "", "address to listen on") 321 cmd.Flags().StringVar(&flags.sshAddr, "ssh-addr", "", "address to listen on for ssh/sftp") 322 + cmd.Flags().StringVar(&flags.smtpAddr, "smtp-addr", "", "address to listen on") 323 cmd.Flags().StringVar(&flags.sshHostKey, "ssh-host-key", "~/.ssh/id_ed25519", "ssh host key") 324 cmd.Flags().BoolVar(&flags.onDemandTLS, "on-demand-tls", false, "enable on-demand TLS") 325 cmd.Flags().StringVar(&flags.cert, "cert", "", "tls certificate file") ··· 328 329 cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "cert") 330 cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "key") 331 + cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "http-addr") 332 333 return cmd 334 } ··· 462 return nil, fmt.Errorf("failed to load app: %w", err) 463 } 464 465 + wk := worker.NewWorker(a) 466 467 wk.Logger = me.logger 468 if err := wk.Start(); err != nil {
examples/email/main.ts examples/inbox/main.ts
+6
examples/inbox/email.eml
···
··· 1 + Subject: Ceci est un test 2 + From: achille.lacoin@gmail.com 3 + To: inbox@smallweb.localhost 4 + Content-Type: text/plain; charset=utf-8 5 + 6 + yo
+6
examples/inbox/run.sh
···
··· 1 + #!/bin/sh 2 + 3 + curl smtp://localhost:2525 \ 4 + --mail-from 'myself@example.com' \ 5 + --mail-rcpt 'inbox@smallweb.localhost' \ 6 + --upload-file email.eml
+1
go.mod
··· 31 github.com/google/uuid v1.6.0 32 github.com/knadh/koanf/providers/confmap v0.1.0 33 github.com/knadh/koanf/providers/posflag v0.1.0 34 github.com/pkg/sftp v1.13.7 35 github.com/robfig/cron/v3 v3.0.1 36 golang.org/x/crypto v0.31.0
··· 31 github.com/google/uuid v1.6.0 32 github.com/knadh/koanf/providers/confmap v0.1.0 33 github.com/knadh/koanf/providers/posflag v0.1.0 34 + github.com/mhale/smtpd v0.8.3 35 github.com/pkg/sftp v1.13.7 36 github.com/robfig/cron/v3 v3.0.1 37 golang.org/x/crypto v0.31.0
+2
go.sum
··· 298 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 299 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 300 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 301 github.com/mholt/acmez/v2 v2.0.3 h1:CgDBlEwg3QBp6s45tPQmFIBrkRIkBT4rW4orMM6p4sw= 302 github.com/mholt/acmez/v2 v2.0.3/go.mod h1:pQ1ysaDeGrIMvJ9dfJMk5kJNkn7L2sb3UhyrX6Q91cw= 303 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
··· 298 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 299 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 300 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 301 + github.com/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8= 302 + github.com/mhale/smtpd v0.8.3/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4= 303 github.com/mholt/acmez/v2 v2.0.3 h1:CgDBlEwg3QBp6s45tPQmFIBrkRIkBT4rW4orMM6p4sw= 304 github.com/mholt/acmez/v2 v2.0.3/go.mod h1:pQ1ysaDeGrIMvJ9dfJMk5kJNkn7L2sb3UhyrX6Q91cw= 305 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+15 -19
worker/worker.go
··· 52 53 type Worker struct { 54 App app.App 55 - RootDir string 56 - Domain string 57 Env map[string]string 58 StartedAt time.Time 59 ··· 64 activeRequests atomic.Int32 65 } 66 67 - func commandEnv(a app.App, rootDir string, domain string) []string { 68 env := []string{} 69 70 for k, v := range a.Env { ··· 76 env = append(env, fmt.Sprintf("DENO_DIR=%s", filepath.Join(xdg.CacheHome, "smallweb", "deno"))) 77 78 env = append(env, fmt.Sprintf("SMALLWEB_VERSION=%s", build.Version)) 79 - env = append(env, fmt.Sprintf("SMALLWEB_DIR=%s", rootDir)) 80 - env = append(env, fmt.Sprintf("SMALLWEB_DOMAIN=%s", domain)) 81 env = append(env, fmt.Sprintf("SMALLWEB_APP_NAME=%s", a.Name)) 82 env = append(env, fmt.Sprintf("SMALLWEB_APP_DOMAIN=%s", a.Domain)) 83 env = append(env, fmt.Sprintf("SMALLWEB_APP_URL=%s", a.URL)) ··· 88 89 if a.Admin { 90 env = append(env, "SMALLWEB_ADMIN=1") 91 - env = append(env, "SMALLWEB_LOG_PATH=%s", utils.GetLogFilename(domain)) 92 } 93 94 return env 95 } 96 97 - func NewWorker(app app.App, rootDir string, domain string) *Worker { 98 worker := &Worker{ 99 - App: app, 100 - RootDir: rootDir, 101 - Domain: domain, 102 } 103 104 return worker ··· 128 if a.Admin { 129 args = append( 130 args, 131 - fmt.Sprintf("--allow-read=%s,%s,%s,%s", me.RootDir, sandboxPath, deno, npmCache), 132 - fmt.Sprintf("--allow-write=%s", me.RootDir), 133 ) 134 135 return args 136 } 137 138 - appRoot := a.Root() 139 // if root is not a symlink 140 if fi, err := os.Lstat(appRoot); err == nil && fi.Mode()&os.ModeSymlink == 0 { 141 args = append( ··· 192 args = append(args, sandboxPath, input.String()) 193 194 command := exec.Command(deno, args...) 195 - command.Dir = me.App.Root() 196 - command.Env = commandEnv(me.App, me.RootDir, me.Domain) 197 198 stdoutPipe, err := command.StdoutPipe() 199 if err != nil { ··· 505 denoArgs = append(denoArgs, sandboxPath, input.String()) 506 507 command := exec.CommandContext(ctx, deno, denoArgs...) 508 - command.Dir = me.App.Root() 509 510 - command.Env = commandEnv(me.App, me.RootDir, me.Domain) 511 512 return command, nil 513 } ··· 534 denoArgs = append(denoArgs, sandboxPath, input.String()) 535 536 command := exec.CommandContext(ctx, deno, denoArgs...) 537 - command.Dir = me.App.Root() 538 command.Stderr = os.Stderr 539 - command.Env = commandEnv(me.App, me.RootDir, me.Domain) 540 541 stdin, err := command.StdinPipe() 542 if err != nil {
··· 52 53 type Worker struct { 54 App app.App 55 Env map[string]string 56 StartedAt time.Time 57 ··· 62 activeRequests atomic.Int32 63 } 64 65 + func commandEnv(a app.App) []string { 66 env := []string{} 67 68 for k, v := range a.Env { ··· 74 env = append(env, fmt.Sprintf("DENO_DIR=%s", filepath.Join(xdg.CacheHome, "smallweb", "deno"))) 75 76 env = append(env, fmt.Sprintf("SMALLWEB_VERSION=%s", build.Version)) 77 + env = append(env, fmt.Sprintf("SMALLWEB_DIR=%s", a.RootDir)) 78 + env = append(env, fmt.Sprintf("SMALLWEB_DOMAIN=%s", a.RootDomain)) 79 env = append(env, fmt.Sprintf("SMALLWEB_APP_NAME=%s", a.Name)) 80 env = append(env, fmt.Sprintf("SMALLWEB_APP_DOMAIN=%s", a.Domain)) 81 env = append(env, fmt.Sprintf("SMALLWEB_APP_URL=%s", a.URL)) ··· 86 87 if a.Admin { 88 env = append(env, "SMALLWEB_ADMIN=1") 89 + env = append(env, "SMALLWEB_LOG_PATH=%s", utils.GetLogFilename(a.RootDomain)) 90 } 91 92 return env 93 } 94 95 + func NewWorker(app app.App) *Worker { 96 worker := &Worker{ 97 + App: app, 98 } 99 100 return worker ··· 124 if a.Admin { 125 args = append( 126 args, 127 + fmt.Sprintf("--allow-read=%s,%s,%s,%s", me.App.RootDir, sandboxPath, deno, npmCache), 128 + fmt.Sprintf("--allow-write=%s", me.App.RootDir), 129 ) 130 131 return args 132 } 133 134 + appRoot := a.DataDir() 135 // if root is not a symlink 136 if fi, err := os.Lstat(appRoot); err == nil && fi.Mode()&os.ModeSymlink == 0 { 137 args = append( ··· 188 args = append(args, sandboxPath, input.String()) 189 190 command := exec.Command(deno, args...) 191 + command.Dir = me.App.DataDir() 192 + command.Env = commandEnv(me.App) 193 194 stdoutPipe, err := command.StdoutPipe() 195 if err != nil { ··· 501 denoArgs = append(denoArgs, sandboxPath, input.String()) 502 503 command := exec.CommandContext(ctx, deno, denoArgs...) 504 + command.Dir = me.App.DataDir() 505 506 + command.Env = commandEnv(me.App) 507 508 return command, nil 509 } ··· 530 denoArgs = append(denoArgs, sandboxPath, input.String()) 531 532 command := exec.CommandContext(ctx, deno, denoArgs...) 533 + command.Dir = me.App.DataDir() 534 command.Stderr = os.Stderr 535 + command.Env = commandEnv(me.App) 536 537 stdin, err := command.StdinPipe() 538 if err != nil {