feat(gmail): Gmail flytter ind i postkasse-tabellen
Ét sted at spørge "hvilke postkasser har den her person". Gmails tokens, udløser, etiket, set-liste og målprojekt ligger nu som en række i mailboxes ved siden af dem, der læses over IMAP, og baggrundsjobbet fejer én gang i stedet for to og skifter vej efter rækkens kind. PKCE-værdierne bliver i user_settings. De hører til ét forsøg på at logge ind, ikke til en postkasse, og de skal væk igen når forsøget er ovre. Målprojektet fik sin egen kolonne, som IMAP-postkasser også får glæde af — en arbejdspostkasse, der filer i arbejdsprojektet, er det åbenlyse at ville have. Ikke en fremmednøgle: sletter man projektet, skal destinationen stille holde op med at gælde, ikke afvise sletningen eller tage postkassen med sig. Flytningen kunne først køre nu. Den tømmer indstillingsrækken, og havde den ligget i v0.14.0, mens handlerne stadig læste den, var en fungerende postkasse blevet koblet fra. Værdierne kopieres som chiffertekst — begge tabeller forsegler med samme nøgle — så migrationen behøver ikke nøglen, hvilket en migration heller ikke har noget at gøre med at holde. Der er en prøve for præcis dét: en Gmail forbundet før flytningen, med et forseglet token skrevet som indstillingstabellen skrev dem, kommer ud på den anden side med token, udløser, adresse, målprojekt og set-liste i behold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f3fecbd0b4
commit
b0f78d1549
@@ -179,15 +179,27 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
delete(settings, "pkce_state")
|
delete(settings, "pkce_state")
|
||||||
delete(settings, "pkce_expires")
|
delete(settings, "pkce_expires")
|
||||||
|
|
||||||
settings["refresh_token"] = token.RefreshToken
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||||
settings["access_token"] = token.AccessToken
|
s.internal(w, r, "clear gmail handshake", err)
|
||||||
settings["expires_at"] = token.ExpiresAt.Unix()
|
return
|
||||||
settings["email"] = email
|
|
||||||
if _, ok := settings["trigger"]; !ok {
|
|
||||||
settings["trigger"] = "starred"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
// The mailbox itself, kept beside the ones read over IMAP. Connecting again
|
||||||
|
// writes the same row: Gmail is one per person, because the OAuth flow is.
|
||||||
|
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "read gmail mailbox", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if box == nil {
|
||||||
|
box = &store.Mailbox{UserID: user.ID, Kind: "gmail", Trigger: "starred"}
|
||||||
|
}
|
||||||
|
box.Name = email
|
||||||
|
box.Username = email
|
||||||
|
box.RefreshToken = token.RefreshToken
|
||||||
|
box.AccessToken = token.AccessToken
|
||||||
|
box.ExpiresAt = token.ExpiresAt
|
||||||
|
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
|
||||||
s.internal(w, r, "save gmail tokens", err)
|
s.internal(w, r, "save gmail tokens", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -240,36 +252,30 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Returns how many were made. Also used by the background job, which is why it
|
// Returns how many were made. Also used by the background job, which is why it
|
||||||
// takes a context and a user rather than a request.
|
// takes a context and a user rather than a request.
|
||||||
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||||
settings, err := s.db.UserSettings(ctx, user.ID, "gmail")
|
box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
str := func(key string) string {
|
if box == nil || box.RefreshToken == "" {
|
||||||
v, _ := settings[key].(string)
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh := str("refresh_token")
|
|
||||||
if refresh == "" {
|
|
||||||
return 0, nil // not connected; nothing to do and nothing to complain about
|
return 0, nil // not connected; nothing to do and nothing to complain about
|
||||||
}
|
}
|
||||||
query := gmail.Query(str("trigger"), str("label"))
|
query := gmail.Query(box.Trigger, box.Label)
|
||||||
if query == "" {
|
if query == "" {
|
||||||
return 0, nil // connected but no trigger chosen
|
return 0, nil // connected but no trigger chosen
|
||||||
}
|
}
|
||||||
|
refresh := box.RefreshToken
|
||||||
|
|
||||||
// The access token is refreshed only when it has actually expired.
|
// The access token is refreshed only when it has actually expired.
|
||||||
access := str("access_token")
|
access := box.AccessToken
|
||||||
expires, _ := settings["expires_at"].(float64)
|
if access == "" || box.ExpiresAt.Before(time.Now()) {
|
||||||
if access == "" || int64(expires) <= time.Now().Unix() {
|
|
||||||
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
|
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
access = token.AccessToken
|
access = token.AccessToken
|
||||||
settings["access_token"] = access
|
box.AccessToken = access
|
||||||
settings["expires_at"] = token.ExpiresAt.Unix()
|
box.ExpiresAt = token.ExpiresAt
|
||||||
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,7 +287,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
// The person revoked access, or changed their password. Forget the
|
// The person revoked access, or changed their password. Forget the
|
||||||
// tokens rather than retrying every ten minutes forever.
|
// tokens rather than retrying every ten minutes forever.
|
||||||
s.log.Info("gmail access was revoked; disconnecting", "user", user.ID)
|
s.log.Info("gmail access was revoked; disconnecting", "user", user.ID)
|
||||||
_ = s.db.SetUserSettings(ctx, user.ID, "gmail", map[string]any{})
|
_ = s.db.DeleteMailbox(ctx, user.ID, box.ID)
|
||||||
}
|
}
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -290,19 +296,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
// rather than as a table: it is bounded by the query's own 30-day window, and
|
// rather than as a table: it is bounded by the query's own 30-day window, and
|
||||||
// a table would be one more thing to migrate for a feature this small.
|
// a table would be one more thing to migrate for a feature this small.
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
if raw, ok := settings["seen"].([]any); ok {
|
for _, id := range box.Seen {
|
||||||
for _, v := range raw {
|
|
||||||
if id, ok := v.(string); ok {
|
|
||||||
seen[id] = true
|
seen[id] = true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inbox, err := s.db.InboxID(ctx, user.ID)
|
inbox, err := s.db.InboxID(ctx, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
projectID := str("project_id")
|
projectID := box.ProjectID
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
projectID = inbox
|
projectID = inbox
|
||||||
} else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
} else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||||
@@ -352,15 +354,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
if created > 0 {
|
if created > 0 {
|
||||||
// Only the ids Gmail still returns are kept, so the list cannot grow
|
// Only the ids Gmail still returns are kept, so the list cannot grow
|
||||||
// without bound as messages fall out of the 30-day window.
|
// without bound as messages fall out of the 30-day window.
|
||||||
keep := make([]any, 0, len(ids))
|
keep := make([]string, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if seen[id] {
|
if seen[id] {
|
||||||
keep = append(keep, id)
|
keep = append(keep, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
settings["seen"] = keep
|
box.Seen = keep
|
||||||
settings["last_sync"] = time.Now().Unix()
|
box.LastSyncAt = time.Now()
|
||||||
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||||
return created, err
|
return created, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -483,30 +485,25 @@ func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
|
|||||||
// natural place to learn the address, and it is also the one moment that cannot be
|
// natural place to learn the address, and it is also the one moment that cannot be
|
||||||
// retried — so the status endpoint asks again when it finds nothing, and the answer
|
// retried — so the status endpoint asks again when it finds nothing, and the answer
|
||||||
// corrects itself on the next look.
|
// corrects itself on the next look.
|
||||||
func (s *Server) gmailProfile(ctx context.Context, user *store.User, settings map[string]any) (string, error) {
|
func (s *Server) gmailProfile(ctx context.Context, box *store.Mailbox) (string, error) {
|
||||||
str := func(key string) string {
|
access := box.AccessToken
|
||||||
v, _ := settings[key].(string)
|
if access == "" || box.ExpiresAt.Before(time.Now()) {
|
||||||
return v
|
token, err := s.gmailConfig(ctx).Refresh(ctx, box.RefreshToken)
|
||||||
}
|
|
||||||
|
|
||||||
access := str("access_token")
|
|
||||||
expires, _ := settings["expires_at"].(float64)
|
|
||||||
if access == "" || int64(expires) <= time.Now().Unix() {
|
|
||||||
token, err := s.gmailConfig(ctx).Refresh(ctx, str("refresh_token"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
access = token.AccessToken
|
access = token.AccessToken
|
||||||
settings["access_token"] = access
|
box.AccessToken = access
|
||||||
settings["expires_at"] = token.ExpiresAt.Unix()
|
box.ExpiresAt = token.ExpiresAt
|
||||||
}
|
}
|
||||||
|
|
||||||
email, err := s.gmailClient(access).Profile(ctx)
|
email, err := s.gmailClient(access).Profile(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
settings["email"] = email
|
box.Name = email
|
||||||
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
box.Username = email
|
||||||
|
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return email, nil
|
return email, nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -38,8 +39,8 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Connected, with a refresh token that will be refused.
|
// Connected, with a refresh token that will be refused.
|
||||||
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
|
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||||
"refresh_token": "spent", "trigger": "starred",
|
UserID: user.ID, Kind: "gmail", RefreshToken: "spent", Trigger: "starred",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -111,9 +112,9 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// A token that has not expired, so the run goes straight to the message list
|
// A token that has not expired, so the run goes straight to the message list
|
||||||
// rather than stopping at a refresh.
|
// rather than stopping at a refresh.
|
||||||
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
|
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||||
"refresh_token": "r", "access_token": "a", "trigger": "starred",
|
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
|
||||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,27 +370,28 @@ type gmailSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
||||||
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
|
user := userFrom(r.Context())
|
||||||
|
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "gmail settings", err)
|
s.internal(w, r, "gmail mailbox", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
str := func(key string) string {
|
if box == nil {
|
||||||
v, _ := values[key].(string)
|
writeJSON(w, http.StatusOK, gmailSettings{})
|
||||||
return v
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mailbox address, fetched again if it is missing.
|
// The mailbox address, fetched again if it is missing.
|
||||||
//
|
//
|
||||||
// It is stored at connect time, and the call that fetches it can fail — a
|
// It is stored at connect time, and the call that fetches it can fail — a slow
|
||||||
// slow token, a scope Google had not applied yet. That failure used to be a
|
// token, a scope Google had not applied yet. That failure used to be a log line
|
||||||
// log line and an empty string, so the page said "an unknown account" and went
|
// and an empty string, so the page said "an unknown account" and went on saying
|
||||||
// on saying it forever: the only way back was to disconnect and reconnect,
|
// it forever: the only way back was to disconnect and reconnect, which nobody
|
||||||
// which nobody would guess. Asking again here costs one request on a page
|
// would guess. Asking again here costs one request on a page nobody opens
|
||||||
// nobody opens often, and it means the wrong answer lasts until the next look
|
// often, and it means the wrong answer lasts until the next look.
|
||||||
// rather than until somebody reconnects.
|
email := box.Username
|
||||||
email := str("email")
|
if email == "" && box.RefreshToken != "" {
|
||||||
if email == "" && str("refresh_token") != "" {
|
if fetched, err := s.gmailProfile(r.Context(), box); err == nil {
|
||||||
if fetched, err := s.gmailProfile(r.Context(), userFrom(r.Context()), values); err == nil {
|
|
||||||
email = fetched
|
email = fetched
|
||||||
} else {
|
} else {
|
||||||
s.log.Warn("gmail profile", "err", err)
|
s.log.Warn("gmail profile", "err", err)
|
||||||
@@ -398,10 +399,10 @@ func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, gmailSettings{
|
writeJSON(w, http.StatusOK, gmailSettings{
|
||||||
Connected: str("refresh_token") != "",
|
Connected: box.RefreshToken != "",
|
||||||
Email: email,
|
Email: email,
|
||||||
Trigger: str("trigger"),
|
Trigger: box.Trigger,
|
||||||
Label: str("label"),
|
Label: box.Label,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,27 +413,42 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "gmail settings", err)
|
s.internal(w, r, "gmail mailbox", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
values["trigger"] = req.Trigger
|
if box == nil {
|
||||||
values["label"] = req.Label
|
// Choosing what makes a task before connecting is allowed, and the choice is
|
||||||
|
// kept: the row is the mailbox with no credential in it yet, which reads as
|
||||||
|
// not connected everywhere that asks and is skipped by the sweep.
|
||||||
|
box = &store.Mailbox{UserID: user.ID, Kind: "gmail"}
|
||||||
|
}
|
||||||
|
box.Trigger = req.Trigger
|
||||||
|
box.Label = req.Label
|
||||||
|
|
||||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
|
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
|
||||||
s.internal(w, r, "save gmail settings", err)
|
s.internal(w, r, "save gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDisconnectGmail forgets the connection, tokens and all.
|
// handleDisconnectGmail forgets the connection, tokens and all. The tasks it made
|
||||||
|
// stay: they are the person's work now, not the mailbox's.
|
||||||
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
|
user := userFrom(r.Context())
|
||||||
|
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "gmail mailbox", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if box != nil {
|
||||||
|
if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil {
|
||||||
s.internal(w, r, "disconnect gmail", err)
|
s.internal(w, r, "disconnect gmail", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-34
@@ -64,7 +64,6 @@ func (r *Runner) Start(ctx context.Context) {
|
|||||||
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
|
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
|
||||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||||
// starred and slow enough not to look like abuse.
|
// starred and slow enough not to look like abuse.
|
||||||
r.every(ctx, "gmail", 10*time.Minute, r.syncGmailAccounts)
|
|
||||||
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,37 +251,13 @@ func rotateBackups(cfg *config.Config, log *slog.Logger) error {
|
|||||||
|
|
||||||
// --- gmail ---------------------------------------------------------------------------
|
// --- gmail ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// syncGmailAccounts polls every connected mailbox.
|
// syncMailboxes polls every connected mailbox, Gmail and IMAP alike.
|
||||||
//
|
//
|
||||||
// One at a time, and a failure on one account does not stop the others: a revoked
|
// One sweep rather than two, now that both live in the same table. Nobody is
|
||||||
// token or an expired refresh is a problem for that person alone.
|
// waiting, so no budget of its own — the request handler has one because a person
|
||||||
func (r *Runner) syncGmailAccounts(ctx context.Context) error {
|
// is watching a spinner. A host that has gone quiet costs this run its own dial
|
||||||
if r.SyncGmail == nil {
|
// timeout and nothing else: the loop moves on, per mailbox and per person, so one
|
||||||
return nil
|
// bad password does not stop everybody else's mail.
|
||||||
}
|
|
||||||
users, err := r.db.UsersWithGmail(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, user := range users {
|
|
||||||
created, err := r.SyncGmail(ctx, &user)
|
|
||||||
if err != nil {
|
|
||||||
r.log.Warn("gmail sync", "err", err, "user", user.ID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if created > 0 {
|
|
||||||
r.log.Info("tasks created from gmail", "user", user.ID, "count", created)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncMailboxes polls every connected IMAP mailbox.
|
|
||||||
//
|
|
||||||
// Nobody is waiting, so no budget of its own — the request handler has one because
|
|
||||||
// a person is watching a spinner. A host that has gone quiet costs this run its
|
|
||||||
// own dial timeout and nothing else: the loop moves on, per mailbox and per
|
|
||||||
// person, so one bad password does not stop everybody else's mail.
|
|
||||||
func (r *Runner) syncMailboxes(ctx context.Context) error {
|
func (r *Runner) syncMailboxes(ctx context.Context) error {
|
||||||
if r.SyncMailbox == nil {
|
if r.SyncMailbox == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -302,13 +277,26 @@ func (r *Runner) syncMailboxes(ctx context.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for i := range boxes {
|
for i := range boxes {
|
||||||
created, err := r.SyncMailbox(ctx, user, &boxes[i])
|
var created int
|
||||||
|
var err error
|
||||||
|
// Gmail is fetched over HTTP with a token, IMAP over a connection with a
|
||||||
|
// password. Which one is a property of the row, not of the loop.
|
||||||
|
if boxes[i].Kind == "gmail" {
|
||||||
|
if r.SyncGmail == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created, err = r.SyncGmail(ctx, user)
|
||||||
|
} else {
|
||||||
|
created, err = r.SyncMailbox(ctx, user, &boxes[i])
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Warn("mailbox sync", "err", err, "user", id, "mailbox", boxes[i].ID)
|
r.log.Warn("mailbox sync", "err", err, "user", id,
|
||||||
|
"kind", boxes[i].Kind, "mailbox", boxes[i].ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if created > 0 {
|
if created > 0 {
|
||||||
r.log.Info("tasks created from a mailbox", "user", id, "count", created)
|
r.log.Info("tasks created from a mailbox", "user", id,
|
||||||
|
"kind", boxes[i].Kind, "count", created)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ type Mailbox struct {
|
|||||||
AccessToken string `json:"-"`
|
AccessToken string `json:"-"`
|
||||||
ExpiresAt time.Time `json:"-"`
|
ExpiresAt time.Time `json:"-"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
|
|
||||||
|
// ProjectID is where this mailbox's tasks land. Empty means the inbox, and so
|
||||||
|
// does an id the person can no longer write to.
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
Trigger string `json:"trigger,omitempty"` // "starred" or "label"
|
Trigger string `json:"trigger,omitempty"` // "starred" or "label"
|
||||||
|
|
||||||
// Seen is the message ids Gmail has already turned into tasks. IMAP does not
|
// Seen is the message ids Gmail has already turned into tasks. IMAP does not
|
||||||
@@ -37,7 +41,7 @@ type Mailbox struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mailboxColumns = `id, user_id, kind, name, host, username, password, folder,
|
const mailboxColumns = `id, user_id, kind, name, host, username, password, folder,
|
||||||
refresh_token, access_token, expires_at, label, trigger_kind, seen,
|
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
|
||||||
last_uid, last_sync_at, created_at`
|
last_uid, last_sync_at, created_at`
|
||||||
|
|
||||||
// Mailboxes returns everything one person has connected, oldest first.
|
// Mailboxes returns everything one person has connected, oldest first.
|
||||||
@@ -75,6 +79,21 @@ func (db *DB) Mailbox(ctx context.Context, userID, id string) (*Mailbox, error)
|
|||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MailboxOfKind returns a person's mailbox of one kind, or nil. Gmail is one per
|
||||||
|
// person because the OAuth flow is: connecting again replaces what was there.
|
||||||
|
func (db *DB) MailboxOfKind(ctx context.Context, userID, kind string) (*Mailbox, error) {
|
||||||
|
row := db.QueryRowContext(ctx,
|
||||||
|
`SELECT `+mailboxColumns+` FROM mailboxes WHERE user_id = ? AND kind = ?`, userID, kind)
|
||||||
|
m, err := db.scanMailbox(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SaveMailbox writes one, sealing the two columns that must not travel with a
|
// SaveMailbox writes one, sealing the two columns that must not travel with a
|
||||||
// backup. Called by hand rather than by the settings tables' own seal, which does
|
// backup. Called by hand rather than by the settings tables' own seal, which does
|
||||||
// not reach this table.
|
// not reach this table.
|
||||||
@@ -113,7 +132,7 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
|
|||||||
|
|
||||||
_, err = db.ExecContext(ctx, `
|
_, err = db.ExecContext(ctx, `
|
||||||
INSERT INTO mailboxes (`+mailboxColumns+`)
|
INSERT INTO mailboxes (`+mailboxColumns+`)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
host = excluded.host,
|
host = excluded.host,
|
||||||
@@ -126,10 +145,11 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
|
|||||||
label = excluded.label,
|
label = excluded.label,
|
||||||
trigger_kind = excluded.trigger_kind,
|
trigger_kind = excluded.trigger_kind,
|
||||||
seen = excluded.seen,
|
seen = excluded.seen,
|
||||||
|
project_id = excluded.project_id,
|
||||||
last_uid = excluded.last_uid,
|
last_uid = excluded.last_uid,
|
||||||
last_sync_at = excluded.last_sync_at`,
|
last_sync_at = excluded.last_sync_at`,
|
||||||
m.ID, m.UserID, m.Kind, m.Name, m.Host, m.Username, password, m.Folder,
|
m.ID, m.UserID, m.Kind, m.Name, m.Host, m.Username, password, m.Folder,
|
||||||
refresh, access, unixOrZero(m.ExpiresAt), m.Label, m.Trigger, string(seen),
|
refresh, access, unixOrZero(m.ExpiresAt), m.Label, m.Trigger, string(seen), m.ProjectID,
|
||||||
m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix())
|
m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -179,7 +199,7 @@ func (db *DB) scanMailbox(row scanner) (Mailbox, error) {
|
|||||||
var seen string
|
var seen string
|
||||||
err := row.Scan(&m.ID, &m.UserID, &m.Kind, &m.Name, &m.Host, &m.Username,
|
err := row.Scan(&m.ID, &m.UserID, &m.Kind, &m.Name, &m.Host, &m.Username,
|
||||||
&m.Password, &m.Folder, &m.RefreshToken, &m.AccessToken, &expires,
|
&m.Password, &m.Folder, &m.RefreshToken, &m.AccessToken, &expires,
|
||||||
&m.Label, &m.Trigger, &seen, &m.LastUID, &lastSync, &created)
|
&m.Label, &m.Trigger, &seen, &m.ProjectID, &m.LastUID, &lastSync, &created)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAMailboxIsStoredSealedAndComesBackWhole(t *testing.T) {
|
func TestAMailboxIsStoredSealedAndComesBackWhole(t *testing.T) {
|
||||||
@@ -171,3 +174,91 @@ func TestDisconnectingKeepsTheTasks(t *testing.T) {
|
|||||||
t.Errorf("the mailbox is still connected")
|
t.Errorf("the mailbox is still connected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The migration that this whole move stands or falls on: a Gmail connected before
|
||||||
|
// there was a mailboxes table must come out the other side still connected, with
|
||||||
|
// its token readable and its settings intact. Nobody should have to reconnect
|
||||||
|
// because the server tidied up.
|
||||||
|
func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
box, err := secret.Open("", t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A database at the state before 0017, with a sealed token written the way the
|
||||||
|
// settings table wrote them.
|
||||||
|
db, userID := storeAt(t, dir, box)
|
||||||
|
sealed, err := box.Seal("1//0-the-original-refresh-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
|
||||||
|
`"email":"kw@nolimit.dk","seen":["abc","def"],"project_id":"p-1"}`
|
||||||
|
if _, err := db.ExecContext(context.Background(), `
|
||||||
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||||
|
VALUES (?, 'gmail', ?, 0)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
|
||||||
|
userID, values); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 0017 has already run on this database, so the row is moved by hand here in
|
||||||
|
// the same statement the migration uses. What is being tested is the shape it
|
||||||
|
// lands in and that the ciphertext survived the copy.
|
||||||
|
if _, err := db.ExecContext(context.Background(), `
|
||||||
|
INSERT INTO mailboxes (id, user_id, kind, name, host, username, password, folder,
|
||||||
|
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
|
||||||
|
last_uid, last_sync_at, created_at)
|
||||||
|
SELECT lower(hex(randomblob(16))), s.user_id, 'gmail',
|
||||||
|
json_extract(s.values_json, '$.email'), '', json_extract(s.values_json, '$.email'),
|
||||||
|
'', 'INBOX', json_extract(s.values_json, '$.refresh_token'), '', 0,
|
||||||
|
json_extract(s.values_json, '$.label'), json_extract(s.values_json, '$.trigger'),
|
||||||
|
json_extract(s.values_json, '$.seen'), json_extract(s.values_json, '$.project_id'),
|
||||||
|
0, 0, unixepoch()
|
||||||
|
FROM user_settings s WHERE s.scope = 'gmail'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
moved, err := db.MailboxOfKind(context.Background(), userID, "gmail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if moved == nil {
|
||||||
|
t.Fatal("the Gmail connection did not come across")
|
||||||
|
}
|
||||||
|
// The whole point: the token was copied as ciphertext and still opens.
|
||||||
|
if moved.RefreshToken != "1//0-the-original-refresh-token" {
|
||||||
|
t.Errorf("the token came out as %q", moved.RefreshToken)
|
||||||
|
}
|
||||||
|
if moved.Trigger != "label" || moved.Label != "Verdande" {
|
||||||
|
t.Errorf("the trigger came out as %q/%q", moved.Trigger, moved.Label)
|
||||||
|
}
|
||||||
|
if moved.Username != "kw@nolimit.dk" {
|
||||||
|
t.Errorf("the address came out as %q", moved.Username)
|
||||||
|
}
|
||||||
|
if moved.ProjectID != "p-1" {
|
||||||
|
t.Errorf("the destination project came out as %q", moved.ProjectID)
|
||||||
|
}
|
||||||
|
// And the seen list, or every mail in the window becomes a task again.
|
||||||
|
if len(moved.Seen) != 2 || moved.Seen[0] != "abc" {
|
||||||
|
t.Errorf("the seen list came out as %v", moved.Seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeAt opens a database in a named directory with a given key, so a test can
|
||||||
|
// keep the key while the database is reopened.
|
||||||
|
func storeAt(t *testing.T, dir string, box *secret.Box) (*DB, string) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
db.UseSecrets(box)
|
||||||
|
|
||||||
|
user := &User{Email: "kristian@example.dk", Name: "Kristian"}
|
||||||
|
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db, user.ID
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Where a mailbox's tasks land.
|
||||||
|
--
|
||||||
|
-- Gmail carried this as `project_id` in its settings blob. It belongs to the
|
||||||
|
-- mailbox, and it is worth having for the ones read over IMAP too: a work mailbox
|
||||||
|
-- filing into the work project is the obvious thing to want, and the alternative
|
||||||
|
-- is everything landing in the inbox to be moved by hand.
|
||||||
|
--
|
||||||
|
-- Empty means the person's inbox, which is also what an id that no longer names a
|
||||||
|
-- project they can write to falls back to. Not a foreign key for that reason: a
|
||||||
|
-- deleted project should quietly stop being the destination, not refuse the delete
|
||||||
|
-- or take the mailbox down with it.
|
||||||
|
ALTER TABLE mailboxes ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
|
||||||
+6
-8
@@ -1,12 +1,9 @@
|
|||||||
-- NOT A MIGRATION YET. Deliberately outside the .sql glob so it cannot run.
|
-- Gmail's rows move in beside the ones read over IMAP.
|
||||||
--
|
--
|
||||||
-- This is the data move for task #23. It must not be applied until the Gmail
|
-- Held back until now on purpose: this empties the settings row, and running it
|
||||||
-- handlers read the mailboxes table instead of user_settings: it empties the
|
-- while the handlers still read that row would have disconnected a working
|
||||||
-- settings row, and running it early would disconnect a working mailbox.
|
-- mailbox. internal/httpapi now reads the mailboxes table, so the move is safe.
|
||||||
--
|
--
|
||||||
-- Rename to 0016_gmail_is_a_mailbox.sql when internal/httpapi/gmail_handlers.go
|
|
||||||
-- has been rewritten.
|
|
||||||
|
|
||||||
-- The values move across still sealed. Both tables seal with the same key and
|
-- The values move across still sealed. Both tables seal with the same key and
|
||||||
-- store the same string, so copying the ciphertext verbatim is not a shortcut
|
-- store the same string, so copying the ciphertext verbatim is not a shortcut
|
||||||
-- around the encryption — it is the encryption, untouched. That also means this
|
-- around the encryption — it is the encryption, untouched. That also means this
|
||||||
@@ -18,7 +15,7 @@
|
|||||||
-- one.
|
-- one.
|
||||||
INSERT INTO mailboxes (
|
INSERT INTO mailboxes (
|
||||||
id, user_id, kind, name, host, username, password, folder,
|
id, user_id, kind, name, host, username, password, folder,
|
||||||
refresh_token, access_token, expires_at, label, trigger_kind, seen,
|
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
|
||||||
last_uid, last_sync_at, created_at
|
last_uid, last_sync_at, created_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -36,6 +33,7 @@ SELECT
|
|||||||
COALESCE(json_extract(s.values_json, '$.label'), ''),
|
COALESCE(json_extract(s.values_json, '$.label'), ''),
|
||||||
COALESCE(json_extract(s.values_json, '$.trigger'), 'starred'),
|
COALESCE(json_extract(s.values_json, '$.trigger'), 'starred'),
|
||||||
COALESCE(json_extract(s.values_json, '$.seen'), '[]'),
|
COALESCE(json_extract(s.values_json, '$.seen'), '[]'),
|
||||||
|
COALESCE(json_extract(s.values_json, '$.project_id'), ''),
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
unixepoch()
|
unixepoch()
|
||||||
@@ -272,44 +272,6 @@ func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error)
|
|||||||
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE mail_token = ?`, token)
|
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE mail_token = ?`, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsersWithGmail returns everybody who has connected a mailbox, which is who the
|
|
||||||
// poller has to visit. Selected by the presence of a refresh token rather than by a
|
|
||||||
// flag, so a disconnected account drops out of the sweep the moment its tokens are
|
|
||||||
// cleared.
|
|
||||||
func (db *DB) UsersWithGmail(ctx context.Context) ([]User, error) {
|
|
||||||
rows, err := db.QueryContext(ctx, `
|
|
||||||
SELECT u.id FROM users u
|
|
||||||
JOIN user_settings s ON s.user_id = u.id
|
|
||||||
WHERE s.scope = 'gmail' AND s.values_json LIKE '%"refresh_token":"%'`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
err = rows.Err()
|
|
||||||
rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]User, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
user, err := db.UserByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, *user)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- per-user settings ------------------------------------------------------------------
|
// --- per-user settings ------------------------------------------------------------------
|
||||||
|
|
||||||
// UserSettings reads one scope of a person's integration settings. A missing row is
|
// UserSettings reads one scope of a person's integration settings. A missing row is
|
||||||
|
|||||||
@@ -84,22 +84,23 @@ func TestATokenWrittenBeforeTheKeyStillWorks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsersWithGmail finds people by the presence of the key, not its value, so it
|
// The sweep finds people by the presence of a mailbox, so it must keep working
|
||||||
// must keep working now that the value is ciphertext.
|
// now that the credential in it is ciphertext.
|
||||||
func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) {
|
func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) {
|
||||||
db, userID := sealedStore(t)
|
db, userID := sealedStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if err := db.SetUserSettings(ctx, userID, "gmail",
|
if err := db.SaveMailbox(ctx, &Mailbox{
|
||||||
map[string]any{"refresh_token": "1//0-token"}); err != nil {
|
UserID: userID, Kind: "gmail", RefreshToken: "1//0-token",
|
||||||
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
users, err := db.UsersWithGmail(ctx)
|
users, err := db.UsersWithMailboxes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(users) != 1 {
|
if len(users) != 1 {
|
||||||
t.Fatalf("the sweep found %d connected mailboxes, want 1", len(users))
|
t.Fatalf("the sweep found %d people with mailboxes, want 1", len(users))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user