diff --git a/internal/air/handlers_drafts.go b/internal/air/handlers_drafts.go index af8c5bf..c67fbd4 100644 --- a/internal/air/handlers_drafts.go +++ b/internal/air/handlers_drafts.go @@ -1,6 +1,9 @@ package air import ( + "context" + "errors" + "fmt" "net/http" "strings" "time" @@ -8,6 +11,10 @@ import ( "github.com/nylas/cli/internal/domain" ) +// errSendGrantNotFound indicates the caller-supplied grant_id is not a +// stored, Air-supported grant for this user. +var errSendGrantNotFound = errors.New("grant not found or not supported by Air") + // handleDrafts handles POST /api/drafts (create) and GET /api/drafts (list). func (s *Server) handleDrafts(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -231,13 +238,52 @@ func (s *Server) handleSendDraft(w http.ResponseWriter, r *http.Request, draftID }) } +// resolveSendGrantID returns the grant ID to send from. If the request supplies +// a grant_id it must match one of the user's stored, Air-supported grants +// (errSendGrantNotFound otherwise). When unset, the active default grant is +// used so older clients keep working. +func (s *Server) resolveSendGrantID(requestedGrantID, defaultGrantID string) (string, error) { + if requestedGrantID == "" { + return defaultGrantID, nil + } + supported, err := s.listSupportedGrants() + if err != nil { + return "", err + } + for i := range supported { + if supported[i].ID == requestedGrantID { + return requestedGrantID, nil + } + } + return "", errSendGrantNotFound +} + +// sendMessageForGrant sends via the per-grant /v3/grants/{id}/messages/send +// endpoint for every provider, populating From for Nylas-managed grants since +// the API rejects the request otherwise. Per-grant send is what archives the +// message to the sender's Sent folder; the transactional /v3/domains/... +// endpoint is a relay that does not archive, so it is *not* used here even +// for Nylas-provider grants. +func (s *Server) sendMessageForGrant(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) { + if len(req.From) == 0 { + grant, err := s.nylasClient.GetGrant(ctx, grantID) + if err != nil { + return nil, fmt.Errorf("fetch grant: %w", err) + } + if grant != nil && grant.Provider == domain.ProviderNylas && grant.Email != "" { + req.From = []domain.EmailParticipant{{Email: grant.Email}} + } + } + return s.nylasClient.SendMessage(ctx, grantID, req) +} + // handleSendMessage sends a message directly without creating a draft first. func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) { if !requireMethod(w, r, http.MethodPost) { return } - grantID := s.withAuthGrant(w, SendMessageResponse{Success: true, MessageID: "demo-sent-" + time.Now().Format("20060102150405"), Message: "Email sent (demo mode)"}) - if grantID == "" { + defaultGrantID := s.withAuthGrant(w, SendMessageResponse{Success: true, MessageID: "demo-sent-" + time.Now().Format("20060102150405"), Message: "Email sent (demo mode)"}) + if defaultGrantID == "" { return } @@ -254,6 +300,19 @@ func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) { return } + grantID, err := s.resolveSendGrantID(req.GrantID, defaultGrantID) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, errSendGrantNotFound) { + status = http.StatusBadRequest + } + writeJSON(w, status, SendMessageResponse{ + Success: false, + Error: "Invalid sender account: " + err.Error(), + }) + return + } + ctx, cancel := s.withTimeout(r) defer cancel() @@ -266,7 +325,7 @@ func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) { ReplyToMsgID: req.ReplyToMsgID, } - msg, err := s.nylasClient.SendMessage(ctx, grantID, sendReq) + msg, err := s.sendMessageForGrant(ctx, grantID, sendReq) if err != nil { writeJSON(w, http.StatusInternalServerError, SendMessageResponse{ Success: false, diff --git a/internal/air/handlers_drafts_send_test.go b/internal/air/handlers_drafts_send_test.go new file mode 100644 index 0000000..027bb29 --- /dev/null +++ b/internal/air/handlers_drafts_send_test.go @@ -0,0 +1,294 @@ +package air + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + nylasmock "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" +) + +// newSendTestServer builds a non-demo Server with a mock Nylas client and a +// grant store containing the supplied grants. defaultGrantID identifies which +// grant the server should treat as the active default. +func newSendTestServer(t *testing.T, grants []domain.GrantInfo, defaultGrantID string) (*Server, *nylasmock.MockClient) { + t.Helper() + + client := nylasmock.NewMockClient() + store := &testGrantStore{ + grants: append([]domain.GrantInfo(nil), grants...), + defaultGrant: defaultGrantID, + } + return &Server{ + grantStore: store, + nylasClient: client, + }, client +} + +// resetTransactionalMock clears the package-level SendTransactionalMessageFunc +// after each test that touches it. +func resetTransactionalMock(t *testing.T) { + t.Helper() + t.Cleanup(func() { + nylasmock.SendTransactionalMessageFunc = nil + }) +} + +func sendRequest(t *testing.T, body map[string]any) *http.Request { + t.Helper() + encoded, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/send", bytes.NewBuffer(encoded)) + req.Header.Set("Content-Type", "application/json") + return req +} + +func TestHandleSendMessage_RequestGrantWins_GoogleStaysGoogle(t *testing.T) { + resetTransactionalMock(t) + + const googleID = "grant-google" + const nylasID = "grant-nylas" + server, client := newSendTestServer(t, []domain.GrantInfo{ + {ID: nylasID, Email: "managed@example.nylas.email", Provider: domain.ProviderNylas}, + {ID: googleID, Email: "qasim.m@nylas.com", Provider: domain.ProviderGoogle}, + }, nylasID) // default points at Nylas to prove request grant wins + + var seenGrantID string + client.GetGrantFunc = func(_ context.Context, id string) (*domain.Grant, error) { + seenGrantID = id + return &domain.Grant{ID: id, Email: "qasim.m@nylas.com", Provider: domain.ProviderGoogle}, nil + } + + nylasmock.SendTransactionalMessageFunc = func(_ context.Context, _ string, _ *domain.SendMessageRequest) (*domain.Message, error) { + t.Fatalf("transactional endpoint must not be called for a Google-provider grant") + return nil, nil + } + + var sentGrantID string + var sentReq *domain.SendMessageRequest + client.SendMessageFunc = func(_ context.Context, id string, r *domain.SendMessageRequest) (*domain.Message, error) { + sentGrantID = id + sentReq = r + return &domain.Message{ID: "msg-1"}, nil + } + + w := httptest.NewRecorder() + server.handleSendMessage(w, sendRequest(t, map[string]any{ + "grant_id": googleID, + "to": []map[string]string{{"email": "to@example.com"}}, + "subject": "hi", + "body": "hello", + })) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if seenGrantID != googleID { + t.Errorf("GetGrant got %q, want %q", seenGrantID, googleID) + } + if sentGrantID != googleID { + t.Errorf("SendMessage got %q, want %q", sentGrantID, googleID) + } + if sentReq == nil || len(sentReq.From) != 0 { + t.Errorf("standard providers should not have From auto-populated, got %+v", sentReq) + } +} + +func TestHandleSendMessage_NylasGrantArchivesViaPerGrantSend(t *testing.T) { + // Per /v3/grants/{id}/messages/send + From is what archives a message to + // the Sent folder for Nylas-managed grants. The transactional endpoint is + // a non-archiving relay and must not be used here. + resetTransactionalMock(t) + + const nylasID = "grant-nylas" + const grantEmail = "support@managed.nylas.email" + server, client := newSendTestServer(t, []domain.GrantInfo{ + {ID: nylasID, Email: grantEmail, Provider: domain.ProviderNylas}, + }, nylasID) + + client.GetGrantFunc = func(_ context.Context, id string) (*domain.Grant, error) { + return &domain.Grant{ID: id, Email: grantEmail, Provider: domain.ProviderNylas}, nil + } + nylasmock.SendTransactionalMessageFunc = func(_ context.Context, _ string, _ *domain.SendMessageRequest) (*domain.Message, error) { + t.Fatal("transactional endpoint must not be used; per-grant send is what archives to Sent") + return nil, nil + } + + var sentGrantID string + var sentReq *domain.SendMessageRequest + client.SendMessageFunc = func(_ context.Context, id string, r *domain.SendMessageRequest) (*domain.Message, error) { + sentGrantID = id + sentReq = r + return &domain.Message{ID: "msg-archived"}, nil + } + + w := httptest.NewRecorder() + server.handleSendMessage(w, sendRequest(t, map[string]any{ + "grant_id": nylasID, + "to": []map[string]string{{"email": "to@example.com"}}, + "subject": "hi", + "body": "hello", + })) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if sentGrantID != nylasID { + t.Errorf("SendMessage grantID = %q, want %q", sentGrantID, nylasID) + } + if sentReq == nil || len(sentReq.From) != 1 || sentReq.From[0].Email != grantEmail { + t.Errorf("Nylas grant must have From auto-populated to %q, got %+v", grantEmail, sentReq) + } +} + +func TestHandleSendMessage_NoGrantID_FallsBackToDefault(t *testing.T) { + resetTransactionalMock(t) + + const googleID = "grant-google" + server, client := newSendTestServer(t, []domain.GrantInfo{ + {ID: googleID, Email: "qasim.m@nylas.com", Provider: domain.ProviderGoogle}, + }, googleID) + + client.GetGrantFunc = func(_ context.Context, id string) (*domain.Grant, error) { + return &domain.Grant{ID: id, Email: "qasim.m@nylas.com", Provider: domain.ProviderGoogle}, nil + } + var sentGrantID string + client.SendMessageFunc = func(_ context.Context, id string, _ *domain.SendMessageRequest) (*domain.Message, error) { + sentGrantID = id + return &domain.Message{ID: "msg-default"}, nil + } + + w := httptest.NewRecorder() + server.handleSendMessage(w, sendRequest(t, map[string]any{ + // no grant_id — server must use default + "to": []map[string]string{{"email": "to@example.com"}}, + "subject": "hi", + "body": "hello", + })) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if sentGrantID != googleID { + t.Errorf("SendMessage got %q, want default %q", sentGrantID, googleID) + } +} + +func TestHandleSendMessage_UnknownGrantID_Rejected(t *testing.T) { + resetTransactionalMock(t) + + server, client := newSendTestServer(t, []domain.GrantInfo{ + {ID: "grant-known", Email: "x@y.com", Provider: domain.ProviderGoogle}, + }, "grant-known") + + client.SendMessageFunc = func(_ context.Context, _ string, _ *domain.SendMessageRequest) (*domain.Message, error) { + t.Fatal("send must not run when grant_id does not belong to user") + return nil, nil + } + nylasmock.SendTransactionalMessageFunc = func(_ context.Context, _ string, _ *domain.SendMessageRequest) (*domain.Message, error) { + t.Fatal("transactional send must not run when grant_id is unknown") + return nil, nil + } + + w := httptest.NewRecorder() + server.handleSendMessage(w, sendRequest(t, map[string]any{ + "grant_id": "grant-evil", + "to": []map[string]string{{"email": "to@example.com"}}, + "subject": "hi", + "body": "hello", + })) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for unknown grant, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestResolveSendGrantID(t *testing.T) { + server, _ := newSendTestServer(t, []domain.GrantInfo{ + {ID: "g1", Email: "a@x.com", Provider: domain.ProviderGoogle}, + {ID: "g2", Email: "b@y.com", Provider: domain.ProviderNylas}, + }, "g1") + + tests := []struct { + name string + requested string + want string + wantErr error + }{ + {name: "empty falls back to default", requested: "", want: "g1"}, + {name: "valid request grant", requested: "g2", want: "g2"}, + {name: "unknown grant rejected", requested: "g-bogus", wantErr: errSendGrantNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := server.resolveSendGrantID(tt.requested, "g1") + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("err = %v, want %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestSendMessageForGrant_GetGrantError(t *testing.T) { + resetTransactionalMock(t) + server, client := newSendTestServer(t, []domain.GrantInfo{ + {ID: "g1", Email: "a@x.com", Provider: domain.ProviderGoogle}, + }, "g1") + client.GetGrantFunc = func(_ context.Context, _ string) (*domain.Grant, error) { + return nil, errors.New("boom") + } + + _, err := server.sendMessageForGrant(context.Background(), "g1", &domain.SendMessageRequest{}) + if err == nil || !strings.Contains(err.Error(), "fetch grant") { + t.Fatalf("expected fetch-grant wrapped error, got %v", err) + } +} + +func TestSendMessageForGrant_PreservesCallerFrom(t *testing.T) { + // If the caller already supplied a From, sendMessageForGrant must respect + // it (no auto-populate, no GetGrant call). + resetTransactionalMock(t) + + server, client := newSendTestServer(t, []domain.GrantInfo{ + {ID: "g1", Email: "real@x.com", Provider: domain.ProviderNylas}, + }, "g1") + client.GetGrantFunc = func(_ context.Context, _ string) (*domain.Grant, error) { + t.Fatal("GetGrant must not be called when From is already set") + return nil, nil + } + + var sentReq *domain.SendMessageRequest + client.SendMessageFunc = func(_ context.Context, _ string, r *domain.SendMessageRequest) (*domain.Message, error) { + sentReq = r + return &domain.Message{ID: "msg"}, nil + } + + original := []domain.EmailParticipant{{Email: "explicit@example.com", Name: "Caller"}} + _, err := server.sendMessageForGrant(context.Background(), "g1", &domain.SendMessageRequest{ + From: original, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sentReq == nil || len(sentReq.From) != 1 || sentReq.From[0].Email != "explicit@example.com" { + t.Errorf("From was rewritten; want explicit@example.com, got %+v", sentReq) + } +} diff --git a/internal/air/handlers_types.go b/internal/air/handlers_types.go index 3e80165..6a6902c 100644 --- a/internal/air/handlers_types.go +++ b/internal/air/handlers_types.go @@ -155,7 +155,12 @@ type DraftsResponse struct { } // SendMessageRequest represents a request to send a message directly. +// +// GrantID, when set, identifies which connected account to send from. It must +// match one of the user's stored, Air-supported grants. If empty, the server +// falls back to the active default grant. type SendMessageRequest struct { + GrantID string `json:"grant_id,omitempty"` To []EmailParticipantResponse `json:"to"` Cc []EmailParticipantResponse `json:"cc,omitempty"` Bcc []EmailParticipantResponse `json:"bcc,omitempty"` diff --git a/internal/air/static/js/compose.js b/internal/air/static/js/compose.js index e01105e..600963d 100644 --- a/internal/air/static/js/compose.js +++ b/internal/air/static/js/compose.js @@ -170,6 +170,14 @@ const ComposeManager = { body: els.body?.value || '' }; + // Pin the send to the grant the page was rendered for so the user's + // visible "from" account matches what the backend actually uses, + // even if the persisted default has drifted. + const grantId = document.body && document.body.dataset && document.body.dataset.defaultGrantId; + if (grantId) { + data.grant_id = grantId; + } + // For replies, include reply_to_message_id and thread_id for proper threading if (this.mode === 'reply' && this.replyToEmail) { data.reply_to_message_id = this.replyToEmail.id; diff --git a/internal/air/static/js/settings.js b/internal/air/static/js/settings.js index 0f4f58f..dc19f59 100644 --- a/internal/air/static/js/settings.js +++ b/internal/air/static/js/settings.js @@ -262,39 +262,37 @@ function startRefreshTimer() { console.log(`%c🔄 Auto-refresh started: every ${settingsState.refreshInterval}s`, 'color: #22c55e;'); } -// Refresh emails function +// Refresh emails function. Re-fetches the currently visible folder so newly +// arrived messages show up without requiring a full page reload. function refreshEmails() { lastRefreshTime = Date.now(); - // Show refresh indicator in status bar if it exists const syncStatus = document.querySelector('.sync-status'); if (syncStatus) { syncStatus.classList.add('syncing'); - setTimeout(() => syncStatus.classList.remove('syncing'), 1000); } - // Simulate fetching new emails - console.log('%c📬 Checking for new emails...', 'color: #3b82f6;'); - - // Random chance of new email for demo - if (Math.random() > 0.7) { - setTimeout(() => { - if (typeof showToast === 'function') { - showToast('info', 'New Email', '1 new message received'); - } - // Update unread count - const badge = document.querySelector('.mobile-nav-badge'); - if (badge) { - const count = parseInt(badge.textContent) + 1; - badge.textContent = count; - badge.classList.add('new-mail'); - setTimeout(() => badge.classList.remove('new-mail'), 300); - } - }, 500); + const clearSpinner = () => { + if (syncStatus) syncStatus.classList.remove('syncing'); + }; + + if (typeof EmailListManager === 'undefined' || typeof EmailListManager.loadEmails !== 'function') { + clearSpinner(); + return; + } + + if (EmailListManager.isLoading) { + clearSpinner(); + return; } + + const folder = EmailListManager.currentFolder || 'INBOX'; + Promise.resolve(EmailListManager.loadEmails(folder)) + .catch(err => console.error('[refreshEmails] failed:', err)) + .finally(clearSpinner); } -// Manual refresh +// Manual refresh triggered by a user action (e.g. pull-to-refresh, button). function manualRefresh() { refreshEmails(); if (typeof showToast === 'function') { diff --git a/internal/air/templates/base.gohtml b/internal/air/templates/base.gohtml index 935da28..8485ad6 100644 --- a/internal/air/templates/base.gohtml +++ b/internal/air/templates/base.gohtml @@ -22,7 +22,7 @@ -
+