From b6d254d54660345c1c8d11f7e3ca8ca433c54397 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Mon, 5 Oct 2026 14:48:38 -0400 Subject: [PATCH] feat(msmail): sync shared and delegated mailboxes over Microsoft Graph `add-o365 --graph --as ` signs in as the user, verifies the ID token is that user's, requests Mail.Read.Shared, and saves the token under the mailbox's address. The source records the user in its sync config, and sync points the Graph mail client at /users/ instead of /me, so the same per-folder delta walk and $value MIME download read the shared mailbox. delete-staged refuses these accounts for now: deletion would need Mail.ReadWrite.Shared, and the own-mailbox client must not act on them. TJC-3129 Co-Authored-By: Claude Opus 5.5 --- cmd/msgvault/cmd/addo365.go | 60 ++++++++++++- cmd/msgvault/cmd/addo365_shared_test.go | 113 ++++++++++++++++++++++++ cmd/msgvault/cmd/deletions.go | 12 +++ cmd/msgvault/cmd/sync.go | 2 +- cmd/msgvault/cmd/sync_msmail.go | 58 +++++++++++- docs/changelog.md | 7 +- docs/cli-reference.md | 1 + docs/guides/oauth-setup.md | 24 +++++ internal/microsoft/graph_oauth.go | 34 ++++++- internal/microsoft/graph_oauth_test.go | 61 +++++++++++++ internal/msmail/client.go | 33 +++++-- internal/msmail/importer.go | 6 +- internal/msmail/importer_test.go | 50 ++++++++++- 13 files changed, 436 insertions(+), 25 deletions(-) create mode 100644 cmd/msgvault/cmd/addo365_shared_test.go 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)