diff --git a/src/internal/operations/test/helper_test.go b/src/internal/operations/test/helper_test.go index afad482ed..0d7ca3751 100644 --- a/src/internal/operations/test/helper_test.go +++ b/src/internal/operations/test/helper_test.go @@ -738,6 +738,7 @@ func verifyExtensionData( case path.OneDriveService: detailsSize = itemInfo.OneDrive.Size case path.GroupsService: + // FIXME: needs update for message. detailsSize = itemInfo.Groups.Size default: assert.Fail(t, "unrecognized data type") diff --git a/src/pkg/backup/details/groups.go b/src/pkg/backup/details/groups.go index b6e5011fe..daacee301 100644 --- a/src/pkg/backup/details/groups.go +++ b/src/pkg/backup/details/groups.go @@ -42,8 +42,11 @@ type GroupsInfo struct { Modified time.Time `json:"modified,omitempty"` // Channels Specific - Message ChannelMessageInfo `json:"message"` - LastReply ChannelMessageInfo `json:"lastReply"` + Message ChannelMessageInfo `json:"message,omitempty"` + LastReply ChannelMessageInfo `json:"lastReply,omitempty"` + + // Conversations Specific + Post ConversationPostInfo `json:"post,omitempty"` // SharePoint specific Created time.Time `json:"created,omitempty"` @@ -57,6 +60,14 @@ type GroupsInfo struct { WebURL string `json:"webURL,omitempty"` } +type ConversationPostInfo struct { + CreatedAt time.Time `json:"createdAt,omitempty"` + Creator string `json:"creator,omitempty"` + Preview string `json:"preview,omitempty"` + Size int64 `json:"size,omitempty"` + Subject string `json:"subject,omitempty"` +} + type ChannelMessageInfo struct { AttachmentNames []string `json:"attachmentNames,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` diff --git a/src/pkg/backup/details/iteminfo.go b/src/pkg/backup/details/iteminfo.go index 180572db3..e2eaf357e 100644 --- a/src/pkg/backup/details/iteminfo.go +++ b/src/pkg/backup/details/iteminfo.go @@ -39,7 +39,8 @@ const ( FolderItem ItemType = 306 // Groups/Teams(40x) - GroupsChannelMessage ItemType = 401 + GroupsChannelMessage ItemType = 401 + GroupsConversationPost ItemType = 402 ) func UpdateItem(item *ItemInfo, newLocPath *path.Builder) { diff --git a/src/pkg/services/m365/api/channels.go b/src/pkg/services/m365/api/channels.go index d7190dfdd..55060df3d 100644 --- a/src/pkg/services/m365/api/channels.go +++ b/src/pkg/services/m365/api/channels.go @@ -52,11 +52,8 @@ func (c Channels) GetChannel( Channels(). ByChannelId(containerID). Get(ctx, config) - if err != nil { - return nil, graph.Stack(ctx, err) - } - return resp, nil + return resp, graph.Stack(ctx, err).OrNil() } // GetChannelByName fetches a channel by name diff --git a/src/pkg/services/m365/api/channels_pager.go b/src/pkg/services/m365/api/channels_pager.go index 7284d61ef..87b95fe39 100644 --- a/src/pkg/services/m365/api/channels_pager.go +++ b/src/pkg/services/m365/api/channels_pager.go @@ -76,7 +76,6 @@ func (c Channels) NewChannelMessagePager( } // GetChannelMessages fetches a delta of all messages in the channel. -// returns two maps: addedItems, deletedItems func (c Channels) GetChannelMessages( ctx context.Context, teamID, channelID string, diff --git a/src/pkg/services/m365/api/conversations.go b/src/pkg/services/m365/api/conversations.go new file mode 100644 index 000000000..104fd3cd8 --- /dev/null +++ b/src/pkg/services/m365/api/conversations.go @@ -0,0 +1,130 @@ +package api + +import ( + "context" + + "github.com/alcionai/clues" + "github.com/jaytaylor/html2text" + "github.com/microsoftgraph/msgraph-sdk-go/groups" + "github.com/microsoftgraph/msgraph-sdk-go/models" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/common/str" + "github.com/alcionai/corso/src/internal/m365/graph" + "github.com/alcionai/corso/src/pkg/backup/details" +) + +// --------------------------------------------------------------------------- +// controller +// --------------------------------------------------------------------------- + +func (c Client) Conversations() Conversations { + return Conversations{c} +} + +// Conversations is an interface-compliant provider of the client. +type Conversations struct { + Client +} + +// --------------------------------------------------------------------------- +// Item (conversation thread post) +// --------------------------------------------------------------------------- + +func (c Conversations) GetConversationPost( + ctx context.Context, + groupID, conversationID, threadID, postID string, + cc CallConfig, +) (models.Postable, *details.GroupsInfo, error) { + config := &groups.ItemConversationsItemThreadsItemPostsPostItemRequestBuilderGetRequestConfiguration{ + QueryParameters: &groups.ItemConversationsItemThreadsItemPostsPostItemRequestBuilderGetQueryParameters{}, + } + + if len(cc.Select) > 0 { + config.QueryParameters.Select = cc.Select + } + + if len(cc.Expand) > 0 { + config.QueryParameters.Expand = append(config.QueryParameters.Expand, cc.Expand...) + } + + post, err := c.Stable. + Client(). + Groups(). + ByGroupId(groupID). + Conversations(). + ByConversationId(conversationID). + Threads(). + ByConversationThreadId(threadID). + Posts(). + ByPostId(postID). + Get(ctx, config) + if err != nil { + return nil, nil, graph.Stack(ctx, err) + } + + return post, conversationPostInfo(post), graph.Stack(ctx, err).OrNil() +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func conversationPostInfo( + post models.Postable, +) *details.GroupsInfo { + if post == nil { + return nil + } + + preview, contentLen, err := getConversationPostContentPreview(post) + if err != nil { + preview = "malformed or unparseable html" + preview + } + + var sender string + if post.GetSender() != nil && post.GetSender().GetEmailAddress() != nil { + sender = ptr.Val(post.GetSender().GetEmailAddress().GetAddress()) + } + + size := contentLen + + for _, a := range post.GetAttachments() { + size += int64(ptr.Val(a.GetSize())) + } + + cpi := details.ConversationPostInfo{ + CreatedAt: ptr.Val(post.GetCreatedDateTime()), + Creator: sender, + Preview: preview, + Size: size, + } + + return &details.GroupsInfo{ + ItemType: details.GroupsConversationPost, + Modified: ptr.Val(post.GetLastModifiedDateTime()), + Post: cpi, + } +} + +func getConversationPostContentPreview(post models.Postable) (string, int64, error) { + content, origSize, err := stripConversationPostHTML(post) + return str.Preview(content, 128), origSize, clues.Stack(err).OrNil() +} + +func stripConversationPostHTML(post models.Postable) (string, int64, error) { + var ( + content string + origSize int64 + ) + + if post.GetBody() != nil { + content = ptr.Val(post.GetBody().GetContent()) + } + + origSize = int64(len(content)) + + content, err := html2text.FromString(content) + + return content, origSize, clues.Stack(err).OrNil() +} diff --git a/src/pkg/services/m365/api/conversations_pager.go b/src/pkg/services/m365/api/conversations_pager.go new file mode 100644 index 000000000..bc9cd2a32 --- /dev/null +++ b/src/pkg/services/m365/api/conversations_pager.go @@ -0,0 +1,232 @@ +package api + +import ( + "context" + + "github.com/alcionai/clues" + "github.com/microsoftgraph/msgraph-sdk-go/groups" + "github.com/microsoftgraph/msgraph-sdk-go/models" + + "github.com/alcionai/corso/src/internal/m365/graph" + "github.com/alcionai/corso/src/pkg/services/m365/api/pagers" +) + +// --------------------------------------------------------------------------- +// conversation pager +// --------------------------------------------------------------------------- + +var _ pagers.NonDeltaHandler[models.Conversationable] = &conversationsPageCtrl{} + +type conversationsPageCtrl struct { + resourceID string + gs graph.Servicer + builder *groups.ItemConversationsRequestBuilder + options *groups.ItemConversationsRequestBuilderGetRequestConfiguration +} + +func (p *conversationsPageCtrl) SetNextLink(nextLink string) { + p.builder = groups.NewItemConversationsRequestBuilder(nextLink, p.gs.Adapter()) +} + +func (p *conversationsPageCtrl) GetPage( + ctx context.Context, +) (pagers.NextLinkValuer[models.Conversationable], error) { + resp, err := p.builder.Get(ctx, p.options) + return resp, graph.Stack(ctx, err).OrNil() +} + +func (p *conversationsPageCtrl) ValidModTimes() bool { + return true +} + +func (c Conversations) NewConversationsPager( + groupID string, + cc CallConfig, +) *conversationsPageCtrl { + builder := c.Stable. + Client(). + Groups(). + ByGroupId(groupID). + Conversations() + + options := &groups.ItemConversationsRequestBuilderGetRequestConfiguration{ + QueryParameters: &groups.ItemConversationsRequestBuilderGetQueryParameters{}, + Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize)), + } + + if len(cc.Select) > 0 { + options.QueryParameters.Select = cc.Select + } + + return &conversationsPageCtrl{ + resourceID: groupID, + builder: builder, + gs: c.Stable, + options: options, + } +} + +// GetConversations fetches all conversations in the group. +func (c Conversations) GetConversations( + ctx context.Context, + groupID string, + cc CallConfig, +) ([]models.Conversationable, error) { + pager := c.NewConversationsPager(groupID, cc) + items, err := pagers.BatchEnumerateItems[models.Conversationable](ctx, pager) + + return items, graph.Stack(ctx, err).OrNil() +} + +// --------------------------------------------------------------------------- +// conversation thread pager +// --------------------------------------------------------------------------- + +var _ pagers.NonDeltaHandler[models.ConversationThreadable] = &conversationThreadsPageCtrl{} + +type conversationThreadsPageCtrl struct { + resourceID, conversationID string + gs graph.Servicer + builder *groups.ItemConversationsItemThreadsRequestBuilder + options *groups.ItemConversationsItemThreadsRequestBuilderGetRequestConfiguration +} + +func (p *conversationThreadsPageCtrl) SetNextLink(nextLink string) { + p.builder = groups.NewItemConversationsItemThreadsRequestBuilder(nextLink, p.gs.Adapter()) +} + +func (p *conversationThreadsPageCtrl) GetPage( + ctx context.Context, +) (pagers.NextLinkValuer[models.ConversationThreadable], error) { + resp, err := p.builder.Get(ctx, p.options) + return resp, graph.Stack(ctx, err).OrNil() +} + +func (p *conversationThreadsPageCtrl) ValidModTimes() bool { + return true +} + +func (c Conversations) NewConversationThreadsPager( + groupID, conversationID string, + cc CallConfig, +) *conversationThreadsPageCtrl { + builder := c.Stable. + Client(). + Groups(). + ByGroupId(groupID). + Conversations(). + ByConversationId(conversationID). + Threads() + + options := &groups.ItemConversationsItemThreadsRequestBuilderGetRequestConfiguration{ + QueryParameters: &groups.ItemConversationsItemThreadsRequestBuilderGetQueryParameters{}, + Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize)), + } + + if len(cc.Select) > 0 { + options.QueryParameters.Select = cc.Select + } + + if len(cc.Expand) > 0 { + options.QueryParameters.Expand = cc.Expand + } + + return &conversationThreadsPageCtrl{ + resourceID: groupID, + conversationID: conversationID, + builder: builder, + gs: c.Stable, + options: options, + } +} + +// GetConversations fetches all conversations in the group. +func (c Conversations) GetConversationThreads( + ctx context.Context, + groupID, conversationID string, + cc CallConfig, +) ([]models.ConversationThreadable, error) { + ctx = clues.Add(ctx, "conversation_id", conversationID) + pager := c.NewConversationThreadsPager(groupID, conversationID, cc) + items, err := pagers.BatchEnumerateItems[models.ConversationThreadable](ctx, pager) + + return items, graph.Stack(ctx, err).OrNil() +} + +// --------------------------------------------------------------------------- +// conversation thread post pager +// --------------------------------------------------------------------------- + +var _ pagers.NonDeltaHandler[models.Postable] = &conversationThreadPostsPageCtrl{} + +type conversationThreadPostsPageCtrl struct { + resourceID, conversationID, threadID string + gs graph.Servicer + builder *groups.ItemConversationsItemThreadsItemPostsRequestBuilder + options *groups.ItemConversationsItemThreadsItemPostsRequestBuilderGetRequestConfiguration +} + +func (p *conversationThreadPostsPageCtrl) SetNextLink(nextLink string) { + p.builder = groups.NewItemConversationsItemThreadsItemPostsRequestBuilder(nextLink, p.gs.Adapter()) +} + +func (p *conversationThreadPostsPageCtrl) GetPage( + ctx context.Context, +) (pagers.NextLinkValuer[models.Postable], error) { + resp, err := p.builder.Get(ctx, p.options) + return resp, graph.Stack(ctx, err).OrNil() +} + +func (p *conversationThreadPostsPageCtrl) ValidModTimes() bool { + return true +} + +func (c Conversations) NewConversationThreadPostsPager( + groupID, conversationID, threadID string, + cc CallConfig, +) *conversationThreadPostsPageCtrl { + builder := c.Stable. + Client(). + Groups(). + ByGroupId(groupID). + Conversations(). + ByConversationId(conversationID). + Threads(). + ByConversationThreadId(threadID). + Posts() + + options := &groups.ItemConversationsItemThreadsItemPostsRequestBuilderGetRequestConfiguration{ + QueryParameters: &groups.ItemConversationsItemThreadsItemPostsRequestBuilderGetQueryParameters{}, + Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize)), + } + + if len(cc.Select) > 0 { + options.QueryParameters.Select = cc.Select + } + + if len(cc.Expand) > 0 { + options.QueryParameters.Expand = cc.Expand + } + + return &conversationThreadPostsPageCtrl{ + resourceID: groupID, + conversationID: conversationID, + threadID: threadID, + builder: builder, + gs: c.Stable, + options: options, + } +} + +// GetConversations fetches all conversations in the group. +func (c Conversations) GetConversationThreadPosts( + ctx context.Context, + groupID, conversationID, threadID string, + cc CallConfig, +) ([]models.Postable, error) { + ctx = clues.Add(ctx, "conversation_id", conversationID) + pager := c.NewConversationThreadPostsPager(groupID, conversationID, threadID, cc) + items, err := pagers.BatchEnumerateItems[models.Postable](ctx, pager) + + return items, graph.Stack(ctx, err).OrNil() +} diff --git a/src/pkg/services/m365/api/conversations_pager_test.go b/src/pkg/services/m365/api/conversations_pager_test.go new file mode 100644 index 000000000..43023ad65 --- /dev/null +++ b/src/pkg/services/m365/api/conversations_pager_test.go @@ -0,0 +1,120 @@ +package api + +import ( + "testing" + + "github.com/alcionai/clues" + "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/internal/tester/tconfig" +) + +type ConversationsPagerIntgSuite struct { + tester.Suite + its intgTesterSetup +} + +func TestConversationsPagerIntgSuite(t *testing.T) { + suite.Run(t, &ConversationsPagerIntgSuite{ + Suite: tester.NewIntegrationSuite( + t, + [][]string{tconfig.M365AcctCredEnvs}), + }) +} + +func (suite *ConversationsPagerIntgSuite) SetupSuite() { + suite.its = newIntegrationTesterSetup(suite.T()) +} + +func (suite *ConversationsPagerIntgSuite) TestEnumerateConversations_withThreadsAndPosts() { + var ( + t = suite.T() + ac = suite.its.ac.Conversations() + ) + + ctx, flush := tester.NewContext(t) + defer flush() + + convs, err := ac.GetConversations(ctx, suite.its.group.id, CallConfig{}) + require.NoError(t, err, clues.ToCore(err)) + require.NotEmpty(t, convs) + + for _, conv := range convs { + threads := testEnumerateConvThreads(suite, conv) + + for _, thread := range threads { + posts := testEnumerateConvPosts(suite, conv, thread) + + for _, post := range posts { + testGetPostByID(suite, conv, thread, post) + } + } + } +} + +func testEnumerateConvThreads( + suite *ConversationsPagerIntgSuite, + conv models.Conversationable, +) []models.ConversationThreadable { + var threads []models.ConversationThreadable + + suite.Run("threads", func() { + var ( + t = suite.T() + ac = suite.its.ac.Conversations() + err error + ) + + ctx, flush := tester.NewContext(t) + defer flush() + + threads, err = ac.GetConversationThreads( + ctx, + suite.its.group.id, + ptr.Val(conv.GetId()), + CallConfig{}) + require.NoError(t, err, clues.ToCore(err)) + // to the best of our knowledge, there's only ever one + // thread per conversation. Even trying to create a new + // thread within a conversation will create an entirely + // new conversation. We want this test to fail as a potential + // identifier if that changes on us. + require.Equal(t, 1, len(threads)) + }) + + return threads +} + +func testEnumerateConvPosts( + suite *ConversationsPagerIntgSuite, + conv models.Conversationable, + thread models.ConversationThreadable, +) []models.Postable { + var posts []models.Postable + + suite.Run("posts", func() { + var ( + t = suite.T() + ac = suite.its.ac.Conversations() + err error + ) + + ctx, flush := tester.NewContext(t) + defer flush() + + posts, err = ac.GetConversationThreadPosts( + ctx, + suite.its.group.id, + ptr.Val(conv.GetId()), + ptr.Val(thread.GetId()), + CallConfig{}) + require.NoError(t, err, clues.ToCore(err)) + require.NotEmpty(t, posts) + }) + + return posts +} diff --git a/src/pkg/services/m365/api/conversations_test.go b/src/pkg/services/m365/api/conversations_test.go new file mode 100644 index 000000000..366fafdfb --- /dev/null +++ b/src/pkg/services/m365/api/conversations_test.go @@ -0,0 +1,267 @@ +package api + +import ( + "testing" + "time" + + "github.com/alcionai/clues" + "github.com/h2non/gock" + "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/internal/tester/tconfig" + "github.com/alcionai/corso/src/pkg/backup/details" +) + +// called by the pager test, since it is already enumerating +// posts. +func testGetPostByID( + suite *ConversationsPagerIntgSuite, + conv models.Conversationable, + thread models.ConversationThreadable, + post models.Postable, +) { + suite.Run("post_by_id", func() { + var ( + t = suite.T() + ac = suite.its.ac.Conversations() + ) + + ctx, flush := tester.NewContext(t) + defer flush() + + resp, _, err := ac.GetConversationPost( + ctx, + suite.its.group.id, + ptr.Val(conv.GetId()), + ptr.Val(thread.GetId()), + ptr.Val(post.GetId()), + CallConfig{}) + require.NoError(t, err, clues.ToCore(err)) + require.Equal(t, ptr.Val(post.GetId()), ptr.Val(resp.GetId())) + }) +} + +type ConversationsAPIUnitSuite struct { + tester.Suite +} + +func TestConversationsAPIUnitSuite(t *testing.T) { + suite.Run(t, &ConversationsAPIUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *ConversationsAPIUnitSuite) TestConversationPostInfo() { + var ( + now = time.Now() + content = "content" + body = models.NewItemBody() + ) + + body.SetContent(ptr.To(content)) + + tests := []struct { + name string + postAndInfo func() (models.Postable, *details.GroupsInfo) + }{ + { + name: "No body", + postAndInfo: func() (models.Postable, *details.GroupsInfo) { + post := models.NewPost() + post.SetCreatedDateTime(&now) + post.SetLastModifiedDateTime(&now) + + sender := "foo@bar.com" + sea := models.NewEmailAddress() + sea.SetAddress(&sender) + + recipient := models.NewRecipient() + recipient.SetEmailAddress(sea) + + post.SetSender(recipient) + + iden := models.NewIdentity() + iden.SetDisplayName(ptr.To("user")) + + i := &details.GroupsInfo{ + ItemType: details.GroupsConversationPost, + Modified: now, + Post: details.ConversationPostInfo{ + CreatedAt: now, + Creator: "foo@bar.com", + Preview: "", + Size: 0, + // TODO: feed the subject in from the conversation + Subject: "", + }, + } + + return post, i + }, + }, + } + for _, test := range tests { + suite.Run(test.name, func() { + t := suite.T() + + chMsg, expected := test.postAndInfo() + result := conversationPostInfo(chMsg) + + assert.Equal(t, expected, result) + }) + } +} + +type ConversationAPIIntgSuite struct { + tester.Suite + its intgTesterSetup +} + +// We do end up mocking the actual request, but creating the rest +// similar to full integration tests. +func TestConversationAPIIntgSuite(t *testing.T) { + suite.Run(t, &ConversationAPIIntgSuite{ + Suite: tester.NewIntegrationSuite( + t, + [][]string{tconfig.M365AcctCredEnvs}), + }) +} + +func (suite *ConversationAPIIntgSuite) SetupSuite() { + suite.its = newIntegrationTesterSetup(suite.T()) +} + +func (suite *ConversationAPIIntgSuite) TestConversations_attachmentListDownload() { + pid := "fake-post-id" + aid := "fake-attachment-id" + + tests := []struct { + name string + setupf func() + attachmentCount int + size int64 + expect assert.ErrorAssertionFunc + }{ + { + name: "no attachments", + setupf: func() { + itm := models.NewPost() + itm.SetId(&pid) + + interceptV1Path( + "groups", + "group", + "conversations", + "conv", + "threads", + "thread", + "posts", + pid). + Reply(200). + JSON(requireParseableToMap(suite.T(), itm)) + }, + expect: assert.NoError, + }, + { + name: "fetch with attachment", + setupf: func() { + itm := models.NewPost() + itm.SetId(&pid) + itm.SetHasAttachments(ptr.To(true)) + + attch := models.NewAttachment() + attch.SetSize(ptr.To[int32](50)) + + itm.SetAttachments([]models.Attachmentable{attch}) + + interceptV1Path( + "groups", + "group", + "conversations", + "conv", + "threads", + "thread", + "posts", + pid). + Reply(200). + JSON(requireParseableToMap(suite.T(), itm)) + }, + attachmentCount: 1, + size: 50, + expect: assert.NoError, + }, + { + name: "fetch multiple individual attachments", + setupf: func() { + truthy := true + itm := models.NewPost() + itm.SetId(&pid) + itm.SetHasAttachments(&truthy) + + attch := models.NewAttachment() + attch.SetId(&aid) + attch.SetSize(ptr.To[int32](200)) + + itm.SetAttachments([]models.Attachmentable{attch, attch, attch, attch, attch}) + + interceptV1Path( + "groups", + "group", + "conversations", + "conv", + "threads", + "thread", + "posts", + pid). + Reply(200). + JSON(requireParseableToMap(suite.T(), itm)) + }, + attachmentCount: 5, + size: 1000, + expect: assert.NoError, + }, + } + + for _, test := range tests { + suite.Run(test.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + defer gock.Off() + test.setupf() + + item, _, err := suite.its.gockAC. + Conversations(). + GetConversationPost( + ctx, + "group", + "conv", + "thread", + pid, + CallConfig{}) + test.expect(t, err) + + var size int64 + + if item.GetBody() != nil { + content := ptr.Val(item.GetBody().GetContent()) + size = int64(len(content)) + } + + attachments := item.GetAttachments() + for _, attachment := range attachments { + size += int64(*attachment.GetSize()) + } + + assert.Equal(t, *item.GetId(), pid) + assert.Equal(t, test.attachmentCount, len(attachments), "attachment count") + assert.Equal(t, test.size, size, "item size") + assert.True(t, gock.IsDone(), "made all requests") + }) + } +} diff --git a/src/pkg/services/m365/api/mail.go b/src/pkg/services/m365/api/mail.go index beb012e6d..33edb25a1 100644 --- a/src/pkg/services/m365/api/mail.go +++ b/src/pkg/services/m365/api/mail.go @@ -324,7 +324,7 @@ func (c Mail) GetItem( logger.CtxErr(ctx, err).Info("fetching all attachments by id") // Getting size just to log in case of error - attachConfig.QueryParameters.Select = []string{"id", "size"} + attachConfig.QueryParameters.Select = idAnd("size") attachments, err := c.LargeItem. Client(). @@ -348,6 +348,11 @@ func (c Mail) GetItem( Headers: newPreferHeaders(preferImmutableIDs(immutableIDs)), } + ictx := clues.Add( + ctx, + "attachment_id", ptr.Val(a.GetId()), + "attachment_size", ptr.Val(a.GetSize())) + att, err := c.Stable. Client(). Users(). @@ -356,17 +361,13 @@ func (c Mail) GetItem( ByMessageId(itemID). Attachments(). ByAttachmentId(ptr.Val(a.GetId())). - Get(ctx, attachConfig) + Get(ictx, attachConfig) if err != nil { // CannotOpenFileAttachment errors are not transient and // happens possibly from the original item somehow getting // deleted from M365 and so we can skip these if graph.IsErrCannotOpenFileAttachment(err) { - logger.CtxErr(ctx, err). - With( - "attachment_id", ptr.Val(a.GetId()), - "attachment_size", ptr.Val(a.GetSize())). - Info("attachment not found") + logger.CtxErr(ictx, err).Info("attachment not found") // TODO This should use a `AddSkip` once we have // figured out the semantics for skipping // subcomponents of an item @@ -374,8 +375,7 @@ func (c Mail) GetItem( continue } - return nil, nil, graph.Wrap(ctx, err, "getting mail attachment"). - With("attachment_id", ptr.Val(a.GetId()), "attachment_size", ptr.Val(a.GetSize())) + return nil, nil, graph.Wrap(ictx, err, "getting mail attachment") } atts = append(atts, att) diff --git a/src/pkg/services/m365/api/mail_test.go b/src/pkg/services/m365/api/mail_test.go index de040082a..0d515a0b9 100644 --- a/src/pkg/services/m365/api/mail_test.go +++ b/src/pkg/services/m365/api/mail_test.go @@ -204,7 +204,7 @@ func (suite *MailAPIIntgSuite) SetupSuite() { suite.its = newIntegrationTesterSetup(suite.T()) } -func (suite *MailAPIIntgSuite) TestHugeAttachmentListDownload() { +func (suite *MailAPIIntgSuite) TestMail_attachmentListDownload() { mid := "fake-message-id" aid := "fake-attachment-id" @@ -369,7 +369,7 @@ func (suite *MailAPIIntgSuite) TestHugeAttachmentListDownload() { } } -func (suite *MailAPIIntgSuite) TestMail_RestoreLargeAttachment() { +func (suite *MailAPIIntgSuite) TestMail_PostLargeAttachment() { t := suite.T() ctx, flush := tester.NewContext(t)