Self-hosted useSend accepts and sends the mail, but Email.latestStatus never advances past SENT. Delivery, bounce and complaint events are all silently dropped.
Symptom
SES publishes the events, SNS delivers them to /api/ses_callback, and the app rejects every one:
{"level":40,"service":"next-app","messageId":"ca4fc00f-be59-57ca-99d0-a2fbff85b840","topicArn":"arn:aws:sns:sa-east-1:...:<prefix>-sa-east-1-unsend","msg":"Rejected SNS message with invalid signature"}
The configuration sets and the SNS topic are correctly wired (SesSetting.callbackSuccess, configGeneralSuccess, configFullSuccess all true), the endpoint is publicly reachable, and the message reaches the route — it only fails signature verification.
Cause
apps/web/src/server/aws/sns-message-validator.ts:
return fields
.filter((field) => values[field] !== undefined)
.flatMap((field) => [field, values[field] as string])
.join("\n");
AWS builds the string to sign as name\nvalue\n for each field — every value is terminated by a newline, including the last one (this is what the official sns-validator package does: chunks.push(key + "\n"); chunks.push(message[key] + "\n")). .join("\n") omits that final terminator, so the digest never matches and no authentic notification can ever verify.
Why the test does not catch it
sns-message-validator.unit.test.ts signs the output of buildSnsStringToSign itself:
Signature: sign("RSA-SHA256", Buffer.from(buildSnsStringToSign(message)), privateKey)
That is self-confirming — it passes for any canonical form, correct or not. The uses the SNS canonical field order test asserts the same .join("\n") shape, so it locks the defect in rather than detecting it.
Fix
return fields
.filter((field) => values[field] !== undefined)
.map((field) => `${field}\n${values[field] as string}\n`)
.join("");
and assert an independently written literal in the test instead of re-using the function under test.
Verified in a fork against real SES/SNS traffic in sa-east-1. Related: #439. Happy to open a PR for both.
Self-hosted useSend accepts and sends the mail, but
Email.latestStatusnever advances pastSENT. Delivery, bounce and complaint events are all silently dropped.Symptom
SES publishes the events, SNS delivers them to
/api/ses_callback, and the app rejects every one:The configuration sets and the SNS topic are correctly wired (
SesSetting.callbackSuccess,configGeneralSuccess,configFullSuccessall true), the endpoint is publicly reachable, and the message reaches the route — it only fails signature verification.Cause
apps/web/src/server/aws/sns-message-validator.ts:AWS builds the string to sign as
name\nvalue\nfor each field — every value is terminated by a newline, including the last one (this is what the officialsns-validatorpackage does:chunks.push(key + "\n"); chunks.push(message[key] + "\n"))..join("\n")omits that final terminator, so the digest never matches and no authentic notification can ever verify.Why the test does not catch it
sns-message-validator.unit.test.tssigns the output ofbuildSnsStringToSignitself:That is self-confirming — it passes for any canonical form, correct or not. The
uses the SNS canonical field ordertest asserts the same.join("\n")shape, so it locks the defect in rather than detecting it.Fix
and assert an independently written literal in the test instead of re-using the function under test.
Verified in a fork against real SES/SNS traffic in
sa-east-1. Related: #439. Happy to open a PR for both.