From b40a145e741503a336d7035b040cc4264a39f535 Mon Sep 17 00:00:00 2001 From: kjxcodez Date: Thu, 10 Sep 2026 02:29:09 +0530 Subject: [PATCH] fix(gmail): correctly normalize Google API 400 403 and 429 errors Inspect Google REST API error payloads in GmailProvider.sendMessage(): disambiguate HTTP 400 malformed payloads from invalid recipients to prevent false-positive suppression; disambiguate HTTP 403 between daily sending quotas, abuse/spam policies, and genuine auth failures without false reauth mutation; align HTTP 429 tokens to PROVIDER_RATE_LIMITED to activate mailbox cooldown. Closes #33. --- apps/api/src/middleware/error-handler.ts | 1 + apps/api/src/services/email/email.service.ts | 67 +++- .../outbound-provider-rejection-audit.test.ts | 50 +-- apps/api/src/services/email/types.ts | 3 + .../google/gmail-error-normalization.test.ts | 367 ++++++++++++++++++ .../api/src/services/google/gmail.provider.ts | 107 ++++- 6 files changed, 544 insertions(+), 51 deletions(-) create mode 100644 apps/api/src/services/google/gmail-error-normalization.test.ts diff --git a/apps/api/src/middleware/error-handler.ts b/apps/api/src/middleware/error-handler.ts index 7ff8b97c..fb408247 100644 --- a/apps/api/src/middleware/error-handler.ts +++ b/apps/api/src/middleware/error-handler.ts @@ -70,6 +70,7 @@ export function errorHandler(error: Error, c: Context): Response { statusCode = 401; } else if ( code === 'MAILBOX_DISCONNECTED' || + code === 'POLICY_BLOCKED' || code === 'ATTACHMENT_ACCESS_DENIED' || code === 'DRIVE_ATTACHMENT_ACCESS_DENIED' || code === 'DRIVE_ACCESS_DENIED' diff --git a/apps/api/src/services/email/email.service.ts b/apps/api/src/services/email/email.service.ts index ab217199..d65844bc 100644 --- a/apps/api/src/services/email/email.service.ts +++ b/apps/api/src/services/email/email.service.ts @@ -89,35 +89,66 @@ export function classifyEmailFailure(err: any): { code === 'PROVIDER_RATE_LIMITED' || code === 'SENDER_RATE_LIMITED' || code === 'EMAIL_RATE_LIMITED' || + code === 'QUOTA_EXCEEDED' || err?.isRateLimit || + err?.classification === 'provider_rate_limited' || + err?.classification === 'rate_limit' || lowerMsg.includes('429') || lowerMsg.includes('ratelimitexceeded') || lowerMsg.includes('quotaexceeded') || - lowerMsg.includes('user-rate limit exceeded') + lowerMsg.includes('user-rate limit exceeded') || + lowerMsg.includes('daily sending quota exceeded') ) { return { - code, + code: 'PROVIDER_RATE_LIMITED', category: EmailFailureCategory.RATE_LIMIT, safeHumanMessage: 'Gmail sending rate limit reached. Outgoing message paused until cooldown expires.', technicalMessage: msg, retryable: true, - ambiguous: false + ambiguous: false, + bounceCategory: BounceCategory.RATE_LIMIT, + isHardBounce: false }; } - // 4. Outreach Policy & Safety Gates (Internal LeadForge policy) - if (code === 'CAMPAIGN_NOT_ACTIVE' || code === 'CONTACT_NOT_ELIGIBLE') { + // 4. Outreach Policy & Safety Gates (Internal LeadForge policy & Provider Policy Block) + if ( + code === 'POLICY_BLOCKED' || + code === 'CAMPAIGN_NOT_ACTIVE' || + code === 'CONTACT_NOT_ELIGIBLE' || + err?.classification === 'policy_rejection' + ) { return { - code, + code: code || 'POLICY_BLOCKED', category: EmailFailureCategory.POLICY, - safeHumanMessage: 'Outreach policy prevented send: campaign is not active or contact is ineligible.', + safeHumanMessage: 'Outreach policy or email provider anti-abuse filter prevented send.', technicalMessage: msg, retryable: false, - ambiguous: false + ambiguous: false, + bounceCategory: BounceCategory.POLICY_REJECTION, + isHardBounce: false + }; + } + + // 5. Malformed Payload / Provider Bad Request (Non-Recipient Error) + if ( + code === 'MALFORMED_PAYLOAD' || + code === 'INVALID_ARGUMENT' || + err?.classification === 'malformed_payload' || + err?.classification === 'invalid_request' + ) { + return { + code: 'MALFORMED_PAYLOAD', + category: EmailFailureCategory.INTERNAL, + safeHumanMessage: 'Outgoing email message was rejected by provider as malformed or invalid request.', + technicalMessage: msg, + retryable: false, + ambiguous: false, + isHardBounce: false }; } - // 5. Invalid Subject + // 6. Invalid Subject if (code === 'INVALID_SUBJECT') { return { code, @@ -130,7 +161,7 @@ export function classifyEmailFailure(err: any): { }; } - // 6. Internal / Attachment Handling Failures + // 7. Internal / Attachment Handling Failures if (typeof code === 'string' && (code.startsWith('ATTACHMENT_') || code.startsWith('DRIVE_'))) { return { code, @@ -142,7 +173,7 @@ export function classifyEmailFailure(err: any): { }; } - // 7. Transient Network Failures (Connection glitches to provider API) + // 8. Transient Network Failures (Connection glitches to provider API) if ( code === 'TRANSIENT_NETWORK_ERROR' || code === 'ECONNRESET' || @@ -846,8 +877,15 @@ export class EmailService { throw err; } - // If provider rate limited (e.g. Google 429), set mailbox provider cooldown - if (err.code === 'PROVIDER_RATE_LIMITED' || err.classification === 'provider_rate_limited') { + // If provider rate limited (e.g. Google 429, daily quota), set mailbox provider cooldown + if ( + err.code === 'PROVIDER_RATE_LIMITED' || + err.code === 'SENDER_RATE_LIMITED' || + err.code === 'QUOTA_EXCEEDED' || + err.classification === 'provider_rate_limited' || + err.classification === 'rate_limit' || + failure.category === EmailFailureCategory.RATE_LIMIT + ) { const cooldownSec = err.retryAfterSec || 60; await this.accountRepo.setProviderCooldown(input.accountId, cooldownSec); } @@ -881,7 +919,8 @@ export class EmailService { const isHardBounce = failure.isHardBounce === true || (failure.category === EmailFailureCategory.INVALID_RECIPIENT && - err.code !== 'INVALID_SUBJECT'); + err.code !== 'INVALID_SUBJECT' && + err.code !== 'MALFORMED_PAYLOAD'); if (isHardBounce) { try { diff --git a/apps/api/src/services/email/outbound-provider-rejection-audit.test.ts b/apps/api/src/services/email/outbound-provider-rejection-audit.test.ts index 1ca6c804..abd61a37 100644 --- a/apps/api/src/services/email/outbound-provider-rejection-audit.test.ts +++ b/apps/api/src/services/email/outbound-provider-rejection-audit.test.ts @@ -42,8 +42,8 @@ describe('Phase 5 Item A — Forensic Audit: Outbound Provider Rejection & Failu }); }); - describe('Finding 2: Google REST API Error Mapping Anomalies in GmailProvider', () => { - it('CONFIRMED: HTTP 403 Daily Quota / Anti-Abuse Block is misclassified as MAILBOX_REAUTH_REQUIRED', async () => { + describe('Finding 2: Google REST API Error Mapping Anomalies in GmailProvider (Remediated in Issue #33)', () => { + it('REMEDIATED: HTTP 403 Daily Quota is correctly classified as PROVIDER_RATE_LIMITED without reauth mutation', async () => { const mockAuthService: any = { getValidAccessToken: vi.fn().mockResolvedValue('mock-access-token') }; @@ -63,7 +63,6 @@ describe('Phase 5 Item A — Forensic Audit: Outbound Provider Rejection & Failu ); }); - // Mock GoogleConnectionModel.findById const { GoogleConnectionModel } = await import('../../db/models/google-connection.model.js'); vi.spyOn(GoogleConnectionModel, 'findById').mockResolvedValue({ _id: 'conn_123', @@ -71,16 +70,7 @@ describe('Phase 5 Item A — Forensic Audit: Outbound Provider Rejection & Failu status: 'active', gmailStatus: 'connected' } as any); - vi.spyOn(GoogleConnectionModel, 'updateOne').mockResolvedValue({} as any); - - await expect( - provider.sendMessage({ - connectionId: 'conn_123', - from: 'sender@leadforge.ai', - to: 'lead@target.com', - subject: 'Audit Test' - }) - ).rejects.toThrowError(EmailDomainError); + const updateOneSpy = vi.spyOn(GoogleConnectionModel, 'updateOne').mockResolvedValue({} as any); try { await provider.sendMessage({ @@ -89,14 +79,17 @@ describe('Phase 5 Item A — Forensic Audit: Outbound Provider Rejection & Failu to: 'lead@target.com', subject: 'Audit Test' }); + expect.unreachable('Should have thrown EmailDomainError'); } catch (err: any) { - // Confirmed defect: 403 quota/abuse block is misclassified as MAILBOX_REAUTH_REQUIRED! - expect(err.code).toBe('MAILBOX_REAUTH_REQUIRED'); - expect(err.classification).toBe('authentication'); + expect(err.code).toBe('PROVIDER_RATE_LIMITED'); + expect(err.classification).toBe('provider_rate_limited'); + expect(err.retryable).toBe(true); + expect(err.reauthRequired).toBe(false); + expect(updateOneSpy).not.toHaveBeenCalled(); } }); - it('CONFIRMED: HTTP 400 Bad Request / Malformed MIME is misclassified as INVALID_RECIPIENT', async () => { + it('REMEDIATED: HTTP 400 Bad Request / Malformed MIME is classified as MALFORMED_PAYLOAD', async () => { const mockAuthService: any = { getValidAccessToken: vi.fn().mockResolvedValue('mock-access-token') }; @@ -131,32 +124,29 @@ describe('Phase 5 Item A — Forensic Audit: Outbound Provider Rejection & Failu to: 'valid.lead@target.com', subject: 'Audit Test' }); + expect.unreachable('Should have thrown EmailDomainError'); } catch (err: any) { - // Confirmed defect: 400 Bad Request is misclassified as INVALID_RECIPIENT! - // In EmailService.send, this causes valid contacts to be suppressed as a HARD_BOUNCE. - expect(err.code).toBe('INVALID_RECIPIENT'); - expect(err.classification).toBe('invalid_request'); + expect(err.code).toBe('MALFORMED_PAYLOAD'); + expect(err.classification).toBe('malformed_payload'); + expect(err.code).not.toBe('INVALID_RECIPIENT'); } }); - it('CONFIRMED: Error code mismatch on HTTP 429 rate limit between GmailProvider and EmailService', () => { - // In gmail.provider.ts line 161, GmailProvider throws: + it('REMEDIATED: Error code match on HTTP 429 rate limit between GmailProvider and EmailService', () => { const providerError = new EmailDomainError( - 'SENDER_RATE_LIMITED', + 'PROVIDER_RATE_LIMITED', 'Gmail API rate limit exceeded for sender: quota exceeded', false, true, - 'rate_limit' + 'provider_rate_limited', + 60 ); - // In email.service.ts line 751: - // if (err.code === 'PROVIDER_RATE_LIMITED' || err.classification === 'provider_rate_limited') const matchesCode = providerError.code === 'PROVIDER_RATE_LIMITED'; const matchesClassification = (providerError as any).classification === 'provider_rate_limited'; - // Proves that EmailService.send line 751 fails to match, skipping setProviderCooldown! - expect(matchesCode).toBe(false); - expect(matchesClassification).toBe(false); + expect(matchesCode).toBe(true); + expect(matchesClassification).toBe(true); }); }); diff --git a/apps/api/src/services/email/types.ts b/apps/api/src/services/email/types.ts index 3563f67f..5e2cedc6 100644 --- a/apps/api/src/services/email/types.ts +++ b/apps/api/src/services/email/types.ts @@ -92,6 +92,9 @@ export interface EmailProviderErrorShape { | 'EMAIL_RATE_LIMITED' | 'PROVIDER_RATE_LIMITED' | 'SENDER_RATE_LIMITED' + | 'QUOTA_EXCEEDED' + | 'POLICY_BLOCKED' + | 'MALFORMED_PAYLOAD' | 'GMAIL_OAUTH_NOT_CONFIGURED' | 'GMAIL_OAUTH_CALLBACK_FAILED' | 'GMAIL_OAUTH_FAILED' diff --git a/apps/api/src/services/google/gmail-error-normalization.test.ts b/apps/api/src/services/google/gmail-error-normalization.test.ts new file mode 100644 index 00000000..348dcab4 --- /dev/null +++ b/apps/api/src/services/google/gmail-error-normalization.test.ts @@ -0,0 +1,367 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GmailProvider } from './gmail.provider.js'; +import { classifyEmailFailure } from '../email/email.service.js'; +import { EmailDomainError } from '../email/types.js'; +import { EmailFailureCategory, BounceCategory } from '@leadforge/schema'; +import { GoogleConnectionModel } from '../../db/models/google-connection.model.js'; + +describe('Issue #33 — Google REST API Error Normalization & Downstream Alignment', () => { + let mockAuthService: any; + let provider: GmailProvider; + + beforeEach(() => { + vi.restoreAllMocks(); + mockAuthService = { + getValidAccessToken: vi.fn().mockResolvedValue('mock-access-token') + }; + provider = new GmailProvider(mockAuthService); + + vi.spyOn(GoogleConnectionModel, 'findById').mockResolvedValue({ + _id: 'conn_test_123', + email: 'sender@leadforge.ai', + status: 'active', + gmailStatus: 'connected' + } as any); + }); + + describe('HTTP 400 Bad Request Normalization', () => { + it('normalizes RFC 2822 payload / header length failure to MALFORMED_PAYLOAD without recipient suppression', async () => { + provider.setTransport(async () => { + return new Response( + JSON.stringify({ + error: { + code: 400, + message: 'Invalid RFC 2822 message payload or header length exceeded', + status: 'INVALID_ARGUMENT' + } + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + }); + + try { + await provider.sendMessage({ + connectionId: 'conn_test_123', + from: 'sender@leadforge.ai', + to: 'valid.contact@domain.com', + subject: 'Test Subject' + }); + expect.unreachable('Should throw EmailDomainError'); + } catch (err: any) { + expect(err.code).toBe('MALFORMED_PAYLOAD'); + expect(err.classification).toBe('malformed_payload'); + expect(err.reauthRequired).toBe(false); + expect(err.retryable).toBe(false); + + // Verify downstream classification + const classified = classifyEmailFailure(err); + expect(classified.category).toBe(EmailFailureCategory.INTERNAL); + expect(classified.isHardBounce).toBe(false); + expect(classified.retryable).toBe(false); + } + }); + + it('normalizes generic 400 malformed request format to MALFORMED_PAYLOAD', async () => { + provider.setTransport(async () => { + return new Response( + JSON.stringify({ + error: { + code: 400, + message: 'Bad Request: invalid base64 encoding in raw MIME body', + status: 'INVALID_ARGUMENT' + } + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + }); + + try { + await provider.sendMessage({ + connectionId: 'conn_test_123', + from: 'sender@leadforge.ai', + to: 'valid.contact@domain.com', + subject: 'Test Subject' + }); + expect.unreachable('Should throw EmailDomainError'); + } catch (err: any) { + expect(err.code).toBe('MALFORMED_PAYLOAD'); + expect(err.classification).toBe('malformed_payload'); + + const classified = classifyEmailFailure(err); + expect(classified.category).toBe(EmailFailureCategory.INTERNAL); + expect(classified.isHardBounce).toBe(false); + } + }); + + it('identifies explicit invalid recipient address in 400 as INVALID_RECIPIENT', async () => { + provider.setTransport(async () => { + return new Response( + JSON.stringify({ + error: { + code: 400, + message: 'Invalid recipient: bad address format', + status: 'INVALID_ARGUMENT', + errors: [{ reason: 'invalidRecipient', message: 'Invalid recipient' }] + } + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + }); + + try { + await provider.sendMessage({ + connectionId: 'conn_test_123', + from: 'sender@leadforge.ai', + to: 'bad-address', + subject: 'Test Subject' + }); + expect.unreachable('Should throw EmailDomainError'); + } catch (err: any) { + expect(err.code).toBe('INVALID_RECIPIENT'); + expect(err.classification).toBe('invalid_recipient'); + + const classified = classifyEmailFailure(err); + expect(classified.category).toBe(EmailFailureCategory.INVALID_RECIPIENT); + expect(classified.isHardBounce).toBe(true); + } + }); + }); + + describe('HTTP 403 Forbidden Normalization', () => { + it('normalizes daily sending quota exceeded to PROVIDER_RATE_LIMITED without reauth mutation', async () => { + const updateOneSpy = vi.spyOn(GoogleConnectionModel, 'updateOne').mockResolvedValue({} as any); + + provider.setTransport(async () => { + return new Response( + JSON.stringify({ + error: { + code: 403, + message: 'Daily sending quota exceeded.', + status: 'PERMISSION_DENIED', + errors: [{ reason: 'dailyLimitExceeded', domain: 'usageLimits' }] + } + }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); + }); + + try { + await provider.sendMessage({ + connectionId: 'conn_test_123', + from: 'sender@leadforge.ai', + to: 'contact@domain.com', + subject: 'Test Subject' + }); + expect.unreachable('Should throw EmailDomainError'); + } catch (err: any) { + expect(err.code).toBe('PROVIDER_RATE_LIMITED'); + expect(err.classification).toBe('provider_rate_limited'); + expect(err.retryable).toBe(true); + expect(err.reauthRequired).toBe(false); + expect(err.retryAfterSec).toBe(3600); // 1h for daily limit + expect(updateOneSpy).not.toHaveBeenCalled(); + + const classified = classifyEmailFailure(err); + expect(classified.category).toBe(EmailFailureCategory.RATE_LIMIT); + expect(classified.retryable).toBe(true); + expect(classified.isHardBounce).toBe(false); + } + }); + + it('normalizes anti-abuse / bulk sender filter blocks to POLICY_BLOCKED without reauth mutation', async () => { + const updateOneSpy = vi.spyOn(GoogleConnectionModel, 'updateOne').mockResolvedValue({} as any); + + provider.setTransport(async () => { + return new Response( + JSON.stringify({ + error: { + code: 403, + message: 'Blocked for abuse: message detected as likely unsolicited mail.', + status: 'PERMISSION_DENIED', + errors: [{ reason: 'abuse', domain: 'gmail' }] + } + }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); + }); + + try { + await provider.sendMessage({ + connectionId: 'conn_test_123', + from: 'sender@leadforge.ai', + to: 'contact@domain.com', + subject: 'Test Subject' + }); + expect.unreachable('Should throw EmailDomainError'); + } catch (err: any) { + expect(err.code).toBe('POLICY_BLOCKED'); + expect(err.classification).toBe('policy_rejection'); + expect(err.retryable).toBe(false); + expect(err.reauthRequired).toBe(false); + expect(updateOneSpy).not.toHaveBeenCalled(); + + const classified = classifyEmailFailure(err); + expect(classified.category).toBe(EmailFailureCategory.POLICY); + expect(classified.retryable).toBe(false); + expect(classified.isHardBounce).toBe(false); + } + }); + + it('normalizes genuine permission / scope failure to MAILBOX_REAUTH_REQUIRED and mutates connection', async () => { + const updateOneSpy = vi.spyOn(GoogleConnectionModel, 'updateOne').mockResolvedValue({} as any); + + provider.setTransport(async () => { + return new Response( + JSON.stringify({ + error: { + code: 403, + message: 'Request had insufficient authentication scopes.', + status: 'PERMISSION_DENIED', + errors: [{ reason: 'insufficientPermissions' }] + } + }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); + }); + + try { + await provider.sendMessage({ + connectionId: 'conn_test_123', + from: 'sender@leadforge.ai', + to: 'contact@domain.com', + subject: 'Test Subject' + }); + expect.unreachable('Should throw EmailDomainError'); + } catch (err: any) { + expect(err.code).toBe('MAILBOX_REAUTH_REQUIRED'); + expect(err.classification).toBe('authentication'); + expect(err.reauthRequired).toBe(true); + expect(err.retryable).toBe(false); + expect(updateOneSpy).toHaveBeenCalledWith( + { _id: 'conn_test_123' }, + expect.objectContaining({ + $set: expect.objectContaining({ + gmailStatus: 'reauth_required', + status: 'reauth_required' + }) + }) + ); + + const classified = classifyEmailFailure(err); + expect(classified.category).toBe(EmailFailureCategory.AUTH); + expect(classified.retryable).toBe(false); + } + }); + }); + + describe('HTTP 429 Rate Limit Normalization', () => { + it('normalizes HTTP 429 to PROVIDER_RATE_LIMITED with retryAfterSec', async () => { + provider.setTransport(async () => { + return new Response( + JSON.stringify({ + error: { + code: 429, + message: 'Rate limit exceeded: Too many concurrent requests', + status: 'RESOURCE_EXHAUSTED' + } + }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '120' + } + } + ); + }); + + try { + await provider.sendMessage({ + connectionId: 'conn_test_123', + from: 'sender@leadforge.ai', + to: 'contact@domain.com', + subject: 'Test Subject' + }); + expect.unreachable('Should throw EmailDomainError'); + } catch (err: any) { + expect(err.code).toBe('PROVIDER_RATE_LIMITED'); + expect(err.classification).toBe('provider_rate_limited'); + expect(err.retryable).toBe(true); + expect(err.reauthRequired).toBe(false); + expect(err.retryAfterSec).toBe(120); + + const classified = classifyEmailFailure(err); + expect(classified.category).toBe(EmailFailureCategory.RATE_LIMIT); + expect(classified.retryable).toBe(true); + expect(classified.isHardBounce).toBe(false); + } + }); + }); + + describe('Downstream Cooldown & False-Positive Suppression Protection', () => { + it('ensures rate-limit errors match downstream cooldown conditions', () => { + const err = new EmailDomainError( + 'PROVIDER_RATE_LIMITED', + 'Rate limit hit', + false, + true, + 'provider_rate_limited', + 60 + ); + + // Verify the conditions in EmailService.send line 880 + const matchesCooldown = + err.code === 'PROVIDER_RATE_LIMITED' || + err.code === 'SENDER_RATE_LIMITED' || + err.code === 'QUOTA_EXCEEDED' || + err.classification === 'provider_rate_limited' || + err.classification === 'rate_limit'; + + expect(matchesCooldown).toBe(true); + }); + + it('ensures MALFORMED_PAYLOAD does not trigger contact suppression as hard bounce', () => { + const err = new EmailDomainError( + 'MALFORMED_PAYLOAD', + 'Invalid RFC 2822 payload', + false, + false, + 'malformed_payload' + ); + + const failure = classifyEmailFailure(err); + + // Test suppression check in EmailService.send line 912: + // isHardBounce = failure.isHardBounce === true || (failure.category === INVALID_RECIPIENT && err.code !== 'INVALID_SUBJECT' && err.code !== 'MALFORMED_PAYLOAD') + const isHardBounce = + failure.isHardBounce === true || + (failure.category === EmailFailureCategory.INVALID_RECIPIENT && + err.code !== 'INVALID_SUBJECT' && + err.code !== 'MALFORMED_PAYLOAD'); + + expect(isHardBounce).toBe(false); + expect(failure.category).not.toBe(EmailFailureCategory.INVALID_RECIPIENT); + }); + + it('ensures POLICY_BLOCKED does not trigger contact suppression as hard bounce', () => { + const err = new EmailDomainError( + 'POLICY_BLOCKED', + 'Blocked by anti-abuse filters', + false, + false, + 'policy_rejection' + ); + + const failure = classifyEmailFailure(err); + + const isHardBounce = + failure.isHardBounce === true || + (failure.category === EmailFailureCategory.INVALID_RECIPIENT && + err.code !== 'INVALID_SUBJECT' && + err.code !== 'MALFORMED_PAYLOAD'); + + expect(isHardBounce).toBe(false); + expect(failure.category).toBe(EmailFailureCategory.POLICY); + }); + }); +}); diff --git a/apps/api/src/services/google/gmail.provider.ts b/apps/api/src/services/google/gmail.provider.ts index adba4ef9..41acd543 100644 --- a/apps/api/src/services/google/gmail.provider.ts +++ b/apps/api/src/services/google/gmail.provider.ts @@ -136,7 +136,78 @@ export class GmailProvider { 'Gmail messages.send returned error response' ); - if (res.status === 401 || res.status === 403) { + const lowerMsg = fullErrorText.toLowerCase(); + const statusStr = body?.error?.status || ''; + const errorsList: any[] = Array.isArray(body?.error?.errors) ? body.error.errors : []; + const errorReasons: string[] = errorsList.map((e: any) => String(e.reason || '')); + + // 1. Authentication / Credential Revocation (HTTP 401) + if (res.status === 401) { + await GoogleConnectionModel.updateOne( + { _id: options.connectionId }, + { + $set: { + gmailStatus: 'reauth_required', + status: 'reauth_required', + lastError: fullErrorText || 'Gmail authorization expired or revoked' + } + } + ); + throw new EmailDomainError( + 'MAILBOX_REAUTH_REQUIRED', + `Gmail authorization expired or was revoked (${fullErrorText}). Please reconnect the mailbox.`, + true, + false, + 'authentication' + ); + } + + // 2. HTTP 403 Forbidden: Disambiguate Quota, Policy/Abuse, and Genuine Auth + if (res.status === 403) { + const isQuotaError = + errorReasons.some((r: string) => + ['dailyLimitExceeded', 'userRateLimitExceeded', 'rateLimitExceeded', 'quotaExceeded'].includes(r) + ) || + lowerMsg.includes('quota') || + lowerMsg.includes('sending limit') || + lowerMsg.includes('user-rate limit exceeded') || + statusStr === 'RESOURCE_EXHAUSTED'; + + if (isQuotaError) { + const retryAfterHeader = res.headers?.get ? res.headers.get('retry-after') : (res.headers as any)?.['retry-after']; + const parsedSec = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN; + const retryAfterSec = !isNaN(parsedSec) && parsedSec > 0 ? parsedSec : (lowerMsg.includes('daily') ? 3600 : 300); + throw new EmailDomainError( + 'PROVIDER_RATE_LIMITED', + `Gmail sending quota or rate limit exceeded for sender "${connection.email}": ${fullErrorText}. Please back off before retrying.`, + false, + true, + 'provider_rate_limited', + retryAfterSec + ); + } + + const isPolicyError = + errorReasons.some((r: string) => + ['abuse', 'spam', 'policyRejection', 'bulkSendingLimitExceeded'].includes(r) + ) || + lowerMsg.includes('spam') || + lowerMsg.includes('abuse') || + lowerMsg.includes('policy') || + lowerMsg.includes('bulk sending') || + lowerMsg.includes('unsolicited mail'); + + if (isPolicyError) { + throw new EmailDomainError( + 'POLICY_BLOCKED', + `Gmail blocked message due to sending policy or anti-abuse filters: ${fullErrorText}`, + false, + false, + 'policy_rejection' + ); + } + + // Genuine Auth / Permission / Scope failure (e.g. PERMISSION_DENIED without quota, invalid scopes) await GoogleConnectionModel.updateOne( { _id: options.connectionId }, { @@ -156,23 +227,45 @@ export class GmailProvider { ); } - if (res.status === 429 || body?.error?.status === 'RESOURCE_EXHAUSTED') { + // 3. HTTP 429 Too Many Requests / Resource Exhausted + if (res.status === 429 || statusStr === 'RESOURCE_EXHAUSTED') { + const retryAfterHeader = res.headers?.get ? res.headers.get('retry-after') : (res.headers as any)?.['retry-after']; + const parsedSec = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN; + const retryAfterSec = !isNaN(parsedSec) && parsedSec > 0 ? parsedSec : 60; throw new EmailDomainError( - 'SENDER_RATE_LIMITED', + 'PROVIDER_RATE_LIMITED', `Gmail API rate limit exceeded for sender "${connection.email}": ${fullErrorText}. Please back off before retrying.`, false, true, - 'rate_limit' + 'provider_rate_limited', + retryAfterSec ); } + // 4. HTTP 400 Bad Request: Differentiate Malformed Payload from Invalid Recipient if (res.status === 400) { + const isRecipientError = + lowerMsg.includes('invalid recipient') || + lowerMsg.includes('recipient address was rejected') || + lowerMsg.includes('recipient address invalid') || + errorReasons.some((r: string) => r.toLowerCase().includes('recipient')); + + if (isRecipientError) { + throw new EmailDomainError( + 'INVALID_RECIPIENT', + `Gmail rejected recipient address: ${fullErrorText}`, + false, + false, + 'invalid_recipient' + ); + } + throw new EmailDomainError( - 'INVALID_RECIPIENT', - `Gmail rejected message as invalid request: ${fullErrorText}`, + 'MALFORMED_PAYLOAD', + `Gmail rejected message payload or request format: ${fullErrorText}`, false, false, - 'invalid_request' + 'malformed_payload' ); }