FRE-680: Initial project scaffold with auth & API client

- Set up Go module with Cobra CLI skeleton
- Implemented login/logout/session commands with 2FA support
- Created ProtonMail API client with rate limiting
- Added config management for ~/.config/pop/
- Configured CI/CD pipeline with GitHub Actions
- Added Makefile for build/test/lint targets

Files:
- main.go, go.mod, go.sum
- cmd/root.go, cmd/auth.go
- internal/auth/session.go
- internal/config/config.go
- internal/api/client.go
- Makefile, README.md, .gitignore
- .github/workflows/ci.yml
This commit is contained in:
2026-04-26 09:45:10 -04:00
commit 25836e27b9
12 changed files with 646 additions and 0 deletions

118
internal/api/client.go Normal file
View File

@@ -0,0 +1,118 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/frenocorp/pop/internal/config"
)
type ProtonMailClient struct {
baseURL string
httpClient *http.Client
config *config.Config
rateLimiter *RateLimiter
authHeader string
authMu sync.RWMutex
}
type RateLimiter struct {
mu sync.Mutex
requests []time.Time
limit int
window time.Duration
}
func NewProtonMailClient(cfg *config.Config) *ProtonMailClient {
return &ProtonMailClient{
baseURL: cfg.APIBaseURL,
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
config: cfg,
rateLimiter: &RateLimiter{
requests: make([]time.Time, 0, cfg.RateLimitReq),
limit: cfg.RateLimitReq,
window: time.Duration(cfg.RateLimitWin) * time.Second,
},
}
}
func (c *ProtonMailClient) SetAuthHeader(token string) {
c.authMu.Lock()
defer c.authMu.Unlock()
c.authHeader = token
}
func (c *ProtonMailClient) getAuthHeader() string {
c.authMu.RLock()
defer c.authMu.RUnlock()
return c.authHeader
}
func (rl *RateLimiter) Wait() {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
windowStart := now.Add(-rl.window)
// Remove old requests outside the window
validRequests := make([]time.Time, 0, rl.limit)
for _, t := range rl.requests {
if t.After(windowStart) {
validRequests = append(validRequests, t)
}
}
rl.requests = validRequests
// Wait if at limit
if len(rl.requests) >= rl.limit {
sleep := rl.requests[0].Add(rl.window).Sub(now)
if sleep > 0 {
time.Sleep(sleep)
}
}
}
func (c *ProtonMailClient) Do(req *http.Request) (*http.Response, error) {
c.rateLimiter.Wait()
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.getAuthHeader()))
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
// Record the request
c.rateLimiter.mu.Lock()
c.rateLimiter.requests = append(c.rateLimiter.requests, time.Now())
c.rateLimiter.mu.Unlock()
// Check for API errors
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
var apiErr APIError
if err := json.Unmarshal(body, &apiErr); err == nil {
resp.Body = io.NopCloser(io.MultiReader(io.NopCloser(bytes.NewReader(body)), bytes.NewReader(body)))
return resp, &apiErr
}
}
return resp, nil
}
type APIError struct {
HTTPStatus int `json:"-"`
Code int `json:"Code,omitempty"`
Message string `json:"Message,omitempty"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("API error %d: %s", e.HTTPStatus, e.Message)
}

90
internal/auth/session.go Normal file
View File

@@ -0,0 +1,90 @@
package auth
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/frenocorp/pop/internal/config"
)
type Session struct {
UID string `json:"uid"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
TwoFAEnabled bool `json:"two_factor_enabled"`
}
type SessionManager struct {
configDir string
sessionFile string
}
func NewSessionManager() *SessionManager {
cfg := config.NewConfigManager()
return &SessionManager{
configDir: cfg.ConfigDir(),
sessionFile: filepath.Join(cfg.ConfigDir(), "session.json"),
}
}
func (m *SessionManager) Login() error {
// TODO: Implement interactive login with 2FA support
// This will call the ProtonMail API and store the session
session := Session{
UID: "placeholder-uid",
AccessToken: "placeholder-token",
RefreshToken: "placeholder-refresh",
ExpiresAt: 0,
TwoFAEnabled: false,
}
if err := os.MkdirAll(m.configDir, 0755); err != nil {
return fmt.Errorf("failed to create config dir: %w", err)
}
data, err := json.MarshalIndent(session, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal session: %w", err)
}
if err := os.WriteFile(m.sessionFile, data, 0600); err != nil {
return fmt.Errorf("failed to write session file: %w", err)
}
fmt.Println("Logged in successfully")
return nil
}
func (m *SessionManager) Logout() error {
if err := os.Remove(m.sessionFile); err != nil {
return fmt.Errorf("failed to remove session file: %w", err)
}
fmt.Println("Logged out successfully")
return nil
}
func (m *SessionManager) GetSession() (*Session, error) {
data, err := os.ReadFile(m.sessionFile)
if err != nil {
return nil, fmt.Errorf("failed to read session file: %w", err)
}
var session Session
if err := json.Unmarshal(data, &session); err != nil {
return nil, fmt.Errorf("failed to parse session: %w", err)
}
return &session, nil
}
func (m *SessionManager) IsAuthenticated() (bool, error) {
_, err := m.GetSession()
if err != nil {
return false, err
}
return true, nil
}

78
internal/config/config.go Normal file
View File

@@ -0,0 +1,78 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type Config struct {
APIBaseURL string `json:"api_base_url"`
TimeoutSec int `json:"timeout_sec"`
RateLimitReq int `json:"rate_limit_requests"`
RateLimitWin int `json:"rate_limit_window_sec"`
}
type ConfigManager struct {
configDir string
configFile string
}
func NewConfigManager() *ConfigManager {
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = "~"
}
configDir := filepath.Join(homeDir, ".config", "pop")
return &ConfigManager{
configDir: configDir,
configFile: filepath.Join(configDir, "config.json"),
}
}
func (m *ConfigManager) ConfigDir() string {
return m.configDir
}
func (m *ConfigManager) Load() (*Config, error) {
defaultConfig := &Config{
APIBaseURL: "https://api.protonmail.ch",
TimeoutSec: 30,
RateLimitReq: 100,
RateLimitWin: 60,
}
data, err := os.ReadFile(m.configFile)
if err != nil {
if os.IsNotExist(err) {
return defaultConfig, nil
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
return &config, nil
}
func (m *ConfigManager) Save(config *Config) error {
if err := os.MkdirAll(m.configDir, 0755); err != nil {
return fmt.Errorf("failed to create config dir: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(m.configFile, data, 0600); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}