Add Gmail integration frontend (#6784)

Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
Suchintan 2026-06-24 00:14:42 -04:00 committed by GitHub
parent 3235d4e648
commit 6f46a25fca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 545 additions and 45 deletions

View file

@ -12027,7 +12027,7 @@
"continue_on_empty": {
"type": "boolean",
"title": "Continue On Empty",
"description": "When the run download directory has no files, fail the block (False, default) instead of reporting success. Set True to allow an empty upload (success with no files uploaded).",
"description": "When the run download directory has no files, allow the empty upload only after confirming no registered, browser-session, or alternate candidate downloads exist (False, default). Set True to always allow an empty upload.",
"default": false
}
},

View file

@ -314,8 +314,12 @@ export interface GoogleOAuthCredential {
id: string;
organization_id: string;
credential_name: string;
scopes: string | null;
valid: boolean;
provider?: string;
state?: string;
scopes_requested?: string[] | string | null;
scopes_granted?: string[] | string | null;
scopes?: string[] | string | null;
valid?: boolean | null;
created_at: string;
modified_at: string;
}
@ -332,6 +336,7 @@ export interface GoogleOAuthCredentialListResponse {
export interface CreateGoogleOAuthAuthorizeRequest {
redirect_uri: string;
credential_name?: string;
scope_profile?: string;
app_origin?: string;
}

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { OtpType } from "@/api/types";
import { buildSendTotpCodeRequest } from "./pushTotpCodeRequest";
describe("buildSendTotpCodeRequest", () => {
it("includes explicit magic-link type and optional metadata", () => {
expect(
buildSendTotpCodeRequest({
identifier: " user@example.com ",
content: " https://example.com/login?token=abc ",
otpType: OtpType.MagicLink,
workflowRunId: " wr_123 ",
workflowId: " wf_123 ",
taskId: " tsk_123 ",
}),
).toEqual({
totp_identifier: "user@example.com",
content: "https://example.com/login?token=abc",
type: OtpType.MagicLink,
source: "manual_ui",
workflow_run_id: "wr_123",
workflow_id: "wf_123",
task_id: "tsk_123",
});
});
it("omits blank optional metadata for numeric codes", () => {
expect(
buildSendTotpCodeRequest({
identifier: " user@example.com ",
content: " 123456 ",
otpType: OtpType.Totp,
workflowRunId: " ",
workflowId: "",
taskId: "",
}),
).toEqual({
totp_identifier: "user@example.com",
content: "123456",
type: OtpType.Totp,
source: "manual_ui",
});
});
});

View file

@ -1,13 +1,25 @@
import { type FormEventHandler, useEffect, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { OtpType, type OtpType as OtpTypeValue } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { cn } from "@/util/utils";
import {
buildSendTotpCodeRequest,
type SendTotpCodeRequest,
} from "./pushTotpCodeRequest";
type Props = {
className?: string;
@ -19,15 +31,6 @@ type Props = {
onSuccess?: () => void;
};
type SendTotpCodeRequest = {
totp_identifier: string;
content: string;
workflow_run_id?: string;
workflow_id?: string;
task_id?: string;
source?: string;
};
function PushTotpCodeForm({
className,
defaultIdentifier,
@ -44,6 +47,7 @@ function PushTotpCodeForm({
);
const [workflowId, setWorkflowId] = useState(defaultWorkflowId?.trim() ?? "");
const [taskId, setTaskId] = useState(defaultTaskId?.trim() ?? "");
const [otpType, setOtpType] = useState<OtpTypeValue>(OtpType.Totp);
const [advancedOpen, setAdvancedOpen] = useState(false);
const credentialGetter = useCredentialGetter();
@ -128,21 +132,14 @@ function PushTotpCodeForm({
return;
}
const payload: SendTotpCodeRequest = {
totp_identifier: trimmedIdentifier,
const payload = buildSendTotpCodeRequest({
identifier: trimmedIdentifier,
content: trimmedContent,
source: "manual_ui",
};
if (trimmedWorkflowRunId !== "") {
payload.workflow_run_id = trimmedWorkflowRunId;
}
if (trimmedWorkflowId !== "") {
payload.workflow_id = trimmedWorkflowId;
}
if (trimmedTaskId !== "") {
payload.task_id = trimmedTaskId;
}
otpType,
workflowRunId: trimmedWorkflowRunId,
workflowId: trimmedWorkflowId,
taskId: trimmedTaskId,
});
mutation.mutate(payload);
};
@ -168,7 +165,11 @@ function PushTotpCodeForm({
<Label htmlFor="totp-content-input">Verification content</Label>
<AutoResizingTextarea
id="totp-content-input"
placeholder="Paste the full email/SMS body or the 6-digit code"
placeholder={
otpType === OtpType.MagicLink
? "Paste the full email body or magic link"
: "Paste the full email/SMS body or the 6-digit code"
}
value={content}
onChange={(event) => setContent(event.target.value)}
readOnly={mutation.isPending}
@ -179,6 +180,22 @@ function PushTotpCodeForm({
sensitive data.
</p>
</div>
<div className="space-y-1">
<Label htmlFor="totp-type-input">OTP Type</Label>
<Select
value={otpType}
onValueChange={(value: OtpTypeValue) => setOtpType(value)}
disabled={mutation.isPending}
>
<SelectTrigger id="totp-type-input" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={OtpType.Totp}>Numeric code</SelectItem>
<SelectItem value={OtpType.MagicLink}>Magic link</SelectItem>
</SelectContent>
</Select>
</div>
{showAdvancedFields && (
<div className="space-y-2">

View file

@ -0,0 +1,39 @@
type Props = {
className?: string;
};
function GmailIcon({ className }: Props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 256 193"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M58.181 192.05V93.14L27.507 65.077 0 49.504V174.595C0 184.253 7.825 192.05 17.455 192.05H58.181Z"
fill="#4285F4"
/>
<path
d="M197.819 192.05H238.546C248.204 192.05 256 184.224 256 174.595V49.505L224.844 67.342 197.819 93.14V192.05Z"
fill="#34A853"
/>
<path
d="M58.181 93.14L54.007 54.493 58.181 17.504 128 69.868 197.819 17.504 202.488 52.496 197.819 93.14 128 145.504 58.181 93.14Z"
fill="#EA4335"
/>
<path
d="M197.819 17.504V93.14L256 49.504V26.231C256 4.646 231.36 -7.659 214.11 5.286L197.819 17.504Z"
fill="#FBBC04"
/>
<path
d="M0 49.504L26.759 69.573 58.181 93.14V17.504L41.89 5.286C24.61 -7.659 0 4.646 0 26.231V49.504Z"
fill="#C5221F"
/>
</svg>
);
}
export { GmailIcon };

View file

@ -0,0 +1,52 @@
import type { OtpType as OtpTypeValue } from "@/api/types";
export type SendTotpCodeRequest = {
totp_identifier: string;
content: string;
type: OtpTypeValue;
workflow_run_id?: string;
workflow_id?: string;
task_id?: string;
source?: string;
};
type BuildSendTotpCodeRequestInput = {
identifier: string;
content: string;
otpType: OtpTypeValue;
workflowRunId: string;
workflowId: string;
taskId: string;
};
export function buildSendTotpCodeRequest({
identifier,
content,
otpType,
workflowRunId,
workflowId,
taskId,
}: BuildSendTotpCodeRequestInput): SendTotpCodeRequest {
const payload: SendTotpCodeRequest = {
totp_identifier: identifier.trim(),
content: content.trim(),
type: otpType,
source: "manual_ui",
};
const trimmedWorkflowRunId = workflowRunId.trim();
const trimmedWorkflowId = workflowId.trim();
const trimmedTaskId = taskId.trim();
if (trimmedWorkflowRunId !== "") {
payload.workflow_run_id = trimmedWorkflowRunId;
}
if (trimmedWorkflowId !== "") {
payload.workflow_id = trimmedWorkflowId;
}
if (trimmedTaskId !== "") {
payload.task_id = trimmedTaskId;
}
return payload;
}

View file

@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import type { GoogleOAuthCredential } from "@/api/types";
import {
getGoogleOAuthCredentialScopesGranted,
getGoogleOAuthCredentialScopesRequested,
matchesGoogleOAuthIntegrationScopes,
normalizeGoogleOAuthScopes,
} from "./useGoogleOAuthCredentials";
const baseCredential: GoogleOAuthCredential = {
id: "credential_1",
organization_id: "org_1",
credential_name: "Google",
created_at: "2026-01-01T00:00:00Z",
modified_at: "2026-01-01T00:00:00Z",
};
describe("Google OAuth credential scope helpers", () => {
it("normalizes array and string scope payloads", () => {
expect(normalizeGoogleOAuthScopes(["scope:a", "scope:b"])).toEqual([
"scope:a",
"scope:b",
]);
expect(normalizeGoogleOAuthScopes("scope:a scope:b,scope:c")).toEqual([
"scope:a",
"scope:b",
"scope:c",
]);
});
it("falls back to legacy string scopes when scopes_granted is absent", () => {
expect(
getGoogleOAuthCredentialScopesGranted({
...baseCredential,
scopes: "https://www.googleapis.com/auth/gmail.readonly openid",
}),
).toEqual(["https://www.googleapis.com/auth/gmail.readonly", "openid"]);
});
it("prefers scopes_granted over legacy scopes", () => {
expect(
getGoogleOAuthCredentialScopesGranted({
...baseCredential,
scopes_granted: ["new:scope"],
scopes: "legacy:scope",
}),
).toEqual(["new:scope"]);
});
it("normalizes requested scopes", () => {
expect(
getGoogleOAuthCredentialScopesRequested({
...baseCredential,
scopes_requested: "requested:a requested:b",
}),
).toEqual(["requested:a", "requested:b"]);
});
it("matches integrations by requested scopes before cumulative granted scopes", () => {
const credential = {
...baseCredential,
scopes_requested: ["gmail"],
scopes_granted: ["gmail", "sheets"],
};
expect(matchesGoogleOAuthIntegrationScopes(credential, ["gmail"])).toBe(
true,
);
expect(matchesGoogleOAuthIntegrationScopes(credential, ["sheets"])).toBe(
false,
);
});
it("falls back to granted scopes when requested scopes are absent", () => {
expect(
matchesGoogleOAuthIntegrationScopes(
{
...baseCredential,
scopes_granted: ["sheets"],
},
["sheets"],
),
).toBe(true);
});
});

View file

@ -10,6 +10,7 @@ import {
GoogleOAuthCredentialResponse,
} from "@/api/types";
import { useToast } from "@/components/ui/use-toast";
export { GOOGLE_SHEETS_REQUIRED_SCOPES } from "@/util/googleScopes";
const BROADCAST_CHANNEL_NAME = "skyvern:google-oauth-credentials";
@ -24,12 +25,74 @@ function broadcastCredentialsChanged() {
credentialBroadcastChannel?.postMessage("invalidate");
}
// Falls back to the first credential even when none are valid, so a single
export function normalizeGoogleOAuthScopes(
scopes: readonly string[] | string | null | undefined,
): readonly string[] | undefined {
if (Array.isArray(scopes)) {
return scopes;
}
if (typeof scopes === "string") {
return scopes
.split(/[\s,]+/)
.map((scope) => scope.trim())
.filter(Boolean);
}
return undefined;
}
export function getGoogleOAuthCredentialScopesGranted(
credential: GoogleOAuthCredential,
): readonly string[] {
return (
normalizeGoogleOAuthScopes(credential.scopes_granted) ??
normalizeGoogleOAuthScopes(credential.scopes) ??
[]
);
}
export function getGoogleOAuthCredentialScopesRequested(
credential: GoogleOAuthCredential,
): readonly string[] {
return normalizeGoogleOAuthScopes(credential.scopes_requested) ?? [];
}
export function isGoogleOAuthCredentialActive(
credential: GoogleOAuthCredential,
): boolean {
if (credential.state) {
return credential.state === "active";
}
return credential.valid === true;
}
export function hasGoogleOAuthCredentialScopes(
credential: GoogleOAuthCredential,
requiredScopes: readonly string[],
): boolean {
const granted = new Set(getGoogleOAuthCredentialScopesGranted(credential));
return requiredScopes.every((scope) => granted.has(scope));
}
export function matchesGoogleOAuthIntegrationScopes(
credential: GoogleOAuthCredential,
requiredScopes: readonly string[],
): boolean {
const requested = getGoogleOAuthCredentialScopesRequested(credential);
if (requested.length > 0) {
const requestedSet = new Set(requested);
return requiredScopes.every((scope) => requestedSet.has(scope));
}
return hasGoogleOAuthCredentialScopes(credential, requiredScopes);
}
// Falls back to the first credential even when none are active, so a single
// needs-reconnect account is still selected rather than left blank.
export function getDefaultGoogleOAuthCredentialId(
credentials: GoogleOAuthCredential[],
): string | undefined {
return credentials.find((c) => c.valid)?.id ?? credentials[0]?.id;
return (
credentials.find(isGoogleOAuthCredentialActive)?.id ?? credentials[0]?.id
);
}
type ApiError = { response?: { data?: { detail?: string } } } & Error;

View file

@ -1,5 +1,7 @@
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { ArrowRightIcon } from "@radix-ui/react-icons";
import { PushTotpCodeForm } from "@/components/PushTotpCodeForm";
import { useTotpCodesQuery } from "@/hooks/useTotpCodesQuery";
import { Input } from "@/components/ui/input";
@ -22,18 +24,65 @@ import {
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import type { OtpType, TotpCode } from "@/api/types";
import {
OtpType,
type OtpType as OtpTypeValue,
type TotpCode,
} from "@/api/types";
import { Skeleton } from "@/components/ui/skeleton";
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
import { GmailIcon } from "@/components/icons/GmailIcon";
type OtpTypeFilter = "all" | OtpType;
type OtpTypeFilter = "all" | OtpTypeValue;
const LIMIT_OPTIONS = [25, 50, 100] as const;
function renderCodeContent(code: TotpCode): string {
function safeHttpUrl(value: string): string | null {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:"
? url.toString()
: null;
} catch {
return null;
}
}
function otpTypeLabel(otpType: OtpTypeValue | null): string {
switch (otpType) {
case OtpType.Totp:
return "Numeric code";
case OtpType.MagicLink:
return "Magic link";
default:
return "unknown";
}
}
function renderCodeContent(code: TotpCode) {
if (!code.code) {
return "—";
}
if (code.otp_type === OtpType.MagicLink) {
const href = safeHttpUrl(code.code);
if (!href) {
return (
<span className="block max-w-xs truncate" title={code.code}>
{code.code}
</span>
);
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline underline-offset-2 hover:text-blue-300"
>
Open magic link
</a>
);
}
return code.code;
}
@ -69,6 +118,30 @@ function CredentialsTotpTab() {
return (
<div className="space-y-6">
<div className="rounded-lg border border-slate-700 bg-slate-elevation1 p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-neutral-200 bg-white dark:border-slate-700">
<GmailIcon className="size-7" />
</div>
<div>
<h2 className="text-base font-semibold">
Connect Gmail for automatic 2FA
</h2>
<p className="mt-1 max-w-2xl text-sm text-neutral-600 dark:text-slate-400">
Skyvern can find verification codes and magic links in a
connected Gmail inbox without manual forwarding.
</p>
</div>
</div>
<Button asChild size="sm" className="shrink-0">
<Link to="/integrations?query=gmail">
Connect Gmail <ArrowRightIcon className="ml-1.5 size-4" />
</Link>
</Button>
</div>
</div>
<div className="rounded-lg border border-slate-700 bg-slate-elevation1 p-6">
<h2 className="text-lg font-semibold">Push a 2FA Code</h2>
<p className="mt-1 text-sm text-neutral-600 dark:text-slate-400">
@ -119,8 +192,8 @@ function CredentialsTotpTab() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="totp">Numeric code</SelectItem>
<SelectItem value="magic_link">Magic link</SelectItem>
<SelectItem value={OtpType.Totp}>Numeric code</SelectItem>
<SelectItem value={OtpType.MagicLink}>Magic link</SelectItem>
</SelectContent>
</Select>
</div>
@ -177,7 +250,7 @@ function CredentialsTotpTab() {
<TableHeader>
<TableRow>
<TableHead className="w-[220px]">Identifier</TableHead>
<TableHead>Code</TableHead>
<TableHead>Verification</TableHead>
<TableHead>Source</TableHead>
<TableHead>Agent Run</TableHead>
<TableHead>Created</TableHead>
@ -221,7 +294,7 @@ function CredentialsTotpTab() {
</TableCell>
<TableCell>
<Badge variant="outline">
{code.otp_type ?? "unknown"}
{otpTypeLabel(code.otp_type)}
</Badge>
{code.source ? (
<span className="ml-2 text-xs text-neutral-600 dark:text-slate-400">

View file

@ -11,7 +11,9 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import {
GOOGLE_SHEETS_REQUIRED_SCOPES,
getDefaultGoogleOAuthCredentialId,
hasGoogleOAuthCredentialScopes,
useGoogleOAuthCredentials,
} from "@/hooks/useGoogleOAuthCredentials";
import { PlusIcon } from "@radix-ui/react-icons";
@ -30,8 +32,15 @@ function GoogleOAuthCredentialSelector({
value,
onChange,
}: Readonly<Props>) {
const { credentials, isLoading, isFetching } = useGoogleOAuthCredentials();
const {
credentials: allCredentials,
isLoading,
isFetching,
} = useGoogleOAuthCredentials();
const [showAdvanced, setShowAdvanced] = useState(false);
const credentials = allCredentials.filter((credential) =>
hasGoogleOAuthCredentialScopes(credential, GOOGLE_SHEETS_REQUIRED_SCOPES),
);
// Keep latest callback without forcing effect re-runs.
const onChangeRef = useRef(onChange);

View file

@ -27,6 +27,16 @@ vi.mock("@/store/WorkflowHasChangesStore", () => ({
let mockCredentials: GoogleOAuthCredential[] = [];
let mockIsLoading = false;
let mockIsFetching = false;
const GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets";
const GOOGLE_DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
const GOOGLE_DRIVE_METADATA_SCOPE =
"https://www.googleapis.com/auth/drive.metadata.readonly";
const GOOGLE_SHEETS_REQUIRED_SCOPES = [
GOOGLE_SHEETS_SCOPE,
GOOGLE_DRIVE_FILE_SCOPE,
GOOGLE_DRIVE_METADATA_SCOPE,
];
const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly";
vi.mock("@/hooks/useGoogleOAuthCredentials", async () => {
const actual = await vi.importActual<
@ -42,12 +52,34 @@ vi.mock("@/hooks/useGoogleOAuthCredentials", async () => {
};
});
function credential(id: string, valid: boolean = true): GoogleOAuthCredential {
function credential(
id: string,
state: string = "active",
scopesGranted: string[] = GOOGLE_SHEETS_REQUIRED_SCOPES,
): GoogleOAuthCredential {
return {
id,
organization_id: "o_1",
credential_name: id,
scopes: null,
provider: "google",
state,
scopes_requested: scopesGranted,
scopes_granted: scopesGranted,
created_at: "",
modified_at: "",
};
}
function legacyCredential(
id: string,
valid: boolean = true,
scopes: string[] = GOOGLE_SHEETS_REQUIRED_SCOPES,
): GoogleOAuthCredential {
return {
id,
organization_id: "o_1",
credential_name: id,
scopes,
valid,
created_at: "",
modified_at: "",
@ -153,10 +185,19 @@ describe("useResolveDefaultGoogleSheetsCredential (SKY-11219)", () => {
});
});
test("fills from the legacy credential response shape during split deploys", () => {
mockCredentials = [legacyCredential("cred_legacy")];
render(<Harness nodes={[writeNode("g1", "")]} />);
expect(updateNodeData).toHaveBeenCalledWith("g1", {
credentialId: "cred_legacy",
});
});
test("prefers the first valid credential over an invalid one", () => {
mockCredentials = [
credential("cred_invalid", false),
credential("cred_valid", true),
credential("cred_invalid", "revoked"),
credential("cred_valid", "active"),
];
render(<Harness nodes={[writeNode("g1", "")]} />);
@ -166,7 +207,7 @@ describe("useResolveDefaultGoogleSheetsCredential (SKY-11219)", () => {
});
test("falls back to the only (invalid) credential rather than leaving it blank", () => {
mockCredentials = [credential("cred_only", false)];
mockCredentials = [credential("cred_only", "revoked")];
render(<Harness nodes={[writeNode("g1", "")]} />);
expect(updateNodeData).toHaveBeenCalledWith("g1", {
@ -174,6 +215,30 @@ describe("useResolveDefaultGoogleSheetsCredential (SKY-11219)", () => {
});
});
test("ignores Gmail-only credentials when filling Sheets blocks", () => {
mockCredentials = [
credential("cred_gmail", "active", [GMAIL_SCOPE]),
credential("cred_sheets"),
];
render(<Harness nodes={[writeNode("g1", "")]} />);
expect(updateNodeData).toHaveBeenCalledWith("g1", {
credentialId: "cred_sheets",
});
});
test("ignores partial Sheets grants that are missing Drive scopes", () => {
mockCredentials = [
credential("cred_spreadsheets_only", "active", [GOOGLE_SHEETS_SCOPE]),
credential("cred_sheets"),
];
render(<Harness nodes={[writeNode("g1", "")]} />);
expect(updateNodeData).toHaveBeenCalledWith("g1", {
credentialId: "cred_sheets",
});
});
test("leaves an already-configured credential untouched", () => {
mockCredentials = [credential("cred_default")];
render(<Harness nodes={[writeNode("g1", "cred_existing")]} />);

View file

@ -2,7 +2,9 @@ import { useReactFlow } from "@xyflow/react";
import { useEffect, useMemo } from "react";
import {
GOOGLE_SHEETS_REQUIRED_SCOPES,
getDefaultGoogleOAuthCredentialId,
hasGoogleOAuthCredentialScopes,
useGoogleOAuthCredentials,
} from "@/hooks/useGoogleOAuthCredentials";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
@ -45,7 +47,19 @@ export function useResolveDefaultGoogleSheetsCredential(
const { credentials, isLoading, isFetching } = useGoogleOAuthCredentials({
enabled: !readOnly && unconfiguredNodeIds.length > 0,
});
const defaultCredentialId = getDefaultGoogleOAuthCredentialId(credentials);
const googleSheetsCredentials = useMemo(
() =>
credentials.filter((credential) =>
hasGoogleOAuthCredentialScopes(
credential,
GOOGLE_SHEETS_REQUIRED_SCOPES,
),
),
[credentials],
);
const defaultCredentialId = getDefaultGoogleOAuthCredentialId(
googleSheetsCredentials,
);
// Stable for a given set of unconfigured blocks regardless of node ordering.
const unconfiguredKey = unconfiguredNodeIds.join(",");

View file

@ -0,0 +1,9 @@
export const GOOGLE_SHEETS_REQUIRED_SCOPES = [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/drive.metadata.readonly",
] as const;
export const GOOGLE_GMAIL_REQUIRED_SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
] as const;

View file

@ -8,8 +8,10 @@ from typing import Any
from urllib.parse import quote
import httpx
import structlog
GMAIL_API_BASE = "https://gmail.googleapis.com/gmail/v1"
LOG = structlog.get_logger()
_OTP_QUERY_TERMS = "(verification OR verify OR code OR passcode OR otp OR 2fa OR one-time OR password)"
_SAFE_EMAIL_QUERY_IDENTIFIER = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+$")
@ -169,8 +171,14 @@ async def search_recent_otp_messages(
params={"format": "full"},
)
candidate = _candidate(message)
if candidate and candidate.internal_date and (not cutoff or candidate.internal_date >= cutoff):
candidates_.append(candidate)
if not candidate:
continue
if not candidate.internal_date:
LOG.debug("Skipping Gmail OTP candidate without internalDate", message_id=candidate.message_id)
continue
if cutoff and candidate.internal_date < cutoff:
continue
candidates_.append(candidate)
return candidates_
if client is None:

View file

@ -1,6 +1,9 @@
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
MAX_SEEN_GMAIL_MESSAGE_IDS = 500
@dataclass
class GmailOTPVerificationContext:
@ -10,3 +13,15 @@ class GmailOTPVerificationContext:
credential_ids_loaded_at: datetime | None = None
last_searched_at_by_credential: dict[str, datetime] = field(default_factory=dict)
seen_message_ids: set[str] = field(default_factory=set)
seen_message_id_order: deque[str] = field(default_factory=deque)
def has_seen_message_id(self, message_id: str) -> bool:
return message_id in self.seen_message_ids
def remember_message_id(self, message_id: str) -> None:
if message_id in self.seen_message_ids:
return
self.seen_message_ids.add(message_id)
self.seen_message_id_order.append(message_id)
while len(self.seen_message_id_order) > MAX_SEEN_GMAIL_MESSAGE_IDS:
self.seen_message_ids.discard(self.seen_message_id_order.popleft())

View file

@ -465,6 +465,8 @@ async def poll_otp_value(
# intentional backstops only when the webhook has no code yet.
if totp_verification_url:
if org_token is None:
# The org token is only needed for webhook polling. Gmail
# and DB-only polling should not fail on missing webhook auth.
org_token = await app.DATABASE.organizations.get_valid_org_auth_token(
organization_id, OrganizationAuthTokenType.api.value
)