diff --git a/packages/ui/src/Atoms/ThirdParties/SendGrid.tsx b/packages/ui/src/Atoms/ThirdParties/SendGrid.tsx
new file mode 100644
index 000000000..7c125a8c7
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/SendGrid.tsx
@@ -0,0 +1,21 @@
+import type { ComponentProps } from "react";
+
+export function SendGrid(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
index 06dc03498..f3ee9f028 100644
--- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
+++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
@@ -42,6 +42,7 @@ import { OpenAI } from "./OpenAI";
import { PagerDuty } from "./PagerDuty";
import { PostHog } from "./PostHog";
import { Resend } from "./Resend";
+import { SendGrid } from "./SendGrid";
import { Sentry } from "./Sentry";
import { Slack } from "./Slack";
import { Supabase } from "./Supabase";
@@ -81,6 +82,7 @@ const thirdParties: Record>> = {
PAGERDUTY: PagerDuty,
POSTHOG: PostHog,
RESEND: Resend,
+ SENDGRID: SendGrid,
SENTRY: Sentry,
SLACK: Slack,
SUPABASE: Supabase,
diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts
index 4d9526c82..b12188d41 100644
--- a/packages/ui/src/Atoms/ThirdParties/index.ts
+++ b/packages/ui/src/Atoms/ThirdParties/index.ts
@@ -26,6 +26,7 @@ export { OpenAI } from "./OpenAI";
export { PagerDuty } from "./PagerDuty";
export { PostHog } from "./PostHog";
export { Resend } from "./Resend";
+export { SendGrid } from "./SendGrid";
export { Sentry } from "./Sentry";
export { Slack } from "./Slack";
export { Supabase } from "./Supabase";
diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go
index 1230935e2..c81bd0cac 100644
--- a/pkg/accessreview/drivers/name_resolver.go
+++ b/pkg/accessreview/drivers/name_resolver.go
@@ -486,6 +486,54 @@ func (r *anthropicNameResolver) ResolveInstanceName(ctx context.Context) (string
return resp.Name, nil
}
+// sendGridNameResolver resolves the SendGrid account's company name from
+// the user profile endpoint, used as the AccessSource instance label.
+type sendGridNameResolver struct {
+ httpClient *http.Client
+}
+
+func NewSendGridNameResolver(httpClient *http.Client) NameResolver {
+ return &sendGridNameResolver{httpClient: httpClient}
+}
+
+func (r *sendGridNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ "https://api.sendgrid.com/v3/user/profile",
+ nil,
+ )
+ if err != nil {
+ return "", fmt.Errorf("cannot create sendgrid profile request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ httpResp, err := r.httpClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("cannot execute sendgrid profile request: %w", err)
+ }
+
+ defer func() { _ = httpResp.Body.Close() }()
+
+ // Best-effort: a non-2xx (revoked key, or a key without the
+ // user.profile.read scope) must not make the source-name worker retry
+ // forever. Give up gracefully and keep the generic source name; a dead
+ // key surfaces on the next ListAccounts.
+ if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
+ return "", nil
+ }
+
+ var resp struct {
+ Company string `json:"company"`
+ }
+ if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
+ return "", fmt.Errorf("cannot decode sendgrid profile response: %w", err)
+ }
+
+ return resp.Company, nil
+}
+
// sentryNameResolver resolves the Sentry organization name.
type sentryNameResolver struct {
httpClient *http.Client
diff --git a/pkg/accessreview/drivers/sendgrid.go b/pkg/accessreview/drivers/sendgrid.go
index f85d1deef..bbd078e2c 100644
--- a/pkg/accessreview/drivers/sendgrid.go
+++ b/pkg/accessreview/drivers/sendgrid.go
@@ -212,15 +212,31 @@ func sendGridRole(userType string, isAdmin bool) string {
}
}
+// sendGridMFAStatus derives a teammate's MFA status from the auto-set 2fa
+// scopes SendGrid attaches to the teammate detail. A restricted teammate
+// carries exactly one of them to reflect their real status. Full-access
+// users (the account owner and full-access teammates) are the exception:
+// their scope list is the entire catalog and therefore contains BOTH
+// 2fa_exempt and 2fa_required, which says nothing about their actual MFA.
+// Only report a definitive status when exactly one scope is present;
+// both-or-neither is ambiguous, so report Unknown rather than guessing.
func sendGridMFAStatus(scopes []string) coredata.MFAStatus {
+ var exempt, required bool
for _, scope := range scopes {
switch scope {
case "2fa_exempt":
- return coredata.MFAStatusDisabled
+ exempt = true
case "2fa_required":
- return coredata.MFAStatusEnabled
+ required = true
}
}
- return coredata.MFAStatusUnknown
+ switch {
+ case required && !exempt:
+ return coredata.MFAStatusEnabled
+ case exempt && !required:
+ return coredata.MFAStatusDisabled
+ default:
+ return coredata.MFAStatusUnknown
+ }
}
diff --git a/pkg/accessreview/drivers/sendgrid_test.go b/pkg/accessreview/drivers/sendgrid_test.go
index 2598a0bbd..ec013936c 100644
--- a/pkg/accessreview/drivers/sendgrid_test.go
+++ b/pkg/accessreview/drivers/sendgrid_test.go
@@ -33,30 +33,22 @@ func TestSendGridDriver(t *testing.T) {
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
- require.Len(t, records, 3)
+ require.Len(t, records, 1)
+ // Recorded against a live SendGrid account that has only the owner. The
+ // list endpoint carries no scopes, so the driver fetches the teammate
+ // detail to read them.
owner := records[0]
assert.Equal(t, "owner@example.com", owner.Email)
- assert.Equal(t, "Olivia Owner", owner.FullName)
+ assert.Empty(t, owner.FullName)
assert.Equal(t, "Owner", owner.Role)
assert.True(t, owner.IsAdmin)
- assert.Equal(t, "owner-user", owner.ExternalID)
+ assert.Equal(t, "owner@example.com", owner.ExternalID)
assert.Equal(t, coredata.AccessEntryAccountTypeUser, owner.AccountType)
- assert.Equal(t, coredata.MFAStatusEnabled, owner.MFAStatus)
-
- admin := records[1]
- assert.Equal(t, "admin@example.com", admin.Email)
- assert.Equal(t, "Admin", admin.Role)
- assert.True(t, admin.IsAdmin)
- assert.Equal(t, "admin-user", admin.ExternalID)
- assert.Equal(t, coredata.MFAStatusEnabled, admin.MFAStatus)
-
- teammate := records[2]
- assert.Equal(t, "teammate@example.com", teammate.Email)
- assert.Equal(t, "Teammate", teammate.Role)
- assert.False(t, teammate.IsAdmin)
- assert.Equal(t, "teammate-user", teammate.ExternalID)
- assert.Equal(t, coredata.MFAStatusDisabled, teammate.MFAStatus)
+ // The owner is a full-access user whose scope catalog contains BOTH
+ // 2fa_exempt and 2fa_required, so the MFA signal is ambiguous and the
+ // driver reports Unknown rather than guessing from scope ordering.
+ assert.Equal(t, coredata.MFAStatusUnknown, owner.MFAStatus)
}
func TestSendGridRole(t *testing.T) {
@@ -127,7 +119,8 @@ func TestSendGridMFAStatus(t *testing.T) {
}{
{name: "required", scopes: []string{"mail.send", "2fa_required"}, want: coredata.MFAStatusEnabled},
{name: "exempt", scopes: []string{"mail.send", "2fa_exempt"}, want: coredata.MFAStatusDisabled},
- {name: "unknown", scopes: []string{"mail.send"}, want: coredata.MFAStatusUnknown},
+ {name: "both is ambiguous", scopes: []string{"2fa_exempt", "2fa_required", "mail.send"}, want: coredata.MFAStatusUnknown},
+ {name: "neither", scopes: []string{"mail.send"}, want: coredata.MFAStatusUnknown},
}
for _, tt := range tests {
diff --git a/pkg/accessreview/drivers/testdata/sendgrid.yaml b/pkg/accessreview/drivers/testdata/sendgrid.yaml
index 96fd45f0f..b66223f84 100644
--- a/pkg/accessreview/drivers/testdata/sendgrid.yaml
+++ b/pkg/accessreview/drivers/testdata/sendgrid.yaml
@@ -19,22 +19,62 @@ interactions:
url: https://api.sendgrid.com/v3/teammates?limit=500&offset=0
method: GET
response:
- proto: HTTP/2.0
- proto_major: 2
- proto_minor: 0
+ proto: HTTP/1.1
+ proto_major: 1
+ proto_minor: 1
content_length: -1
- uncompressed: true
- body: '{"result":[{"username":"owner-user","email":"owner@example.com","first_name":"Olivia","last_name":"Owner","user_type":"owner","is_admin":true},{"username":"admin-user","email":"admin@example.com","first_name":"","last_name":"","user_type":"admin","is_admin":true},{"username":"teammate-user","email":"teammate@example.com","first_name":"Taylor","last_name":"Teammate","user_type":"teammate","is_admin":false},{"username":"missing-email","email":"","first_name":"Missing","last_name":"Email","user_type":"teammate","is_admin":false}]}'
+ body: |
+ {"result":[{"username":"owner@example.com","email":"owner@example.com","first_name":"","last_name":"","address":"","address2":"","city":"","state":"","zip":"","country":"","company":"","website":"","phone":"","is_admin":true,"is_sso":false,"user_type":"owner","is_unified":true,"is_partner_sso":false}]}
headers:
+ Access-Control-Allow-Headers:
+ - AUTHORIZATION, Content-Type, On-behalf-of, x-sg-elas-acl, X-Recaptcha, X-Request-Source, Browser-Fingerprint
+ Access-Control-Allow-Methods:
+ - HEAD, GET, PUT, POST, DELETE, OPTIONS, PATCH
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Link, Location
+ Access-Control-Max-Age:
+ - "21600"
+ Cache-Control:
+ - no-cache
+ Connection:
+ - keep-alive
+ Content-Length:
+ - "334"
+ Content-Security-Policy:
+ - default-src https://api.sendgrid.com; frame-src 'none'; object-src 'none'
Content-Type:
- - application/json
+ - application/json; charset=utf8
Date:
- - Fri, 29 May 2026 06:52:00 GMT
+ - Thu, 04 Jun 2026 12:30:33 GMT
+ Link:
+ - ; rel="first"; title="1", ; rel="last"; title="1"
+ Powered-By:
+ - Mako
+ Referrer-Policy:
+ - strict-origin-when-cross-origin
Server:
- nginx
+ Strict-Transport-Security:
+ - max-age=31536000
+ X-Client-Ff:
+ - "1000"
+ X-Content-Type-Options:
+ - nosniff
+ X-Envoy-Upstream-Service-Time:
+ - "231"
+ X-Ratelimit-Limit:
+ - "600"
+ X-Ratelimit-Remaining:
+ - "599"
+ X-Ratelimit-Reset:
+ - "1780576260"
+ X-Robots-Tag:
+ - noindex, nofollow
status: 200 OK
code: 200
- duration: 18ms
+ duration: 323.748208ms
- id: 1
request:
proto: HTTP/1.1
@@ -45,80 +85,61 @@ interactions:
headers:
Accept:
- application/json
- url: https://api.sendgrid.com/v3/teammates/owner-user
+ url: https://api.sendgrid.com/v3/teammates/owner@example.com
method: GET
response:
- proto: HTTP/2.0
- proto_major: 2
- proto_minor: 0
- content_length: -1
- uncompressed: true
- body: '{"result":{"username":"owner-user","email":"owner@example.com","first_name":"Olivia","last_name":"Owner","user_type":"owner","is_admin":true,"scopes":["mail.send","2fa_required"]}}'
- headers:
- Content-Type:
- - application/json
- Date:
- - Fri, 29 May 2026 06:52:01 GMT
- Server:
- - nginx
- status: 200 OK
- code: 200
- duration: 16ms
- - id: 2
- request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
- content_length: 0
- host: api.sendgrid.com
- headers:
- Accept:
- - application/json
- url: https://api.sendgrid.com/v3/teammates/admin-user
- method: GET
- response:
- proto: HTTP/2.0
- proto_major: 2
- proto_minor: 0
+ transfer_encoding:
+ - chunked
content_length: -1
uncompressed: true
- body: '{"result":{"username":"admin-user","email":"admin@example.com","first_name":"","last_name":"","user_type":"admin","is_admin":true,"scopes":["mail.send","2fa_required"]}}'
+ body: |
+ {"username":"owner@example.com","email":"owner@example.com","first_name":"","last_name":"","address":"","address2":"","city":"","state":"","zip":"","country":"","company":"","website":"","phone":"","is_admin":true,"is_sso":false,"user_type":"owner","scopes":["2fa_exempt","2fa_required","access_settings.activity.read","access_settings.whitelist.create","access_settings.whitelist.delete","access_settings.whitelist.read","access_settings.whitelist.update","alerts.create","alerts.delete","alerts.read","alerts.update","api_keys.create","api_keys.delete","api_keys.read","api_keys.update","asm.groups.create","asm.groups.delete","asm.groups.read","asm.groups.suppressions.create","asm.groups.suppressions.delete","asm.groups.suppressions.read","asm.groups.suppressions.update","asm.groups.update","asm.suppressions.global.create","asm.suppressions.global.delete","asm.suppressions.global.read","asm.suppressions.global.update","billing.create","billing.delete","billing.read","billing.update","browsers.stats.read","categories.create","categories.delete","categories.read","categories.stats.read","categories.stats.sums.read","categories.update","clients.desktop.stats.read","clients.phone.stats.read","clients.stats.read","clients.tablet.stats.read","clients.webmail.stats.read","credentials.create","credentials.delete","credentials.read","credentials.update","design_library.create","design_library.delete","design_library.read","design_library.update","devices.stats.read","email_testing.read","email_testing.write","geo.stats.read","ips.pools.ips.read","mail.batch.create","mail.batch.delete","mail.batch.read","mail.batch.update","mail.send","mail_settings.address_whitelist.create","mail_settings.address_whitelist.delete","mail_settings.address_whitelist.read","mail_settings.address_whitelist.update","mail_settings.bcc.create","mail_settings.bcc.delete","mail_settings.bcc.read","mail_settings.bcc.update","mail_settings.bounce_purge.create","mail_settings.bounce_purge.delete","mail_settings.bounce_purge.read","mail_settings.bounce_purge.update","mail_settings.footer.create","mail_settings.footer.delete","mail_settings.footer.read","mail_settings.footer.update","mail_settings.forward_bounce.create","mail_settings.forward_bounce.delete","mail_settings.forward_bounce.read","mail_settings.forward_bounce.update","mail_settings.forward_spam.create","mail_settings.forward_spam.delete","mail_settings.forward_spam.read","mail_settings.forward_spam.update","mail_settings.plain_content.create","mail_settings.plain_content.delete","mail_settings.plain_content.read","mail_settings.plain_content.update","mail_settings.read","mail_settings.spam_check.create","mail_settings.spam_check.delete","mail_settings.spam_check.read","mail_settings.spam_check.update","mail_settings.template.create","mail_settings.template.delete","mail_settings.template.read","mail_settings.template.update","mailbox_providers.stats.read","marketing.automation.read","marketing.read","messages.read","partner_settings.new_relic.create","partner_settings.new_relic.delete","partner_settings.new_relic.read","partner_settings.new_relic.update","partner_settings.read","partner_settings.sendwithus.create","partner_settings.sendwithus.delete","partner_settings.sendwithus.read","partner_settings.sendwithus.update","recipients.erasejob.create","recipients.erasejob.read","sender_verification_eligible","signup.trigger_confirmation","stats.global.read","stats.read","subusers.stats.monthly.read","subusers.stats.read","subusers.stats.sums.read","suppression.blocks.create","suppression.blocks.delete","suppression.blocks.read","suppression.blocks.update","suppression.bounces.create","suppression.bounces.delete","suppression.bounces.read","suppression.bounces.update","suppression.create","suppression.delete","suppression.invalid_emails.create","suppression.invalid_emails.delete","suppression.invalid_emails.read","suppression.invalid_emails.update","suppression.read","suppression.spam_reports.create","suppression.spam_reports.delete","suppression.spam_reports.read","suppression.spam_reports.update","suppression.unsubscribes.create","suppression.unsubscribes.delete","suppression.unsubscribes.read","suppression.unsubscribes.update","suppression.update","teammates.create","teammates.delete","teammates.read","teammates.update","templates.create","templates.delete","templates.read","templates.update","templates.versions.activate.create","templates.versions.activate.delete","templates.versions.activate.read","templates.versions.activate.update","templates.versions.create","templates.versions.delete","templates.versions.read","templates.versions.update","tracking_settings.click.create","tracking_settings.click.delete","tracking_settings.click.read","tracking_settings.click.update","tracking_settings.google_analytics.create","tracking_settings.google_analytics.delete","tracking_settings.google_analytics.read","tracking_settings.google_analytics.update","tracking_settings.open.create","tracking_settings.open.delete","tracking_settings.open.read","tracking_settings.open.update","tracking_settings.read","tracking_settings.subscription.create","tracking_settings.subscription.delete","tracking_settings.subscription.read","tracking_settings.subscription.update","ui.confirm_email","ui.provision","ui.signup_complete","user.account.read","user.credits.read","user.email.read","user.profile.create","user.profile.delete","user.profile.read","user.profile.update","user.scheduled_sends.create","user.scheduled_sends.delete","user.scheduled_sends.read","user.scheduled_sends.update","user.settings.enforced_tls.read","user.settings.enforced_tls.update","user.timezone.create","user.timezone.delete","user.timezone.read","user.timezone.update","user.username.read","user.webhooks.event.settings.create","user.webhooks.event.settings.delete","user.webhooks.event.settings.read","user.webhooks.event.settings.update","user.webhooks.event.test.create","user.webhooks.event.test.delete","user.webhooks.event.test.read","user.webhooks.event.test.update","user.webhooks.parse.settings.create","user.webhooks.parse.settings.delete","user.webhooks.parse.settings.read","user.webhooks.parse.settings.update","user.webhooks.parse.stats.read","whitelabel.create","whitelabel.delete","whitelabel.read","whitelabel.update"],"is_read_only":false,"is_unified":true,"is_partner_sso":false}
headers:
+ Access-Control-Allow-Headers:
+ - AUTHORIZATION, Content-Type, On-behalf-of, x-sg-elas-acl, X-Recaptcha, X-Request-Source, Browser-Fingerprint
+ Access-Control-Allow-Methods:
+ - HEAD, GET, PUT, POST, DELETE, OPTIONS, PATCH
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Link, Location
+ Access-Control-Max-Age:
+ - "21600"
+ Cache-Control:
+ - no-cache
+ Connection:
+ - keep-alive
+ Content-Security-Policy:
+ - default-src https://api.sendgrid.com; frame-src 'none'; object-src 'none'
Content-Type:
- - application/json
+ - application/json; charset=utf8
Date:
- - Fri, 29 May 2026 06:52:02 GMT
+ - Thu, 04 Jun 2026 12:30:33 GMT
+ Powered-By:
+ - Mako
+ Referrer-Policy:
+ - strict-origin-when-cross-origin
Server:
- nginx
+ Strict-Transport-Security:
+ - max-age=31536000
+ X-Client-Ff:
+ - "1000"
+ X-Content-Type-Options:
+ - nosniff
+ X-Envoy-Upstream-Service-Time:
+ - "264"
+ X-Ratelimit-Limit:
+ - "600"
+ X-Ratelimit-Remaining:
+ - "599"
+ X-Ratelimit-Reset:
+ - "1780576260"
+ X-Robots-Tag:
+ - noindex, nofollow
status: 200 OK
code: 200
- duration: 17ms
- - id: 3
- request:
- proto: HTTP/1.1
- proto_major: 1
- proto_minor: 1
- content_length: 0
- host: api.sendgrid.com
- headers:
- Accept:
- - application/json
- url: https://api.sendgrid.com/v3/teammates/teammate-user
- method: GET
- response:
- proto: HTTP/2.0
- proto_major: 2
- proto_minor: 0
- content_length: -1
- uncompressed: true
- body: '{"result":{"username":"teammate-user","email":"teammate@example.com","first_name":"Taylor","last_name":"Teammate","user_type":"teammate","is_admin":false,"scopes":["mail.send","2fa_exempt"]}}'
- headers:
- Content-Type:
- - application/json
- Date:
- - Fri, 29 May 2026 06:52:03 GMT
- Server:
- - nginx
- status: 200 OK
- code: 200
- duration: 17ms
+ duration: 283.823083ms
diff --git a/pkg/connector/provider/sendgrid.go b/pkg/connector/provider/sendgrid.go
index f5ae08898..6e75927ed 100644
--- a/pkg/connector/provider/sendgrid.go
+++ b/pkg/connector/provider/sendgrid.go
@@ -32,5 +32,8 @@ func sendgridRegistration() *Registration {
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
return drivers.NewSendGridDriver(c), nil
},
+ NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver {
+ return drivers.NewSendGridNameResolver(c)
+ },
}
}
diff --git a/pkg/coredata/migrations/20260529T065300Z.sql b/pkg/coredata/migrations/20260529T713864Z.sql
similarity index 100%
rename from pkg/coredata/migrations/20260529T065300Z.sql
rename to pkg/coredata/migrations/20260529T713864Z.sql