Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/api/src/middleware/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
67 changes: 53 additions & 14 deletions apps/api/src/services/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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' ||
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
};
Expand All @@ -63,24 +63,14 @@ 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',
email: 'sender@leadforge.ai',
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({
Expand All @@ -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')
};
Expand Down Expand Up @@ -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);
});
});

Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/services/email/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading