Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,25 @@ involved — nothing is auto-created.

**Label Operations:**
- `gwcli labels list` - List all labels
- `gwcli labels create <name>` - Create a new user label
- `gwcli labels update <label> [--name <new>]` - Rename / change visibility
- `gwcli labels delete <label> --force` - Delete a user label (`--force` required)
- `gwcli labels apply <label> --message <id>` - Apply label to message
- `gwcli labels remove <label> --message <id>` - Remove label from message

Labels are created/deleted in the Gmail UI. gwcli does not manage label
creation/deletion.
gwcli manages labels imperatively via the Gmail API (`users.labels` CRUD,
implemented in `labels.go` with the connection methods `CreateLabel`,
`UpdateLabel`, `DeleteLabel` in `connection.go`). `update` and `delete` accept a
label **name or ID** (resolved via the shared `resolveLabelID` helper, the same
name/ID matching used elsewhere); nested labels use `/` in the name (e.g.
`Work/Urgent`). Visibility flags map to the Gmail API:
`--message-list-visibility` (`show`/`hide`) and `--label-list-visibility`
(`labelShow`/`labelShowIfUnread`/`labelHide`). `create` rejects a duplicate
name; `delete` refuses to remove Gmail system labels (INBOX, SENT, ...) and
requires `--force` (non-interactive rule #3). Any mutation invalidates the
connection's label cache so the next access reloads from the API. The
`gmail.labels` OAuth scope (already requested) covers all of this — no
re-consent needed.

### Filter Management

Expand Down Expand Up @@ -675,7 +689,7 @@ Note: Service accounts require domain-wide delegation with the `https://www.goog
2. **Kong syntax**: Command matching uses exact strings like `"messages list"` not path-style routes
3. **No interactive prompts**: All commands must work non-interactively (for scripting). Destructive commands require an explicit `--force` flag instead of prompting (e.g. `filters delete`).
4. **Label IDs vs Names**: Always handle both - users may provide either
5. **Label create/delete**: Done in the Gmail UI; gwcli does not create/delete labels
5. **Label create/delete**: Managed via the Gmail API (`labels create`/`update`/`delete`); `delete` requires `--force` and rejects system labels
6. **Labels load from the Gmail API**: Labels are fetched directly from the API (all user labels + system labels). There is no config file and nothing is auto-created.

## Error Handling Pattern
Expand Down
114 changes: 114 additions & 0 deletions labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,120 @@ func runLabelsList(ctx context.Context, conn *gwcli.CmdG, systemOnly, userOnly b
return out.writeTable(headers, rows)
}

func runLabelsCreate(ctx context.Context, conn *gwcli.CmdG, name, messageListVisibility, labelListVisibility string, out *outputWriter) error {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("label name is required")
}

out.writeVerbose("Loading labels to check for duplicates...")
if err := conn.LoadLabels(ctx, out.verbose); err != nil {
return fmt.Errorf("failed to load labels: %w", err)
}
for _, l := range conn.Labels() {
if strings.EqualFold(l.Label, name) {
return fmt.Errorf("label %q already exists (ID: %s)", l.Label, l.ID)
}
}

out.writeVerbose("Creating label %q...", name)
created, err := conn.CreateLabel(ctx, name, messageListVisibility, labelListVisibility)
if err != nil {
return fmt.Errorf("failed to create label: %w", err)
}

if out.json {
o := labelListOutput{
ID: created.Id,
Name: created.Name,
Type: created.Type,
MessageListView: created.MessageListVisibility,
LabelListView: created.LabelListVisibility,
}
if created.Color != nil {
o.Color = created.Color.BackgroundColor
}
return out.writeJSON(o)
}

out.writeMessage(fmt.Sprintf("Created label %q (ID: %s)", created.Name, created.Id))
return nil
}

func runLabelsUpdate(ctx context.Context, conn *gwcli.CmdG, labelRef, newName, messageListVisibility, labelListVisibility string, out *outputWriter) error {
if newName == "" && messageListVisibility == "" && labelListVisibility == "" {
return fmt.Errorf("nothing to update: provide --name, --message-list-visibility, or --label-list-visibility")
}

out.writeVerbose("Loading labels to resolve %q...", labelRef)
if err := conn.LoadLabels(ctx, out.verbose); err != nil {
return fmt.Errorf("failed to load labels: %w", err)
}

resolvedID, err := resolveLabelID(conn, labelRef)
if err != nil {
return err
}
out.writeVerbose("Resolved label %q to ID %q", labelRef, resolvedID)

updated, err := conn.UpdateLabel(ctx, resolvedID, newName, messageListVisibility, labelListVisibility)
if err != nil {
return fmt.Errorf("failed to update label: %w", err)
}

if out.json {
o := labelListOutput{
ID: updated.Id,
Name: updated.Name,
Type: updated.Type,
MessageListView: updated.MessageListVisibility,
LabelListView: updated.LabelListVisibility,
}
if updated.Color != nil {
o.Color = updated.Color.BackgroundColor
}
return out.writeJSON(o)
}

out.writeMessage(fmt.Sprintf("Updated label %q (ID: %s)", updated.Name, updated.Id))
return nil
}

func runLabelsDelete(ctx context.Context, conn *gwcli.CmdG, labelRef string, force bool, out *outputWriter) error {
if !force {
return fmt.Errorf("refusing to delete without --force")
}

out.writeVerbose("Loading labels to resolve %q...", labelRef)
if err := conn.LoadLabels(ctx, out.verbose); err != nil {
return fmt.Errorf("failed to load labels: %w", err)
}

resolvedID, err := resolveLabelID(conn, labelRef)
if err != nil {
return err
}

// Guard against deleting Gmail system labels (INBOX, SENT, ...), which the
// API rejects anyway — give a clearer error up front.
for _, l := range conn.Labels() {
if l.ID == resolvedID && l.Response != nil && l.Response.Type == "system" {
return fmt.Errorf("cannot delete system label %q", l.Label)
}
}

out.writeVerbose("Deleting label %q (ID: %s)...", labelRef, resolvedID)
if err := conn.DeleteLabel(ctx, resolvedID); err != nil {
return fmt.Errorf("failed to delete label: %w", err)
}

if out.json {
return out.writeJSON(map[string]string{"status": "deleted", "id": resolvedID})
}

out.writeMessage(fmt.Sprintf("Deleted label (ID: %s)", resolvedID))
return nil
}

func runLabelsApply(ctx context.Context, conn *gwcli.CmdG, labelID, messageID string, stdin bool, verbose bool, out *outputWriter) error {
var ids []string
var err error
Expand Down
Loading
Loading