diff --git a/.env.example b/.env.example deleted file mode 100644 index cf2ebfb..0000000 --- a/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# ─────────────────────────────────────────────────────── -# inBOXER – Environment Configuration -# ─────────────────────────────────────────────────────── -# Copy this file to .env and fill in your credentials: -# cp .env.example .env -# nano .env -# -# The application also reads these variables from the -# systemd EnvironmentFile (if deployed via install.sh). -# ─────────────────────────────────────────────────────── - -# DeepSeek API key – for AI-based email classification -# Sign up at https://platform.deepseek.com/ to get one. -DEEPSEEK_API_KEY=your_deepseek_api_key_here - -# SMTP credentials – used to send one-time passwords (OTP) -# for user login. Supports STARTTLS on port 587. -SMTP_HOST=your.smtp.host.example.com -SMTP_PORT=587 -SMTP_USER=your-email@example.com -SMTP_PASS=your-smtp-password - -# (Optional) Override the session_secret from bin/config.yaml. -# Leave commented out to use the value in config.yaml. -# APP_SECRET=change-me-in-production diff --git a/.gitignore b/.gitignore index 27c015e..2ae203f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ -# Environment +# Environment (legacy — app no longer reads .env, but guard against accidental commit) .env +# Stale build artifact from go build . (use go build ./src/cmd instead) +cmd + # Build artifacts (binary is distributed with the repo) bin/*.log bin/db.sqlite diff --git a/bin/config.yaml b/bin/config.yaml index e69131a..6f979e6 100644 --- a/bin/config.yaml +++ b/bin/config.yaml @@ -1,16 +1,26 @@ # inBOXER Configuration +# ====================== +# Single configuration file — all settings including secrets live here. +# Replace placeholder values before deploying to production. # Server configuration server: port: 8080 host: "0.0.0.0" - session_secret: "change-me-in-production" # Override with APP_SECRET from .env + session_secret: "change-me-in-production" # Database configuration database: path: "bin/db.sqlite" auto_migrate: true +# SMTP credentials for sending OTP login emails +smtp: + host: "your.smtp.host.example.com" + port: 587 + username: "your-email@example.com" + password: "your-smtp-password" + # IMAP configuration (user-specific, stored encrypted in database) imap_defaults: host: "imap.example.com" @@ -22,6 +32,7 @@ imap_defaults: # AI classification configuration ai: model: "deepseek-chat" + api_key: "your_deepseek_api_key_here" max_tokens: 1000 temperature: 0.1 prompt_file: "bin/prompt.txt" diff --git a/bin/inboxer b/bin/inboxer index d98ec07..ccdd435 100755 Binary files a/bin/inboxer and b/bin/inboxer differ diff --git a/go.mod b/go.mod index 1a6575b..a99c8fb 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module inboxer go 1.22 require ( + github.com/emersion/go-imap v1.2.1 github.com/gorilla/mux v1.8.1 github.com/gorilla/sessions v1.2.0 - github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.17.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.5.4 @@ -13,10 +13,7 @@ require ( ) require ( - github.com/emersion/go-imap v1.2.1 // indirect - github.com/emersion/go-message v0.15.0 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect - github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index 0956d2e..ceb62eb 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,8 @@ github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= -github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY= github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= -github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -19,8 +17,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= diff --git a/install.sh b/install.sh index 2c8dee5..cb04965 100755 --- a/install.sh +++ b/install.sh @@ -113,40 +113,6 @@ sed -i 's|file: "bin/inboxer.log"|file: "'"${LOGS_DIR}"'/inboxer.log"|' "${BIN_D # prompt_file: "bin/prompt.txt" works as-is relative to the working directory, # since WorkingDirectory=/opt/inboxer resolves it to /opt/inboxer/bin/prompt.txt -# ─── Create .env from template ────────────────────────────────────────────── -ENV_FILE="${INSTALL_DIR}/.env" -if [[ ! -f "${ENV_FILE}" ]]; then - # Prefer .env.example from repo (single source of truth) - if [[ -f "${REPO_DIR}/.env.example" ]]; then - info "Copying .env.example from repository to ${ENV_FILE} ..." - cp "${REPO_DIR}/.env.example" "${ENV_FILE}" - else - info "Creating .env template at ${ENV_FILE} ..." - cat > "${ENV_FILE}" << 'ENVEOF' -# inBOXER Environment Configuration -# ==================================== -# Set your credentials below. The service reads these variables on startup. -# You can also set them directly in the systemd EnvironmentFile or via -# the shell environment (godotenv.Load() checks both the .env file and os.Getenv). - -# DeepSeek API key – used to classify incoming emails -DEEPSEEK_API_KEY=your_deepseek_api_key_here - -# SMTP credentials – used to send one-time passwords (OTP) via email -SMTP_HOST=your.smtp.host -SMTP_PORT=587 -SMTP_USER=your-email@example.com -SMTP_PASS=your-smtp-password - -# (Optional) Override the session_secret from config.yaml. -# APP_SECRET=change-me-in-production -ENVEOF - fi - info ".env file created." -else - info ".env already exists, keeping existing file." -fi - # ─── Create systemd service unit ───────────────────────────────────────────── info "Creating systemd service unit at ${SERVICE_FILE} ..." cat > "${SERVICE_FILE}" << UNITEOF @@ -161,7 +127,6 @@ Type=simple User=${SERVICE_USER} Group=${SERVICE_GROUP} WorkingDirectory=${INSTALL_DIR} -EnvironmentFile=${ENV_FILE} ExecStart=${BIN_DIR}/inboxer Restart=on-failure RestartSec=10 @@ -189,8 +154,8 @@ chmod 755 "${BIN_DIR}" chmod 750 "${DATA_DIR}" # database file is sensitive chmod 750 "${LOGS_DIR}" -# .env must be readable by the service user (and owner-only for secrets) -chmod 640 "${ENV_FILE}" +# config.yaml contains secrets so restrict access +chmod 640 "${BIN_DIR}/config.yaml" # ─── Register & start service ─────────────────────────────────────────────── info "Reloading systemd daemon..." @@ -227,14 +192,20 @@ info " Configuration: ${BIN_DIR}/config.yaml" info " Prompt file: ${BIN_DIR}/prompt.txt" info " Data (SQLite): ${DATA_DIR}/" info " Logs: ${LOGS_DIR}/" -info " Environment: ${ENV_FILE}" echo "" -info " Edit the environment file with your credentials:" -info " sudo nano ${ENV_FILE}" +info " ⚙ Edit config.yaml with your credentials before first start:" +info " sudo nano ${BIN_DIR}/config.yaml" +info "" +info " Required settings:" +info " - ai.api_key (DeepSeek API key)" +info " - smtp.host/port (SMTP server for OTP emails)" +info " - smtp.username (SMTP login)" +info " - smtp.password (SMTP password)" +info " - server.session_secret (change from the default)" echo "" -if grep -q "your_deepseek_api_key_here" "${ENV_FILE}" 2>/dev/null; then - warn " ⚠ The .env file still contains placeholder values!" - warn " Edit ${ENV_FILE} before the service will function." +if grep -q "your_deepseek_api_key_here\|your.smtp.host\|change-me-in-production" "${BIN_DIR}/config.yaml" 2>/dev/null; then + warn " ⚠ config.yaml still contains placeholder values!" + warn " Edit ${BIN_DIR}/config.yaml before the service will function." echo "" fi info " Service management:" diff --git a/src/cmd/main.go b/src/cmd/main.go index 84b0164..dbf9607 100644 --- a/src/cmd/main.go +++ b/src/cmd/main.go @@ -26,7 +26,7 @@ func main() { configPath = "bin/config.yaml" } - cfg, env, err := config.LoadConfig(configPath) + cfg, err := config.LoadConfig(configPath) if err != nil { log.Fatalf("Failed to load configuration: %v", err) } @@ -38,19 +38,19 @@ func main() { } defer database.Close() - // Initialize SMTP sender + // Initialize SMTP sender (credentials from config.yaml) smtpConfig := auth.SMTPConfig{ - Host: env.SMTPHost, - Port: env.SMTPPort, - Username: env.SMTPUser, - Password: env.SMTPPass, - From: env.SMTPUser, + Host: cfg.SMTP.Host, + Port: cfg.SMTP.Port, + Username: cfg.SMTP.Username, + Password: cfg.SMTP.Password, + From: cfg.SMTP.Username, // From address matches SMTP username } - // Validate SMTP config (but don't fail if not set - user might configure later) + // Validate SMTP config (but don't fail if not set — user might configure later) if err := smtpConfig.ValidateConfig(); err != nil { log.Printf("Warning: SMTP configuration incomplete: %v", err) - log.Println("OTP emails will not be sent until SMTP is configured in .env") + log.Println("OTP emails will not be sent until SMTP is configured in config.yaml") } smtpSender := auth.NewSMTPSender(smtpConfig) @@ -70,7 +70,7 @@ func main() { // Initialize AI classifier deepSeekAPI := ai.NewDeepSeekAPI( - env.DeepSeekAPIKey, + cfg.AI.APIKey, cfg.AI.Model, cfg.AI.MaxTokens, cfg.AI.Temperature, diff --git a/src/internal/auth/otp_test.go b/src/internal/auth/otp_test.go index 2d03ab2..7ff40ae 100644 --- a/src/internal/auth/otp_test.go +++ b/src/internal/auth/otp_test.go @@ -110,7 +110,7 @@ func TestInMemoryOTPStore(t *testing.T) { func TestSMTPSenderValidation(t *testing.T) { config := SMTPConfig{ Host: "smtp.example.com", - Port: "587", + Port: 587, Username: "user@example.com", Password: "password", From: "user@example.com", @@ -123,11 +123,11 @@ func TestSMTPSenderValidation(t *testing.T) { // Test invalid configs invalidConfigs := []SMTPConfig{ - {Host: "", Port: "587", Username: "user", Password: "pass", From: "user"}, - {Host: "smtp.example.com", Port: "", Username: "user", Password: "pass", From: "user"}, - {Host: "smtp.example.com", Port: "587", Username: "", Password: "pass", From: "user"}, - {Host: "smtp.example.com", Port: "587", Username: "user", Password: "", From: "user"}, - {Host: "smtp.example.com", Port: "587", Username: "user", Password: "pass", From: ""}, + {Host: "", Port: 587, Username: "user", Password: "pass", From: "user"}, + {Host: "smtp.example.com", Port: 0, Username: "user", Password: "pass", From: "user"}, + {Host: "smtp.example.com", Port: 587, Username: "", Password: "pass", From: "user"}, + {Host: "smtp.example.com", Port: 587, Username: "user", Password: "", From: "user"}, + {Host: "smtp.example.com", Port: 587, Username: "user", Password: "pass", From: ""}, } for _, cfg := range invalidConfigs { diff --git a/src/internal/auth/smtp.go b/src/internal/auth/smtp.go index a852848..54bcf5d 100644 --- a/src/internal/auth/smtp.go +++ b/src/internal/auth/smtp.go @@ -12,7 +12,7 @@ import ( // SMTPConfig holds SMTP server configuration type SMTPConfig struct { Host string - Port string + Port int Username string Password string From string @@ -51,7 +51,7 @@ func heloHostname(from, smtpHost string) string { // setting the HELO/EHLO hostname explicitly. Some SMTP servers // reject messages when the HELO doesn't match a valid domain. func (s *SMTPSender) sendMail(to []string, msg []byte) error { - addr := fmt.Sprintf("%s:%s", s.config.Host, s.config.Port) + addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port) helo := heloHostname(s.config.From, s.config.Host) // Connect to the SMTP server @@ -159,7 +159,7 @@ func (s *SMTPConfig) ValidateConfig() error { if s.Host == "" { errors = append(errors, "SMTP host is required") } - if s.Port == "" { + if s.Port == 0 { errors = append(errors, "SMTP port is required") } if s.Username == "" { diff --git a/src/pkg/config/config.go b/src/pkg/config/config.go index 6f98648..b5e4446 100644 --- a/src/pkg/config/config.go +++ b/src/pkg/config/config.go @@ -5,14 +5,15 @@ import ( "os" "path/filepath" - "github.com/joho/godotenv" "gopkg.in/yaml.v3" ) -// Config represents the application configuration +// Config represents the application configuration. +// All settings live in a single config.yaml — no separate .env file. type Config struct { Server ServerConfig `yaml:"server"` Database DatabaseConfig `yaml:"database"` + SMTP SMTPSettings `yaml:"smtp"` IMAP IMAPConfig `yaml:"imap_defaults"` AI AIConfig `yaml:"ai"` Worker WorkerConfig `yaml:"worker"` @@ -22,40 +23,51 @@ type Config struct { // ServerConfig holds web server configuration type ServerConfig struct { - Port int `yaml:"port"` - Host string `yaml:"host"` + Port int `yaml:"port"` + Host string `yaml:"host"` SessionSecret string `yaml:"session_secret"` } // DatabaseConfig holds database configuration type DatabaseConfig struct { - Path string `yaml:"path"` + Path string `yaml:"path"` AutoMigrate bool `yaml:"auto_migrate"` } +// SMTPSettings holds SMTP credentials for sending OTP emails. +// Previously stored in a separate .env file; now in config.yaml. +type SMTPSettings struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + // IMAPConfig holds default IMAP configuration type IMAPConfig struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - TLS bool `yaml:"tls"` - BatchSize int `yaml:"batch_size"` - PollIntervalMinutes int `yaml:"poll_interval_minutes"` + Host string `yaml:"host"` + Port int `yaml:"port"` + TLS bool `yaml:"tls"` + BatchSize int `yaml:"batch_size"` + PollIntervalMinutes int `yaml:"poll_interval_minutes"` } -// AIConfig holds AI classification configuration +// AIConfig holds AI classification configuration. +// api_key was previously in .env; now stored here. type AIConfig struct { - Model string `yaml:"model"` - MaxTokens int `yaml:"max_tokens"` + Model string `yaml:"model"` + MaxTokens int `yaml:"max_tokens"` Temperature float64 `yaml:"temperature"` - PromptFile string `yaml:"prompt_file"` + PromptFile string `yaml:"prompt_file"` + APIKey string `yaml:"api_key"` } // WorkerConfig holds background worker configuration type WorkerConfig struct { - SteadyStateBatchSize int `yaml:"steady_state_batch_size"` - SteadyStateIntervalMinutes int `yaml:"steady_state_interval_minutes"` - CatchUpBatchSize int `yaml:"catch_up_batch_size"` - CatchUpCooldownSeconds int `yaml:"catch_up_cooldown_seconds"` + SteadyStateBatchSize int `yaml:"steady_state_batch_size"` + SteadyStateIntervalMinutes int `yaml:"steady_state_interval_minutes"` + CatchUpBatchSize int `yaml:"catch_up_batch_size"` + CatchUpCooldownSeconds int `yaml:"catch_up_cooldown_seconds"` } // FolderConfig holds email folder names @@ -71,68 +83,37 @@ type FolderConfig struct { // LoggingConfig holds logging configuration type LoggingConfig struct { - Level string `yaml:"level"` - File string `yaml:"file"` - MaxSizeMB int `yaml:"max_size_mb"` - MaxBackups int `yaml:"max_backups"` - MaxAgeDays int `yaml:"max_age_days"` + Level string `yaml:"level"` + File string `yaml:"file"` + MaxSizeMB int `yaml:"max_size_mb"` + MaxBackups int `yaml:"max_backups"` + MaxAgeDays int `yaml:"max_age_days"` } -// Environment variables -type Environment struct { - DeepSeekAPIKey string - SMTPHost string - SMTPPort string - SMTPUser string - SMTPPass string - AppSecret string -} - -// LoadConfig loads configuration from YAML file and environment variables -func LoadConfig(configPath string) (*Config, *Environment, error) { - // Load .env file - if err := godotenv.Load(); err != nil { - // It's okay if .env doesn't exist in production - fmt.Printf("Note: .env file not found: %v\n", err) - } - - // Read YAML config +// LoadConfig loads configuration from a YAML file. +// No .env file is consulted — everything is in config.yaml. +func LoadConfig(configPath string) (*Config, error) { data, err := os.ReadFile(configPath) if err != nil { - return nil, nil, fmt.Errorf("failed to read config file: %w", err) + return nil, fmt.Errorf("failed to read config file: %w", err) } var config Config if err := yaml.Unmarshal(data, &config); err != nil { - return nil, nil, fmt.Errorf("failed to parse config file: %w", err) + return nil, fmt.Errorf("failed to parse config file: %w", err) } - // Load environment variables - env := &Environment{ - DeepSeekAPIKey: os.Getenv("DEEPSEEK_API_KEY"), - SMTPHost: os.Getenv("SMTP_HOST"), - SMTPPort: os.Getenv("SMTP_PORT"), - SMTPUser: os.Getenv("SMTP_USER"), - SMTPPass: os.Getenv("SMTP_PASS"), - AppSecret: os.Getenv("APP_SECRET"), - } - - // Use APP_SECRET from environment if available - if env.AppSecret != "" { - config.Server.SessionSecret = env.AppSecret - } - - return &config, env, nil + return &config, nil } -// GetDefaultConfigPath returns the default path to config.yaml +// GetDefaultConfigPath returns the default path to config.yaml. +// It looks for config.yaml in the same directory as the executable. func GetDefaultConfigPath() (string, error) { execPath, err := os.Executable() if err != nil { - // Fallback to relative path return "bin/config.yaml", nil } - + execDir := filepath.Dir(execPath) return filepath.Join(execDir, "config.yaml"), nil } \ No newline at end of file