Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions cmd/msgvault/cmd/addaccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import (
)

var (
headless bool
headless bool
accountDisplayName string
)

var addAccountCmd = &cobra.Command{
Expand All @@ -20,9 +21,10 @@ var addAccountCmd = &cobra.Command{
By default, opens a browser for authorization. Use --headless to see instructions
for authorizing on headless servers (Google does not support Gmail in device flow).

Example:
Examples:
msgvault add-account you@gmail.com
msgvault add-account you@gmail.com --headless`,
msgvault add-account you@gmail.com --headless
msgvault add-account you@gmail.com --display-name "Work Account"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
email := args[0]
Expand Down Expand Up @@ -60,10 +62,15 @@ Example:
if oauthMgr.HasToken(email) {
// Still create the source record - needed for headless setup
// where token was copied but account not yet registered
_, err = s.GetOrCreateSource("gmail", email)
source, err := s.GetOrCreateSource("gmail", email)
if err != nil {
return fmt.Errorf("create source: %w", err)
}
if accountDisplayName != "" {
if err := s.UpdateSourceDisplayName(source.ID, accountDisplayName); err != nil {
return fmt.Errorf("set display name: %w", err)
}
}
fmt.Printf("Account %s is ready.\n", email)
fmt.Println("You can now run: msgvault sync-full", email)
return nil
Expand All @@ -77,11 +84,18 @@ Example:
}

// Create source record in database
_, err = s.GetOrCreateSource("gmail", email)
source, err := s.GetOrCreateSource("gmail", email)
if err != nil {
return fmt.Errorf("create source: %w", err)
}

// Set display name if provided
if accountDisplayName != "" {
if err := s.UpdateSourceDisplayName(source.ID, accountDisplayName); err != nil {
return fmt.Errorf("set display name: %w", err)
}
}

fmt.Printf("\nAccount %s authorized successfully!\n", email)
fmt.Println("You can now run: msgvault sync-full", email)

Expand All @@ -91,5 +105,6 @@ Example:

func init() {
addAccountCmd.Flags().BoolVar(&headless, "headless", false, "Show instructions for headless server setup")
addAccountCmd.Flags().StringVar(&accountDisplayName, "display-name", "", "Display name for the account (e.g., \"Work\", \"Personal\")")
rootCmd.AddCommand(addAccountCmd)
}
156 changes: 156 additions & 0 deletions cmd/msgvault/cmd/list_accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"text/tabwriter"
"time"

"github.com/spf13/cobra"
"github.com/wesm/msgvault/internal/store"
)

var listAccountsJSON bool

var listAccountsCmd = &cobra.Command{
Use: "list-accounts",
Short: "List synced email accounts",
Long: `List all email accounts that have been added to msgvault.

Shows account email, message count, and last sync time.

Examples:
msgvault list-accounts
msgvault list-accounts --json`,
RunE: func(cmd *cobra.Command, args []string) error {
dbPath := cfg.DatabaseDSN()
s, err := store.Open(dbPath)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer s.Close()

if err := s.InitSchema(); err != nil {
return fmt.Errorf("init schema: %w", err)
}

sources, err := s.ListSources("")
if err != nil {
return fmt.Errorf("list accounts: %w", err)
}

if len(sources) == 0 {
fmt.Println("No accounts found. Use 'msgvault add-account <email>' to add one.")
return nil
}

// Gather stats for each account
stats := make([]accountStats, len(sources))
for i, src := range sources {
count, err := s.CountMessagesForSource(src.ID)
if err != nil {
return fmt.Errorf("count messages for %s: %w", src.Identifier, err)
}

var lastSync *time.Time
if src.LastSyncAt.Valid {
lastSync = &src.LastSyncAt.Time
}

displayName := ""
if src.DisplayName.Valid {
displayName = src.DisplayName.String
}

stats[i] = accountStats{
ID: src.ID,
Email: src.Identifier,
Type: src.SourceType,
DisplayName: displayName,
MessageCount: count,
LastSync: lastSync,
}
}

if listAccountsJSON {
return outputAccountsJSON(stats)
}
outputAccountsTable(stats)
return nil
},
}

func outputAccountsTable(stats []accountStats) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tACCOUNT\tTYPE\tDISPLAY NAME\tMESSAGES\tLAST SYNC")

for _, s := range stats {
displayName := s.DisplayName
if displayName == "" {
displayName = "-"
}
lastSync := "-"
if s.LastSync != nil && !s.LastSync.IsZero() {
lastSync = s.LastSync.Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", s.ID, s.Email, s.Type, displayName, formatCount(s.MessageCount), lastSync)
}

w.Flush()
}

func outputAccountsJSON(stats []accountStats) error {
output := make([]map[string]interface{}, len(stats))
for i, s := range stats {
entry := map[string]interface{}{
"id": s.ID,
"email": s.Email,
"type": s.Type,
"display_name": s.DisplayName,
"message_count": s.MessageCount,
}
if s.LastSync != nil && !s.LastSync.IsZero() {
entry["last_sync"] = s.LastSync.Format(time.RFC3339)
} else {
entry["last_sync"] = nil
}
output[i] = entry
}

enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(output)
}

// formatCount formats a number with thousand separators.
func formatCount(n int64) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
}

// Format with commas
s := fmt.Sprintf("%d", n)
result := make([]byte, 0, len(s)+(len(s)-1)/3)
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result = append(result, ',')
}
result = append(result, byte(c))
}
return string(result)
}

type accountStats struct {
ID int64
Email string
Type string
DisplayName string
MessageCount int64
LastSync *time.Time
}

func init() {
rootCmd.AddCommand(listAccountsCmd)
listAccountsCmd.Flags().BoolVar(&listAccountsJSON, "json", false, "Output as JSON")
}
57 changes: 57 additions & 0 deletions cmd/msgvault/cmd/update_account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"
"github.com/wesm/msgvault/internal/store"
)

var updateDisplayName string

var updateAccountCmd = &cobra.Command{
Use: "update-account <email>",
Short: "Update account settings",
Long: `Update settings for an existing account.

Currently supports updating the display name for an account.

Examples:
msgvault update-account you@gmail.com --display-name "Work"
msgvault update-account you@gmail.com --display-name "Personal Email"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
email := args[0]

if updateDisplayName == "" {
return fmt.Errorf("nothing to update: use --display-name to set a display name")
}

dbPath := cfg.DatabaseDSN()
s, err := store.Open(dbPath)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer s.Close()

source, err := s.GetSourceByIdentifier(email)
if err != nil {
return fmt.Errorf("get account: %w", err)
}
if source == nil {
return fmt.Errorf("account not found: %s", email)
}

if err := s.UpdateSourceDisplayName(source.ID, updateDisplayName); err != nil {
return fmt.Errorf("update display name: %w", err)
}

fmt.Printf("Updated account %s: display name set to %q\n", email, updateDisplayName)
return nil
},
}

func init() {
rootCmd.AddCommand(updateAccountCmd)
updateAccountCmd.Flags().StringVar(&updateDisplayName, "display-name", "", "Set the display name for the account")
}
32 changes: 32 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,38 @@ func TestStore_Source_UpdateSyncCursor(t *testing.T) {
}
}

func TestStore_Source_UpdateDisplayName(t *testing.T) {
f := storetest.New(t)

err := f.Store.UpdateSourceDisplayName(f.Source.ID, "Work Account")
testutil.MustNoErr(t, err, "UpdateSourceDisplayName()")

// Verify display name was updated
updated, err := f.Store.GetSourceByIdentifier("test@example.com")
testutil.MustNoErr(t, err, "GetSourceByIdentifier()")

if !updated.DisplayName.Valid || updated.DisplayName.String != "Work Account" {
t.Errorf("DisplayName = %v, want 'Work Account'", updated.DisplayName)
}
}

func TestStore_ListSources(t *testing.T) {
f := storetest.New(t)

sources, err := f.Store.ListSources("")
testutil.MustNoErr(t, err, "ListSources()")

if len(sources) != 1 {
t.Fatalf("len(sources) = %d, want 1", len(sources))
}
if sources[0].Identifier != "test@example.com" {
t.Errorf("Identifier = %q, want %q", sources[0].Identifier, "test@example.com")
}
if sources[0].ID != f.Source.ID {
t.Errorf("ID = %d, want %d", sources[0].ID, f.Source.ID)
}
}

func TestStore_Conversation(t *testing.T) {
f := storetest.New(t)

Expand Down
10 changes: 10 additions & 0 deletions internal/store/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,16 @@ func (s *Store) ListSources(sourceType string) ([]*Source, error) {
return sources, nil
}

// UpdateSourceDisplayName updates the display name for a source.
func (s *Store) UpdateSourceDisplayName(sourceID int64, displayName string) error {
_, err := s.db.Exec(`
UPDATE sources
SET display_name = ?, updated_at = datetime('now')
WHERE id = ?
`, displayName, sourceID)
return err
}

// GetSourceByIdentifier returns a source by its identifier (email address).
func (s *Store) GetSourceByIdentifier(identifier string) (*Source, error) {
row := s.db.QueryRow(`
Expand Down