Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ docs/.zensical-build.*
docs/assets/static/
docs/assets/generated/
docs/screenshots/demo-data/
.kata.local.toml
4 changes: 4 additions & 0 deletions .kata.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version = 1

[project]
name = "msgvault"
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ make lint # Run linter
./msgvault sync-incremental you@gmail.com # Incremental sync

# TUI and analytics
./msgvault tui # Launch TUI
./msgvault tui --account you@gmail.com # Filter by account
./msgvault tui # Launch TUI (press 'a' inside to filter by account)
./msgvault tui --local # Force local (override remote config)
./msgvault build-cache # Build Parquet cache
./msgvault build-cache --full-rebuild # Full rebuild
Expand All @@ -68,6 +67,11 @@ make lint # Run linter
./msgvault import-emlx --account me@gmail.com # Specific account(s)
./msgvault import-emlx /path/to/dir --identifier me@gmail.com # Manual fallback

# Microsoft Teams (delegated Graph)
./msgvault add-teams you@tenant.com # Authorize Teams (browser OAuth)
./msgvault sync-teams you@tenant.com # Sync Teams chats + channels
./msgvault sync-teams you@tenant.com --no-channels --limit 50

# Daemon mode (NAS/server deployment)
./msgvault serve # Start HTTP API + scheduled syncs

Expand Down
104 changes: 104 additions & 0 deletions cmd/msgvault/cmd/add_teams.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cmd

import (
"errors"
"fmt"

"github.com/spf13/cobra"
"go.kenn.io/msgvault/internal/microsoft"
"go.kenn.io/msgvault/internal/store"
)

var (
teamsTenantID string
noDefaultIdentityAddTeams bool
)

var addTeamsCmd = &cobra.Command{
Use: "add-teams <email>",
Short: "Authorize Microsoft Teams (delegated Graph) for an account",
Long: `Authorize a Microsoft Teams account using OAuth2 (delegated Graph API).

This opens a browser for Microsoft authorization, then stores the token for
Teams message ingestion.

Requires a [microsoft] section in config.toml with your Azure AD app's client_id.
See the docs for Azure AD app registration setup.

Examples:
msgvault add-teams user@company.com
msgvault add-teams user@company.com --tenant my-tenant-id`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
email := args[0]

if cfg.Microsoft.ClientID == "" {
return errors.New("microsoft OAuth not configured\n\n" +
"Add to your config.toml:\n\n" +
" [microsoft]\n" +
" client_id = \"your-azure-app-client-id\"\n\n" +
"See docs for Azure AD app registration setup")
}

tenantID := cfg.Microsoft.EffectiveTenantID()
if teamsTenantID != "" {
tenantID = teamsTenantID
}

mgr := microsoft.NewGraphManager(
cfg.Microsoft.ClientID,
tenantID,
cfg.TokensDir(),
logger,
)

fmt.Printf("Authorizing %s with Microsoft Teams...\n", email)
if err := mgr.Authorize(cmd.Context(), email); err != nil {
return fmt.Errorf("authorize Teams: %w", err)
}

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

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

source, err := s.GetOrCreateSource(sourceTypeTeams, email)
if err != nil {
return fmt.Errorf("create source: %w", err)
}
if err := s.UpdateSourceDisplayName(source.ID, email); err != nil {
return fmt.Errorf("set display name: %w", err)
}

if !noDefaultIdentityAddTeams {
confirmDefaultIdentity(cmd.OutOrStdout(), s, source.ID, email, email, "account-identifier")
}
if err := runPostSourceCreateMigrations(s); err != nil {
return fmt.Errorf("post-source-create migrations: %w", err)
}

fmt.Printf("\nMicrosoft Teams account authorized successfully!\n")
fmt.Printf(" Email: %s\n", email)
fmt.Println()
fmt.Println("You can now run:")
fmt.Printf(" msgvault sync-teams %s\n", email)

return nil
},
}

func init() {
addTeamsCmd.Flags().StringVar(&teamsTenantID, "tenant", "",
"Azure AD tenant ID (default: \"common\" for multi-tenant)")
addTeamsCmd.Flags().BoolVar(&noDefaultIdentityAddTeams, "no-default-identity", false, noDefaultIdentityHelp)
rootCmd.AddCommand(addTeamsCmd)
}
128 changes: 128 additions & 0 deletions cmd/msgvault/cmd/backfill_teams_media.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package cmd

import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"time"

"github.com/spf13/cobra"
"go.kenn.io/msgvault/internal/microsoft"
"go.kenn.io/msgvault/internal/store"
"go.kenn.io/msgvault/internal/teams"
)

var backfillTeamsMediaOnlyIncomplete bool

var backfillTeamsMediaCmd = &cobra.Command{
Use: "backfill-teams-media <email>",
Short: "Re-fetch Teams inline media (hostedContents) for already-imported messages",
Long: `Re-fetch Microsoft Teams inline media (hostedContents) for messages that
were already imported but whose inline images were never downloaded.

This targets ONLY messages whose stored HTML body contains a hostedContents
URL, instead of re-walking every message. It is idempotent: content-addressed
storage dedupes, so it is safe to re-run.

Use --only-incomplete to retry just the messages whose inline media is still
missing (e.g. after transient fetch failures), instead of re-fetching all.

Examples:
msgvault backfill-teams-media user@company.com
msgvault backfill-teams-media user@company.com --only-incomplete`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
email := args[0]

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

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

if cfg.Microsoft.ClientID == "" {
return errors.New("microsoft OAuth not configured\n\n" +
"Add to your config.toml:\n\n" +
" [microsoft]\n" +
" client_id = \"your-azure-app-client-id\"\n\n" +
"See docs for Azure AD app registration setup")
}

mgr := microsoft.NewGraphManager(
cfg.Microsoft.ClientID,
cfg.Microsoft.EffectiveTenantID(),
cfg.TokensDir(),
logger,
)
tokenFn, err := mgr.TokenSource(cmd.Context(), email)
if err != nil {
return fmt.Errorf("load Teams token: %w (run 'add-teams' first)", err)
}

ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChan)
go func() {
select {
case <-sigChan:
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "\nInterrupted. Stopping...")
cancel()
case <-ctx.Done():
}
}()

qps := float64(cfg.Sync.RateLimitQPS)
if qps <= 0 {
qps = 5
}
client := teams.NewClient("https://graph.microsoft.com/v1.0", teams.TokenFunc(tokenFn), qps)
imp := teams.NewImporter(s, client)

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Backfilling Teams inline media for %s\n\n", email)

sum, err := imp.BackfillInlineMedia(ctx, teams.ImportOptions{
Email: email,
AttachmentsDir: cfg.AttachmentsDir(),
OnlyIncomplete: backfillTeamsMediaOnlyIncomplete,
Progress: func(s string) { fmt.Println(s) },
})
if ctx.Err() != nil {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\nInterrupted — re-run backfill-teams-media to resume (idempotent).")
rebuildCacheAfterWrite(dbPath)
return nil
}
if err != nil {
return fmt.Errorf("teams inline-media backfill failed: %w", err)
}

_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Teams inline-media backfill complete!")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " Duration: %s\n", sum.Duration.Round(time.Second))
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " Messages processed: %d\n", sum.MessagesProcessed)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " Inline images copied:%d\n", sum.InlineImagesCopied)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " Errors: %d\n", sum.Errors)

rebuildCacheAfterWrite(dbPath)
return nil
},
}

func init() {
backfillTeamsMediaCmd.Flags().BoolVar(&backfillTeamsMediaOnlyIncomplete, "only-incomplete", false,
"retry only messages whose inline media is still missing (e.g. after transient failures)")
rootCmd.AddCommand(backfillTeamsMediaCmd)
}
1 change: 1 addition & 0 deletions cmd/msgvault/cmd/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const (
sourceTypeGmail = "gmail"
sourceTypeIMAP = "imap"
sourceTypeMbox = "mbox"
sourceTypeTeams = "teams"
)

// Analytics dataset / SQLite table names: the Parquet subdirectory under
Expand Down
6 changes: 2 additions & 4 deletions cmd/msgvault/cmd/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ msgvault search "quarterly report" --limit 100 --offset 50
| `newer_than:` | Relative date | `newer_than:7d` |
| `larger:` | Minimum size | `larger:10M` |
| `smaller:` | Maximum size | `smaller:100K` |
| `message_type:` | Message type | `message_type:sms` |
| `message_type:` | Message type | `message_type:teams` |

Bare words and `"quoted phrases"` perform full-text search across subject and body.

Expand Down Expand Up @@ -267,11 +267,9 @@ msgvault version

```bash
# Launch the TUI (auto-builds analytics cache if needed)
# Press 'a' inside the TUI to filter by account
msgvault tui

# Filter by account
msgvault tui --account user@gmail.com

# Force local database (override remote config)
msgvault tui --local
```
Expand Down
12 changes: 12 additions & 0 deletions cmd/msgvault/cmd/remove_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,18 @@ func runRemoveAccount(cmd *cobra.Command, args []string) error {
tokenPath, err,
)
}
case sourceTypeTeams:
graphMgr := microsoft.NewGraphManager(
cfg.Microsoft.ClientID,
cfg.Microsoft.EffectiveTenantID(),
cfg.TokensDir(),
logger,
)
if err := graphMgr.DeleteToken(source.Identifier); err != nil {
fmt.Fprintf(os.Stderr,
"Warning: could not remove Microsoft Graph token: %v\n", err,
)
}
case sourceTypeIMAP:
if source.SyncConfig.Valid && source.SyncConfig.String != "" {
imapCfg, parseErr := imaplib.ConfigFromJSON(source.SyncConfig.String)
Expand Down
42 changes: 42 additions & 0 deletions cmd/msgvault/cmd/remove_account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
assertpkg "github.com/stretchr/testify/assert"
requirepkg "github.com/stretchr/testify/require"
"go.kenn.io/msgvault/internal/config"
"go.kenn.io/msgvault/internal/microsoft"
"go.kenn.io/msgvault/internal/oauth"
"go.kenn.io/msgvault/internal/store"
)
Expand Down Expand Up @@ -412,6 +413,47 @@ func TestRemoveAccountCmd_GmailRemovesToken(t *testing.T) {
assertpkg.True(t, os.IsNotExist(err), "token file should be removed for gmail source")
}

func TestRemoveAccountCmd_TeamsRemovesGraphToken(t *testing.T) {
require := requirepkg.New(t)
tmpDir := t.TempDir()
dbPath := tmpDir + "/msgvault.db"
tokensDir := filepath.Join(tmpDir, "tokens")
require.NoError(os.MkdirAll(tokensDir, 0700), "mkdir tokens")

s, err := store.Open(dbPath)
require.NoError(err, "open store")
require.NoError(s.InitSchema(), "init schema")
_, err = s.GetOrCreateSource("teams", "tok@example.com")
require.NoError(err, "create source")
_ = s.Close()

mgr := microsoft.NewGraphManager("client-id", "", tokensDir, nil)
tokenPath := mgr.TokenPath("tok@example.com")
require.NoError(os.WriteFile(tokenPath, []byte(`{}`), 0600), "write teams token")

savedCfg := cfg
defer func() { cfg = savedCfg }()

cfg = &config.Config{
HomeDir: tmpDir,
Data: config.DataConfig{DataDir: tmpDir},
Microsoft: config.MicrosoftConfig{
ClientID: "client-id",
},
}

root := newTestRootCmd()
root.AddCommand(newRemoveAccountCmd())
root.SetArgs([]string{
"remove-account", "tok@example.com", "--yes", "--type", "teams",
})

require.NoError(root.Execute(), "remove-account")

_, err = os.Stat(tokenPath)
assertpkg.True(t, os.IsNotExist(err), "Graph token file should be removed for teams source")
}

func TestRemoveAccountCmd_NonGmailSkipsToken(t *testing.T) {
require := requirepkg.New(t)
tmpDir := t.TempDir()
Expand Down
2 changes: 1 addition & 1 deletion cmd/msgvault/cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Supported operators (local mode only - remote uses simple text search):
newer_than: Relative date
larger: Size filter (5M, 100K)
smaller: Size filter
message_type: Message type filter (sms, mms, whatsapp, email)
message_type: Message type filter (sms, mms, whatsapp, teams, email)

Bare words and "quoted phrases" perform full-text search.

Expand Down
Loading