Consolidate all secrets into config.yaml — remove .env entirely

All configuration (including secrets) now lives in a single file:
bin/config.yaml. The separate .env file has been eliminated.

Changes:
- config.go: Added SMTPSettings struct + AI.APIKey to Config; removed
  godotenv import, Environment struct, and all os.Getenv() calls
- config.yaml: Added smtp section (host/port/username/password) and
  ai.api_key field with placeholder values
- main.go: Reads SMTP and API key from cfg instead of env
- smtp.go: Changed Port field from string to int
- otp_test.go: Updated Port values to int
- .env.example: Deleted (all config is in config.yaml)
- .gitignore: Removed .env.example; kept .env for safety
- go.mod/go.sum: Removed github.com/joho/godotenv dependency
- install.sh: No longer creates .env or uses EnvironmentFile;
  warns about placeholder values in config.yaml instead
This commit is contained in:
Claus Lohmar 2026-04-23 20:06:16 +00:00
parent 54dd30a2d6
commit 01a58156a1
11 changed files with 94 additions and 160 deletions

View file

@ -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

5
.gitignore vendored
View file

@ -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

View file

@ -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"

Binary file not shown.

5
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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:"

View file

@ -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,

View file

@ -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 {

View file

@ -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 == "" {

View file

@ -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,65 +83,34 @@ 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
}