feat(gmail,contacts): land label style and contact gender

Co-authored-by: iskkw9973 <ishikawam.dev@gmail.com>
Co-authored-by: klodr <klodr@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-04-20 19:17:47 +01:00
parent dbeca138c3
commit 64ab3772bf
No known key found for this signature in database
9 changed files with 439 additions and 46 deletions

View File

@ -8,6 +8,8 @@
- Slides: add `slides thumbnail` / `slides thumb` to fetch rendered slide thumbnail URLs or download PNG/JPEG images. (#498) — thanks @gianpaj.
- Calendar: add `calendar create-calendar` / `new-calendar` to create secondary calendars with description, timezone, and location. (#455) — thanks @alexknowshtml.
- Gmail: add `gmail forward` / `gmail fwd` to forward a message with optional note, verified send-as alias, and original attachments. (#482) — thanks @spencer-c-reed.
- Gmail: add `gmail labels style` to update user label colors and list/message visibility. (#457) — thanks @iskw9973.
- Contacts: add `--gender` to `contacts create` and `contacts update`, and include gender in `contacts get` text output. (#438) — thanks @klodr.
- Gmail: add `gmail autoreply` to reply once to matching messages, label the thread for dedupe, and optionally archive/mark read. Includes docs and regression coverage for skip/reply flows.
- Gmail: add `gmail messages search --full` to print complete message bodies instead of truncating text output. (#447) — thanks @GodsBoy.
- Drive: allow `drive share --role commenter` for comment-only sharing. (#443) — thanks @pavelzak.

View File

@ -674,6 +674,7 @@ gog gmail labels list
gog gmail labels get INBOX --json # Includes message counts
gog gmail labels create "My Label"
gog gmail labels rename "Old Label" "New Label"
gog gmail labels style "My Label" --text-color "#ffffff" --background-color "#4285f4"
gog gmail labels modify <threadId> --add STARRED --remove INBOX
gog gmail labels delete <labelIdOrName> # Deletes user label (guards system labels; confirm)
@ -1022,6 +1023,7 @@ gog contacts create \
--email "john@example.com" \
--phone "+1234567890" \
--address "12 St James's Square, London" \
--gender "male" \
--relation "spouse=Jane Doe"
gog contacts update people/<resourceName> \
@ -1029,6 +1031,7 @@ gog contacts update people/<resourceName> \
--email "jane@example.com" \
--address "1 Infinite Loop, Cupertino" \
--birthday "1990-05-12" \
--gender "female" \
--notes "Met at WWDC" \
--relation "friend=Bob"

View File

@ -277,6 +277,7 @@ Generated from `gog schema --json`.
- `gog gmail (mail,email) labels (label) list (ls)` - List labels
- `gog gmail (mail,email) labels (label) modify (update,edit,set) <threadId> ... [flags]` - Modify labels on threads
- `gog gmail (mail,email) labels (label) rename (mv) <labelIdOrName> <newName>` - Rename a label
- `gog gmail (mail,email) labels (label) style (color,colour) <labelIdOrName> [flags]` - Change a user label color or visibility
- `gog gmail (mail,email) mark-read (read-messages) [<messageId> ...] [flags]` - Mark messages as read
- `gog gmail (mail,email) messages (message,msg,msgs) <command>` - Message operations
- `gog gmail (mail,email) messages (message,msg,msgs) modify (update,edit,set) <messageId> [flags]` - Modify labels on a single message

View File

@ -166,6 +166,26 @@ func formatPartialDate(d *people.Date) string {
return strings.Join(parts, "-")
}
func primaryGender(p *people.Person) string {
if p == nil || len(p.Genders) == 0 {
return ""
}
for _, g := range p.Genders {
if g == nil {
continue
}
if g.Metadata != nil && g.Metadata.Primary {
return firstNonEmpty(g.FormattedValue, g.Value)
}
}
for _, g := range p.Genders {
if g != nil {
return firstNonEmpty(g.FormattedValue, g.Value)
}
}
return ""
}
func sanitizeTab(s string) string {
return strings.ReplaceAll(s, "\t", " ")
}

View File

@ -17,8 +17,8 @@ import (
const (
contactsReadMask = "names,emailAddresses,phoneNumbers,organizations,urls"
contactsGetReadMask = contactsReadMask + ",birthdays,biographies,addresses,userDefined,relations,metadata"
contactsUpdateReadMask = contactsReadMask + ",birthdays,biographies,addresses,userDefined,relations,metadata"
contactsGetReadMask = contactsReadMask + ",birthdays,biographies,addresses,genders,userDefined,relations,metadata"
contactsUpdateReadMask = contactsReadMask + ",birthdays,biographies,addresses,genders,userDefined,relations,metadata"
)
type ContactsListCmd struct {
@ -165,6 +165,9 @@ func (c *ContactsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
if bd := primaryBirthday(p); bd != "" {
u.Out().Printf("birthday\t%s", bd)
}
if gender := primaryGender(p); gender != "" {
u.Out().Printf("gender\t%s", gender)
}
if org, title := primaryOrganization(p); org != "" || title != "" {
switch {
case org != "" && title != "":
@ -214,6 +217,7 @@ type ContactsCreateCmd struct {
URL []string `name:"url" help:"URL (can be repeated for multiple URLs)"`
Note string `name:"note" help:"Note/biography"`
Address []string `name:"address" sep:";" help:"Postal address (can be repeated for multiple addresses)"`
Gender string `name:"gender" help:"Gender value"`
Custom []string `name:"custom" help:"Custom field as key=value (can be repeated)"`
Relation []string `name:"relation" help:"Relation as type=person (can be repeated)"`
}
@ -290,6 +294,17 @@ func contactsAddresses(values []string) []*people.Address {
return out
}
func contactsGenders(value string) []*people.Gender {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return []*people.Gender{{
Value: value,
Metadata: &people.FieldMetadata{Primary: true},
}}
}
func contactsApplyPersonName(person *people.Person, givenSet bool, given string, familySet bool, family string) {
curGiven := ""
curFamily := ""
@ -372,6 +387,9 @@ func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
p.Addresses = addrs
}
}
if genders := contactsGenders(c.Gender); len(genders) > 0 {
p.Genders = genders
}
if len(c.Custom) > 0 {
userDefined, _, parseErr := parseCustomUserDefined(c.Custom, false)
if parseErr != nil {
@ -413,6 +431,7 @@ type ContactsUpdateCmd struct {
URL []string `name:"url" help:"URL (can be repeated; empty clears all)"`
Note string `name:"note" help:"Note/biography (empty clears)"`
Address []string `name:"address" sep:";" help:"Postal address (can be repeated; empty clears all)"`
Gender string `name:"gender" help:"Gender value (empty clears)"`
Custom []string `name:"custom" help:"Custom field as key=value (can be repeated; empty clears all)"`
Relation []string `name:"relation" help:"Relation as type=person (can be repeated; empty clears all)"`
FromFile string `name:"from-file" help:"Update from contact JSON file (use - for stdin)"`
@ -442,7 +461,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
if strings.TrimSpace(c.FromFile) != "" {
if flagProvided(kctx, "given") || flagProvided(kctx, "family") || flagProvided(kctx, "email") || flagProvided(kctx, "phone") ||
flagProvided(kctx, "org") || flagProvided(kctx, "title") || flagProvided(kctx, "url") ||
flagProvided(kctx, "note") || flagProvided(kctx, "address") || flagProvided(kctx, "custom") ||
flagProvided(kctx, "note") || flagProvided(kctx, "address") || flagProvided(kctx, "gender") || flagProvided(kctx, "custom") ||
flagProvided(kctx, "birthday") || flagProvided(kctx, "notes") || flagProvided(kctx, "relation") {
return usage("can't combine --from-file with other update flags")
}
@ -465,6 +484,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
wantURL := flagProvided(kctx, "url")
wantNote := flagProvided(kctx, "note")
wantAddress := flagProvided(kctx, "address")
wantGender := flagProvided(kctx, "gender")
wantBirthday := flagProvided(kctx, "birthday")
wantNotes := flagProvided(kctx, "notes")
wantCustom := flagProvided(kctx, "custom")
@ -520,6 +540,15 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
}
updateFields = append(updateFields, "addresses")
}
if wantGender {
genders := contactsGenders(c.Gender)
if len(genders) == 0 {
existing.Genders = nil // will be forced to [] for patch
} else {
existing.Genders = genders
}
updateFields = append(updateFields, "genders")
}
if wantCustom {
userDefined, clearAll, parseErr := parseCustomUserDefined(c.Custom, true)
if parseErr != nil {

View File

@ -461,6 +461,114 @@ func TestContactsCreate_Address_Set(t *testing.T) {
}
}
func TestContactsCreate_Gender_Set(t *testing.T) {
var gotGenders []map[string]any
svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, ":createContact") && r.Method == http.MethodPost:
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if genders, ok := body["genders"].([]any); ok {
for _, gender := range genders {
if m, ok := gender.(map[string]any); ok {
gotGenders = append(gotGenders, m)
}
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"})
return
default:
http.NotFound(w, r)
}
}))
t.Cleanup(closeSrv)
stubPeopleServices(t, svc)
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
if err := runKong(t, &ContactsCreateCmd{}, []string{"--given", "Ada", "--gender", "female"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
if len(gotGenders) != 1 || gotGenders[0]["value"] != "female" {
t.Fatalf("unexpected genders: %#v", gotGenders)
}
}
func TestContactsUpdate_Gender_SetAndClear(t *testing.T) {
var gotGetFields string
var gotUpdateFields []string
var gotGender string
var sawClear bool
svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"):
gotGetFields = r.URL.Query().Get("personFields")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"resourceName": "people/c1",
"names": []map[string]any{{"givenName": "Ada", "familyName": "Lovelace"}},
"genders": []map[string]any{{"value": "female"}},
})
return
case strings.Contains(r.URL.Path, ":updateContact") && (r.Method == http.MethodPatch || r.Method == http.MethodPost):
gotUpdateFields = append(gotUpdateFields, r.URL.Query().Get("updatePersonFields"))
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
genders, _ := body["genders"].([]any)
if len(genders) == 0 {
sawClear = true
} else if first, ok := genders[0].(map[string]any); ok {
gotGender = primaryValue(first, "value")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"})
return
default:
http.NotFound(w, r)
}
}))
t.Cleanup(closeSrv)
stubPeopleServices(t, svc)
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--gender", "male"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong set: %v", err)
}
if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--gender", ""}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong clear: %v", err)
}
if !strings.Contains(gotGetFields, "genders") {
t.Fatalf("missing genders in people.get fields: %q", gotGetFields)
}
if len(gotUpdateFields) != 2 || !strings.Contains(gotUpdateFields[0], "genders") || !strings.Contains(gotUpdateFields[1], "genders") {
t.Fatalf("missing gender update fields: %#v", gotUpdateFields)
}
if gotGender != "male" {
t.Fatalf("unexpected gender payload: %q", gotGender)
}
if !sawClear {
t.Fatal("expected empty genders payload for clear")
}
}
func leftPad2(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {

View File

@ -17,6 +17,7 @@ type GmailLabelsCmd struct {
Get GmailLabelsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get label details (including counts)"`
Create GmailLabelsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a new label"`
Rename GmailLabelsRenameCmd `cmd:"" name:"rename" aliases:"mv" help:"Rename a label"`
Style GmailLabelsStyleCmd `cmd:"" name:"style" aliases:"color,colour" help:"Change a user label color or visibility"`
Modify GmailLabelsModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" help:"Modify labels on threads"`
Delete GmailLabelsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del" help:"Delete a label"`
}
@ -139,27 +140,9 @@ func (c *GmailLabelsRenameCmd) Run(ctx context.Context, flags *RootFlags) error
return err
}
// For destructive-ish rename operations, try exact ID first before name lookup.
label, err := svc.Users.Labels.Get("me", oldRaw).Context(ctx).Do()
label, err := resolveMutableGmailLabel(ctx, svc, oldRaw)
if err != nil {
if !isNotFoundAPIError(err) {
return err
}
if looksLikeCustomLabelID(oldRaw) {
return fmt.Errorf("label not found: %s", oldRaw)
}
idMap, mapErr := fetchLabelNameOnlyToID(svc)
if mapErr != nil {
return mapErr
}
id, ok := idMap[strings.ToLower(oldRaw)]
if !ok {
return fmt.Errorf("label not found: %s", oldRaw)
}
label, err = svc.Users.Labels.Get("me", id).Context(ctx).Do()
if err != nil {
return err
}
return err
}
if label.Type == "system" {
@ -192,6 +175,106 @@ func (c *GmailLabelsRenameCmd) Run(ctx context.Context, flags *RootFlags) error
return nil
}
type GmailLabelsStyleCmd struct {
Label string `arg:"" name:"labelIdOrName" help:"User label ID or name"`
TextColor string `name:"text-color" help:"Text color as #RRGGBB"`
BackgroundColor string `name:"background-color" help:"Background color as #RRGGBB"`
LabelListVisibility string `name:"label-list-visibility" help:"Label-list visibility: labelShow|labelShowIfUnread|labelHide"`
MessageListVisibility string `name:"message-list-visibility" help:"Message-list visibility: show|hide"`
}
func (c *GmailLabelsStyleCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(ctx, account)
if err != nil {
return err
}
label, err := resolveMutableGmailLabel(ctx, svc, c.Label)
if err != nil {
return err
}
if label.Type == "system" {
return fmt.Errorf("cannot style system label %q", label.Name)
}
textColor, err := normalizeGmailLabelHexColor(c.TextColor, "--text-color")
if err != nil {
return err
}
backgroundColor, err := normalizeGmailLabelHexColor(c.BackgroundColor, "--background-color")
if err != nil {
return err
}
if textColor == "" && backgroundColor == "" && c.LabelListVisibility == "" && c.MessageListVisibility == "" {
return usage("specify at least one style field")
}
if err := validateGmailLabelVisibility(c.LabelListVisibility, "--label-list-visibility", "labelShow", "labelShowIfUnread", "labelHide"); err != nil {
return err
}
if err := validateGmailLabelVisibility(c.MessageListVisibility, "--message-list-visibility", "show", "hide"); err != nil {
return err
}
patch := &gmail.Label{
LabelListVisibility: strings.TrimSpace(c.LabelListVisibility),
MessageListVisibility: strings.TrimSpace(c.MessageListVisibility),
}
if textColor != "" || backgroundColor != "" {
color := &gmail.LabelColor{}
if label.Color != nil {
color.TextColor = label.Color.TextColor
color.BackgroundColor = label.Color.BackgroundColor
}
if textColor != "" {
color.TextColor = textColor
}
if backgroundColor != "" {
color.BackgroundColor = backgroundColor
}
patch.Color = color
}
if exit := dryRunExit(ctx, flags, "gmail.labels.style", map[string]any{
"id": label.Id,
"name": label.Name,
"textColor": textColor,
"backgroundColor": backgroundColor,
"labelListVisibility": c.LabelListVisibility,
"messageListVisibility": c.MessageListVisibility,
}); exit != nil {
return exit
}
updated, err := svc.Users.Labels.Patch("me", label.Id, patch).Context(ctx).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"label": updated})
}
u.Out().Printf("Styled label: %s (id: %s)", updated.Name, updated.Id)
return nil
}
func validateGmailLabelVisibility(value, field string, allowed ...string) error {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
for _, candidate := range allowed {
if value == candidate {
return nil
}
}
return usagef("%s must be one of: %s", field, strings.Join(allowed, ", "))
}
type GmailLabelsListCmd struct{}
func (c *GmailLabelsListCmd) Run(ctx context.Context, flags *RootFlags) error {
@ -338,30 +421,9 @@ func (c *GmailLabelsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error
return err
}
raw := strings.TrimSpace(c.Label)
if raw == "" {
return usage("empty label")
}
// For destructive operations, try exact ID match first before name lookup.
label, err := svc.Users.Labels.Get("me", raw).Context(ctx).Do()
label, err := resolveMutableGmailLabel(ctx, svc, c.Label)
if err != nil {
if !isNotFoundAPIError(err) {
return err
}
// Exact ID not found; resolve by label name only.
idMap, mapErr := fetchLabelNameOnlyToID(svc)
if mapErr != nil {
return mapErr
}
id, ok := idMap[strings.ToLower(raw)]
if !ok {
return fmt.Errorf("label not found: %s", raw)
}
label, err = svc.Users.Labels.Get("me", id).Context(ctx).Do()
if err != nil {
return err
}
return err
}
// System labels cannot be deleted

View File

@ -520,6 +520,128 @@ func TestGmailLabelsCreateCmd_DuplicateName_APIError(t *testing.T) {
}
}
func TestGmailLabelsStyleCmd_PatchColorAndVisibility(t *testing.T) {
var gotPatch bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && (strings.HasSuffix(r.URL.Path, "/users/me/labels") || strings.HasSuffix(r.URL.Path, "/gmail/v1/users/me/labels")):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"labels": []map[string]any{{"id": "Label_1", "name": "Custom", "type": "user"}},
})
return
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/labels/Custom"):
http.NotFound(w, r)
return
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/labels/Label_1"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "Label_1",
"name": "Custom",
"type": "user",
"color": map[string]any{
"textColor": "#000000",
"backgroundColor": "#ffffff",
},
})
return
case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/labels/Label_1"):
gotPatch = true
var body struct {
Color struct {
TextColor string `json:"textColor"`
BackgroundColor string `json:"backgroundColor"`
} `json:"color"`
LabelListVisibility string `json:"labelListVisibility"`
MessageListVisibility string `json:"messageListVisibility"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode patch: %v", err)
}
if body.Color.TextColor != "#112233" || body.Color.BackgroundColor != "#ffffff" {
t.Fatalf("unexpected color patch: %#v", body.Color)
}
if body.LabelListVisibility != "labelShowIfUnread" || body.MessageListVisibility != "hide" {
t.Fatalf("unexpected visibility patch: %#v", body)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "Label_1",
"name": "Custom",
"type": "user",
"labelListVisibility": body.LabelListVisibility,
"messageListVisibility": body.MessageListVisibility,
"color": map[string]any{
"textColor": body.Color.TextColor,
"backgroundColor": body.Color.BackgroundColor,
},
})
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
stubGmailService(t, srv)
out := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := &GmailLabelsStyleCmd{}
err := runKong(t, cmd, []string{
"Custom",
"--text-color", "#112233",
"--label-list-visibility", "labelShowIfUnread",
"--message-list-visibility", "hide",
}, ctx, &RootFlags{Account: "a@b.com"})
if err != nil {
t.Fatalf("execute: %v", err)
}
})
if !gotPatch {
t.Fatal("expected patch call")
}
if !strings.Contains(out, `"textColor": "#112233"`) {
t.Fatalf("missing color in output: %q", out)
}
}
func TestGmailLabelsStyleCmd_RejectsSystemLabel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/labels/INBOX") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "INBOX",
"name": "INBOX",
"type": "system",
})
}))
defer srv.Close()
stubGmailService(t, srv)
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
cmd := &GmailLabelsStyleCmd{}
err := runKong(t, cmd, []string{"INBOX", "--background-color", "#112233"}, ctx, &RootFlags{Account: "a@b.com"})
if err == nil || !strings.Contains(err.Error(), "cannot style system label") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchLabelIDToName(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(strings.HasSuffix(r.URL.Path, "/users/me/labels") || strings.HasSuffix(r.URL.Path, "/gmail/v1/users/me/labels")) {

View File

@ -1,7 +1,9 @@
package cmd
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
@ -40,6 +42,50 @@ func resolveModifyLabelIDs(svc *gmail.Service, addLabels, removeLabels []string)
return resolveLabelIDs(addLabels, idMap), resolveLabelIDs(removeLabels, idMap), nil
}
func resolveMutableGmailLabel(ctx context.Context, svc *gmail.Service, raw string) (*gmail.Label, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, usage("label is required")
}
label, err := svc.Users.Labels.Get("me", raw).Context(ctx).Do()
if err == nil {
return label, nil
}
if !isNotFoundAPIError(err) {
return nil, err
}
if looksLikeCustomLabelID(raw) {
return nil, fmt.Errorf("label not found: %s", raw)
}
idMap, mapErr := fetchLabelNameOnlyToID(svc)
if mapErr != nil {
return nil, mapErr
}
id, ok := idMap[strings.ToLower(raw)]
if !ok {
return nil, fmt.Errorf("label not found: %s", raw)
}
return svc.Users.Labels.Get("me", id).Context(ctx).Do()
}
func normalizeGmailLabelHexColor(raw, field string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
if len(raw) != 7 || raw[0] != '#' {
return "", usagef("%s must be a #RRGGBB hex color", field)
}
for _, r := range raw[1:] {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
return "", usagef("%s must be a #RRGGBB hex color", field)
}
}
return strings.ToLower(raw), nil
}
func looksLikeCustomLabelID(raw string) bool {
trimmed := strings.TrimSpace(raw)
if !strings.HasPrefix(strings.ToLower(trimmed), "label_") {