Skip to content
Merged
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
65 changes: 62 additions & 3 deletions internal/air/handlers_drafts.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package air

import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"

"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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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()

Expand All @@ -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,
Expand Down
294 changes: 294 additions & 0 deletions internal/air/handlers_drafts_send_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading