2026-08-20 06:46:10 +02:00
|
|
|
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
|
|
|
|
// it asks for.
|
2026-08-17 08:21:51 +02:00
|
|
|
//
|
2026-09-04 08:57:07 +02:00
|
|
|
// The one thing verdande writes back is the star. A starred message becomes a
|
|
|
|
|
// task, and once it has, the star comes off — exactly as the IMAP side takes the
|
|
|
|
|
// flag off a mail it has imported, and for the same reason: the star meant "turn
|
|
|
|
|
// this into a task", the task now exists, so the star is no longer true. Nothing
|
|
|
|
|
// else is touched. Completing the task does not reach back to the mail; the mail
|
|
|
|
|
// is a pointer the task was made from, not a copy the two have to be kept in step.
|
2026-08-17 08:21:51 +02:00
|
|
|
//
|
2026-09-04 08:57:07 +02:00
|
|
|
// That one write is why the scope is gmail.modify rather than gmail.readonly.
|
|
|
|
|
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
|
|
|
|
|
// can do it — so the consent screen asks for more than it used to, and the trade
|
|
|
|
|
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
|
|
|
|
|
// than one that fills with stars nothing ever clears. Still not send, still not
|
|
|
|
|
// delete — modify is labels, and the only label verdande removes is the one that
|
|
|
|
|
// triggered the task.
|
2026-08-20 06:46:10 +02:00
|
|
|
//
|
|
|
|
|
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
|
|
|
|
|
// The names below are aliases so that every call site — and this package's own
|
|
|
|
|
// tests — still read as Gmail's, which is what they are: one registration, one
|
|
|
|
|
// flow, one scope each.
|
2026-08-17 08:21:51 +02:00
|
|
|
package gmail
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
2026-08-20 06:46:10 +02:00
|
|
|
|
|
|
|
|
"github.com/kristianwind/verdande/internal/google"
|
2026-08-17 08:21:51 +02:00
|
|
|
)
|
|
|
|
|
|
2026-09-04 08:57:07 +02:00
|
|
|
// Read mail and change its labels, and only mail. Not profile, not contacts, not
|
|
|
|
|
// send, not delete. The single write is removing the STARRED label from a message
|
|
|
|
|
// that has become a task; see the package comment for why that costs modify.
|
|
|
|
|
const Scope = "https://www.googleapis.com/auth/gmail.modify"
|
2026-08-17 08:21:51 +02:00
|
|
|
|
2026-08-20 06:46:10 +02:00
|
|
|
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
|
|
|
|
|
|
|
|
|
// The OAuth flow, which is Google's rather than Gmail's.
|
|
|
|
|
type (
|
|
|
|
|
Config = google.Config
|
|
|
|
|
PKCE = google.PKCE
|
|
|
|
|
Token = google.Token
|
2026-08-17 08:21:51 +02:00
|
|
|
)
|
|
|
|
|
|
2026-08-20 06:46:10 +02:00
|
|
|
var (
|
|
|
|
|
NewPKCE = google.NewPKCE
|
|
|
|
|
ErrUnauthorized = google.ErrUnauthorized
|
|
|
|
|
)
|
2026-08-17 08:21:51 +02:00
|
|
|
|
|
|
|
|
// --- the API ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
type Client struct {
|
|
|
|
|
accessToken string
|
2026-08-20 06:46:10 +02:00
|
|
|
// base is where the Gmail API is. A field rather than a constant so a test can
|
|
|
|
|
// point it at a server it controls — and "the sync comes back before whatever
|
|
|
|
|
// sits in front of this server gives up on it" is exactly the kind of promise
|
|
|
|
|
// worth a test and impossible to make against the real thing. Empty is Google.
|
|
|
|
|
base string
|
|
|
|
|
http *http.Client
|
2026-08-17 08:21:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewClient(accessToken string) *Client {
|
|
|
|
|
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 13:43:48 +02:00
|
|
|
// At points the client at another server. Tests only; the zero value is Google.
|
2026-08-20 06:46:10 +02:00
|
|
|
func (c *Client) At(base string) *Client {
|
|
|
|
|
c.base = base
|
2026-08-18 13:43:48 +02:00
|
|
|
return c
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 06:46:10 +02:00
|
|
|
func (c *Client) api() string {
|
|
|
|
|
if c.base != "" {
|
|
|
|
|
return c.base
|
|
|
|
|
}
|
|
|
|
|
return defaultAPIBase
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 08:21:51 +02:00
|
|
|
// Message is the part of a Gmail message a task is made from.
|
|
|
|
|
type Message struct {
|
|
|
|
|
ID string
|
|
|
|
|
ThreadID string
|
|
|
|
|
From string
|
|
|
|
|
Subject string
|
|
|
|
|
Snippet string
|
|
|
|
|
// Link opens the message in Gmail's own interface, which is the whole point:
|
|
|
|
|
// the task is a pointer back to the mail, not a copy of it.
|
|
|
|
|
Link string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Profile identifies the connected mailbox, so the settings page can show which
|
|
|
|
|
// account is connected rather than only that one is.
|
|
|
|
|
func (c *Client) Profile(ctx context.Context) (string, error) {
|
|
|
|
|
var parsed struct {
|
|
|
|
|
EmailAddress string `json:"emailAddress"`
|
|
|
|
|
}
|
2026-08-20 06:46:10 +02:00
|
|
|
if err := c.get(ctx, c.api()+"/users/me/profile", &parsed); err != nil {
|
2026-08-17 08:21:51 +02:00
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return parsed.EmailAddress, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// List finds messages matching a Gmail search query.
|
|
|
|
|
//
|
|
|
|
|
// Gmail's own query language is used rather than a filter built here — "is:starred",
|
|
|
|
|
// "label:Til-handling" — because it is what the person already knows from the
|
|
|
|
|
// search box, and it is what their existing filters are written in.
|
|
|
|
|
func (c *Client) List(ctx context.Context, query string, max int) ([]string, error) {
|
|
|
|
|
if max <= 0 || max > 50 {
|
|
|
|
|
max = 25
|
|
|
|
|
}
|
|
|
|
|
v := url.Values{}
|
|
|
|
|
v.Set("q", query)
|
|
|
|
|
v.Set("maxResults", fmt.Sprint(max))
|
|
|
|
|
|
|
|
|
|
var parsed struct {
|
|
|
|
|
Messages []struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
} `json:"messages"`
|
|
|
|
|
}
|
2026-08-20 06:46:10 +02:00
|
|
|
if err := c.get(ctx, c.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
2026-08-17 08:21:51 +02:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ids := make([]string, 0, len(parsed.Messages))
|
|
|
|
|
for _, m := range parsed.Messages {
|
|
|
|
|
ids = append(ids, m.ID)
|
|
|
|
|
}
|
|
|
|
|
return ids, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get fetches the headers of one message. Metadata format only: verdande needs the
|
|
|
|
|
// sender and the subject, and asking for the full body would download attachments
|
|
|
|
|
// it has no use for.
|
|
|
|
|
func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
|
|
|
|
v := url.Values{}
|
|
|
|
|
v.Set("format", "metadata")
|
|
|
|
|
v.Add("metadataHeaders", "From")
|
|
|
|
|
v.Add("metadataHeaders", "Subject")
|
|
|
|
|
|
|
|
|
|
var parsed struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
ThreadID string `json:"threadId"`
|
|
|
|
|
Snippet string `json:"snippet"`
|
|
|
|
|
Payload struct {
|
|
|
|
|
Headers []struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Value string `json:"value"`
|
|
|
|
|
} `json:"headers"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
2026-08-20 06:46:10 +02:00
|
|
|
if err := c.get(ctx, c.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
2026-08-17 08:21:51 +02:00
|
|
|
return Message{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msg := Message{
|
|
|
|
|
ID: parsed.ID, ThreadID: parsed.ThreadID, Snippet: parsed.Snippet,
|
|
|
|
|
Link: "https://mail.google.com/mail/u/0/#inbox/" + parsed.ThreadID,
|
|
|
|
|
}
|
|
|
|
|
for _, h := range parsed.Payload.Headers {
|
|
|
|
|
switch strings.ToLower(h.Name) {
|
|
|
|
|
case "from":
|
|
|
|
|
msg.From = h.Value
|
|
|
|
|
case "subject":
|
|
|
|
|
msg.Subject = h.Value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return msg, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *Client) get(ctx context.Context, url string, out any) error {
|
2026-08-20 06:46:10 +02:00
|
|
|
return google.Get(ctx, c.http, c.accessToken, url, out)
|
2026-08-17 08:21:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 08:57:07 +02:00
|
|
|
// Unstar takes the star off the given messages — one call for all of them, the way
|
|
|
|
|
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
|
|
|
|
|
// not ten.
|
|
|
|
|
//
|
|
|
|
|
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
|
|
|
|
|
// label would need one, and verdande removes none. batchModify answers 204 with no
|
|
|
|
|
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
|
|
|
|
|
// an error on Google's side and nothing worth making a round trip to hear.
|
|
|
|
|
func (c *Client) Unstar(ctx context.Context, ids []string) error {
|
|
|
|
|
if len(ids) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
body := struct {
|
|
|
|
|
IDs []string `json:"ids"`
|
|
|
|
|
RemoveLabelIDs []string `json:"removeLabelIds"`
|
|
|
|
|
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
|
|
|
|
|
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 08:21:51 +02:00
|
|
|
// SenderName pulls "Anders Jensen" out of "Anders Jensen <anders@example.dk>",
|
|
|
|
|
// falling back to the address when there is no display name.
|
|
|
|
|
func SenderName(from string) string {
|
|
|
|
|
from = strings.TrimSpace(from)
|
|
|
|
|
if i := strings.Index(from, "<"); i > 0 {
|
|
|
|
|
// Space first, then quotes. A display name arrives as `"Jensen, Anders" `
|
|
|
|
|
// — with a trailing space before the angle bracket — so trimming quotes
|
|
|
|
|
// first stops at the space and leaves the closing quote behind.
|
|
|
|
|
name := strings.Trim(strings.TrimSpace(from[:i]), `"`)
|
|
|
|
|
if name != "" {
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return strings.Trim(strings.Trim(from, "<>"), `"`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Query builds the Gmail search for the configured trigger.
|
|
|
|
|
func Query(trigger, label string) string {
|
|
|
|
|
var terms []string
|
|
|
|
|
switch trigger {
|
|
|
|
|
case "starred":
|
|
|
|
|
terms = append(terms, "is:starred")
|
|
|
|
|
case "label":
|
|
|
|
|
if label != "" {
|
|
|
|
|
terms = append(terms, `label:"`+label+`"`)
|
|
|
|
|
}
|
|
|
|
|
case "both":
|
|
|
|
|
if label != "" {
|
|
|
|
|
// Gmail's OR needs the braces form to group correctly.
|
|
|
|
|
terms = append(terms, `{is:starred label:"`+label+`"}`)
|
|
|
|
|
} else {
|
|
|
|
|
terms = append(terms, "is:starred")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(terms) == 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
// Only recent mail. Without this, connecting an account with a decade of
|
|
|
|
|
// starred messages would create a decade of tasks in one sweep.
|
|
|
|
|
terms = append(terms, "newer_than:30d")
|
|
|
|
|
return strings.Join(terms, " ")
|
|
|
|
|
}
|