Skip to content

feat: integrate upstream v1.11 and add fork release assets - #9

Merged
biglandy71 merged 4 commits into
mainfrom
feat/upstream-v1.11-token-release
Aug 10, 2026
Merged

feat: integrate upstream v1.11 and add fork release assets#9
biglandy71 merged 4 commits into
mainfrom
feat/upstream-v1.11-token-release

Conversation

@biglandy71

@biglandy71 biglandy71 commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • merge the exact upstream olkcli v1.11.0 commit b2e5ed84f24fbecffbf9899092497c2aeb4bb14a with an explicit two-parent merge commit
  • retain PlanMonster injected-token mode while making OLK_ACCESS_TOKEN environment-only (no --access-token, Kong help/schema field, argv propagation, persistence, or credential-store fallback)
  • retain upstream v1.11 guarded provider contracts, structured errors, immutable IDs, mailbox delegation, MCP behavior, and fork capability/expiry guards
  • add a fork-only GitHub Release workflow for checksummed native assets under the disjoint olk-pm-vX.Y.Z.N namespace

Merge and conflict resolution

The only content conflict was internal/cmd/root.go. The resolution keeps upstream's structured command-error writer and immutable-ID setup, while preserving PlanMonster's token-mode branch before Store() / Config() and exit-code 77 mapping.

Post-merge commits:

  • 5610545027d25d7b34d7dfe4001ec66f67824309 — environment-only token hardening
  • 5c9f4c2fae4747fd142d0b4a9434bede32371ab9 — fork-only GitHub release assets

Token-mode behavior

  • OLK_ACCESS_TOKEN is read directly from the environment and has no RootFlags/Kong representation
  • malformed or expired OLK_ACCESS_TOKEN_EXPIRES_AT fails before Graph/client/store/config access; expiry retains exit 77
  • token mode remains before keyring, account lookup, refresh, and persistence
  • incidental timezone formatting no longer reads config in token mode; explicit config get/set refuses before disk access
  • OLK_ACCOUNT_EMAIL remains a backward-compatible, non-authoritative display hint and is never used for authorization
  • OLK_NO_WRITE, OLK_NO_SEND, OLK_NO_INPUT, OLK_WRAP_UNTRUSTED, OLK_ENABLE_COMMANDS_EXACT, mailbox delegation, and MCP composition remain intact

Fork GitHub release design

.github/workflows/release-planmonster.yml triggers only on olk-pm-vX.Y.Z.N (example: olk-pm-v1.11.0.1). It builds six archives, verifies the expected set, emits and verifies checksums.txt, smoke-tests Linux AMD64, and creates a GitHub Release only.

For the example tag, NanoClaw consumes olk_1.11.0.1_linux_amd64.tar.gz and verifies it against checksums.txt. The workflow cannot match upstream v* or fork npm npm-v*, and does not invoke GoReleaser, Homebrew, npm, or the MCP Registry. No release tag was created.

Validation

Passed locally:

  • focused token-mode tests (including environment-only parsing/schema, no keyring/config touch, expiry, guards, MCP, and no output leak)
  • go mod tidy + clean go.mod / go.sum drift check
  • go vet ./...
  • go build ./...
  • go test -race -count=1 ./...
  • golangci-lint v2.11.4 run ./... (0 issues)
  • actionlint across all workflows (custom Blacksmith label allowlisted)
  • trigger-namespace disjointness check
  • git diff --check

scripts/test-npm-package.sh was started but intentionally stopped before completion at the request to stop further optional validation. scripts/test-bootstrap-npm.sh was not run locally. Existing PR CI remains the authority for the package job.

Release / merge safety

This PR does not move any existing tag, rewrite main, create a release tag, publish an artifact, or merge itself. After review and green CI, merge normally; create the first olk-pm-v* tag only as a separate release action following docs/github-releases.md.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

r-wa and others added 4 commits July 31, 2026 13:03
* feat(mail): add bounded page collector

* fix(mail): validate semantic continuation scopes

* feat(mail): collect bounded message pages

* fix(mail): guard empty message page responses

* feat(mail): add list order and JSON projection

* fix(mail): preserve selected JSON safety

* fix(mail): reject explicit empty select

* docs(mail): document bounded list traversal

* fix(mail): reject ordered classification filters

* fix(mail): keep list options immutable

* fix(mail): preserve list top option

* feat(mail): verify provider body format

* fix(mail): hydrate typed thread bodies

* feat(mail): expose protected provider contracts

* fix(cli): emit structured JSON errors

* feat(mail): add complete thread traversal

* fix(mail): preserve complete recipient evidence

* feat(mail): support immutable provider IDs

* fix(mail): accept combined preference-applied headers

* feat(mail): expose stable message observations

* fix(mail): preserve non-json select output

* fix(mail): satisfy lint for provider contracts

---------

Co-authored-by: rlrghb <roshin@roshin-macmini.local>
Amp-Thread-ID: https://ampcode.com/threads/T-019fec28-bf40-76a7-a4de-aa47fdf3c610
Co-authored-by: Dustin Lancaster <dustin@planmonster.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Fork release automation

Layer / File(s) Summary
Build and publish fork releases
.github/workflows/release-planmonster.yml, docs/github-releases.md, AGENTS.md
Adds tag validation, six platform archives, checksum verification, Linux smoke tests, and fork-only GitHub Release publication. Documents the release contract.

CLI and mail capabilities

Layer / File(s) Summary
Authentication and output contracts
internal/cmd/root.go, internal/cmd/token.go, internal/cmd/config.go, internal/cmd/version.go, internal/graphapi/validate.go, tests, documentation
Moves token injection to OLK_ACCESS_TOKEN, blocks stored configuration commands in token mode, adds structured errors, immutable-ID configuration, and advertised capabilities.
Graph mail contracts and safe retrieval
internal/graphapi/*.go, tests
Adds body preferences, immutable IDs, bounded and complete pagination, continuation validation, expanded message models, well-known folders, move receipts, and provider-response validation.
Mail command behavior and projections
internal/cmd/mail_*.go, tests
Adds ordering, validated JSON field selection, body-format options, complete thread retrieval, well-known folder lookup, structured move output, and related contract tests.
Documented CLI and mail contracts
README.md, SKILL.md, docs/npm-publishing.md
Documents mail projections, pagination, ordering, threading, token handling, immutable IDs, errors, and command options.

Sequence Diagram(s)

sequenceDiagram
  participant MailCommand
  participant GraphClient
  participant MicrosoftGraph
  participant BodyContract
  MailCommand->>GraphClient: request message or thread with body preference
  GraphClient->>MicrosoftGraph: send paged or batch request
  MicrosoftGraph-->>GraphClient: return messages, continuation links, and headers
  GraphClient->>BodyContract: validate preference, identity, conversation, and continuation
  BodyContract-->>GraphClient: return validated messages
  GraphClient-->>MailCommand: return bounded or complete results
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@biglandy71
biglandy71 merged commit badf96d into main Aug 10, 2026
4 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (5)
internal/graphapi/mail_pages_test.go (1)

39-42: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

t.Fatalf and t.Fatal run inside HTTP responders in three test files. The shared root cause is one pattern: a FailNow-family call inside an http.RoundTripper responder. t.Fatalf calls runtime.Goexit, and the Go testing package restricts that to the goroutine running the test function. When the HTTP client invokes the responder on another goroutine, the responder returns no response and the test hangs instead of reporting the failure. Several sites also return nil after the call, which yields a nil *http.Response if execution continues. Replace each call with t.Errorf and return a valid synthetic response.

  • internal/graphapi/mail_pages_test.go#L39-L42: change the default branch to t.Errorf and return graphJSONResponse(req, + "" + {"value":[]} + "" + ) instead of nil; apply the same change to the second default branch at Lines 175-178.
  • internal/cmd/mail_list_test.go#L252-L257: change the batch-decode and request-count t.Fatalf calls to t.Errorf and return a well-formed batch response; apply the same change at Lines 343-348 and Lines 384-389.
  • internal/cmd/mail_provider_contract_test.go#L60-L76: in the "no Graph request expected" responder, change t.Fatalf to t.Errorf and return a valid response instead of nil.

Note that t.Fatal calls inside the collectMessagePages page callbacks in internal/graphapi/mail_pages_test.go are correct, because collectMessagePages invokes those callbacks synchronously on the test goroutine.

internal/cmd/mail_folders.go (1)

53-75: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the loop-invariant WellKnown branch out of the row loop.

c.WellKnown != "" does not change between iterations. The loop builds the four-column row, then discards it and rebuilds a five-column row. The header is then set after the loop. Decide the shape once before the loop.

♻️ Proposed refactor
-	headers := []string{"ID", "NAME", "TOTAL", "UNREAD"}
+	wellKnown := c.WellKnown != ""
+	headers := []string{"ID", "NAME", "TOTAL", "UNREAD"}
+	if wellKnown {
+		headers = []string{"ID", "NAME", "WELL-KNOWN", "TOTAL", "UNREAD"}
+	}
 	rows := make([][]string, 0, len(folders))
 	for _, f := range folders {
-		row := []string{
-			f.ID,
-			f.DisplayName,
-			fmt.Sprintf("%d", f.TotalCount),
-			fmt.Sprintf("%d", f.UnreadCount),
-		}
-		if c.WellKnown != "" {
-			row = []string{
-				f.ID,
-				f.DisplayName,
-				f.WellKnownName,
-				fmt.Sprintf("%d", f.TotalCount),
-				fmt.Sprintf("%d", f.UnreadCount),
-			}
-		}
-		rows = append(rows, row)
-	}
-	if c.WellKnown != "" {
-		headers = []string{"ID", "NAME", "WELL-KNOWN", "TOTAL", "UNREAD"}
+		row := []string{f.ID, f.DisplayName}
+		if wellKnown {
+			row = append(row, f.WellKnownName)
+		}
+		row = append(row,
+			fmt.Sprintf("%d", f.TotalCount),
+			fmt.Sprintf("%d", f.UnreadCount),
+		)
+		rows = append(rows, row)
 	}
internal/cmd/mail_list.go (1)

27-41: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard against drift between the selector registry and the projection switch.

Three declarations must stay in sync: mailListSelectableFields, the fields of mailListJSONMessage, and the switch in projectMailMessage. The switch has no default branch. If a future change adds an entry to mailListSelectableFields without a matching case, parseMailSelect accepts the selector, the Graph query requests the field, and the JSON output omits it silently. That is the exact failure mode the comment on Line 24 warns about.

Make the omission loud. One option is a default branch that returns an error. A stronger option is to derive mailListSelectableFields from a single table of setter functions, so one entry defines both admission and projection.

♻️ Minimal guard: single source of truth for admission and projection
-var mailListSelectableFields = map[string]bool{
-	"id":               true,
-	"subject":          true,
-	"from":             true,
-	"toRecipients":     true,
-	"ccRecipients":     true,
-	"bccRecipients":    true,
-	"replyTo":          true,
-	"receivedDateTime": true,
-	"isRead":           true,
-	"hasAttachments":   true,
-	"bodyPreview":      true,
-	"categories":       true,
-	"conversationId":   true,
-}
+var mailListSelectableFields = map[string]func(*mailListJSONMessage, *graphapi.MailMessage){
+	"id":               func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.ID = &m.ID },
+	"subject":          func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.Subject = &m.Subject },
+	"from":             func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.From = &m.From },
+	"toRecipients":     func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.To = &m.To },
+	"ccRecipients":     func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.Cc = &m.Cc },
+	"bccRecipients":    func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.Bcc = &m.Bcc },
+	"replyTo":          func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.ReplyTo = &m.ReplyTo },
+	"receivedDateTime": func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.ReceivedAt = &m.ReceivedAt },
+	"isRead":           func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.IsRead = &m.IsRead },
+	"hasAttachments":   func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.HasAttachments = &m.HasAttachments },
+	"bodyPreview":      func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.BodyPreview = &m.BodyPreview },
+	"categories":       func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.Categories = &m.Categories },
+	"conversationId":   func(p *mailListJSONMessage, m *graphapi.MailMessage) { p.ConversationID = &m.ConversationID },
+}

parseMailSelect already uses the two-value map lookup at Line 174, so it needs no change. projectMailMessage collapses to:

 func projectMailMessage(message *graphapi.MailMessage, selected []string) mailListJSONMessage {
 	projected := mailListJSONMessage{}
 	for _, field := range selected {
-		switch field {
-		case "id":
-			projected.ID = &message.ID
-		// ... eleven more cases ...
-		case "conversationId":
-			projected.ConversationID = &message.ConversationID
-		}
+		if apply, ok := mailListSelectableFields[field]; ok {
+			apply(&projected, message)
+		}
 	}
 	return projected
 }

Also applies to: 225-258

internal/graphapi/mail.go (1)

766-771: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the dead slice initialization.

Lines 790-793 overwrite To, Cc, Bcc, and ReplyTo unconditionally with recipientAddresses, which always returns a non-nil slice. The initializer values are never observed.

♻️ Proposed simplification
-	m := MailMessage{
-		To:      []string{},
-		Cc:      []string{},
-		Bcc:     []string{},
-		ReplyTo: []string{},
-	}
+	m := MailMessage{}
internal/graphapi/mail_provider_contract_test.go (1)

12-47: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the requested $select so the projection contract is covered.

The stub ignores query parameters, so this test passes even though messageDetailSelect in internal/graphapi/mail.go omits replyTo. Real Graph omits unselected properties. Add an assertion on req.URL.Query().Get("$select") that requires every recipient class the test verifies.

♻️ Proposed assertion
 	client := testGraphClient(t, func(req *http.Request) *http.Response {
+		selected := req.URL.Query().Get("$select")
+		for _, field := range []string{"toRecipients", "ccRecipients", "bccRecipients", "replyTo"} {
+			if !strings.Contains(selected, field) {
+				t.Errorf("$select = %q, want %s requested", selected, field)
+			}
+		}
 		return graphJSONResponse(req, `{

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 63a5288b-e8bd-4887-95ff-57ef733f4fd4

📥 Commits

Reviewing files that changed from the base of the PR and between dd851da and 5c9f4c2.

📒 Files selected for processing (36)
  • .github/workflows/release-planmonster.yml
  • AGENTS.md
  • README.md
  • SKILL.md
  • docs/github-releases.md
  • docs/npm-publishing.md
  • internal/cmd/config.go
  • internal/cmd/mail_batch.go
  • internal/cmd/mail_folders.go
  • internal/cmd/mail_get.go
  • internal/cmd/mail_list.go
  • internal/cmd/mail_list_test.go
  • internal/cmd/mail_move.go
  • internal/cmd/mail_provider_contract_test.go
  • internal/cmd/mail_thread.go
  • internal/cmd/root.go
  • internal/cmd/root_error_test.go
  • internal/cmd/token.go
  • internal/cmd/token_test.go
  • internal/cmd/version.go
  • internal/cmd/version_test.go
  • internal/config/config_test.go
  • internal/graphapi/client.go
  • internal/graphapi/client_guards_test.go
  • internal/graphapi/delta.go
  • internal/graphapi/delta_test.go
  • internal/graphapi/mail.go
  • internal/graphapi/mail_batch.go
  • internal/graphapi/mail_batch_test.go
  • internal/graphapi/mail_body_preference.go
  • internal/graphapi/mail_body_preference_test.go
  • internal/graphapi/mail_immutable_id.go
  • internal/graphapi/mail_pages.go
  • internal/graphapi/mail_pages_test.go
  • internal/graphapi/mail_provider_contract_test.go
  • internal/graphapi/validate.go

Comment thread internal/cmd/root.go
Comment on lines +304 to +330
func writeCommandError(
jsonMode bool,
err error,
stdout io.Writer,
stderr io.Writer,
) {
if jsonMode {
code, status := graphapi.ErrorMetadata(err)
value := struct {
Error struct {
Code string `json:"code"`
Status int `json:"status"`
} `json:"error"`
}{}
value.Error.Code = code
value.Error.Status = status
if encodeErr := json.NewEncoder(stdout).Encode(value); encodeErr != nil {
fmt.Fprintln(stderr, "Error: JSON error output failed")
}
return
}
fmt.Fprintf(
stderr,
"Error: %s\n",
outfmt.SanitizeMultiline(err.Error()),
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

JSON mode discards the error message.

ErrorMetadata returns ("CommandFailed", 0) for any non-Graph error. In JSON mode the emitted object then carries only that code and status: 0. The original message text is written nowhere. A validation failure such as --select field "importance" is not available in mail list output becomes undiagnosable for the operator.

Write the sanitized message to stderr in addition to the structured object on stdout. Stdout stays parseable, and stderr keeps the human-readable hint.

🛠️ Proposed fix to retain diagnostics in JSON mode
 	if jsonMode {
 		code, status := graphapi.ErrorMetadata(err)
 		value := struct {
 			Error struct {
 				Code   string `json:"code"`
 				Status int    `json:"status"`
 			} `json:"error"`
 		}{}
 		value.Error.Code = code
 		value.Error.Status = status
 		if encodeErr := json.NewEncoder(stdout).Encode(value); encodeErr != nil {
 			fmt.Fprintln(stderr, "Error: JSON error output failed")
 		}
+		fmt.Fprintf(stderr, "Error: %s\n", outfmt.SanitizeMultiline(err.Error()))
 		return
 	}

As per coding guidelines: "Keep stdout parseable for command output (--json or --plain); send human-readable hints and progress messages to stderr."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func writeCommandError(
jsonMode bool,
err error,
stdout io.Writer,
stderr io.Writer,
) {
if jsonMode {
code, status := graphapi.ErrorMetadata(err)
value := struct {
Error struct {
Code string `json:"code"`
Status int `json:"status"`
} `json:"error"`
}{}
value.Error.Code = code
value.Error.Status = status
if encodeErr := json.NewEncoder(stdout).Encode(value); encodeErr != nil {
fmt.Fprintln(stderr, "Error: JSON error output failed")
}
return
}
fmt.Fprintf(
stderr,
"Error: %s\n",
outfmt.SanitizeMultiline(err.Error()),
)
}
func writeCommandError(
jsonMode bool,
err error,
stdout io.Writer,
stderr io.Writer,
) {
if jsonMode {
code, status := graphapi.ErrorMetadata(err)
value := struct {
Error struct {
Code string `json:"code"`
Status int `json:"status"`
} `json:"error"`
}{}
value.Error.Code = code
value.Error.Status = status
if encodeErr := json.NewEncoder(stdout).Encode(value); encodeErr != nil {
fmt.Fprintln(stderr, "Error: JSON error output failed")
}
fmt.Fprintf(stderr, "Error: %s\n", outfmt.SanitizeMultiline(err.Error()))
return
}
fmt.Fprintf(
stderr,
"Error: %s\n",
outfmt.SanitizeMultiline(err.Error()),
)
}

Source: Coding guidelines

Comment on lines +72 to +79
if preference != MessageBodyDefault {
item := resp.GetResponseById(stepID)
if item == nil || item.GetStatus() == nil || *item.GetStatus() >= 400 {
return nil, fmt.Errorf("batch message %q did not return a successful provider body response", stepID)
}
if err := verifyPreferenceApplied(batchResponseHeader(item.GetHeaders(), preferenceAppliedHeader), preference); err != nil {
return nil, fmt.Errorf("batch message %q: %w", stepID, err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg '(^|/)mail_batch\.go$' || true

echo "== relevant code =="
if [ -f internal/graphapi/mail_batch.go ]; then
  sed -n '1,130p' internal/graphapi/mail_batch.go | cat -n
fi

echo "== search batch status helper / GetResponseById usage =="
rg -n 'GetResponseById|status|GetHeaders|verifyPreferenceApplied|batch message|provider body response' internal/graphapi -S || true

Repository: PlanMonster/olkcli

Length of output: 11208


🌐 Web query:

Microsoft Graph batch request response operations status code successful response 2xx

💡 Result:

When using Microsoft Graph batch requests, it is critical to distinguish between the HTTP status code of the outer batch request and the individual status codes returned for each sub-request [1][2][3]. Outer Batch Response The outer HTTP status code for a batch request confirms only that the batch envelope itself was successfully received and processed by the server [1][3]. - If the batch request is well-formed and parseable, the outer response will typically return a 200 OK status [1][2]. - If the batch request is malformed, the outer response will return a 400 Bad Request status [1][4]. - A 200 OK status on the outer response does not guarantee that any of the individual requests inside the batch were successful [1][2][3]. Individual Request Responses Because individual requests within the batch are processed independently (unless dependencies are specified), you must iterate through the responses array in the batch response body to verify the outcome of each operation [1][2][3]. - Each entry in the responses array contains a status property, which provides the HTTP status code for that specific sub-request [1][4]. - You must check this individual status code for any value in the 2xx range to confirm a successful operation [1][5][2]. - If an individual request fails, it will contain a 4xx or 5xx status code [1][3]. Additionally, if a request has a dependency on another request that fails, the dependent request will typically return a 424 Failed Dependency status [5][3]. It is also important to note that individual responses may not appear in the same order as the requests in the original batch; you should use the id property to correlate responses with their corresponding requests [1][4][3].

Citations:


Reject non-2xx batch subresponses.

Line 74 accepts batch subresponse statuses from 300 through 399 as successful. Reject statuses below 200 or above 299 before checking Preference-Applied or deserializing the body.

Proposed fix
-			if item == nil || item.GetStatus() == nil || *item.GetStatus() >= 400 {
+			if item == nil || item.GetStatus() == nil ||
+				*item.GetStatus() < 200 || *item.GetStatus() >= 300 {
 				return nil, fmt.Errorf("batch message %q did not return a successful provider body response", stepID)
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if preference != MessageBodyDefault {
item := resp.GetResponseById(stepID)
if item == nil || item.GetStatus() == nil || *item.GetStatus() >= 400 {
return nil, fmt.Errorf("batch message %q did not return a successful provider body response", stepID)
}
if err := verifyPreferenceApplied(batchResponseHeader(item.GetHeaders(), preferenceAppliedHeader), preference); err != nil {
return nil, fmt.Errorf("batch message %q: %w", stepID, err)
}
if preference != MessageBodyDefault {
item := resp.GetResponseById(stepID)
if item == nil || item.GetStatus() == nil ||
*item.GetStatus() < 200 || *item.GetStatus() >= 300 {
return nil, fmt.Errorf("batch message %q did not return a successful provider body response", stepID)
}
if err := verifyPreferenceApplied(batchResponseHeader(item.GetHeaders(), preferenceAppliedHeader), preference); err != nil {
return nil, fmt.Errorf("batch message %q: %w", stepID, err)
}

Comment on lines +332 to +345
func mailMessagesDeltaScope(target, folderID string) graphContinuationScope {
return graphContinuationScope{
host: defaultGraphAPIHost,
collectionPath: graphUserCollectionPath(target, "mailFolders/"+url.PathEscape(folderID)+"/messages/delta"),
}
}

func calendarViewDeltaScope(target string) graphContinuationScope {
return graphContinuationScope{host: defaultGraphAPIHost, collectionPath: graphUserCollectionPath(target, "calendarView/delta")}
}

func contactsDeltaScope(target string) graphContinuationScope {
return graphContinuationScope{host: defaultGraphAPIHost, collectionPath: graphUserCollectionPath(target, "contacts/delta")}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Continuation validation pins the global Graph host, so sovereign-cloud paging and delta break. Every scope constructor sets host: defaultGraphAPIHost, and validateGraphContinuation requires an exact host match, while graphAPIHosts and internal/graphapi/delta_test.go still treat regional endpoints as valid.

  • internal/graphapi/mail_pages.go#L332-L345: derive the expected host from the configured Graph base URL instead of defaultGraphAPIHost in mailMessagesDeltaScope, calendarViewDeltaScope, and contactsDeltaScope.
  • internal/graphapi/mail_pages.go#L157-L165: make the scope host an input rather than a fixed constant, and keep the graphAPIHosts allowlist check.
  • internal/graphapi/delta.go#L105-L105: pass the resolved host through mailMessagesDeltaScope so mail delta continuations work on regional clouds.
  • internal/graphapi/delta.go#L134-L134: pass the resolved host through calendarViewDeltaScope.
  • internal/graphapi/delta.go#L159-L159: pass the resolved host through contactsDeltaScope.
📍 Affects 2 files
  • internal/graphapi/mail_pages.go#L332-L345 (this comment)
  • internal/graphapi/mail_pages.go#L157-L165
  • internal/graphapi/delta.go#L105-L105
  • internal/graphapi/delta.go#L134-L134
  • internal/graphapi/delta.go#L159-L159

Comment thread internal/graphapi/mail.go
Comment on lines 77 to 83
// messageDetailSelect is the $select field set for a full single message (used by
// GetMessage and the batch fetch) — includes the body and conversation id.
var messageDetailSelect = []string{
"id", "subject", "from", "toRecipients", "ccRecipients", "bccRecipients",
"receivedDateTime", "isRead", "hasAttachments", "body", "bodyPreview", "conversationId",
"parentFolderId", "changeKey", "flag",
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

messageDetailSelect omits replyTo, so GetMessage and the batch fetch always return an empty replyTo.

MailMessage now exposes ReplyTo, and convertMessage fills it from msg.GetReplyTo(). The list default selection at Lines 171-175 includes replyTo, but the single-message and batch selection does not. Graph omits unselected properties, so replyTo is empty for mail get and mail batch. The contract test passes only because the test transport ignores $select.

🐛 Proposed fix
 var messageDetailSelect = []string{
-	"id", "subject", "from", "toRecipients", "ccRecipients", "bccRecipients",
+	"id", "subject", "from", "toRecipients", "ccRecipients", "bccRecipients", "replyTo",
 	"receivedDateTime", "isRead", "hasAttachments", "body", "bodyPreview", "conversationId",
 	"parentFolderId", "changeKey", "flag",
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// messageDetailSelect is the $select field set for a full single message (used by
// GetMessage and the batch fetch) — includes the body and conversation id.
var messageDetailSelect = []string{
"id", "subject", "from", "toRecipients", "ccRecipients", "bccRecipients",
"receivedDateTime", "isRead", "hasAttachments", "body", "bodyPreview", "conversationId",
"parentFolderId", "changeKey", "flag",
}
// messageDetailSelect is the $select field set for a full single message (used by
// GetMessage and the batch fetch) — includes the body and conversation id.
var messageDetailSelect = []string{
"id", "subject", "from", "toRecipients", "ccRecipients", "bccRecipients", "replyTo",
"receivedDateTime", "isRead", "hasAttachments", "body", "bodyPreview", "conversationId",
"parentFolderId", "changeKey", "flag",
}

Comment thread internal/graphapi/mail.go
Comment on lines 260 to 274
msg, err := c.targetUser(target).Messages().ByMessageId(messageID).Get(ctx, &users.ItemMessagesMessageItemRequestBuilderGetRequestConfiguration{
Headers: c.messageIDHeaders(headers),
Options: options,
QueryParameters: &users.ItemMessagesMessageItemRequestBuilderGetQueryParameters{
Select: messageDetailSelect,
},
})
if err != nil {
return nil, fmt.Errorf("getting message: %w", err)
}
if err := contract.verify(); err != nil {
return nil, fmt.Errorf("getting message: %w", err)
}
m := convertMessage(msg)
fillBody(&m, msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

The new read path dereferences Graph SDK values without nil checks. Both sites assume the SDK returns non-nil values, which violates the repository nil-check rule and can panic at runtime.

  • internal/graphapi/mail.go#L260-L274: return errNilMessageResponse when msg is nil, before convertMessage and fillBody.
  • internal/graphapi/mail.go#L818-L830: skip nil elements in recipients before calling GetEmailAddress().
📍 Affects 1 file
  • internal/graphapi/mail.go#L260-L274 (this comment)
  • internal/graphapi/mail.go#L818-L830

Source: Coding guidelines

Comment thread internal/graphapi/mail.go
Comment on lines +818 to +830
func recipientAddresses(recipients []models.Recipientable) []string {
result := make([]string, 0, len(recipients))
for _, recipient := range recipients {
if recipient.GetEmailAddress() != nil &&
recipient.GetEmailAddress().GetAddress() != nil {
result = append(
result,
*recipient.GetEmailAddress().GetAddress(),
)
}
}
return result
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Nil-check each recipient element.

Graph deserialization can yield a nil element in a []models.Recipientable. recipient.GetEmailAddress() then panics. Add an element guard.

🛡️ Proposed guard
 	for _, recipient := range recipients {
-		if recipient.GetEmailAddress() != nil &&
+		if recipient == nil {
+			continue
+		}
+		if recipient.GetEmailAddress() != nil &&
 			recipient.GetEmailAddress().GetAddress() != nil {

As per coding guidelines "Always nil-check pointer values returned by the Microsoft Graph SDK before dereferencing them."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func recipientAddresses(recipients []models.Recipientable) []string {
result := make([]string, 0, len(recipients))
for _, recipient := range recipients {
if recipient.GetEmailAddress() != nil &&
recipient.GetEmailAddress().GetAddress() != nil {
result = append(
result,
*recipient.GetEmailAddress().GetAddress(),
)
}
}
return result
}
func recipientAddresses(recipients []models.Recipientable) []string {
result := make([]string, 0, len(recipients))
for _, recipient := range recipients {
if recipient == nil {
continue
}
if recipient.GetEmailAddress() != nil &&
recipient.GetEmailAddress().GetAddress() != nil {
result = append(
result,
*recipient.GetEmailAddress().GetAddress(),
)
}
}
return result
}

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants