Fix opt-out asymmetric 8/9-digit bypass letting sends reach blocked numbers
isOptedOut/addOptOut/removeOptOut used equivalentPhoneNumbers, which is not symmetric for a 12-digit local outside the 6-9 range vs its 13-digit counterpart (same root cause already fixed for shared-number detection). An opt-out registered against one form of such a number silently failed to block sends to a lead whose number was stored in the other form, on both the manual send path (reserveOutreachSend) and, independently, in two inlined checks that bypassed isOptedOut entirely: the last-mile recheck in campaign-worker.mjs's reserveCampaignQueueSend, and the opt-out NOT EXISTS clause in campaign-queue.mjs's candidate query. All three now use the symmetric canonicalPhoneVariants key already introduced for shared-number overrides, and the worker's last-mile check now calls isOptedOut directly instead of re-deriving its own number list. Found via a full-project audit; reproduced and verified before fixing.
This commit is contained in:
+9
-3
@@ -929,8 +929,14 @@ export function updateMessageTemplate(db, produto, { mensagem, ativo }) {
|
||||
return db.prepare('SELECT id, produto, mensagem, ativo, updated_at FROM message_templates WHERE produto = ?').get(produto);
|
||||
}
|
||||
|
||||
// Usa canonicalPhoneVariants (e nao equivalentPhoneNumbers): a relacao
|
||||
// precisa ser simetrica, senao um opt-out registrado numa forma do numero
|
||||
// (ex.: com o nono digito) pode nao bater contra a outra forma do mesmo
|
||||
// numero (sem o nono digito) quando o local nao comeca com 6-9 — deixando o
|
||||
// bloqueio de opt-out furavel dependendo de qual forma foi usada em cada
|
||||
// ponta. Mesmo raciocinio de leadsSharingNumber/addSharedNumberOverride.
|
||||
export function isOptedOut(db, numero) {
|
||||
const numbers = equivalentPhoneNumbers(numero);
|
||||
const numbers = canonicalPhoneVariants(numero);
|
||||
if (!numbers.length) return false;
|
||||
const placeholders = numbers.map(() => '?').join(', ');
|
||||
return Boolean(db.prepare(`SELECT 1 FROM outreach_opt_outs WHERE numero IN (${placeholders})`).get(...numbers));
|
||||
@@ -941,7 +947,7 @@ export function listOptOuts(db) {
|
||||
}
|
||||
|
||||
export function addOptOut(db, numero, motivo = '') {
|
||||
const numbers = equivalentPhoneNumbers(numero);
|
||||
const numbers = canonicalPhoneVariants(numero);
|
||||
if (!numbers.length) throw new Error('numero obrigatorio');
|
||||
return transaction(db, () => {
|
||||
const placeholders = numbers.map(() => '?').join(', ');
|
||||
@@ -953,7 +959,7 @@ export function addOptOut(db, numero, motivo = '') {
|
||||
}
|
||||
|
||||
export function removeOptOut(db, numero) {
|
||||
const numbers = equivalentPhoneNumbers(numero);
|
||||
const numbers = canonicalPhoneVariants(numero);
|
||||
if (!numbers.length) return false;
|
||||
return transaction(db, () => {
|
||||
const placeholders = numbers.map(() => '?').join(', ');
|
||||
|
||||
@@ -33,9 +33,11 @@ function candidateQuery(confirmed) {
|
||||
// See task-3-report.md for the full investigation and evidence.
|
||||
const leadNumberExpr = normalizedSql('raw.whatsapp');
|
||||
const leadEquivalentExpr = equivalentSql('raw.whatsapp');
|
||||
// Chave canonica + a outra forma dela: par simetrico usado so na deteccao de
|
||||
// numero compartilhado (opt-out/verificacao/inbound seguem a expansao
|
||||
// unidirecional de equivalentPhoneNumbers).
|
||||
// Chave canonica + a outra forma dela: par simetrico usado na deteccao de
|
||||
// numero compartilhado E no opt-out (a asimetria de equivalentPhoneNumbers
|
||||
// podia deixar um opt-out registrado numa forma do numero nao bloquear a
|
||||
// outra forma do mesmo numero). Verificacao/inbound continuam na expansao
|
||||
// unidirecional de equivalentPhoneNumbers.
|
||||
const leadCanonicalExpr = canonicalKeySql('raw.whatsapp');
|
||||
const leadCanonicalVariantExpr = canonicalVariantSql('raw.whatsapp');
|
||||
const leadNumber = 'candidates.lead_number';
|
||||
@@ -83,7 +85,7 @@ function candidateQuery(confirmed) {
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outreach_opt_outs opt_out
|
||||
WHERE opt_out.numero IN (${leadNumber}, ${leadEquivalent})
|
||||
WHERE opt_out.numero IN (${leadCanonical}, ${leadCanonicalVariant})
|
||||
)
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '../phone.mjs';
|
||||
import {
|
||||
isNumberSharedAcrossLeads,
|
||||
isOptedOut,
|
||||
isSharedNumberOverridden,
|
||||
setSetting
|
||||
} from '../db.mjs';
|
||||
@@ -86,9 +87,11 @@ export function reserveCampaignQueueSend(db, { queueId, now = new Date() }) {
|
||||
let numbers;
|
||||
try {
|
||||
numbers = requireConfirmedNumber(db, row.numero);
|
||||
if (db.prepare(
|
||||
`SELECT 1 FROM outreach_opt_outs WHERE numero IN (${placeholders(numbers)}) LIMIT 1`
|
||||
).get(...numbers)) {
|
||||
// isOptedOut usa a chave canonica simetrica (nao a expansao de
|
||||
// equivalentPhoneNumbers de `numbers` acima, que e assimetrica e podia
|
||||
// deixar passar um numero bloqueado dependendo de qual forma foi usada
|
||||
// para registrar o opt-out).
|
||||
if (isOptedOut(db, row.numero)) {
|
||||
throw permanentPolicyConflict('numero bloqueado por opt-out', 'opt_out');
|
||||
}
|
||||
if (db.prepare(
|
||||
|
||||
@@ -624,3 +624,31 @@ test('variantes 8 e 9 digitos do mesmo numero contam como compartilhado na fila'
|
||||
assert.equal(queue.some(({ lead_id }) => [nineDigits, eightDigits].includes(lead_id)), false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Opt-out registrado na outra forma de um par 12/13 digitos assimetrico
|
||||
// precisa excluir o lead da fila nos dois sentidos (mesma logica de chave
|
||||
// canonica ja usada para numero compartilhado nesta consulta).
|
||||
test('opt-out na outra forma de um par 12/13 digitos assimetrico exclui o lead da fila', async () => {
|
||||
for (const [leadNumero, optOutNumero] of [
|
||||
['557533334444', '5575933334444'],
|
||||
['5575933334444', '557533334444']
|
||||
]) {
|
||||
const { db, campaign } = setupCampaign();
|
||||
const lead = insertLead(db, { key: 'assim-optout', whatsapp: leadNumero, score: 100 });
|
||||
confirmed(db, leadNumero);
|
||||
addOptOut(db, optOutNumero, 'pediu para nao receber mais');
|
||||
|
||||
await prepareDailyQueue({
|
||||
db,
|
||||
whatsapp: noSendWhatsapp(async () => { throw new Error('numero possui cache'); }),
|
||||
campaignId: campaign.id,
|
||||
now: new Date('2026-07-29T14:00:00-03:00')
|
||||
});
|
||||
assert.equal(
|
||||
listCampaignQueue(db, campaign.id).some((row) => row.lead_id === lead),
|
||||
false,
|
||||
`${leadNumero} / opt-out em ${optOutNumero}`
|
||||
);
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -588,6 +588,51 @@ test('ultima milha bloqueia os dois lados de um par 12/13 digitos assimetrico',
|
||||
}
|
||||
});
|
||||
|
||||
// Mesmo par assimetrico, agora para opt-out: addOptOut/isOptedOut usavam
|
||||
// equivalentPhoneNumbers diretamente, entao um opt-out registrado numa forma
|
||||
// do numero podia nao bloquear o envio quando a fila tinha a outra forma.
|
||||
test('ultima milha bloqueia opt-out registrado na outra forma de um par 12/13 digitos assimetrico', async () => {
|
||||
const { runDueCampaignQueue } = await workerModule();
|
||||
for (const [queued, optedOutForm] of [
|
||||
['557533334444', '5575933334444'],
|
||||
['5575933334444', '557533334444']
|
||||
]) {
|
||||
const { db, campaign } = setup();
|
||||
const leadId = Number(db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
|
||||
VALUES ('assimetrico-optout', 'Lead na fila', ?, 'restaurantes', 1)`
|
||||
).run(queued).lastInsertRowid);
|
||||
saveWhatsAppVerification(db, { numero: queued, status: 'confirmado' });
|
||||
const variantId = db.prepare(
|
||||
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
|
||||
).get(campaign.id).id;
|
||||
const queueId = Number(db.prepare(
|
||||
`INSERT INTO outreach_campaign_queue
|
||||
(campaign_id, lead_id, variant_id, produto, numero, segmento,
|
||||
rendered_message, selection_reason, status, scheduled_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'restaurantes', 'Mensagem', 'teste', 'scheduled', '2026-07-29T12:00:00.000Z')`
|
||||
).run(campaign.id, leadId, variantId, campaign.produto, queued).lastInsertRowid);
|
||||
addOptOut(db, optedOutForm, 'pediu para nao receber mais');
|
||||
|
||||
const whatsapp = whatsappFake();
|
||||
const summary = await runDueCampaignQueue({
|
||||
db,
|
||||
whatsapp,
|
||||
now: new Date('2026-07-29T12:00:00.000Z'),
|
||||
log: () => {}
|
||||
});
|
||||
|
||||
assert.equal(summary.enviados, 0, queued);
|
||||
assert.equal(whatsapp.providerCalls, 0, queued);
|
||||
assert.deepEqual(
|
||||
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) },
|
||||
{ status: 'cancelled', cancel_reason: 'opt_out' },
|
||||
queued
|
||||
);
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('afterSend finaliza fila e historico uma unica vez por resultado', async () => {
|
||||
const { runDueCampaignQueue } = await workerModule();
|
||||
for (const [outcome, queueStatus, sendStatus] of [
|
||||
|
||||
@@ -69,6 +69,35 @@ test('opt-out bloqueia e remove as formas equivalentes com e sem nono digito', a
|
||||
db.close();
|
||||
});
|
||||
|
||||
// equivalentPhoneNumbers nao e simetrica para um par 12/13 digitos fora da
|
||||
// faixa 6-9 (ver phone.mjs). addOptOut/isOptedOut usavam essa expansao
|
||||
// diretamente: bloquear numa forma do numero podia nao bloquear a outra forma
|
||||
// do mesmo numero, deixando passar um envio para quem pediu para nao receber.
|
||||
test('opt-out bloqueia os dois lados de um par 12/13 digitos assimetrico', async () => {
|
||||
const { openDb, addOptOut, isOptedOut, removeOptOut, reserveOutreachSend } = await import('../src/db.mjs');
|
||||
const db = openDb(':memory:');
|
||||
db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
|
||||
VALUES ('assim-optout', 'Empresa Assimetrica', '557533334444', 1)`
|
||||
).run();
|
||||
|
||||
addOptOut(db, '5575933334444', 'pedido do contato');
|
||||
for (const numero of ['557533334444', '5575933334444']) {
|
||||
assert.equal(isOptedOut(db, numero), true, numero);
|
||||
assert.throws(
|
||||
() => reserveOutreachSend(db, { numero, mensagem: 'oi' }),
|
||||
(error) => error.statusCode === 409,
|
||||
numero
|
||||
);
|
||||
}
|
||||
|
||||
assert.equal(removeOptOut(db, '557533334444'), true);
|
||||
for (const numero of ['557533334444', '5575933334444']) {
|
||||
assert.equal(isOptedOut(db, numero), false, numero);
|
||||
}
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('listagem e detalhe derivam o estado da verificacao equivalente mais recente', async () => {
|
||||
const { openDb, saveWhatsAppVerification } = await import('../src/db.mjs');
|
||||
const { getLead, listLeads } = await import('../src/treatment/queries.mjs');
|
||||
|
||||
Reference in New Issue
Block a user