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
60 changes: 58 additions & 2 deletions cmd/msgvault/cmd/addo365.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"encoding/json/v2"
"errors"
"fmt"
"strings"
Expand All @@ -16,6 +17,7 @@ var (
o365TenantID string
noDefaultIdentityAddO365 bool
o365Graph bool
o365As string
)

func newAddO365Cmd() *cobra.Command {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
}
Expand All @@ -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 <email> as a shared or delegated mailbox")
registerOAuthPreflightedFlag(cmd)
return cmd
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
113 changes: 113 additions & 0 deletions cmd/msgvault/cmd/addo365_shared_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
}
12 changes: 12 additions & 0 deletions cmd/msgvault/cmd/deletions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<mailbox> 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())
Expand Down
2 changes: 1 addition & 1 deletion cmd/msgvault/cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 54 additions & 4 deletions cmd/msgvault/cmd/sync_msmail.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"encoding/json/v2"
"fmt"
"io"
"time"
Expand All @@ -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 {
Expand All @@ -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(),
Expand All @@ -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
}

Expand Down
7 changes: 6 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
last_edited: "2026-10-03"
last_edited: "2026-10-05"
title: Changelog
description: Release history for msgvault
---
Expand All @@ -8,6 +8,11 @@ All notable changes to msgvault, grouped by release.

## Unreleased

- `add-o365 --graph --as <you>` 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.
Expand Down
1 change: 1 addition & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<email>` 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
Expand Down
Loading