diff --git a/cmd/msgvault/cmd/addo365.go b/cmd/msgvault/cmd/addo365.go index 22b790bf7..8d6e12d0b 100644 --- a/cmd/msgvault/cmd/addo365.go +++ b/cmd/msgvault/cmd/addo365.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json/v2" "errors" "fmt" "strings" @@ -16,6 +17,7 @@ var ( o365TenantID string noDefaultIdentityAddO365 bool o365Graph bool + o365As string ) func newAddO365Cmd() *cobra.Command { @@ -48,6 +50,9 @@ func preflightAddO365Authorize(cmd *cobra.Command, email string) error { if err := requireMicrosoftOAuthConfig(cfg); err != nil { return err } + if err := validateO365As(); err != nil { + return err + } if err := authorizeO365(cmd, email); err != nil { return err } @@ -77,11 +82,18 @@ asks for Mail.ReadWrite, which the app registration must also list. A Graph account is a separate account: if the mailbox is also synced over IMAP, the vault holds two copies, and 'msgvault dedup --collection' hides the extra ones. +With --graph and --as, the account is a shared or delegated mailbox that the +user named by --as can open. msgvault signs in as that user, never as the +mailbox, and reads the mailbox through Microsoft Graph. It needs the +Mail.Read.Shared permission on the app registration. Microsoft Graph checks the +user's access to the mailbox on every request. + Examples: msgvault add-o365 user@outlook.com msgvault add-o365 user@outlook.com --headless msgvault add-o365 user@company.com --tenant my-tenant-id - msgvault add-o365 user@company.com --graph`, + msgvault add-o365 user@company.com --graph + msgvault add-o365 team@company.com --graph --as user@company.com`, Args: cobra.ExactArgs(1), RunE: runAddO365Local, } @@ -91,6 +103,8 @@ Examples: cmd.Flags().BoolVar(&o365Headless, "headless", false, "Sign in with a device code instead of a local browser") cmd.Flags().BoolVar(&o365Graph, "graph", false, "sync through the Microsoft Graph mail API instead of IMAP") + cmd.Flags().StringVar(&o365As, "as", "", + "with --graph: sign in as this user and sync as a shared or delegated mailbox") registerOAuthPreflightedFlag(cmd) return cmd } @@ -107,6 +121,9 @@ func runAddO365Local(cmd *cobra.Command, args []string) error { if err := requireMicrosoftOAuthConfig(cfg); err != nil { return err } + if err := validateO365As(); err != nil { + return err + } if o365Graph { return runAddO365GraphLocal(cmd, email) } @@ -228,7 +245,14 @@ func authorizeO365(cmd *cobra.Command, email string) error { redirect := cfg.Microsoft.EffectiveRedirectURI() fmt.Printf("Authorizing %s with Microsoft...\n", email) var err error - if o365Graph { + if o365Graph && o365SharedMailbox(email) { + fmt.Printf("Signing in as %s to read %s...\n", strings.TrimSpace(o365As), email) + mgr := microsoft.NewGraphMailSharedManager(cfg.Microsoft.ClientID, tenant, redirect, cfg.TokensDir(), logger) + if o365Headless { + mgr.UseDeviceCode() + } + err = mgr.AuthorizeAs(cmd.Context(), strings.TrimSpace(o365As), email) + } else if o365Graph { mgr := microsoft.NewGraphMailManager(cfg.Microsoft.ClientID, tenant, redirect, cfg.TokensDir(), logger) if o365Headless { mgr.UseDeviceCode() @@ -277,6 +301,19 @@ func runAddO365GraphLocal(cmd *cobra.Command, email string) error { if err := s.UpdateSourceDisplayName(source.ID, email); err != nil { return fmt.Errorf("set display name: %w", err) } + // Re-adding without --as turns a shared mailbox source back into the + // signed-in user's own, so the config is always written. + mcfg := msmailSourceConfig{} + if o365SharedMailbox(email) { + mcfg.SignedInAs = strings.TrimSpace(o365As) + } + cfgJSON, err := json.Marshal(mcfg) + if err != nil { + return fmt.Errorf("serialize config: %w", err) + } + if err := s.UpdateSourceSyncConfig(source.ID, string(cfgJSON)); err != nil { + return fmt.Errorf("store config: %w", err) + } if err := setDefaultIdentityOptOut(cmd, s, source, noDefaultIdentityAddO365); err != nil { return err } @@ -289,12 +326,31 @@ func runAddO365GraphLocal(cmd *cobra.Command, email string) error { fmt.Printf("\nMicrosoft 365 account added for Graph mail sync!\n") fmt.Printf(" Email: %s\n", email) + if mcfg.shared() { + fmt.Printf(" Read as: %s (shared or delegated mailbox)\n", mcfg.SignedInAs) + } fmt.Println() fmt.Println("You can now run:") fmt.Printf(" msgvault sync %s\n", email) return nil } +// validateO365As rejects --as without --graph: only Graph mail can read a +// shared or delegated mailbox. +func validateO365As() error { + if strings.TrimSpace(o365As) != "" && !o365Graph { + return errors.New("--as requires --graph: IMAP sync reads only the signed-in user's mailbox") + } + return nil +} + +// o365SharedMailbox reports whether --as names a user other than the +// mailbox, so the account is a shared or delegated mailbox. +func o365SharedMailbox(email string) bool { + as := strings.TrimSpace(o365As) + return as != "" && !strings.EqualFold(as, strings.TrimSpace(email)) +} + // isMicrosoftIMAPSource returns true only if src is an IMAP source already // configured for Microsoft XOAUTH2 with the given username. This prevents // a non-Microsoft IMAP source (e.g. a password-auth source) that happens to diff --git a/cmd/msgvault/cmd/addo365_shared_test.go b/cmd/msgvault/cmd/addo365_shared_test.go new file mode 100644 index 000000000..78b01dac5 --- /dev/null +++ b/cmd/msgvault/cmd/addo365_shared_test.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "database/sql" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/microsoft" + "go.kenn.io/msgvault/internal/store" +) + +func saveO365Flags(t *testing.T) { + t.Helper() + savedGraph, savedAs, savedHeadless, savedTenant, savedNoDefault := o365Graph, o365As, o365Headless, o365TenantID, noDefaultIdentityAddO365 + t.Cleanup(func() { + o365Graph, o365As, o365Headless, o365TenantID, noDefaultIdentityAddO365 = savedGraph, savedAs, savedHeadless, savedTenant, savedNoDefault + }) +} + +// add-o365 --graph --as records the shared mailbox as the account and the +// signing-in user in its config. The scheduled sync then asks for a token +// with Mail.Read.Shared, saved under the mailbox's address. +func TestAddO365GraphSharedMailbox(t *testing.T) { + saveO365Flags(t) + assert, require := assert.New(t), require.New(t) + home := t.TempDir() + cfg := &config.Config{HomeDir: home, Data: config.DataConfig{DataDir: home}, + Microsoft: config.MicrosoftConfig{ClientID: "synthetic-client"}} + ctx := testInvocationContext(t.Context(), cfg, invocationOptions{}) + const mailbox, user = "team@example.com", "user@example.com" + + add := func(args ...string) *store.Source { + t.Helper() + o365As = "" + cmd := newAddO365LocalCmd() + cmd.SetArgs(append([]string{mailbox, "--graph", "--" + oauthPreflightedFlag}, args...)) + require.NoError(cmd.ExecuteContext(ctx)) + st, err := store.Open(cfg.DatabaseDSN()) + require.NoError(err) + t.Cleanup(func() { _ = st.Close() }) + sources, err := st.ListSources(sourceTypeMSMail) + require.NoError(err) + require.Len(sources, 1, "re-adding reuses the account") + assert.Equal(mailbox, sources[0].Identifier) + return sources[0] + } + + src := add("--as", user) + mcfg, err := msmailConfigOf(src) + require.NoError(err) + assert.Equal(user, mcfg.SignedInAs) + + st, err := store.Open(cfg.DatabaseDSN()) + require.NoError(err) + t.Cleanup(func() { _ = st.Close() }) + mgr := microsoft.NewGraphMailManager(cfg.Microsoft.ClientID, "common", "", cfg.TokensDir(), testDiscardLogger()) + require.NoError(os.MkdirAll(cfg.TokensDir(), 0700)) + require.NoError(os.WriteFile(mgr.TokenPath(mailbox), + []byte(`{"access_token":"synthetic","refresh_token":"r","token_type":"Bearer","scopes":["https://graph.microsoft.com/Mail.Read"]}`), 0600)) + err = runScheduledMSMailSync(ctx, src, st, invocationFromContext(ctx)) + require.ErrorContains(err, "Mail.Read.Shared", "a shared mailbox needs the shared scope") + + src = add() + mcfg, err = msmailConfigOf(src) + require.NoError(err) + assert.False(mcfg.shared(), "re-adding without --as makes it the user's own mailbox again") + + src = add("--as", mailbox) + mcfg, err = msmailConfigOf(src) + require.NoError(err) + assert.False(mcfg.shared(), "--as naming the mailbox itself is the own-mailbox case") +} + +func TestAddO365AsRequiresGraph(t *testing.T) { + saveO365Flags(t) + home := t.TempDir() + cfg := &config.Config{HomeDir: home, Data: config.DataConfig{DataDir: home}, + Microsoft: config.MicrosoftConfig{ClientID: "synthetic-client"}} + ctx := testInvocationContext(t.Context(), cfg, invocationOptions{}) + o365Graph = false + cmd := newAddO365LocalCmd() + cmd.SetArgs([]string{"team@example.com", "--as", "user@example.com", "--" + oauthPreflightedFlag}) + require.ErrorContains(t, cmd.ExecuteContext(ctx), "--as requires --graph") +} + +func TestMSMailConfigOf(t *testing.T) { + for _, tc := range []struct { + name string + config sql.NullString + want string + bad bool + }{ + {"no config", sql.NullString{}, "", false}, + {"empty object", sql.NullString{String: `{}`, Valid: true}, "", false}, + {"shared", sql.NullString{String: `{"signed_in_as":"user@example.com"}`, Valid: true}, "user@example.com", false}, + {"corrupt", sql.NullString{String: `{`, Valid: true}, "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + c, err := msmailConfigOf(&store.Source{Identifier: "team@example.com", SyncConfig: tc.config}) + if tc.bad { + require.Error(err) + return + } + require.NoError(err) + assert.Equal(tc.want, c.SignedInAs) + assert.Equal(tc.want != "", c.shared()) + }) + } +} diff --git a/cmd/msgvault/cmd/deletions.go b/cmd/msgvault/cmd/deletions.go index d7260533f..569681ee1 100644 --- a/cmd/msgvault/cmd/deletions.go +++ b/cmd/msgvault/cmd/deletions.go @@ -1051,6 +1051,18 @@ Examples: } account := target.Account src := target.Source + if src.SourceType == sourceTypeMSMail { + // A shared or delegated mailbox needs Mail.ReadWrite.Shared and + // /users/ paths for deletion; neither is wired up yet, + // and the own-mailbox client would act on the wrong mailbox. + mcfg, err := msmailConfigOf(src) + if err != nil { + return err + } + if mcfg.shared() { + return fmt.Errorf("delete-staged does not support the shared mailbox %s yet; it was added with --as %s", account, mcfg.SignedInAs) + } + } // Set up context with cancellation ctx, cancel := context.WithCancel(cmd.Context()) diff --git a/cmd/msgvault/cmd/sync.go b/cmd/msgvault/cmd/sync.go index d6d9b63ef..3e74d9beb 100644 --- a/cmd/msgvault/cmd/sync.go +++ b/cmd/msgvault/cmd/sync.go @@ -208,7 +208,7 @@ func runSyncIncrementalLocal(cmd *cobra.Command, args []string) error { break } fmt.Printf("Syncing Microsoft Graph mail for %s\n", src.Identifier) - sum, err := runMSMailSync(ctx, s, src.Identifier, func(line string) { fmt.Println(line) }, state) + sum, err := runMSMailSync(ctx, s, src, func(line string) { fmt.Println(line) }, state) if err != nil { syncErrors = append(syncErrors, fmt.Sprintf("%s: %v", src.Identifier, err)) continue diff --git a/cmd/msgvault/cmd/sync_msmail.go b/cmd/msgvault/cmd/sync_msmail.go index 402b3f31c..ca18c2c3d 100644 --- a/cmd/msgvault/cmd/sync_msmail.go +++ b/cmd/msgvault/cmd/sync_msmail.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json/v2" "fmt" "io" "time" @@ -26,6 +27,42 @@ func newGraphMailManager(state *invocation) *microsoft.GraphManager { ) } +// msmailSourceConfig is the sync_config of a Graph mail source. It is empty +// for the signed-in user's own mailbox. For a shared or delegated mailbox, +// SignedInAs is the user whose token reads it; the source identifier is the +// mailbox itself. +type msmailSourceConfig struct { + SignedInAs string `json:"signed_in_as,omitempty"` +} + +// shared reports whether the source reads a mailbox other than the signed-in +// user's own. +func (c msmailSourceConfig) shared() bool { return c.SignedInAs != "" } + +func msmailConfigOf(src *store.Source) (msmailSourceConfig, error) { + var c msmailSourceConfig + if src == nil || !src.SyncConfig.Valid || src.SyncConfig.String == "" { + return c, nil + } + if err := json.Unmarshal([]byte(src.SyncConfig.String), &c); err != nil { + return c, fmt.Errorf("read Graph mail config for %s: %w", src.Identifier, err) + } + return c, nil +} + +// newGraphMailSharedManager requests Mail.Read.Shared on top of the sync +// scopes. Shared and delegated mailbox sources use it. +func newGraphMailSharedManager(state *invocation) *microsoft.GraphManager { + cfg := state.cfg + return microsoft.NewGraphMailSharedManager( + cfg.Microsoft.ClientID, + cfg.Microsoft.EffectiveTenantID(), + cfg.Microsoft.EffectiveRedirectURI(), + cfg.TokensDir(), + state.logger, + ) +} + // newGraphMailWriteManager requests Mail.ReadWrite on top of the sync scopes. // delete-staged uses it. func newGraphMailWriteManager(state *invocation) *microsoft.GraphManager { @@ -40,14 +77,27 @@ func newGraphMailWriteManager(state *invocation) *microsoft.GraphManager { } // runMSMailSync syncs one Graph mail account. The first run downloads every -// folder; later runs fetch only the changes. -func runMSMailSync(ctx context.Context, s *store.Store, email string, progress func(string), state *invocation) (*msmail.Summary, error) { +// folder; later runs fetch only the changes. A shared or delegated mailbox is +// read through the token of the user who added it. +func runMSMailSync(ctx context.Context, s *store.Store, src *store.Source, progress func(string), state *invocation) (*msmail.Summary, error) { cfg := state.cfg - tokenFn, err := newGraphMailManager(state).TokenSource(ctx, email) + email := src.Identifier + mcfg, err := msmailConfigOf(src) + if err != nil { + return nil, err + } + mgr := newGraphMailManager(state) + if mcfg.shared() { + mgr = newGraphMailSharedManager(state) + } + tokenFn, err := mgr.TokenSource(ctx, email) if err != nil { return nil, err } client := msmail.NewClient(msmail.GraphBaseURL, tokenFn, msmailQPS) + if mcfg.shared() { + client.ForMailbox(email) + } return msmail.Import(ctx, s, client, msmail.Options{ Email: email, AttachmentsDir: cfg.AttachmentsDir(), @@ -62,7 +112,7 @@ func runScheduledMSMailSync(ctx context.Context, src *store.Source, s *store.Sto if err := runPostSourceCreateMigrationsForInvocation(s, state); err != nil { return fmt.Errorf("post-source-create migrations: %w", err) } - _, err := runMSMailSync(ctx, s, src.Identifier, nil, state) + _, err := runMSMailSync(ctx, s, src, nil, state) return err } diff --git a/docs/changelog.md b/docs/changelog.md index 2ea9bf273..5ab743dc8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,5 @@ --- -last_edited: "2026-10-03" +last_edited: "2026-10-05" title: Changelog description: Release history for msgvault --- @@ -8,6 +8,11 @@ All notable changes to msgvault, grouped by release. ## Unreleased +- `add-o365 --graph --as ` syncs a shared or delegated Microsoft 365 + mailbox through Microsoft Graph, signed in as you. It needs the + `Mail.Read.Shared` permission. See + [shared and delegated mailboxes](guides/oauth-setup.md#shared-and-delegated-mailboxes). + - Rerunning `import-whatsapp` on an Apple `ChatStorage.sqlite` writes only new and changed messages instead of rewriting the whole archive, and picks up edits and senders that `LID.sqlite` resolves later. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fa3de2b9a..76ba6a5d6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -558,6 +558,7 @@ Requires a `[microsoft]` section with `client_id` in `config.toml`. See the [OAu | `--headless` | `false` | Sign in with a device code instead of a local browser | | `--no-default-identity` | `false` | Do not auto-confirm the email address as this account's "me" identity. Saved across syncs and re-authorization; only explicit `--no-default-identity=false` clears the choice. See [saved identity choice](#saved-default-identity-choice) | | `--graph` | `false` | Sync through the Microsoft Graph mail API instead of IMAP. Creates an `msmail` account. Needs the `Mail.Read` permission. `delete-staged` asks for `Mail.ReadWrite` on first use | +| `--as` | — | With `--graph`: sign in as this user and sync `` as a shared or delegated mailbox the user can open. Needs the `Mail.Read.Shared` permission. `delete-staged` does not support these accounts yet | After adding the account, sync it with `msgvault sync-full`. For a `--graph` account, use `msgvault sync`. See diff --git a/docs/guides/oauth-setup.md b/docs/guides/oauth-setup.md index 05e49f735..67eaede4e 100644 --- a/docs/guides/oauth-setup.md +++ b/docs/guides/oauth-setup.md @@ -442,6 +442,30 @@ permission `Mail.ReadWrite`. Sync does not use it. The first `delete-staged` for the account asks to upgrade the token. See [Deleting Email](/docs/usage/deletion/). +#### Shared and delegated mailboxes + +A shared mailbox, or another user's mailbox you have been granted access to, +syncs as its own account. You sign in as yourself; msgvault never signs in as +the shared mailbox. Add the **Microsoft Graph** delegated permission +`Mail.Read.Shared` to the app registration, then name yourself with `--as`: + +```bash +msgvault add-o365 team@example.com --graph --as you@example.com +msgvault sync team@example.com +``` + +- The account is `team@example.com`, and its token is saved under + `tokens/msmail_team@example.com.json`. The token is yours: it is separate from + your own mailbox's token, and removing the account deletes only this copy. +- Microsoft Graph checks your access to the mailbox on every request. If the + access is removed, sync fails with a permission error. +- Sync works as for your own mailbox: every folder on the first run, then + only the changes. +- `delete-staged` does not support shared or delegated mailboxes yet. + +Running `add-o365 team@example.com --graph` again without `--as` turns the +account back into a mailbox you sign in to directly. + A Graph account is a new account. If the same mailbox is also synced over IMAP, the vault holds two copies. Run `msgvault dedup --collection` to hide the extra copies, and `--undo` to reverse it. diff --git a/internal/microsoft/graph_oauth.go b/internal/microsoft/graph_oauth.go index b2dc59c1d..c82d8e37f 100644 --- a/internal/microsoft/graph_oauth.go +++ b/internal/microsoft/graph_oauth.go @@ -27,6 +27,7 @@ const ( scopeGraphChannelMemberRead = "https://graph.microsoft.com/ChannelMember.Read.All" scopeGraphMailRead = "https://graph.microsoft.com/Mail.Read" scopeGraphMailReadWrite = "https://graph.microsoft.com/Mail.ReadWrite" + scopeGraphMailReadShared = "https://graph.microsoft.com/Mail.Read.Shared" ) // GraphScopes returns the OAuth scopes requested for Microsoft Teams ingestion @@ -47,6 +48,13 @@ func GraphMailScopes() []string { return []string{scopeGraphMailRead, scopeGraphUserRead, scopeOfflineAccess, "openid", scopeEmail} } +// GraphMailSharedScopes returns the mail scopes plus Mail.Read.Shared, which +// reading a shared or delegated mailbox needs. It keeps Mail.Read, so the +// token also satisfies the sync manager for the user's own mailbox. +func GraphMailSharedScopes() []string { + return append(GraphMailScopes(), scopeGraphMailReadShared) +} + // GraphMailWriteScopes returns the mail scopes plus Mail.ReadWrite, which // deletion needs. It keeps Mail.Read, so the sync manager still accepts a // token granted with these scopes. @@ -107,6 +115,17 @@ func NewGraphMailWriteManager(clientID, tenantID, redirectURI, tokensDir string, return m } +// NewGraphMailSharedManager is NewGraphMailManager for a shared or delegated +// mailbox: it requests GraphMailSharedScopes. The token belongs to the user +// who signs in, and is saved under the shared mailbox's address. See +// AuthorizeAs. +func NewGraphMailSharedManager(clientID, tenantID, redirectURI, tokensDir string, logger *slog.Logger) *GraphManager { + m := NewGraphMailManager(clientID, tenantID, redirectURI, tokensDir, logger) + m.scopes = GraphMailSharedScopes() + m.reauthCmd = "msgvault add-o365 %s --graph --as " + return m +} + func newGraphManager(clientID, tenantID, redirectURI, tokensDir string, logger *slog.Logger) *GraphManager { if tenantID == "" { tenantID = DefaultTenant @@ -158,13 +177,22 @@ func (m *GraphManager) TokenPath(email string) string { // persists the token. Unlike Manager.Authorize there is no IMAP scope // correction step — Graph scopes are identical across account types. func (m *GraphManager) Authorize(ctx context.Context, email string) error { + return m.AuthorizeAs(ctx, email, email) +} + +// AuthorizeAs signs in as signIn, verifies the ID token matches signIn, and +// saves the token under account. For the user's own mailbox the two are the +// same address. For a shared or delegated mailbox, account is that mailbox: +// msgvault never signs in as it, and Microsoft Graph decides on each request +// whether signIn may read it. +func (m *GraphManager) AuthorizeAs(ctx context.Context, signIn, account string) error { scopes := m.scopes d := m.delegate() - token, nonce, err := d.doBrowserFlow(ctx, email, scopes) + token, nonce, err := d.doBrowserFlow(ctx, signIn, scopes) if err != nil { return err } - _, claims, err := d.resolveTokenEmail(ctx, email, token, nonce) + _, claims, err := d.resolveTokenEmail(ctx, signIn, token, nonce) if err != nil { return err } @@ -172,7 +200,7 @@ func (m *GraphManager) Authorize(ctx context.Context, email string) error { if claims != nil { tenantID = claims.TenantID } - return m.saveToken(email, token, scopes, tenantID) + return m.saveToken(account, token, scopes, tenantID) } // TokenSource loads the persisted Graph token and returns a function yielding a diff --git a/internal/microsoft/graph_oauth_test.go b/internal/microsoft/graph_oauth_test.go index c321f2c5c..80ee4a042 100644 --- a/internal/microsoft/graph_oauth_test.go +++ b/internal/microsoft/graph_oauth_test.go @@ -324,3 +324,64 @@ func TestRefreshingAccessTokenPersistsToEachManagerFileAndNamesProduct(t *testin }) } } + +// A shared mailbox is read through the signed-in user's token: AuthorizeAs +// signs in and verifies that user, requests Mail.Read.Shared, and saves the +// token under the shared mailbox's address. +func TestGraphMailSharedManager_AuthorizeAs(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + dir := t.TempDir() + m := NewGraphMailSharedManager("test-client", "common", "", dir, slog.Default()) + m.verifyIDTokenFn = testVerifyFn + + var hint string + var gotScopes []string + m.browserFlowFn = func(_ context.Context, email string, scopes []string) (*oauth2.Token, string, error) { + hint, gotScopes = email, scopes + idToken := makeIDToken(t, map[string]any{"email": email, "tid": "org-tid"}) + tok := (&oauth2.Token{AccessToken: "graph-access", RefreshToken: "graph-refresh", TokenType: "Bearer"}). + WithExtra(map[string]any{"id_token": idToken}) + return tok, "test-nonce", nil + } + + require.NoError(m.AuthorizeAs(t.Context(), "user@company.com", "team@company.com")) + assert.Equal("user@company.com", hint, "the sign-in is the user, never the shared mailbox") + assert.Contains(gotScopes, "https://graph.microsoft.com/Mail.Read.Shared") + assert.True(m.HasToken("team@company.com")) + assert.False(m.HasToken("user@company.com"), "the user's own mail token is untouched") + + ok, err := m.HasScopes("team@company.com") + require.NoError(err) + assert.True(ok) + _, err = NewGraphMailManager("test-client", "common", "", dir, slog.Default()).TokenSource(t.Context(), "team@company.com") + require.NoError(err, "the shared grant keeps Mail.Read") +} + +func TestGraphMailSharedManager_AuthorizeAsMismatch(t *testing.T) { + dir := t.TempDir() + m := NewGraphMailSharedManager("test-client", "common", "", dir, slog.Default()) + m.verifyIDTokenFn = testVerifyFn + m.browserFlowFn = func(_ context.Context, _ string, _ []string) (*oauth2.Token, string, error) { + idToken := makeIDToken(t, map[string]any{"email": "team@company.com"}) + tok := (&oauth2.Token{AccessToken: "x", TokenType: "Bearer"}). + WithExtra(map[string]any{"id_token": idToken}) + return tok, "nonce", nil + } + err := m.AuthorizeAs(t.Context(), "user@company.com", "team@company.com") + mismatch := &TokenMismatchError{} + require.ErrorAs(t, err, &mismatch, "signing in as the mailbox instead of the user is refused") + assert.False(t, m.HasToken("team@company.com")) +} + +// A token saved before Mail.Read.Shared was requested does not satisfy the +// shared manager, and the error says how to re-authorize. +func TestGraphMailSharedManager_RequiresSharedScope(t *testing.T) { + dir := t.TempDir() + m := NewGraphMailSharedManager("test-client", "common", "", dir, slog.Default()) + token := &oauth2.Token{AccessToken: "graph-access", RefreshToken: "graph-refresh", TokenType: "Bearer"} + require.NoError(t, m.saveToken("team@company.com", token, GraphMailScopes(), "org-tid")) + _, err := m.TokenSource(t.Context(), "team@company.com") + require.ErrorContains(t, err, "Mail.Read.Shared") + require.ErrorContains(t, err, "--as") +} diff --git a/internal/msmail/client.go b/internal/msmail/client.go index 49bf3af86..6122cc780 100644 --- a/internal/msmail/client.go +++ b/internal/msmail/client.go @@ -19,6 +19,10 @@ const GraphBaseURL = "https://graph.microsoft.com/v1.0" // Client adds the mail endpoints to the shared Graph transport. type Client struct { *msgraph.Client + + // root is the mailbox every request reads: "/me" for the signed-in + // user, or "/users/
" for a shared or delegated mailbox. + root string } // NewClient creates a mail Client. Every request asks for immutable IDs, so a @@ -26,7 +30,18 @@ type Client struct { func NewClient(baseURL string, token msgraph.TokenFunc, qps float64) *Client { c := msgraph.NewClient(baseURL, token, qps) c.Headers = map[string]string{"Prefer": `IdType="ImmutableId", odata.maxpagesize=1000`} - return &Client{c} + return &Client{Client: c, root: "/me"} +} + +// ForMailbox points the client at another user's mailbox, one the signed-in +// user can open: a shared mailbox or a delegated one. Graph checks the access +// on every request, and the token needs Mail.Read.Shared. An empty address +// keeps the signed-in user's own mailbox. +func (c *Client) ForMailbox(address string) *Client { + if address != "" { + c.root = "/users/" + url.PathEscape(address) + } + return c } // Folder is a mail folder. Path joins the display names from the top of the @@ -68,28 +83,28 @@ func (c *Client) ListFolders(ctx context.Context) ([]Folder, error) { } out = append(out, f) if f.ChildFolderCount > 0 { - if err := walk("/me/mailFolders/"+url.PathEscape(f.ID)+"/childFolders"+folderSelect, f.Path); err != nil { + if err := walk(c.root+"/mailFolders/"+url.PathEscape(f.ID)+"/childFolders"+folderSelect, f.Path); err != nil { return err } } } return nil } - return out, walk("/me/mailFolders"+folderSelect, "") + return out, walk(c.root+"/mailFolders"+folderSelect, "") } // WellKnownFolderID returns the ID of a well-known folder such as "sentitems". // It returns msgraph.ErrNotFound when the mailbox does not have that folder. func (c *Client) WellKnownFolderID(ctx context.Context, name string) (string, error) { var f Folder - err := c.GetJSON(ctx, "/me/mailFolders/"+name+"?$select=id", &f) + err := c.GetJSON(ctx, c.root+"/mailFolders/"+name+"?$select=id", &f) return f.ID, err } // DeltaStartURL is the first delta request for a folder with no saved cursor. // It returns every message in the folder and ends with a deltaLink. -func DeltaStartURL(folderID string) string { - return "/me/mailFolders/" + url.PathEscape(folderID) + "/messages/delta?$select=receivedDateTime" +func (c *Client) DeltaStartURL(folderID string) string { + return c.root + "/mailFolders/" + url.PathEscape(folderID) + "/messages/delta?$select=receivedDateTime" } // DeltaPage fetches one page of a delta walk. The page carries a NextLink @@ -104,7 +119,7 @@ func (c *Client) DeltaPage(ctx context.Context, pageURL string) (*msgraph.ListRe // GetMIME returns the full RFC 5322 source of a message. func (c *Client) GetMIME(ctx context.Context, id string) ([]byte, error) { - return c.GetRawWithTimeout(ctx, "/me/messages/"+url.PathEscape(id)+"/$value", 10*time.Minute) + return c.GetRawWithTimeout(ctx, c.root+"/messages/"+url.PathEscape(id)+"/$value", 10*time.Minute) } // MessageInfo is where a message is now and when it arrived. @@ -117,7 +132,7 @@ type MessageInfo struct { // msgraph.ErrNotFound when the message no longer exists. func (c *Client) LookupMessage(ctx context.Context, id string) (MessageInfo, error) { var m MessageInfo - err := c.GetJSON(ctx, "/me/messages/"+url.PathEscape(id)+"?$select=parentFolderId,receivedDateTime", &m) + err := c.GetJSON(ctx, c.root+"/messages/"+url.PathEscape(id)+"?$select=parentFolderId,receivedDateTime", &m) return m, err } @@ -141,7 +156,7 @@ func (c *Client) BatchDeleteMessages(context.Context, []string) error { } func (c *Client) post(ctx context.Context, id, action string, body any) error { - path := "/me/messages/" + url.PathEscape(id) + "/" + action + path := c.root + "/messages/" + url.PathEscape(id) + "/" + action err := c.Post(ctx, path, body) if errors.Is(err, msgraph.ErrNotFound) { return &gmail.NotFoundError{Path: path} diff --git a/internal/msmail/importer.go b/internal/msmail/importer.go index 762faac6a..06dfafeec 100644 --- a/internal/msmail/importer.go +++ b/internal/msmail/importer.go @@ -137,9 +137,9 @@ func Import(ctx context.Context, st *store.Store, c *Client, opts Options, log * restarted := false switch { case link == "": - link, seen = DeltaStartURL(f.ID), map[string]bool{} + link, seen = c.DeltaStartURL(f.ID), map[string]bool{} case strings.HasPrefix(link, walkPrefix): - link, seen = DeltaStartURL(f.ID), map[string]bool{} + link, seen = c.DeltaStartURL(f.ID), map[string]bool{} } for { page, perr := c.DeltaPage(ctx, link) @@ -147,7 +147,7 @@ func Import(ctx context.Context, st *store.Store, c *Client, opts Options, log * // The token expired. Walk the folder again; messages already // in the vault are not downloaded again. log.Info("delta token expired, walking folder again", "folder", f.Path) - link, seen, restarted = DeltaStartURL(f.ID), map[string]bool{}, true + link, seen, restarted = c.DeltaStartURL(f.ID), map[string]bool{}, true continue } if perr != nil { diff --git a/internal/msmail/importer_test.go b/internal/msmail/importer_test.go index 9da21260c..4245e9612 100644 --- a/internal/msmail/importer_test.go +++ b/internal/msmail/importer_test.go @@ -50,7 +50,8 @@ type fakeGraph struct { throttle bool // answer the next $value with 429 once denied bool // answer move and permanentDelete with 403 pageSize int - stopAt int // fail the delta page at this skip offset, when non-zero + stopAt int // fail the delta page at this skip offset, when non-zero + root string // mailbox path the server answers for; "/me" when empty mimeCalls atomic.Int32 walkStarts atomic.Int32 // delta requests with no token and no nextLink @@ -116,10 +117,25 @@ func (f *fakeGraph) writeJSON(w http.ResponseWriter, v any) { assert.NoError(f.t, json.MarshalWrite(w, v)) } +// mailboxRoot is the path prefix of the mailbox the server holds, as Graph +// writes it into delta links. +func (f *fakeGraph) mailboxRoot() string { + if f.root == "" { + return "/me" + } + return f.root +} + func (f *fakeGraph) serve(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() p := r.URL.Path + // Serve one mailbox: a request for any other one is a test failure. + if !strings.HasPrefix(p, f.mailboxRoot()+"/") { + http.Error(w, "unexpected mailbox "+p, http.StatusBadRequest) + return + } + p = "/me" + strings.TrimPrefix(p, f.mailboxRoot()) q := r.URL.Query() switch { case p == "/me/mailFolders": @@ -232,7 +248,7 @@ func (f *fakeGraph) delta(w http.ResponseWriter, folder string, q map[string][]s return "" } link := func(kind string, vals ...string) string { - return f.srv.URL + "/me/mailFolders/" + folder + "/messages/delta?" + kind + "&" + strings.Join(vals, "&") + return f.srv.URL + f.mailboxRoot() + "/mailFolders/" + folder + "/messages/delta?" + kind + "&" + strings.Join(vals, "&") } if f.gone[folder] { http.Error(w, `{"error":{"code":"syncStateNotFound"}}`, http.StatusGone) @@ -300,6 +316,9 @@ func (f *fakeGraph) delta(w http.ResponseWriter, folder string, q map[string][]s func (f *fakeGraph) sync(t *testing.T, st *store.Store) (*Summary, error) { t.Helper() c := NewClient(f.srv.URL, func(context.Context) (string, error) { return "tok", nil }, 1000) + if f.root != "" { + c.ForMailbox(strings.TrimPrefix(f.root, "/users/")) + } dir := f.attachDir if dir == "" { dir = f.t.TempDir() @@ -365,6 +384,33 @@ func TestImportFirstSyncThenNoChange(t *testing.T) { assert.EqualValues(0, f.mimeCalls.Load()) } +// A shared or delegated mailbox is read at /users/
: every request, +// including the delta links Graph hands back, stays on that mailbox, and the +// signed-in user's own mailbox at /me is never touched. +func TestImportSharedMailbox(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + f := newFakeGraph(t) + f.root = "/users/team@example.com" + f.put("m1", "inbox") + f.put("m2", "inbox") + f.put("m3", "archive") + + sum, err := f.sync(t, st) + require.NoError(err) + assert.Equal(3, sum.Added) + assert.Equal(map[string]string{"m1": "Inbox", "m2": "Inbox", "m3": "Archive"}, state(t, st)) + + f.put("m4", "inbox") + f.put("m1", "archive") + sum, err = f.sync(t, st) + require.NoError(err) + assert.Equal(1, sum.Added) + assert.Equal(1, sum.Moved) + assert.Equal(map[string]string{"m1": "Archive", "m2": "Inbox", "m3": "Archive", "m4": "Inbox"}, state(t, st)) +} + func TestImportMoveAndDelete(t *testing.T) { require := require.New(t) assert := assert.New(t)