Skip to content

Commit 0032fc7

Browse files
rianjsclaude
andauthored
feat: port gmail client package from gmail-ro (#11)
Port Gmail API client with full test coverage (13 test functions): - client.go: OAuth2 authentication, label caching - messages.go: Message/Attachment types, search, thread retrieval - attachments.go: Attachment download (regular and inline) Updates: - Config directory: google-readonly (was gmail-readonly) - Import paths: github.com/open-cli-collective/google-readonly Closes #3 Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7ec8956 commit 0032fc7

8 files changed

Lines changed: 1685 additions & 1 deletion

File tree

go.mod

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,36 @@ require (
66
github.com/spf13/cobra v1.8.0
77
github.com/stretchr/testify v1.11.1
88
golang.org/x/oauth2 v0.34.0
9+
google.golang.org/api v0.262.0
910
)
1011

1112
require (
13+
cloud.google.com/go/auth v0.18.1 // indirect
14+
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
15+
cloud.google.com/go/compute/metadata v0.9.0 // indirect
16+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
1217
github.com/davecgh/go-spew v1.1.1 // indirect
18+
github.com/felixge/httpsnoop v1.0.4 // indirect
19+
github.com/go-logr/logr v1.4.3 // indirect
20+
github.com/go-logr/stdr v1.2.2 // indirect
21+
github.com/google/s2a-go v0.1.9 // indirect
22+
github.com/google/uuid v1.6.0 // indirect
23+
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
24+
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
1325
github.com/inconshreveable/mousetrap v1.1.0 // indirect
1426
github.com/pmezard/go-difflib v1.0.0 // indirect
1527
github.com/spf13/pflag v1.0.5 // indirect
28+
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
29+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
30+
go.opentelemetry.io/otel v1.39.0 // indirect
31+
go.opentelemetry.io/otel/metric v1.39.0 // indirect
32+
go.opentelemetry.io/otel/trace v1.39.0 // indirect
33+
golang.org/x/crypto v0.47.0 // indirect
34+
golang.org/x/net v0.49.0 // indirect
35+
golang.org/x/sys v0.40.0 // indirect
36+
golang.org/x/text v0.33.0 // indirect
37+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575 // indirect
38+
google.golang.org/grpc v1.78.0 // indirect
39+
google.golang.org/protobuf v1.36.11 // indirect
1640
gopkg.in/yaml.v3 v3.0.1 // indirect
1741
)

go.sum

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,92 @@
1+
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
2+
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
3+
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
4+
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
5+
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
6+
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
7+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
8+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
19
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
210
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
311
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12+
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
13+
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
14+
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
15+
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
16+
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
17+
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
18+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
19+
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
20+
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
21+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
22+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
23+
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
24+
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
25+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
26+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
27+
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
28+
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
29+
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
30+
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
431
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
532
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
33+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
34+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
35+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
36+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
637
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
738
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
39+
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
40+
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
841
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
942
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
1043
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
1144
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
1245
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
1346
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
1447
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
48+
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
49+
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
50+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
51+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
52+
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
53+
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
54+
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
55+
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
56+
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
57+
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
58+
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
59+
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
60+
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
61+
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
62+
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
63+
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
64+
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
65+
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
1566
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
1667
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
17-
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
68+
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
69+
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
70+
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
71+
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
72+
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
73+
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
74+
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
75+
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
76+
google.golang.org/api v0.262.0 h1:4B+3u8He2GwyN8St3Jhnd3XRHlIvc//sBmgHSp78oNY=
77+
google.golang.org/api v0.262.0/go.mod h1:jNwmH8BgUBJ/VrUG6/lIl9YiildyLd09r9ZLHiQ6cGI=
78+
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
79+
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
80+
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
81+
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
82+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575 h1:vzOYHDZEHIsPYYnaSYo60AqHkJronSu0rzTz/s4quL0=
83+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
84+
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
85+
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
86+
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
87+
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
1888
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
89+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
90+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
1991
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
2092
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/gmail/attachments.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package gmail
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"strconv"
7+
"strings"
8+
9+
"google.golang.org/api/gmail/v1"
10+
)
11+
12+
// GetAttachments retrieves attachment metadata for a message
13+
func (c *Client) GetAttachments(messageID string) ([]*Attachment, error) {
14+
msg, err := c.Service.Users.Messages.Get(c.UserID, messageID).Format("full").Do()
15+
if err != nil {
16+
return nil, fmt.Errorf("failed to get message: %w", err)
17+
}
18+
19+
return extractAttachments(msg.Payload, ""), nil
20+
}
21+
22+
// DownloadAttachment downloads a single attachment by message ID and attachment ID
23+
func (c *Client) DownloadAttachment(messageID string, attachmentID string) ([]byte, error) {
24+
att, err := c.Service.Users.Messages.Attachments.Get(c.UserID, messageID, attachmentID).Do()
25+
if err != nil {
26+
return nil, fmt.Errorf("failed to download attachment: %w", err)
27+
}
28+
29+
data, err := base64.URLEncoding.DecodeString(att.Data)
30+
if err != nil {
31+
return nil, fmt.Errorf("failed to decode attachment data: %w", err)
32+
}
33+
34+
return data, nil
35+
}
36+
37+
// DownloadInlineAttachment downloads an attachment that has inline data
38+
func (c *Client) DownloadInlineAttachment(messageID string, partID string) ([]byte, error) {
39+
msg, err := c.Service.Users.Messages.Get(c.UserID, messageID).Format("full").Do()
40+
if err != nil {
41+
return nil, fmt.Errorf("failed to get message: %w", err)
42+
}
43+
44+
part := findPart(msg.Payload, partID)
45+
if part == nil {
46+
return nil, fmt.Errorf("attachment part not found: %s", partID)
47+
}
48+
49+
if part.Body == nil || part.Body.Data == "" {
50+
return nil, fmt.Errorf("attachment has no inline data")
51+
}
52+
53+
data, err := base64.URLEncoding.DecodeString(part.Body.Data)
54+
if err != nil {
55+
return nil, fmt.Errorf("failed to decode inline attachment: %w", err)
56+
}
57+
58+
return data, nil
59+
}
60+
61+
// findPart recursively finds a message part by its path (e.g., "0.1.2")
62+
func findPart(payload *gmail.MessagePart, partPath string) *gmail.MessagePart {
63+
if partPath == "" {
64+
return payload
65+
}
66+
67+
parts := strings.Split(partPath, ".")
68+
current := payload
69+
70+
for _, indexStr := range parts {
71+
index, err := strconv.Atoi(indexStr)
72+
if err != nil || index < 0 || index >= len(current.Parts) {
73+
return nil
74+
}
75+
current = current.Parts[index]
76+
}
77+
78+
return current
79+
}

internal/gmail/attachments_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package gmail
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"google.golang.org/api/gmail/v1"
8+
)
9+
10+
func TestFindPart(t *testing.T) {
11+
t.Run("returns payload for empty path", func(t *testing.T) {
12+
payload := &gmail.MessagePart{
13+
MimeType: "text/plain",
14+
}
15+
result := findPart(payload, "")
16+
assert.Equal(t, payload, result)
17+
})
18+
19+
t.Run("finds part at index 0", func(t *testing.T) {
20+
child := &gmail.MessagePart{MimeType: "text/plain", Filename: "file.txt"}
21+
payload := &gmail.MessagePart{
22+
MimeType: "multipart/mixed",
23+
Parts: []*gmail.MessagePart{child},
24+
}
25+
result := findPart(payload, "0")
26+
assert.Equal(t, child, result)
27+
})
28+
29+
t.Run("finds nested part", func(t *testing.T) {
30+
deepChild := &gmail.MessagePart{MimeType: "application/pdf", Filename: "nested.pdf"}
31+
payload := &gmail.MessagePart{
32+
MimeType: "multipart/mixed",
33+
Parts: []*gmail.MessagePart{
34+
{
35+
MimeType: "multipart/alternative",
36+
Parts: []*gmail.MessagePart{
37+
{MimeType: "text/plain"},
38+
deepChild,
39+
},
40+
},
41+
},
42+
}
43+
result := findPart(payload, "0.1")
44+
assert.Equal(t, deepChild, result)
45+
})
46+
47+
t.Run("returns nil for invalid index", func(t *testing.T) {
48+
payload := &gmail.MessagePart{
49+
MimeType: "multipart/mixed",
50+
Parts: []*gmail.MessagePart{{MimeType: "text/plain"}},
51+
}
52+
result := findPart(payload, "5")
53+
assert.Nil(t, result)
54+
})
55+
56+
t.Run("returns nil for negative index", func(t *testing.T) {
57+
payload := &gmail.MessagePart{
58+
MimeType: "multipart/mixed",
59+
Parts: []*gmail.MessagePart{{MimeType: "text/plain"}},
60+
}
61+
result := findPart(payload, "-1")
62+
assert.Nil(t, result)
63+
})
64+
65+
t.Run("returns nil for non-numeric path", func(t *testing.T) {
66+
payload := &gmail.MessagePart{
67+
MimeType: "multipart/mixed",
68+
Parts: []*gmail.MessagePart{{MimeType: "text/plain"}},
69+
}
70+
result := findPart(payload, "abc")
71+
assert.Nil(t, result)
72+
})
73+
74+
t.Run("returns nil for out of bounds nested path", func(t *testing.T) {
75+
payload := &gmail.MessagePart{
76+
MimeType: "multipart/mixed",
77+
Parts: []*gmail.MessagePart{
78+
{
79+
MimeType: "multipart/alternative",
80+
Parts: []*gmail.MessagePart{{MimeType: "text/plain"}},
81+
},
82+
},
83+
}
84+
result := findPart(payload, "0.5")
85+
assert.Nil(t, result)
86+
})
87+
88+
t.Run("handles deeply nested path", func(t *testing.T) {
89+
deepest := &gmail.MessagePart{Filename: "deep.txt"}
90+
payload := &gmail.MessagePart{
91+
Parts: []*gmail.MessagePart{
92+
{
93+
Parts: []*gmail.MessagePart{
94+
{
95+
Parts: []*gmail.MessagePart{
96+
deepest,
97+
},
98+
},
99+
},
100+
},
101+
},
102+
}
103+
result := findPart(payload, "0.0.0")
104+
assert.Equal(t, deepest, result)
105+
})
106+
}

0 commit comments

Comments
 (0)