diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index e5320f782..0352bc41a 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -22,4 +22,4 @@ jobs: - run: terraform fmt -check -recursive infra - run: terraform -chdir=infra init -backend=false -input=false - run: terraform -chdir=infra validate - - run: node --test infra/check-web-plan.test.mjs + - run: node --test infra/check-web-plan.test.mjs infra/ses-isolation.test.mjs diff --git a/infra/README.md b/infra/README.md index 6f3f30276..72e917091 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,8 +1,8 @@ # Orbit infrastructure -This directory is one Terraform state for the Render production and staging resources, Cloudflare DNS and Turnstile, and AWS SSM parameters. `production.tf` imports the existing API and declares the production database, web service, and landing site. `staging.tf` declares the staging database, API, web service, and landing site. `configuration.tf` owns four environment groups and their AWS SSM inputs. `cloudflare.tf` owns the zone, DNS records, widget, and its secret parameters. `variables.tf` contains the cutover switches and image digests. `versions.tf` pins the providers and configures the encrypted S3 state with native locking. +This directory is one Terraform state for the Render production and staging resources, Cloudflare DNS and Turnstile, and AWS SSM parameters. `production.tf` imports the existing API and declares the production database, web service, and landing site. `staging.tf` declares the staging database, API, web service, and landing site. `configuration.tf` owns four environment groups and their AWS SSM inputs. `cloudflare.tf` owns the zone, existing DNS records, widget, and its secret parameters. `ses.tf` owns SES identities, new DNS records, event routing, and send credentials. `variables.tf` contains the cutover switches and image digests. `versions.tf` pins the providers and configures the encrypted S3 state with native locking. -The Render provider reads `RENDER_API_KEY` from the environment. The Cloudflare provider reads `CLOUDFLARE_API_TOKEN` from the environment. The AWS provider uses the default credential chain in `us-east-2`. The S3 state contains decrypted SecureString values, including the Turnstile secret, so access to the bucket and its lock file must stay restricted. The existing API is imported with its service ID; do not remove that import block or replace the service. +The Render provider reads `RENDER_API_KEY` from the environment. The Cloudflare provider reads `CLOUDFLARE_API_TOKEN` from the environment. The AWS provider uses the default credential chain in `us-east-2`. The S3 state contains decrypted SecureString values, including the Turnstile and SES secrets, so access to the bucket and its lock file must stay restricted. The existing API is imported with its service ID; do not remove that import block or replace the service. ## SSM parameters @@ -67,6 +67,10 @@ Every staging secret must be independently generated or issued for staging. `/or Google Play uses the configured service account for both live and license-tester subscription purchases. Set up license testers on the internal and closed tracks, then verify a test purchase through `play/verify`. The staging Pub/Sub push subscription must use `https://api-staging.useorbit.org/api/subscriptions/play/rtdn` as both its endpoint and OIDC audience, with the configured `GooglePlay__RtdnServiceAccountEmail` as the token issuer. Confirm an RTDN push reaches staging and updates the linked purchase. The production API's non-secret values mirror the existing Render service; staging overrides its environment, CORS origin, return URLs, RTDN audience, and billing IDs. +Terraform creates `/orbit//api/Ses__AccessKeyId` and `/orbit//api/Ses__SecretAccessKey` as SecureString parameters and places the matching values in each Render API environment group. `orbit-api-ses` can send with the production configuration sets; `orbit-api-ses-staging` can send with the staging sets. Both users can send from the two shared SES identities. The production sets `orbit-transactional` and `orbit-marketing` publish to `orbit-ses-events`, which subscribes only to `https://api.useorbit.org/api/email/ses-events`. The staging sets `orbit-staging-transactional` and `orbit-staging-marketing` publish to `orbit-ses-events-staging`, which subscribes only to `https://api-staging.useorbit.org/api/email/ses-events`. Each API group receives its own `Ses__TransactionalConfigurationSet`, `Ses__MarketingConfigurationSet`, and `Ses__TopicArn`. Both groups also provide `Ses__Region`, `Ses__FromEmail`, `Ses__MarketingFromEmail`, and `Ses__SupportEmail`. `Email__Provider` comes from separate `production_email_provider` and `staging_email_provider` Terraform variables, each defaulting to `Resend`. After SES production access and identity verification are complete, set the intended environment's variable to `Ses` in `infra/local.tfvars`, plan, and apply. Roll back by setting it to `Resend` in that file, planning, and applying again. Confirm each SNS subscription through its own signed webhook before enabling that environment's SES provider. + +The SES DKIM CNAME records and the MAIL FROM MX and SPF records are managed directly in `ses.tf` using the SES identity outputs. They are unproxied and use `bounce.send.useorbit.org` and `bounce.updates.useorbit.org`, leaving the existing Resend records intact. The Cloudflare zone is created on the Free plan without a zone subscription resource because that resource requires billing scope unavailable to the DNS token. + Terraform creates `/orbit/production/api/BotProtection__SecretKey` and `/orbit/staging/api/BotProtection__SecretKey` as SecureString parameters from the managed Turnstile widget. Both API environment groups read the corresponding parameter. `BotProtection__Enabled` remains at its current setting. The public `turnstile_site_key` output is for the web and landing builds. ## DNS cutover diff --git a/infra/cloudflare.tf b/infra/cloudflare.tf index 77a4d845b..c700b2caf 100644 --- a/infra/cloudflare.tf +++ b/infra/cloudflare.tf @@ -6,13 +6,6 @@ resource "cloudflare_zone" "orbit" { type = "full" } -resource "cloudflare_zone_subscription" "orbit" { - zone_id = cloudflare_zone.orbit.id - rate_plan = { - id = "free" - } -} - locals { existing_dns_records = { apex_a = { diff --git a/infra/configuration.tf b/infra/configuration.tf index ce5a14b34..ceee78daa 100644 --- a/infra/configuration.tf +++ b/infra/configuration.tf @@ -49,6 +49,14 @@ locals { Jwt__ExpiryMinutes = "0" Jwt__Issuer = "OrbitApi" Jwt__RefreshExpiryDays = "90" + Email__Provider = var.production_email_provider + Ses__Region = "us-east-2" + Ses__FromEmail = "Orbit " + Ses__MarketingFromEmail = "Orbit " + Ses__SupportEmail = "contact@useorbit.org" + Ses__TransactionalConfigurationSet = aws_sesv2_configuration_set.orbit["transactional"].configuration_set_name + Ses__MarketingConfigurationSet = aws_sesv2_configuration_set.orbit["marketing"].configuration_set_name + Ses__TopicArn = aws_sns_topic.ses_events.arn Resend__FromEmail = "Orbit " Resend__SupportEmail = "contact@useorbit.org" Sentry__Environment = "production" @@ -65,19 +73,23 @@ locals { } staging_api_values = merge(local.production_api_values, { - ASPNETCORE_ENVIRONMENT = "Staging" - Database__MigrateOnStartup = "true" - Cors__AllowedOrigins__0 = "https://staging.useorbit.org" - Frontend__BaseUrl = "https://staging.useorbit.org" - GooglePlay__RtdnAudience = "https://api-staging.useorbit.org/api/subscriptions/play/rtdn" - Google__AllowedRedirectUris__0 = "https://staging.useorbit.org/auth-callback" - Google__AllowedRedirectUris__1 = "https://app.useorbit.org/auth-callback" - Marketing__ApiBaseUrl = "https://api-staging.useorbit.org" - Sentry__Environment = "staging" - Stripe__CancelUrl = "https://staging.useorbit.org/upgrade" - Stripe__SuccessUrl = "https://staging.useorbit.org/settings?subscription=success" - Supabase__Url = "https://staging-storage-disabled.invalid" - Waitlist__ApiBaseUrl = "https://api-staging.useorbit.org" + Email__Provider = var.staging_email_provider + Ses__TransactionalConfigurationSet = aws_sesv2_configuration_set.staging["transactional"].configuration_set_name + Ses__MarketingConfigurationSet = aws_sesv2_configuration_set.staging["marketing"].configuration_set_name + Ses__TopicArn = aws_sns_topic.ses_events_staging.arn + ASPNETCORE_ENVIRONMENT = "Staging" + Database__MigrateOnStartup = "true" + Cors__AllowedOrigins__0 = "https://staging.useorbit.org" + Frontend__BaseUrl = "https://staging.useorbit.org" + GooglePlay__RtdnAudience = "https://api-staging.useorbit.org/api/subscriptions/play/rtdn" + Google__AllowedRedirectUris__0 = "https://staging.useorbit.org/auth-callback" + Google__AllowedRedirectUris__1 = "https://app.useorbit.org/auth-callback" + Marketing__ApiBaseUrl = "https://api-staging.useorbit.org" + Sentry__Environment = "staging" + Stripe__CancelUrl = "https://staging.useorbit.org/upgrade" + Stripe__SuccessUrl = "https://staging.useorbit.org/settings?subscription=success" + Supabase__Url = "https://staging-storage-disabled.invalid" + Waitlist__ApiBaseUrl = "https://api-staging.useorbit.org" }) production_web_values = { @@ -147,6 +159,8 @@ resource "render_env_group" "production_api" { { for key in local.api_secret_keys : key => { value = data.aws_ssm_parameter.production_api[key].value } }, { for key, value in local.production_api_database_values : key => { value = value } }, { BotProtection__SecretKey = { value = aws_ssm_parameter.turnstile_secret["production"].value } }, + { Ses__AccessKeyId = { value = aws_ssm_parameter.api_ses_access_key_id["production"].value } }, + { Ses__SecretAccessKey = { value = aws_ssm_parameter.api_ses_secret_access_key["production"].value } }, ) } @@ -158,6 +172,8 @@ resource "render_env_group" "staging_api" { { for key in local.staging_api_billing_keys : key => { value = data.aws_ssm_parameter.staging_api_billing[key].value } }, { for key in local.database_keys : key => { value = local.staging_render_npgsql } }, { BotProtection__SecretKey = { value = aws_ssm_parameter.turnstile_secret["staging"].value } }, + { Ses__AccessKeyId = { value = aws_ssm_parameter.api_ses_access_key_id["staging"].value } }, + { Ses__SecretAccessKey = { value = aws_ssm_parameter.api_ses_secret_access_key["staging"].value } }, ) } diff --git a/infra/example.tfvars b/infra/example.tfvars index fa7d08537..d323b8ca7 100644 --- a/infra/example.tfvars +++ b/infra/example.tfvars @@ -1,2 +1,4 @@ -production_web_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" -staging_web_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" +production_web_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" +staging_web_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" +production_email_provider = "Resend" +staging_email_provider = "Resend" diff --git a/infra/ses-isolation.test.mjs b/infra/ses-isolation.test.mjs new file mode 100644 index 000000000..91713dc28 --- /dev/null +++ b/infra/ses-isolation.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { test } from "node:test" + +const root = dirname(fileURLToPath(import.meta.url)) +const ses = readFileSync(join(root, "ses.tf"), "utf8") +const configuration = readFileSync(join(root, "configuration.tf"), "utf8") +const literal = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +function block(source, header) { + const start = source.indexOf(header) + assert.notEqual(start, -1, `Missing ${header}`) + const opening = source.indexOf("{", start + header.length - (header.endsWith("{") ? 1 : 0)) + let depth = 0 + for (let index = opening; index < source.length; index++) { + if (source[index] === "{") depth++ + if (source[index] === "}" && --depth === 0) return source.slice(opening + 1, index) + } + assert.fail(`Unclosed ${header}`) +} + +test("each API environment uses its own SES configuration sets and event topic", () => { + const production = block(configuration, "production_api_values = {") + const staging = block(configuration, "staging_api_values = merge(") + + for (const [environment, resource, topic, values] of [ + ["production", "orbit", "ses_events", production], + ["staging", "staging", "ses_events_staging", staging], + ]) { + for (const stream of ["transactional", "marketing"]) { + const key = stream === "transactional" ? "Ses__TransactionalConfigurationSet" : "Ses__MarketingConfigurationSet" + assert.match(values, new RegExp(`${literal(key)}\\s*=\\s*aws_sesv2_configuration_set\\.${literal(resource)}\\["${literal(stream)}"\\]\\.configuration_set_name`), environment) + } + assert.match(values, new RegExp(`Ses__TopicArn\\s*=\\s*aws_sns_topic\\.${literal(topic)}\\.arn`), environment) + } +}) + +test("SES event destinations and subscriptions stay within the sending environment", () => { + for (const [resource, destination, topic, subscription, endpoint] of [ + ["orbit", "sns", "ses_events", "ses_events_api", "https://api.useorbit.org/api/email/ses-events"], + ["staging", "sns_staging", "ses_events_staging", "ses_events_staging_api", "https://api-staging.useorbit.org/api/email/ses-events"], + ]) { + const event = block(ses, `resource "aws_sesv2_configuration_set_event_destination" "${destination}"`) + const hook = block(ses, `resource "aws_sns_topic_subscription" "${subscription}"`) + assert.match(event, new RegExp(`for_each\\s*=\\s*aws_sesv2_configuration_set\\.${literal(resource)}`)) + assert.match(event, new RegExp(`topic_arn\\s*=\\s*aws_sns_topic\\.${literal(topic)}\\.arn`)) + assert.match(hook, new RegExp(`topic_arn\\s*=\\s*aws_sns_topic\\.${literal(topic)}\\.arn`)) + assert.ok(hook.includes(`endpoint = "${endpoint}"`)) + } +}) + +test("each API environment has credentials scoped to its own configuration sets", () => { + const productionPolicy = block(ses, 'resource "aws_iam_user_policy" "api_ses"') + const stagingPolicy = block(ses, 'resource "aws_iam_user_policy" "api_ses_staging"') + const keyParameter = block(ses, 'resource "aws_ssm_parameter" "api_ses_access_key_id"') + const secretParameter = block(ses, 'resource "aws_ssm_parameter" "api_ses_secret_access_key"') + + assert.match(productionPolicy, /aws_sesv2_configuration_set\.orbit/) + assert.doesNotMatch(productionPolicy, /aws_sesv2_configuration_set\.staging/) + assert.match(stagingPolicy, /aws_sesv2_configuration_set\.staging/) + assert.doesNotMatch(stagingPolicy, /aws_sesv2_configuration_set\.orbit/) + assert.match(keyParameter, /each\.key == "production" \? aws_iam_access_key\.api_ses\.id : aws_iam_access_key\.api_ses_staging\.id/) + assert.match(secretParameter, /each\.key == "production" \? aws_iam_access_key\.api_ses\.secret : aws_iam_access_key\.api_ses_staging\.secret/) +}) diff --git a/infra/ses.tf b/infra/ses.tf new file mode 100644 index 000000000..48545e9b8 --- /dev/null +++ b/infra/ses.tf @@ -0,0 +1,271 @@ +locals { + ses_streams = { + transactional = { + domain = "send.useorbit.org" + mail_from_domain = "bounce.send.useorbit.org" + configuration_set = "orbit-transactional" + } + marketing = { + domain = "updates.useorbit.org" + mail_from_domain = "bounce.updates.useorbit.org" + configuration_set = "orbit-marketing" + } + } +} + +resource "aws_sesv2_email_identity" "orbit" { + for_each = local.ses_streams + email_identity = each.value.domain + + dkim_signing_attributes { + next_signing_key_length = "RSA_2048_BIT" + } +} + +resource "aws_sesv2_email_identity_mail_from_attributes" "orbit" { + for_each = local.ses_streams + email_identity = aws_sesv2_email_identity.orbit[each.key].email_identity + mail_from_domain = each.value.mail_from_domain + behavior_on_mx_failure = "REJECT_MESSAGE" +} + +resource "cloudflare_dns_record" "ses_dkim" { + for_each = { + for pair in flatten([ + for stream in keys(local.ses_streams) : [ + for index in range(3) : { + key = "${stream}-${index}" + stream = stream + index = index + } + ] + ]) : pair.key => pair + } + + zone_id = cloudflare_zone.orbit.id + name = "${aws_sesv2_email_identity.orbit[each.value.stream].dkim_signing_attributes[0].tokens[each.value.index]}._domainkey.${local.ses_streams[each.value.stream].domain}" + type = "CNAME" + content = "${aws_sesv2_email_identity.orbit[each.value.stream].dkim_signing_attributes[0].tokens[each.value.index]}.dkim.amazonses.com" + ttl = 1 + proxied = false +} + +resource "cloudflare_dns_record" "ses_mail_from_mx" { + for_each = aws_sesv2_email_identity_mail_from_attributes.orbit + + zone_id = cloudflare_zone.orbit.id + name = each.value.mail_from_domain + type = "MX" + content = "feedback-smtp.us-east-2.amazonses.com" + priority = 10 + ttl = 1 + proxied = false +} + +resource "cloudflare_dns_record" "ses_mail_from_spf" { + for_each = aws_sesv2_email_identity_mail_from_attributes.orbit + + zone_id = cloudflare_zone.orbit.id + name = each.value.mail_from_domain + type = "TXT" + content = "v=spf1 include:amazonses.com ~all" + ttl = 1 + proxied = false +} + +resource "aws_sesv2_configuration_set" "orbit" { + for_each = local.ses_streams + configuration_set_name = each.value.configuration_set +} + +resource "aws_sesv2_configuration_set" "staging" { + for_each = local.ses_streams + configuration_set_name = "orbit-staging-${each.key}" +} + +resource "aws_sns_topic" "ses_events" { + name = "orbit-ses-events" + signature_version = 2 + kms_master_key_id = aws_kms_key.ses_events.arn +} + +resource "aws_sns_topic" "ses_events_staging" { + name = "orbit-ses-events-staging" + signature_version = 2 + kms_master_key_id = aws_kms_key.ses_events.arn +} + +resource "aws_kms_key" "ses_events" { + description = "Encrypt SES event notifications in SNS" + enable_key_rotation = true + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnableAccountAdministration" + Effect = "Allow" + Principal = { AWS = "arn:aws:iam::713285551626:root" } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "AllowSesEventPublishing" + Effect = "Allow" + Principal = { Service = "ses.amazonaws.com" } + Action = ["kms:GenerateDataKey*", "kms:Decrypt"] + Resource = "*" + }, + { + Sid = "AllowSnsEncryption" + Effect = "Allow" + Principal = { Service = "sns.amazonaws.com" } + Action = ["kms:GenerateDataKey*", "kms:Decrypt"] + Resource = "*" + } + ] + }) +} + +resource "aws_sns_topic_policy" "ses_events" { + arn = aws_sns_topic.ses_events.arn + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowSesEvents" + Effect = "Allow" + Principal = { Service = "ses.amazonaws.com" } + Action = "SNS:Publish" + Resource = aws_sns_topic.ses_events.arn + Condition = { + StringEquals = { "AWS:SourceAccount" = "713285551626" } + ArnEquals = { "AWS:SourceArn" = [for configuration in aws_sesv2_configuration_set.orbit : configuration.arn] } + } + }] + }) +} + +resource "aws_sns_topic_policy" "ses_events_staging" { + arn = aws_sns_topic.ses_events_staging.arn + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowStagingSesEvents" + Effect = "Allow" + Principal = { Service = "ses.amazonaws.com" } + Action = "SNS:Publish" + Resource = aws_sns_topic.ses_events_staging.arn + Condition = { + StringEquals = { "AWS:SourceAccount" = "713285551626" } + ArnEquals = { "AWS:SourceArn" = [for configuration in aws_sesv2_configuration_set.staging : configuration.arn] } + } + }] + }) +} + +resource "aws_sesv2_configuration_set_event_destination" "sns" { + for_each = aws_sesv2_configuration_set.orbit + configuration_set_name = each.value.configuration_set_name + event_destination_name = "orbit-sns" + + event_destination { + matching_event_types = ["BOUNCE", "COMPLAINT", "REJECT"] + sns_destination { + topic_arn = aws_sns_topic.ses_events.arn + } + } + + depends_on = [aws_sns_topic_policy.ses_events] +} + +resource "aws_sesv2_configuration_set_event_destination" "sns_staging" { + for_each = aws_sesv2_configuration_set.staging + configuration_set_name = each.value.configuration_set_name + event_destination_name = "orbit-sns" + + event_destination { + matching_event_types = ["BOUNCE", "COMPLAINT", "REJECT"] + sns_destination { + topic_arn = aws_sns_topic.ses_events_staging.arn + } + } + + depends_on = [aws_sns_topic_policy.ses_events_staging] +} + +resource "aws_sns_topic_subscription" "ses_events_api" { + topic_arn = aws_sns_topic.ses_events.arn + protocol = "https" + endpoint = "https://api.useorbit.org/api/email/ses-events" +} + +resource "aws_sns_topic_subscription" "ses_events_staging_api" { + topic_arn = aws_sns_topic.ses_events_staging.arn + protocol = "https" + endpoint = "https://api-staging.useorbit.org/api/email/ses-events" +} + +resource "aws_sesv2_account_suppression_attributes" "orbit" { + suppressed_reasons = ["BOUNCE", "COMPLAINT"] +} + +resource "aws_iam_user" "api_ses" { + name = "orbit-api-ses" +} + +resource "aws_iam_user" "api_ses_staging" { + name = "orbit-api-ses-staging" +} + +resource "aws_iam_user_policy" "api_ses" { + name = "orbit-api-ses-send" + user = aws_iam_user.api_ses.name + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["ses:SendEmail", "ses:SendRawEmail"] + Resource = concat( + [for identity in aws_sesv2_email_identity.orbit : identity.arn], + [for configuration in aws_sesv2_configuration_set.orbit : configuration.arn] + ) + }] + }) +} + +resource "aws_iam_access_key" "api_ses" { + user = aws_iam_user.api_ses.name +} + +resource "aws_iam_user_policy" "api_ses_staging" { + name = "orbit-api-ses-staging-send" + user = aws_iam_user.api_ses_staging.name + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["ses:SendEmail", "ses:SendRawEmail"] + Resource = concat( + [for identity in aws_sesv2_email_identity.orbit : identity.arn], + [for configuration in aws_sesv2_configuration_set.staging : configuration.arn] + ) + }] + }) +} + +resource "aws_iam_access_key" "api_ses_staging" { + user = aws_iam_user.api_ses_staging.name +} + +resource "aws_ssm_parameter" "api_ses_access_key_id" { + for_each = toset(["production", "staging"]) + name = "/orbit/${each.key}/api/Ses__AccessKeyId" + type = "SecureString" + value = each.key == "production" ? aws_iam_access_key.api_ses.id : aws_iam_access_key.api_ses_staging.id +} + +resource "aws_ssm_parameter" "api_ses_secret_access_key" { + for_each = toset(["production", "staging"]) + name = "/orbit/${each.key}/api/Ses__SecretAccessKey" + type = "SecureString" + value = each.key == "production" ? aws_iam_access_key.api_ses.secret : aws_iam_access_key.api_ses_staging.secret +} diff --git a/infra/variables.tf b/infra/variables.tf index e53407d65..da2b63eed 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -15,6 +15,28 @@ variable "api_database" { } } +variable "production_email_provider" { + description = "Email provider for the production API." + type = string + default = "Resend" + + validation { + condition = contains(["Resend", "Ses"], var.production_email_provider) + error_message = "production_email_provider must be Resend or Ses." + } +} + +variable "staging_email_provider" { + description = "Email provider for the staging API." + type = string + default = "Resend" + + validation { + condition = contains(["Resend", "Ses"], var.staging_email_provider) + error_message = "staging_email_provider must be Resend or Ses." + } +} + variable "production_web_digest" { description = "Published GHCR digest for the production web image." type = string diff --git a/src/Orbit.Api/Controllers/EmailController.cs b/src/Orbit.Api/Controllers/EmailController.cs new file mode 100644 index 000000000..4fd10362e --- /dev/null +++ b/src/Orbit.Api/Controllers/EmailController.cs @@ -0,0 +1,25 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Orbit.Application.Email.Commands; +using Orbit.Api.RateLimiting; + +namespace Orbit.Api.Controllers; + +[ApiController] +[Route("api/email")] +[AllowAnonymous] +public sealed class EmailController(ISender sender) : ControllerBase +{ + [HttpPost("ses-events")] + [DistributedRateLimit("ses-events")] + [Consumes("text/plain", "application/json")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SesEvents(CancellationToken cancellationToken) + { + using var reader = new StreamReader(Request.Body); + var payload = await reader.ReadToEndAsync(cancellationToken); + return await sender.Send(new ProcessSesEventCommand(payload), cancellationToken) ? Ok() : BadRequest(); + } +} diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs index 722ae1d35..e3abb5eae 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs @@ -22,7 +22,7 @@ private static string RequireConfigValue(WebApplicationBuilder builder, string k return value; } - private static void AddEmailAndSupabaseClients(WebApplicationBuilder builder, TimeSpan httpTimeout) + internal static void AddEmailAndSupabaseClients(WebApplicationBuilder builder, TimeSpan httpTimeout) { var supabaseUrl = RequireConfigValue(builder, "Supabase:Url"); var supabaseAnonKey = RequireConfigValue(builder, "Supabase:AnonKey"); @@ -60,7 +60,29 @@ private static void AddEmailAndSupabaseClients(WebApplicationBuilder builder, Ti client.Timeout = httpTimeout; }); - builder.Services.AddScoped(); + builder.Services.Configure(builder.Configuration.GetSection(SesSettings.SectionName)); + builder.Services.AddHttpClient("SnsCertificate").ConfigurePrimaryHttpMessageHandler(() => + new HttpClientHandler { AllowAutoRedirect = false }); + builder.Services.AddHttpClient("SnsConfirmation").ConfigurePrimaryHttpMessageHandler(() => + new HttpClientHandler { AllowAutoRedirect = false }); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + var provider = builder.Configuration["Email:Provider"] ?? "Resend"; + if (string.Equals(provider, "Ses", StringComparison.OrdinalIgnoreCase)) + { + var ses = builder.Configuration.GetSection(SesSettings.SectionName).Get() ?? new SesSettings(); + if (string.IsNullOrWhiteSpace(ses.AccessKeyId) || string.IsNullOrWhiteSpace(ses.SecretAccessKey)) + throw new InvalidOperationException("SES credentials are required when Email:Provider is Ses."); + builder.Services.AddSingleton(_ => + new Amazon.SimpleEmailV2.AmazonSimpleEmailServiceV2Client( + new Amazon.Runtime.BasicAWSCredentials(ses.AccessKeyId, ses.SecretAccessKey), + Amazon.RegionEndpoint.GetBySystemName(ses.Region))); + builder.Services.AddScoped(); + } + else if (string.Equals(provider, "Resend", StringComparison.OrdinalIgnoreCase)) + builder.Services.AddScoped(); + else + throw new InvalidOperationException("Email:Provider must be Resend or Ses."); builder.Services.Configure( builder.Configuration.GetSection(WaitlistSettings.SectionName)); diff --git a/src/Orbit.Api/appsettings.json b/src/Orbit.Api/appsettings.json index 56624a216..8d42843d6 100644 --- a/src/Orbit.Api/appsettings.json +++ b/src/Orbit.Api/appsettings.json @@ -54,6 +54,21 @@ "SecretKey": "REPLACE-IN-ENVIRONMENT", "Bucket": "uploads" }, + "Email": { + "Provider": "Resend" + }, + "Ses": { + "Region": "us-east-2", + "AccessKeyId": "", + "SecretAccessKey": "", + "FromEmail": "Orbit ", + "SupportEmail": "contact@useorbit.org", + "MarketingFromEmail": "Orbit ", + "TransactionalConfigurationSet": "orbit-transactional", + "MarketingConfigurationSet": "orbit-marketing", + "TopicArn": "", + "MarketingRetryBaseDelayMs": 500 + }, "Resend": { "ApiKey": "REPLACE-IN-DEVELOPMENT-JSON", "FromEmail": "Orbit ", diff --git a/src/Orbit.Api/openapi.json b/src/Orbit.Api/openapi.json index 4dd74ef2d..2291dc453 100644 --- a/src/Orbit.Api/openapi.json +++ b/src/Orbit.Api/openapi.json @@ -3587,6 +3587,38 @@ } } }, + "/api/email/ses-events": { + "post": { + "tags": [ + "Email" + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/events/ticket": { "post": { "tags": [ @@ -16732,6 +16764,9 @@ { "name": "Config" }, + { + "name": "Email" + }, { "name": "Events" }, diff --git a/src/Orbit.Application/Email/Commands/ProcessSesEventCommand.cs b/src/Orbit.Application/Email/Commands/ProcessSesEventCommand.cs new file mode 100644 index 000000000..56eaab853 --- /dev/null +++ b/src/Orbit.Application/Email/Commands/ProcessSesEventCommand.cs @@ -0,0 +1,12 @@ +using MediatR; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Email.Commands; + +public sealed record ProcessSesEventCommand(string Payload) : IRequest; + +public sealed class ProcessSesEventCommandHandler(ISesEventProcessor processor) : IRequestHandler +{ + public Task Handle(ProcessSesEventCommand request, CancellationToken cancellationToken) => + processor.ProcessAsync(request.Payload, cancellationToken); +} diff --git a/src/Orbit.Application/Email/Validators/ProcessSesEventCommandValidator.cs b/src/Orbit.Application/Email/Validators/ProcessSesEventCommandValidator.cs new file mode 100644 index 000000000..b64e3fe5b --- /dev/null +++ b/src/Orbit.Application/Email/Validators/ProcessSesEventCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Orbit.Application.Email.Commands; + +namespace Orbit.Application.Email.Validators; + +public sealed class ProcessSesEventCommandValidator : AbstractValidator +{ + public ProcessSesEventCommandValidator() + { + RuleFor(command => command.Payload).NotEmpty().MaximumLength(262144); + } +} diff --git a/src/Orbit.Domain/Interfaces/IEmailService.cs b/src/Orbit.Domain/Interfaces/IEmailService.cs index 025257369..d01cc2beb 100644 --- a/src/Orbit.Domain/Interfaces/IEmailService.cs +++ b/src/Orbit.Domain/Interfaces/IEmailService.cs @@ -12,7 +12,7 @@ public interface IEmailService /// /// Sends one marketing broadcast email: wraps in the branded shell /// with a localized legal/unsubscribe footer and one-click List-Unsubscribe headers pointing at - /// . Retries with exponential backoff on Resend 429/5xx so a + /// . Retries provider throttling with exponential backoff so a /// mass send paces itself rather than tripping the provider rate limit. /// Task SendMarketingEmailAsync(string toEmail, string subject, string bodyHtml, string language, string unsubscribeUrl, CancellationToken cancellationToken = default); diff --git a/src/Orbit.Domain/Interfaces/ISesEventProcessor.cs b/src/Orbit.Domain/Interfaces/ISesEventProcessor.cs new file mode 100644 index 000000000..dd9a2d97c --- /dev/null +++ b/src/Orbit.Domain/Interfaces/ISesEventProcessor.cs @@ -0,0 +1,6 @@ +namespace Orbit.Domain.Interfaces; + +public interface ISesEventProcessor +{ + Task ProcessAsync(string payload, CancellationToken cancellationToken); +} diff --git a/src/Orbit.Infrastructure/Configuration/SesSettings.cs b/src/Orbit.Infrastructure/Configuration/SesSettings.cs new file mode 100644 index 000000000..0eb194066 --- /dev/null +++ b/src/Orbit.Infrastructure/Configuration/SesSettings.cs @@ -0,0 +1,16 @@ +namespace Orbit.Infrastructure.Configuration; + +public sealed class SesSettings +{ + public const string SectionName = "Ses"; + public string Region { get; init; } = "us-east-2"; + public string AccessKeyId { get; init; } = ""; + public string SecretAccessKey { get; init; } = ""; + public string FromEmail { get; init; } = "Orbit "; + public string SupportEmail { get; init; } = "contact@useorbit.org"; + public string MarketingFromEmail { get; init; } = "Orbit "; + public string TransactionalConfigurationSet { get; init; } = "orbit-transactional"; + public string MarketingConfigurationSet { get; init; } = "orbit-marketing"; + public string TopicArn { get; init; } = ""; + public double MarketingRetryBaseDelayMs { get; init; } = 500; +} diff --git a/src/Orbit.Infrastructure/Email/EmailMessageComposer.cs b/src/Orbit.Infrastructure/Email/EmailMessageComposer.cs new file mode 100644 index 000000000..e856a8d40 --- /dev/null +++ b/src/Orbit.Infrastructure/Email/EmailMessageComposer.cs @@ -0,0 +1,197 @@ +using System.Net; +using Orbit.Application.Common; + +namespace Orbit.Infrastructure.Email; + +internal sealed record EmailMessage(string Subject, string Html, string? Text); + +internal sealed class EmailMessageComposer(string frontendBaseUrl) +{ + private const string HeadingToken = "heading"; + private const string IntroToken = "intro"; + private const string FooterToken = "footer"; + private const string WarningToken = "warning"; + + private string LogoUrl => $"{frontendBaseUrl}/logo-no-bg.png"; + + public EmailMessage ComposeWelcome(string userName, string language) + { + var isPtBr = LocaleHelper.IsPortuguese(language); + var htmlCopy = EmailCopy.Welcome(isPtBr, WebUtility.HtmlEncode(userName)); + var textCopy = EmailCopy.Welcome(isPtBr, userName); + + var layout = new EmailLayout(LangCode(isPtBr), htmlCopy.Preheader, htmlCopy.Footer, LogoUrl, GradientHeader: true); + var html = EmailTemplateRenderer.RenderHtml("Welcome", layout, WelcomeTokens(htmlCopy)); + var text = EmailTemplateRenderer.RenderText("Welcome", WelcomeTokens(textCopy)); + + return new EmailMessage(htmlCopy.Subject, html, text); + } + + public EmailMessage ComposeVerificationCode(string toEmail, string code, string language) + { + var isPtBr = LocaleHelper.IsPortuguese(language); + var copy = EmailCopy.VerificationCode(isPtBr); + var signInUrl = $"{frontendBaseUrl}/login?email={WebUtility.UrlEncode(toEmail)}&code={code}"; + + var tokens = new Dictionary + { + [HeadingToken] = copy.Heading, + [IntroToken] = copy.Intro, + ["code"] = code, + ["cta"] = copy.Cta, + ["signInUrl"] = signInUrl, + [WarningToken] = copy.Warning, + [FooterToken] = copy.Footer, + }; + + var layout = new EmailLayout(LangCode(isPtBr), copy.Preheader, copy.Footer, LogoUrl, GradientHeader: false); + var html = EmailTemplateRenderer.RenderHtml("VerificationCode", layout, tokens); + var text = EmailTemplateRenderer.RenderText("VerificationCode", tokens); + + return new EmailMessage(copy.Subject, html, text); + } + + public EmailMessage ComposeAccountDeletionCode(string code, string language) + { + var isPtBr = LocaleHelper.IsPortuguese(language); + var copy = EmailCopy.AccountDeletion(isPtBr); + return ComposeSecurityCode("AccountDeletion", copy.Subject, copy.Heading, copy.Intro, + copy.CodeLabel, copy.Warning, copy.Footer, copy.Preheader, code, isPtBr); + } + + public EmailMessage ComposeApiKeyCreationCode(string code, string language) + { + var isPtBr = LocaleHelper.IsPortuguese(language); + var copy = EmailCopy.ApiKeyCreation(isPtBr); + return ComposeSecurityCode("ApiKeyCreation", copy.Subject, copy.Heading, copy.Intro, + copy.CodeLabel, copy.Warning, copy.Footer, copy.Preheader, code, isPtBr); + } + + private EmailMessage ComposeSecurityCode(string template, string subject, string heading, string intro, + string codeLabel, string warning, string footer, string preheader, string code, bool isPtBr) + { + var tokens = new Dictionary + { + [HeadingToken] = heading, + [IntroToken] = intro, + ["codeLabel"] = codeLabel, + ["code"] = code, + [WarningToken] = warning, + [FooterToken] = footer, + }; + + var layout = new EmailLayout(LangCode(isPtBr), preheader, footer, LogoUrl, GradientHeader: false); + var html = EmailTemplateRenderer.RenderHtml(template, layout, tokens); + var text = EmailTemplateRenderer.RenderText(template, tokens); + + return new EmailMessage(subject, html, text); + } + + public EmailMessage ComposeWaitlistConfirmation(string confirmUrl, string language) + { + var isPtBr = LocaleHelper.IsPortuguese(language); + var copy = EmailCopy.WaitlistConfirmation(isPtBr); + + var tokens = new Dictionary + { + [HeadingToken] = copy.Heading, + [IntroToken] = copy.Intro, + ["cta"] = copy.Cta, + ["confirmUrl"] = confirmUrl, + [WarningToken] = copy.Warning, + [FooterToken] = copy.Footer, + }; + + var layout = new EmailLayout(LangCode(isPtBr), copy.Preheader, copy.Footer, LogoUrl, GradientHeader: true); + var html = EmailTemplateRenderer.RenderHtml("WaitlistConfirmation", layout, tokens); + var text = EmailTemplateRenderer.RenderText("WaitlistConfirmation", tokens); + + return new EmailMessage(copy.Subject, html, text); + } + + public EmailMessage ComposeMarketing(string subject, string bodyHtml, string language, string unsubscribeUrl) + { + var isPtBr = LocaleHelper.IsPortuguese(language); + var footer = MarketingFooterHtml(isPtBr, unsubscribeUrl); + var layout = new EmailLayout(LangCode(isPtBr), Preheader: "", footer, LogoUrl, GradientHeader: true); + var readableBody = + "
{bodyHtml}
"; + var html = EmailTemplateRenderer.RenderLayout(layout, readableBody); + + return new EmailMessage(subject, html, null); + } + + private static string MarketingFooterHtml(bool isPtBr, string unsubscribeUrl) + { + const string legalIdentity = + "TL SOFTWARE ENGINEERING LTDA · CNPJ 58.429.979/0001-06 · Av. Nova Independência, 651, Brooklin Paulista, São Paulo/SP · CEP 04570-001"; + + var (reason, unsubscribeLabel) = isPtBr + ? ("Você está recebendo este e-mail porque optou por receber novidades do Orbit.", "Cancelar inscrição") + : ("You're receiving this because you opted in to product updates from Orbit.", "Unsubscribe"); + + var encodedUrl = WebUtility.HtmlEncode(unsubscribeUrl); + return $"{reason}
{legalIdentity}
" + + $"{unsubscribeLabel}"; + } + + public EmailMessage ComposeSupport(string fromName, string fromEmail, string subject, string message) + { + const string supportFooter = "Reply directly to respond to the user."; + + var htmlTokens = new Dictionary + { + ["fromName"] = WebUtility.HtmlEncode(fromName), + ["fromEmail"] = WebUtility.HtmlEncode(fromEmail), + ["subject"] = WebUtility.HtmlEncode(subject), + ["message"] = WebUtility.HtmlEncode(message).Replace("\n", "
"), + }; + + var textTokens = new Dictionary + { + ["fromName"] = fromName, + ["fromEmail"] = fromEmail, + ["subject"] = subject, + ["message"] = message, + }; + + var layout = new EmailLayout("en", Preheader: "", Footer: supportFooter, LogoUrl, GradientHeader: false); + var html = EmailTemplateRenderer.RenderHtml("Support", layout, htmlTokens); + var text = EmailTemplateRenderer.RenderText("Support", textTokens); + + return new EmailMessage($"[Orbit Support] {subject}", html, text); + } + + private static string LangCode(bool isPtBr) => isPtBr ? "pt-BR" : "en"; + + private Dictionary WelcomeTokens(EmailCopy.WelcomeCopy copy) => new() + { + [HeadingToken] = copy.Heading, + [IntroToken] = copy.Intro, + ["featuresTitle"] = copy.FeaturesTitle, + ["feature1"] = copy.Feature1, + ["feature2"] = copy.Feature2, + ["feature3"] = copy.Feature3, + ["cta"] = copy.Cta, + ["ctaUrl"] = frontendBaseUrl, + [FooterToken] = copy.Footer, + }; + + public static bool IsTestAccount(string to) + { + var testAccountsEnv = Environment.GetEnvironmentVariable("TEST_ACCOUNTS"); + if (string.IsNullOrEmpty(testAccountsEnv)) + return false; + + var toNormalized = to.Trim(); + foreach (var pair in testAccountsEnv.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = pair.Split(':', 2); + if (parts.Length >= 1 && string.Equals(parts[0].Trim(), toNormalized, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/src/Orbit.Infrastructure/Email/EmailServiceBase.cs b/src/Orbit.Infrastructure/Email/EmailServiceBase.cs new file mode 100644 index 000000000..643acd075 --- /dev/null +++ b/src/Orbit.Infrastructure/Email/EmailServiceBase.cs @@ -0,0 +1,57 @@ +using Orbit.Domain.Interfaces; + +namespace Orbit.Infrastructure.Email; + +public abstract class EmailServiceBase(string frontendBaseUrl, string supportEmail) : IEmailService +{ + private readonly EmailMessageComposer _composer = new(frontendBaseUrl); + + public Task SendWelcomeEmailAsync(string toEmail, string userName, string language = "en", CancellationToken cancellationToken = default) + { + var message = _composer.ComposeWelcome(userName, language); + return SendTransactionalAsync(toEmail, message.Subject, message.Html, message.Text, cancellationToken); + } + + public Task SendVerificationCodeAsync(string toEmail, string code, string language = "en", CancellationToken cancellationToken = default) + { + var message = _composer.ComposeVerificationCode(toEmail, code, language); + return SendTransactionalAsync(toEmail, message.Subject, message.Html, message.Text, cancellationToken); + } + + public Task SendAccountDeletionCodeAsync(string toEmail, string code, string language = "en", CancellationToken cancellationToken = default) + { + var message = _composer.ComposeAccountDeletionCode(code, language); + return SendTransactionalAsync(toEmail, message.Subject, message.Html, message.Text, cancellationToken); + } + + public Task SendApiKeyCreationCodeAsync(string toEmail, string code, string language = "en", CancellationToken cancellationToken = default) + { + var message = _composer.ComposeApiKeyCreationCode(code, language); + return SendTransactionalAsync(toEmail, message.Subject, message.Html, message.Text, cancellationToken); + } + + public Task SendWaitlistConfirmationAsync(string toEmail, string confirmUrl, string language = "en", CancellationToken cancellationToken = default) + { + var message = _composer.ComposeWaitlistConfirmation(confirmUrl, language); + return SendTransactionalAsync(toEmail, message.Subject, message.Html, message.Text, cancellationToken); + } + + public Task SendMarketingEmailAsync( + string toEmail, string subject, string bodyHtml, string language, string unsubscribeUrl, CancellationToken cancellationToken = default) + { + var message = _composer.ComposeMarketing(subject, bodyHtml, language, unsubscribeUrl); + return SendMarketingAsync(toEmail, message.Subject, message.Html, unsubscribeUrl, cancellationToken); + } + + public Task SendSupportEmailAsync(string fromName, string fromEmail, string subject, string message, CancellationToken cancellationToken = default) + { + var email = _composer.ComposeSupport(fromName, fromEmail, subject, message); + return SendTransactionalAsync(supportEmail, email.Subject, email.Html, email.Text, cancellationToken, fromEmail); + } + + protected abstract Task SendTransactionalAsync( + string to, string subject, string html, string? text, CancellationToken cancellationToken, string? replyTo = null); + + protected abstract Task SendMarketingAsync( + string to, string subject, string html, string unsubscribeUrl, CancellationToken cancellationToken); +} diff --git a/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj b/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj index bea23f84b..ba1656b6c 100644 --- a/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj +++ b/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs index ec80d020e..5591236f8 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs @@ -1071,7 +1071,8 @@ private static AgentCapability[] AccountAndAuthCapabilities() [ "AdminController.SendMarketingBroadcast", "MarketingController.Unsubscribe", - "MarketingController.UnsubscribeOneClick" + "MarketingController.UnsubscribeOneClick", + "EmailController.SesEvents" ]) ]; } diff --git a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs index 9dd0e81c7..3b19b835e 100644 --- a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs +++ b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs @@ -43,6 +43,7 @@ public class DistributedRateLimitService(OrbitDbContext dbContext, TimeProvider ["public-profile"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4), ["admin-broadcast"] = new(TimeSpan.FromHours(1), PermitLimit: 5, SegmentCount: 1), ["marketing-unsubscribe"] = new(TimeSpan.FromMinutes(1), PermitLimit: 20, SegmentCount: 4), + ["ses-events"] = new(TimeSpan.FromMinutes(1), PermitLimit: 600, SegmentCount: 4), ["mcp"] = new(TimeSpan.FromMinutes(1), PermitLimit: 60, SegmentCount: 4), ["mcp-ai"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4) }; diff --git a/src/Orbit.Infrastructure/Services/ResendEmailService.cs b/src/Orbit.Infrastructure/Services/ResendEmailService.cs index 66af5f274..4f6f0bbc4 100644 --- a/src/Orbit.Infrastructure/Services/ResendEmailService.cs +++ b/src/Orbit.Infrastructure/Services/ResendEmailService.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Orbit.Application.Common; -using Orbit.Domain.Interfaces; using Orbit.Infrastructure.Common; using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Email; @@ -15,136 +14,18 @@ public partial class ResendEmailService( IHttpClientFactory httpClientFactory, IOptions options, IOptions frontendSettings, - ILogger logger) : IEmailService + ILogger logger) : EmailServiceBase(frontendSettings.Value.BaseUrl, options.Value.SupportEmail) { private const int MaxMarketingRetries = 4; - private const string HeadingToken = "heading"; - private const string IntroToken = "intro"; - private const string FooterToken = "footer"; - private readonly ResendSettings _settings = options.Value; - private readonly string _frontendBaseUrl = frontendSettings.Value.BaseUrl; - - private string LogoUrl => $"{_frontendBaseUrl}/logo-no-bg.png"; - - public async Task SendWelcomeEmailAsync(string toEmail, string userName, string language = "en", CancellationToken cancellationToken = default) - { - var isPtBr = LocaleHelper.IsPortuguese(language); - var htmlCopy = EmailCopy.Welcome(isPtBr, WebUtility.HtmlEncode(userName)); - var textCopy = EmailCopy.Welcome(isPtBr, userName); - - var layout = new EmailLayout(LangCode(isPtBr), htmlCopy.Preheader, htmlCopy.Footer, LogoUrl, GradientHeader: true); - var html = EmailTemplateRenderer.RenderHtml("Welcome", layout, WelcomeTokens(htmlCopy)); - var text = EmailTemplateRenderer.RenderText("Welcome", WelcomeTokens(textCopy)); - - await SendEmailAsync(toEmail, htmlCopy.Subject, html, text, cancellationToken); - } - - public async Task SendVerificationCodeAsync(string toEmail, string code, string language = "en", CancellationToken cancellationToken = default) - { - var isPtBr = LocaleHelper.IsPortuguese(language); - var copy = EmailCopy.VerificationCode(isPtBr); - var signInUrl = $"{_frontendBaseUrl}/login?email={WebUtility.UrlEncode(toEmail)}&code={code}"; - - var tokens = new Dictionary - { - [HeadingToken] = copy.Heading, - [IntroToken] = copy.Intro, - ["code"] = code, - ["cta"] = copy.Cta, - ["signInUrl"] = signInUrl, - ["warning"] = copy.Warning, - [FooterToken] = copy.Footer, - }; - - var layout = new EmailLayout(LangCode(isPtBr), copy.Preheader, copy.Footer, LogoUrl, GradientHeader: false); - var html = EmailTemplateRenderer.RenderHtml("VerificationCode", layout, tokens); - var text = EmailTemplateRenderer.RenderText("VerificationCode", tokens); - - await SendEmailAsync(toEmail, copy.Subject, html, text, cancellationToken); - } - - public async Task SendAccountDeletionCodeAsync(string toEmail, string code, string language = "en", CancellationToken cancellationToken = default) - { - var isPtBr = LocaleHelper.IsPortuguese(language); - var copy = EmailCopy.AccountDeletion(isPtBr); - var tokens = new Dictionary - { - [HeadingToken] = copy.Heading, - [IntroToken] = copy.Intro, - ["codeLabel"] = copy.CodeLabel, - ["code"] = code, - ["warning"] = copy.Warning, - [FooterToken] = copy.Footer, - }; - - var layout = new EmailLayout(LangCode(isPtBr), copy.Preheader, copy.Footer, LogoUrl, GradientHeader: false); - var html = EmailTemplateRenderer.RenderHtml("AccountDeletion", layout, tokens); - var text = EmailTemplateRenderer.RenderText("AccountDeletion", tokens); - - await SendEmailAsync(toEmail, copy.Subject, html, text, cancellationToken); - } - - public async Task SendApiKeyCreationCodeAsync(string toEmail, string code, string language = "en", CancellationToken cancellationToken = default) + protected override Task SendMarketingAsync( + string to, string subject, string html, string unsubscribeUrl, CancellationToken cancellationToken) { - var isPtBr = LocaleHelper.IsPortuguese(language); - var copy = EmailCopy.ApiKeyCreation(isPtBr); - - var tokens = new Dictionary - { - [HeadingToken] = copy.Heading, - [IntroToken] = copy.Intro, - ["codeLabel"] = copy.CodeLabel, - ["code"] = code, - ["warning"] = copy.Warning, - [FooterToken] = copy.Footer, - }; - - var layout = new EmailLayout(LangCode(isPtBr), copy.Preheader, copy.Footer, LogoUrl, GradientHeader: false); - var html = EmailTemplateRenderer.RenderHtml("ApiKeyCreation", layout, tokens); - var text = EmailTemplateRenderer.RenderText("ApiKeyCreation", tokens); - - await SendEmailAsync(toEmail, copy.Subject, html, text, cancellationToken); - } - - public async Task SendWaitlistConfirmationAsync(string toEmail, string confirmUrl, string language = "en", CancellationToken cancellationToken = default) - { - var isPtBr = LocaleHelper.IsPortuguese(language); - var copy = EmailCopy.WaitlistConfirmation(isPtBr); - - var tokens = new Dictionary - { - [HeadingToken] = copy.Heading, - [IntroToken] = copy.Intro, - ["cta"] = copy.Cta, - ["confirmUrl"] = confirmUrl, - ["warning"] = copy.Warning, - [FooterToken] = copy.Footer, - }; - - var layout = new EmailLayout(LangCode(isPtBr), copy.Preheader, copy.Footer, LogoUrl, GradientHeader: true); - var html = EmailTemplateRenderer.RenderHtml("WaitlistConfirmation", layout, tokens); - var text = EmailTemplateRenderer.RenderText("WaitlistConfirmation", tokens); - - await SendEmailAsync(toEmail, copy.Subject, html, text, cancellationToken); - } - - public async Task SendMarketingEmailAsync( - string toEmail, string subject, string bodyHtml, string language, string unsubscribeUrl, CancellationToken cancellationToken = default) - { - var isPtBr = LocaleHelper.IsPortuguese(language); - var footer = MarketingFooterHtml(isPtBr, unsubscribeUrl); - var layout = new EmailLayout(LangCode(isPtBr), Preheader: "", footer, LogoUrl, GradientHeader: true); - var readableBody = - "
{bodyHtml}
"; - var html = EmailTemplateRenderer.RenderLayout(layout, readableBody); - var payload = new { from = _settings.MarketingFromEmail, - to = new[] { toEmail }, + to = new[] { to }, subject, html, headers = new Dictionary @@ -154,26 +35,12 @@ public async Task SendMarketingEmailAsync( }, }; - await SendMarketingWithBackoffAsync(toEmail, subject, JsonSerializer.Serialize(payload), cancellationToken); - } - - private static string MarketingFooterHtml(bool isPtBr, string unsubscribeUrl) - { - const string legalIdentity = - "TL SOFTWARE ENGINEERING LTDA · CNPJ 58.429.979/0001-06 · Av. Nova Independência, 651, Brooklin Paulista, São Paulo/SP · CEP 04570-001"; - - var (reason, unsubscribeLabel) = isPtBr - ? ("Você está recebendo este e-mail porque optou por receber novidades do Orbit.", "Cancelar inscrição") - : ("You're receiving this because you opted in to product updates from Orbit.", "Unsubscribe"); - - var encodedUrl = WebUtility.HtmlEncode(unsubscribeUrl); - return $"{reason}
{legalIdentity}
" + - $"{unsubscribeLabel}"; + return SendMarketingWithBackoffAsync(to, subject, JsonSerializer.Serialize(payload), cancellationToken); } private async Task SendMarketingWithBackoffAsync(string to, string subject, string serializedPayload, CancellationToken cancellationToken) { - if (IsTestAccount(to)) + if (EmailMessageComposer.IsTestAccount(to)) { if (logger.IsEnabled(LogLevel.Debug)) LogSkippingTestEmail(logger, subject); @@ -237,51 +104,9 @@ private async Task TrySendMarketingAsync( return MarketingSendOutcome.ShouldRetry; } - public async Task SendSupportEmailAsync(string fromName, string fromEmail, string subject, string message, CancellationToken cancellationToken = default) + protected override async Task SendTransactionalAsync(string to, string subject, string html, string? text, CancellationToken cancellationToken, string? replyTo = null) { - const string supportFooter = "Reply directly to respond to the user."; - - var htmlTokens = new Dictionary - { - ["fromName"] = WebUtility.HtmlEncode(fromName), - ["fromEmail"] = WebUtility.HtmlEncode(fromEmail), - ["subject"] = WebUtility.HtmlEncode(subject), - ["message"] = WebUtility.HtmlEncode(message).Replace("\n", "
"), - }; - - var textTokens = new Dictionary - { - ["fromName"] = fromName, - ["fromEmail"] = fromEmail, - ["subject"] = subject, - ["message"] = message, - }; - - var layout = new EmailLayout("en", Preheader: "", Footer: supportFooter, LogoUrl, GradientHeader: false); - var html = EmailTemplateRenderer.RenderHtml("Support", layout, htmlTokens); - var text = EmailTemplateRenderer.RenderText("Support", textTokens); - - await SendEmailAsync(_settings.SupportEmail, $"[Orbit Support] {subject}", html, text, cancellationToken, replyTo: fromEmail); - } - - private static string LangCode(bool isPtBr) => isPtBr ? "pt-BR" : "en"; - - private Dictionary WelcomeTokens(EmailCopy.WelcomeCopy copy) => new() - { - [HeadingToken] = copy.Heading, - [IntroToken] = copy.Intro, - ["featuresTitle"] = copy.FeaturesTitle, - ["feature1"] = copy.Feature1, - ["feature2"] = copy.Feature2, - ["feature3"] = copy.Feature3, - ["cta"] = copy.Cta, - ["ctaUrl"] = _frontendBaseUrl, - [FooterToken] = copy.Footer, - }; - - private async Task SendEmailAsync(string to, string subject, string html, string text, CancellationToken cancellationToken, string? replyTo = null) - { - if (IsTestAccount(to)) + if (EmailMessageComposer.IsTestAccount(to)) { if (logger.IsEnabled(LogLevel.Debug)) LogSkippingTestEmail(logger, subject); @@ -325,23 +150,6 @@ private async Task SendEmailAsync(string to, string subject, string html, string } } - private static bool IsTestAccount(string to) - { - var testAccountsEnv = Environment.GetEnvironmentVariable("TEST_ACCOUNTS"); - if (string.IsNullOrEmpty(testAccountsEnv)) - return false; - - var toNormalized = to.Trim(); - foreach (var pair in testAccountsEnv.Split(',', StringSplitOptions.RemoveEmptyEntries)) - { - var parts = pair.Split(':', 2); - if (parts.Length >= 1 && string.Equals(parts[0].Trim(), toNormalized, StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; - } - [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Skipping email to test account; subject={Subject}")] private static partial void LogSkippingTestEmail(ILogger logger, string subject); @@ -356,5 +164,4 @@ private static bool IsTestAccount(string to) [LoggerMessage(EventId = 5, Level = LogLevel.Warning, Message = "Marketing email rate-limited; retry {Attempt} after {BackoffMs}ms")] private static partial void LogMarketingRetry(ILogger logger, int attempt, double backoffMs); - } diff --git a/src/Orbit.Infrastructure/Services/SesEmailService.cs b/src/Orbit.Infrastructure/Services/SesEmailService.cs new file mode 100644 index 000000000..7cc7fd231 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/SesEmailService.cs @@ -0,0 +1,141 @@ +using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orbit.Application.Common; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Email; + +namespace Orbit.Infrastructure.Services; + +public partial class SesEmailService( + Amazon.SimpleEmailV2.IAmazonSimpleEmailServiceV2 client, + IOptions options, + IOptions frontendSettings, + ILogger logger) : EmailServiceBase(frontendSettings.Value.BaseUrl, options.Value.SupportEmail) +{ + private const int MaxMarketingRetries = 4; + private readonly SesSettings _settings = options.Value; + + protected override Task SendMarketingAsync( + string to, string subject, string html, string unsubscribeUrl, CancellationToken cancellationToken) => + SendMarketingWithBackoffAsync(to, subject, html, unsubscribeUrl, cancellationToken); + + private async Task SendMarketingWithBackoffAsync(string to, string subject, string html, string unsubscribeUrl, CancellationToken cancellationToken) + { + if (EmailMessageComposer.IsTestAccount(to)) + { + if (logger.IsEnabled(LogLevel.Debug)) + LogSkippingTestEmail(logger, subject); + return; + } + + var request = CreateRequest(to, subject, html, null, _settings.MarketingFromEmail, _settings.MarketingConfigurationSet); + request.Content.Simple.Headers = + [ + new Amazon.SimpleEmailV2.Model.MessageHeader { Name = "List-Unsubscribe", Value = $"<{unsubscribeUrl}>" }, + new Amazon.SimpleEmailV2.Model.MessageHeader { Name = "List-Unsubscribe-Post", Value = "List-Unsubscribe=One-Click" }, + ]; + + for (var attempt = 0; attempt <= MaxMarketingRetries; attempt++) + { + try + { + await client.SendEmailAsync(request, cancellationToken); + if (logger.IsEnabled(LogLevel.Debug)) + LogEmailSent(logger, subject); + return; + } + catch (Exception ex) when (IsRetryableThrottle(ex, attempt)) + { + await DelayBeforeRetryAsync(attempt, cancellationToken); + } + catch (Exception ex) + { + if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested) + throw; + LogEmailSendException(logger, ex); + return; + } + } + } + + private static bool IsRetryableThrottle(Exception exception, int attempt) => + attempt < MaxMarketingRetries && + (exception is Amazon.SimpleEmailV2.Model.TooManyRequestsException || + exception is Amazon.Runtime.AmazonServiceException { StatusCode: HttpStatusCode.TooManyRequests }); + + private async Task DelayBeforeRetryAsync(int attempt, CancellationToken cancellationToken) + { + var backoff = TimeSpan.FromMilliseconds(_settings.MarketingRetryBaseDelayMs * Math.Pow(2, attempt)); + if (logger.IsEnabled(LogLevel.Warning)) + LogMarketingRetry(logger, attempt + 1, backoff.TotalMilliseconds); + await Task.Delay(backoff, cancellationToken); + } + + protected override async Task SendTransactionalAsync(string to, string subject, string html, string? text, CancellationToken cancellationToken, string? replyTo = null) + { + if (EmailMessageComposer.IsTestAccount(to)) + { + if (logger.IsEnabled(LogLevel.Debug)) + LogSkippingTestEmail(logger, subject); + return; + } + + var request = CreateRequest(to, subject, html, text, _settings.FromEmail, _settings.TransactionalConfigurationSet); + if (replyTo is not null) + request.ReplyToAddresses = [replyTo]; + + try + { + await client.SendEmailAsync(request, cancellationToken); + if (logger.IsEnabled(LogLevel.Debug)) + LogEmailSent(logger, subject); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + LogEmailSendException(logger, ex); + } + } + + private static Amazon.SimpleEmailV2.Model.SendEmailRequest CreateRequest( + string to, string subject, string html, string? text, string from, string configurationSet) + { + var body = new Amazon.SimpleEmailV2.Model.Body + { + Html = new Amazon.SimpleEmailV2.Model.Content { Data = html, Charset = "UTF-8" }, + }; + if (text is not null) + body.Text = new Amazon.SimpleEmailV2.Model.Content { Data = text, Charset = "UTF-8" }; + + return new Amazon.SimpleEmailV2.Model.SendEmailRequest + { + FromEmailAddress = from, + Destination = new Amazon.SimpleEmailV2.Model.Destination { ToAddresses = [to] }, + ConfigurationSetName = configurationSet, + Content = new Amazon.SimpleEmailV2.Model.EmailContent + { + Simple = new Amazon.SimpleEmailV2.Model.Message + { + Subject = new Amazon.SimpleEmailV2.Model.Content { Data = subject, Charset = "UTF-8" }, + Body = body, + }, + }, + }; + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Skipping email to test account; subject={Subject}")] + private static partial void LogSkippingTestEmail(ILogger logger, string subject); + + [LoggerMessage(EventId = 2, Level = LogLevel.Debug, Message = "Email sent; subject={Subject}")] + private static partial void LogEmailSent(ILogger logger, string subject); + + [LoggerMessage(EventId = 4, Level = LogLevel.Error, Message = "Email send exception")] + private static partial void LogEmailSendException(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 5, Level = LogLevel.Warning, Message = "Marketing email rate-limited; retry {Attempt} after {BackoffMs}ms")] + private static partial void LogMarketingRetry(ILogger logger, int attempt, double backoffMs); +} diff --git a/src/Orbit.Infrastructure/Services/SesEventProcessor.cs b/src/Orbit.Infrastructure/Services/SesEventProcessor.cs new file mode 100644 index 000000000..4df795b01 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/SesEventProcessor.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Infrastructure.Services; + +public sealed class SesEventProcessor( + SnsMessageVerifier verifier, + IHttpClientFactory httpClientFactory, + IGenericRepository contacts, + IUnitOfWork unitOfWork, + ILogger logger) : ISesEventProcessor +{ + private const string BounceEvent = "Bounce"; + + public async Task ProcessAsync(string payload, CancellationToken cancellationToken) + { + JsonDocument document; + try { document = JsonDocument.Parse(payload); } + catch (JsonException) { return false; } + using (document) + { + var envelope = document.RootElement; + if (!await verifier.VerifyAsync(envelope, cancellationToken)) + return false; + return envelope.GetProperty("Type").GetString() == "SubscriptionConfirmation" + ? await ConfirmSubscriptionAsync(envelope, cancellationToken) + : await ProcessNotificationAsync(envelope, cancellationToken); + } + } + + private async Task ConfirmSubscriptionAsync(JsonElement envelope, CancellationToken cancellationToken) + { + var url = envelope.GetProperty("SubscribeURL").GetString(); + if (!verifier.IsSubscriptionUrl(url, envelope.GetProperty("TopicArn").GetString()!, envelope.GetProperty("Token").GetString()!)) + return false; + using var response = await httpClientFactory.CreateClient("SnsConfirmation") + .GetAsync(url, cancellationToken); + return response.IsSuccessStatusCode; + } + + private async Task ProcessNotificationAsync(JsonElement envelope, CancellationToken cancellationToken) + { + if (!envelope.TryGetProperty("Message", out var message) || message.ValueKind != JsonValueKind.String) + return false; + JsonDocument eventDocument; + try { eventDocument = JsonDocument.Parse(message.GetString()!); } + catch (JsonException) { return false; } + using (eventDocument) + { + var emails = GetSuppressedEmails(eventDocument.RootElement); + if (emails is null) + return false; + if (emails.Count == 0) + return true; + await SuppressAsync(emails, cancellationToken); + return true; + } + } + + private Task SuppressAsync(HashSet emails, CancellationToken cancellationToken) => + unitOfWork.ExecuteInTransactionAsync(async ct => + { + foreach (var email in emails) + { + await unitOfWork.AcquireAdvisoryLockAsync($"marketing-contact:{email}", ct); + var contact = await contacts.FindOneTrackedAsync( + candidate => candidate.Email == email, cancellationToken: ct); + if (contact is null) + { + contact = MarketingContact.RecordUserOptOut(email); + await contacts.AddAsync(contact, ct); + } + else + contact.Unsubscribe(); + contact.Suppress(); + } + await unitOfWork.SaveChangesAsync(ct); + }, cancellationToken); + + private HashSet? GetSuppressedEmails(JsonElement eventData) + { + if (!eventData.TryGetProperty("eventType", out var eventType) || eventType.ValueKind != JsonValueKind.String) + return null; + var eventName = eventType.GetString(); + if (eventName is not (BounceEvent or "Complaint")) + return []; + var sectionName = eventName == BounceEvent ? "bounce" : "complaint"; + var recipientName = eventName == BounceEvent ? "bouncedRecipients" : "complainedRecipients"; + if (!eventData.TryGetProperty(sectionName, out var section) || section.ValueKind != JsonValueKind.Object || + !section.TryGetProperty(recipientName, out var recipients) || recipients.ValueKind != JsonValueKind.Array) + return null; + if (eventName == BounceEvent && + (!section.TryGetProperty("bounceType", out var bounceType) || bounceType.GetString() != "Permanent")) + { + logger.LogWarning("Transient SES bounce received"); + return []; + } + + return ParseRecipientEmails(recipients); + } + + private static HashSet? ParseRecipientEmails(JsonElement recipients) + { + var emails = new HashSet(StringComparer.Ordinal); + foreach (var recipient in recipients.EnumerateArray()) + { + if (!recipient.TryGetProperty("emailAddress", out var email) || email.ValueKind != JsonValueKind.String) + return null; + var normalized = email.GetString()?.Trim().ToLowerInvariant(); + if (normalized is null || normalized.Length > 254 || + !System.Net.Mail.MailAddress.TryCreate(normalized, out var address) || address.Address != normalized) + return null; + emails.Add(normalized); + } + return emails; + } + +} diff --git a/src/Orbit.Infrastructure/Services/SnsMessageVerifier.cs b/src/Orbit.Infrastructure/Services/SnsMessageVerifier.cs new file mode 100644 index 000000000..0e2958ecb --- /dev/null +++ b/src/Orbit.Infrastructure/Services/SnsMessageVerifier.cs @@ -0,0 +1,170 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Orbit.Infrastructure.Configuration; + +namespace Orbit.Infrastructure.Services; + +public sealed partial class SnsMessageVerifier( + IHttpClientFactory httpClientFactory, + IOptions options, + IMemoryCache certificateCache) +{ + private const string TopicArnField = "TopicArn"; + private static readonly TimeSpan CertificateCacheDuration = TimeSpan.FromHours(1); + private static readonly SemaphoreSlim[] CertificateFetchLocks = + Enumerable.Range(0, 32).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); + private readonly SesSettings _settings = options.Value; + private readonly X509Certificate2? _trustedRoot; + + internal SnsMessageVerifier(IHttpClientFactory httpClientFactory, IOptions options, + IMemoryCache certificateCache, X509Certificate2 trustedRoot) + : this(httpClientFactory, options, certificateCache) => _trustedRoot = trustedRoot; + + public async Task VerifyAsync(JsonElement envelope, CancellationToken cancellationToken) + { + if (!TryReadEnvelope(envelope, out var type, out var version, out var signatureText, out var certificateUrl)) + return false; + + byte[] signature; + try { signature = Convert.FromBase64String(signatureText); } + catch (FormatException) { return false; } + + if (!TryBuildCanonicalMessage(envelope, type, out var canonical)) + return false; + + try + { + var pem = await GetCertificatePemAsync(certificateUrl, cancellationToken); + if (pem is null) + return false; + using var certificate = X509Certificate2.CreateFromPem(pem); + if (!IsTrustedCertificate(certificate)) + return false; + using var rsa = certificate.GetRSAPublicKey(); + return rsa is not null && rsa.VerifyData( + Encoding.UTF8.GetBytes(canonical), signature, + version == "2" ? HashAlgorithmName.SHA256 : HashAlgorithmName.SHA1, + RSASignaturePadding.Pkcs1); + } + catch (Exception ex) when (ex is HttpRequestException or CryptographicException or FormatException) + { + return false; + } + } + + private bool TryReadEnvelope(JsonElement envelope, out string type, out string version, + out string signature, out string certificateUrl) + { + type = version = signature = certificateUrl = string.Empty; + return Read(envelope, "Type", out type) && type is "Notification" or "SubscriptionConfirmation" && + Read(envelope, TopicArnField, out var topicArn) && !string.IsNullOrWhiteSpace(_settings.TopicArn) && + string.Equals(topicArn, _settings.TopicArn, StringComparison.Ordinal) && + Read(envelope, "SignatureVersion", out version) && version is "1" or "2" && + Read(envelope, "Signature", out signature) && + Read(envelope, "SigningCertURL", out certificateUrl) && IsSnsUrl(certificateUrl, certificate: true); + } + + private static bool TryBuildCanonicalMessage(JsonElement envelope, string type, out string canonical) + { + var fields = type == "Notification" + ? new[] { "Message", "MessageId", "Subject", "Timestamp", TopicArnField, "Type" } + : new[] { "Message", "MessageId", "SubscribeURL", "Timestamp", "Token", TopicArnField, "Type" }; + var builder = new StringBuilder(); + foreach (var field in fields) + { + if (!Read(envelope, field, out var value)) + { + if (field == "Subject" && type == "Notification") + continue; + canonical = string.Empty; + return false; + } + builder.Append(field).Append('\n').Append(value).Append('\n'); + } + canonical = builder.ToString(); + return true; + } + + private async Task GetCertificatePemAsync(string url, CancellationToken cancellationToken) + { + if (certificateCache.TryGetValue(url, out string? cached)) + return cached; + + var fetchLock = CertificateFetchLocks[(int)((uint)url.GetHashCode() % CertificateFetchLocks.Length)]; + await fetchLock.WaitAsync(cancellationToken); + try + { + if (certificateCache.TryGetValue(url, out cached)) + return cached; + using var response = await httpClientFactory.CreateClient("SnsCertificate").GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + return null; + var pem = await response.Content.ReadAsStringAsync(cancellationToken); + certificateCache.Set(url, pem, CertificateCacheDuration); + return pem; + } + finally + { + fetchLock.Release(); + } + } + + private bool IsTrustedCertificate(X509Certificate2 certificate) + { + using var chain = new X509Chain(); + if (_trustedRoot is not null) + { + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(_trustedRoot); + } + return chain.Build(certificate); + } + + public bool IsSnsUrl(string? value, bool certificate = false) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var url) || + url.Scheme != Uri.UriSchemeHttps || url.Port != 443 || !string.IsNullOrEmpty(url.UserInfo) || + !string.Equals(url.Host, $"sns.{_settings.Region}.amazonaws.com", StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrEmpty(url.Fragment)) + return false; + + if (certificate) + return url.Query.Length == 0 && + CertificatePathRegex().IsMatch(url.AbsolutePath); + return url.AbsolutePath == "/"; + } + + public bool IsSubscriptionUrl(string? value, string topicArn, string token) + { + if (!IsSnsUrl(value) || !Uri.TryCreate(value, UriKind.Absolute, out var url)) + return false; + var parameters = new Dictionary(StringComparer.Ordinal); + foreach (var item in url.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = item.Split('=', 2); + if (parts.Length != 2 || !parameters.TryAdd(Uri.UnescapeDataString(parts[0]), Uri.UnescapeDataString(parts[1]))) + return false; + } + return parameters.Count == 3 && parameters.TryGetValue("Action", out var action) && action == "ConfirmSubscription" && + parameters.TryGetValue(TopicArnField, out var actualTopic) && actualTopic == topicArn && + parameters.TryGetValue("Token", out var actualToken) && actualToken == token; + } + + private static bool Read(JsonElement element, string name, out string value) + { + value = ""; + if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(name, out var property) || + property.ValueKind != JsonValueKind.String) + return false; + value = property.GetString() ?? ""; + return !string.IsNullOrEmpty(value); + } + + [GeneratedRegex(@"^/SimpleNotificationService-[0-9a-fA-F]+\.pem$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 100)] + private static partial Regex CertificatePathRegex(); +} diff --git a/tests/Orbit.Application.Tests/Commands/Email/ProcessSesEventCommandTests.cs b/tests/Orbit.Application.Tests/Commands/Email/ProcessSesEventCommandTests.cs new file mode 100644 index 000000000..09a8d7547 --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Email/ProcessSesEventCommandTests.cs @@ -0,0 +1,30 @@ +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Email.Commands; +using Orbit.Application.Email.Validators; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Commands.Email; + +public sealed class ProcessSesEventCommandTests +{ + [Fact] + public async Task HandlerPassesRawPayloadToVerifier() + { + var processor = Substitute.For(); + processor.ProcessAsync("signed payload", Arg.Any()).Returns(true); + var result = await new ProcessSesEventCommandHandler(processor) + .Handle(new ProcessSesEventCommand("signed payload"), CancellationToken.None); + result.Should().BeTrue(); + await processor.Received(1).ProcessAsync("signed payload", Arg.Any()); + } + + [Fact] + public void ValidatorRejectsEmptyOrOversizedPayload() + { + var validator = new ProcessSesEventCommandValidator(); + validator.Validate(new ProcessSesEventCommand("")).IsValid.Should().BeFalse(); + validator.Validate(new ProcessSesEventCommand(new string('x', 262145))).IsValid.Should().BeFalse(); + validator.Validate(new ProcessSesEventCommand("{}")).IsValid.Should().BeTrue(); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/EmailControllerRateLimitTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/EmailControllerRateLimitTests.cs new file mode 100644 index 000000000..951dfec26 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Controllers/EmailControllerRateLimitTests.cs @@ -0,0 +1,17 @@ +using System.Reflection; +using FluentAssertions; +using Orbit.Api.Controllers; +using Orbit.Api.RateLimiting; + +namespace Orbit.Infrastructure.Tests.Controllers; + +public sealed class EmailControllerRateLimitTests +{ + [Fact] + public void SesEvents_UsesDistributedRateLimit() + { + typeof(EmailController).GetMethod(nameof(EmailController.SesEvents))! + .GetCustomAttribute() + .Should().NotBeNull(); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Email/EmailMessageComposerTests.cs b/tests/Orbit.Infrastructure.Tests/Email/EmailMessageComposerTests.cs new file mode 100644 index 000000000..ea1194d5b --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Email/EmailMessageComposerTests.cs @@ -0,0 +1,92 @@ +using FluentAssertions; +using Orbit.Infrastructure.Email; + +namespace Orbit.Infrastructure.Tests.Email; + +public sealed class EmailMessageComposerTests +{ + private readonly EmailMessageComposer _composer = new("https://app.useorbit.org"); + + [Theory] + [InlineData("en", "Your Orbit verification code", "It expires in 5 minutes.")] + [InlineData("pt-BR", "Seu código de verificação do Orbit", "Ele expira em 5 minutos.")] + public void VerificationCodeRendersLocalizedSubjectAndBothParts(string language, string subject, string preheader) + { + var message = _composer.ComposeVerificationCode("a+b@example.com", "123456", language); + + message.Subject.Should().Be(subject); + message.Html.Should().Contain(preheader).And.Contain("logo-no-bg.png") + .And.Contain("email=a%2Bb%40example.com&code=123456").And.NotContain("{{"); + message.Text.Should().Contain("123456"); + } + + [Theory] + [InlineData("en", "Welcome to Orbit")] + [InlineData("pt-BR", "Boas-vindas")] + public void WelcomeEncodesHtmlAndKeepsTextReadable(string language, string subjectFragment) + { + var message = _composer.ComposeWelcome("Ana & Co", language); + + message.Subject.Should().Contain(subjectFragment); + message.Html.Should().Contain("Ana & Co").And.Contain("#22094F"); + message.Text.Should().Contain("Ana & Co"); + } + + [Fact] + public void WelcomeDoesNotSubstituteTokensInsideUserName() + { + _composer.ComposeWelcome("{{footer}}", "en").Html.Should().Contain("{{footer}}"); + } + + [Theory] + [InlineData("en", "Confirm your Orbit account deletion", "Confirm your Orbit API key creation")] + [InlineData("pt-BR", "Confirme a exclusão", "chave de API do Orbit")] + public void SecurityCodesKeepTheirDistinctCopy(string language, string deletionSubject, string apiKeySubject) + { + var deletion = _composer.ComposeAccountDeletionCode("654321", language); + var apiKey = _composer.ComposeApiKeyCreationCode("654321", language); + + deletion.Subject.Should().Contain(deletionSubject); + apiKey.Subject.Should().Contain(apiKeySubject); + deletion.Html.Should().Contain("654321"); + apiKey.Text.Should().Contain("654321"); + apiKey.Html.Should().NotContain("account deletion"); + } + + [Theory] + [InlineData("en", "Confirm your spot on the Orbit iOS waitlist")] + [InlineData("pt-BR", "Confirme sua vaga na lista de espera")] + public void WaitlistIncludesConfirmationUrlAndText(string language, string subject) + { + const string url = "https://api.useorbit.org/api/waitlist/confirm?token=abc.def"; + var message = _composer.ComposeWaitlistConfirmation(url, language); + + message.Subject.Should().Contain(subject); + message.Html.Should().Contain(url).And.NotContain("{{"); + message.Text.Should().Contain(url); + } + + [Fact] + public void SupportEncodesUserContentAndKeepsReplyText() + { + var message = _composer.ComposeSupport("John", "john@test.com", "Bug", "line1\nline2"); + + message.Subject.Should().Be("[Orbit Support] Bug"); + message.Html.Should().Contain("<b>John</b>").And.Contain("line1
line2") + .And.Contain("Reply directly to respond to the user."); + message.Text.Should().Contain("John").And.Contain("line1\nline2"); + } + + [Theory] + [InlineData("en", "Unsubscribe")] + [InlineData("pt-BR", "Cancelar inscrição")] + public void MarketingRendersLocalizedFooterAndEscapesUnsubscribeUrl(string language, string label) + { + var message = _composer.ComposeMarketing("News", "

Update

", language, "https://useorbit.org/u?a=1&b=2"); + + message.Subject.Should().Be("News"); + message.Html.Should().Contain("

Update

").And.Contain(label) + .And.Contain("https://useorbit.org/u?a=1&b=2"); + message.Text.Should().BeNull(); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Extensions/EmailProviderConfigurationTests.cs b/tests/Orbit.Infrastructure.Tests/Extensions/EmailProviderConfigurationTests.cs new file mode 100644 index 000000000..fac364139 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Extensions/EmailProviderConfigurationTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OrbitServiceCollectionExtensions = Orbit.Api.Extensions.ServiceCollectionExtensions; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Extensions; + +public sealed class EmailProviderConfigurationTests +{ + [Theory] + [InlineData(null, typeof(ResendEmailService))] + [InlineData("Resend", typeof(ResendEmailService))] + [InlineData("Ses", typeof(SesEmailService))] + public void ProviderSelectsEmailImplementation(string? provider, Type expected) + { + var builder = Builder(provider); + OrbitServiceCollectionExtensions.AddEmailAndSupabaseClients(builder, TimeSpan.FromSeconds(5)); + builder.Services.Single(service => service.ServiceType == typeof(IEmailService)) + .ImplementationType.Should().Be(expected); + } + + [Fact] + public void SesRequiresCredentials() + { + var builder = Builder("Ses", credentials: false); + var action = () => OrbitServiceCollectionExtensions.AddEmailAndSupabaseClients(builder, TimeSpan.FromSeconds(5)); + action.Should().Throw().WithMessage("SES credentials are required*"); + } + + private static WebApplicationBuilder Builder(string? provider, bool credentials = true) + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration.Sources.Clear(); + var values = new Dictionary + { + ["Supabase:Url"] = "https://example.supabase.co", + ["Supabase:AnonKey"] = "anon", + ["Supabase:SecretKey"] = "secret", + ["Email:Provider"] = provider, + }; + if (credentials) + { + values["Ses:AccessKeyId"] = "key"; + values["Ses:SecretAccessKey"] = "secret"; + } + builder.Configuration.AddInMemoryCollection(values); + return builder; + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServiceTests.cs index 0a91b4f5f..40a5f2fbb 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServiceTests.cs @@ -51,26 +51,6 @@ public async Task SendVerificationCodeAsync_SuccessfulResponse_DoesNotThrow() _handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/emails"); } - [Fact] - public async Task SendVerificationCodeAsync_PortugueseLanguage_SendsPortugueseSubject() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendVerificationCodeAsync("user@test.com", "123456", "pt-BR"); - - _handler.LastRequestBody.Should().Contain("Seu c\\u00F3digo de verifica\\u00E7\\u00E3o do Orbit"); - } - - [Fact] - public async Task SendVerificationCodeAsync_EnglishLanguage_SendsEnglishSubject() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendVerificationCodeAsync("user@test.com", "123456", "en"); - - _handler.LastRequestBody.Should().Contain("Your Orbit verification code"); - } - [Fact] public async Task SendVerificationCodeAsync_ApiFailure_DoesNotThrow() { @@ -166,59 +146,6 @@ public async Task SendWelcomeEmailAsync_SuccessfulResponse_SendsEmail() _handler.LastRequestBody.Should().Contain("Welcome aboard"); } - [Fact] - public async Task SendWelcomeEmailAsync_Portuguese_SendsPortugueseContent() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendWelcomeEmailAsync("user@test.com", "Alex", "pt-BR"); - - _handler.LastRequestBody.Should().Contain("Boas-vindas"); - } - - [Fact] - public async Task SendAccountDeletionCodeAsync_English_SendsEnglish() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendAccountDeletionCodeAsync("user@test.com", "654321", "en"); - - _handler.LastRequestBody.Should().Contain("Confirm your Orbit account deletion"); - } - - [Fact] - public async Task SendAccountDeletionCodeAsync_Portuguese_SendsPortuguese() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendAccountDeletionCodeAsync("user@test.com", "654321", "pt"); - - _handler.LastRequestBody.Should().Contain("Confirme a exclus"); - } - - [Fact] - public async Task SendApiKeyCreationCodeAsync_English_UsesApiKeyWording() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendApiKeyCreationCodeAsync("user@test.com", "654321", "en"); - - _handler.LastRequestBody.Should().Contain("Confirm your Orbit API key creation"); - _handler.LastRequestBody.Should().Contain("No API key will be created"); - _handler.LastRequestBody.Should().NotContain("account deletion"); - } - - [Fact] - public async Task SendApiKeyCreationCodeAsync_Portuguese_UsesApiKeyWording() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendApiKeyCreationCodeAsync("user@test.com", "654321", "pt-BR"); - - _handler.LastRequestBody.Should().Contain("chave de API do Orbit"); - _handler.LastRequestBody.Should().NotContain("exclus"); - } - [Fact] public async Task SendSupportEmailAsync_SendsToSupportEmail() { @@ -230,116 +157,6 @@ public async Task SendSupportEmailAsync_SendsToSupportEmail() _handler.LastRequestBody.Should().Contain("reply_to"); } - [Theory] - [InlineData("en")] - [InlineData("pt-BR")] - public async Task SendVerificationCodeAsync_IncludesPlainTextPart(string language) - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendVerificationCodeAsync("user@test.com", "123456", language); - - _handler.LastRequestBody.Should().Contain("\"text\":"); - } - - [Fact] - public async Task SendVerificationCodeAsync_HtmlHasNoUnreplacedTokens() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendVerificationCodeAsync("user@test.com", "123456"); - - _handler.LastRequestBody.Should().NotContain("{{"); - } - - [Fact] - public async Task SendVerificationCodeAsync_IncludesLogoAndPreheader() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendVerificationCodeAsync("user@test.com", "123456"); - - _handler.LastRequestBody.Should().Contain("logo-no-bg.png"); - _handler.LastRequestBody.Should().Contain("It expires in 5 minutes."); - } - - [Fact] - public async Task SendWelcomeEmailAsync_IncludesTextPartWithRawUserName() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendWelcomeEmailAsync("user@test.com", "Ana & Co"); - - _handler.LastRequestBody.Should().Contain("\"text\":"); - _handler.LastRequestBody.Should().Contain("Welcome aboard, Ana \\u0026 Co!"); - _handler.LastRequestBody.Should().Contain("Welcome aboard, Ana \\u0026amp; Co!"); - } - - [Fact] - public async Task SendWelcomeEmailAsync_UsesGradientHeaderWithSolidFallback() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendWelcomeEmailAsync("user@test.com", "Alex"); - - _handler.LastRequestBody.Should().Contain("#22094F"); - } - - [Fact] - public async Task SendAccountDeletionCodeAsync_IncludesPlainTextPart() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendAccountDeletionCodeAsync("user@test.com", "654321", "en"); - - _handler.LastRequestBody.Should().Contain("\"text\":"); - _handler.LastRequestBody.Should().Contain("654321"); - } - - [Fact] - public async Task SendApiKeyCreationCodeAsync_IncludesPlainTextPart() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendApiKeyCreationCodeAsync("user@test.com", "654321", "en"); - - _handler.LastRequestBody.Should().Contain("\"text\":"); - _handler.LastRequestBody.Should().Contain("654321"); - } - - [Fact] - public async Task SendSupportEmailAsync_AdoptsSharedLayoutWithTextPart() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendSupportEmailAsync("John", "john@test.com", "Bug Report", "Found a bug"); - - _handler.LastRequestBody.Should().Contain("logo-no-bg.png"); - _handler.LastRequestBody.Should().Contain("\"text\":"); - _handler.LastRequestBody.Should().Contain("Reply directly to respond to the user."); - } - - [Fact] - public async Task SendSupportEmailAsync_EncodesHtmlInUserContent() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendSupportEmailAsync("John", "john@test.com", "Bug", "line1\nline2"); - - _handler.LastRequestBody.Should().Contain("\\u0026lt;b\\u0026gt;John\\u0026lt;/b\\u0026gt;"); - _handler.LastRequestBody.Should().Contain("line1\\u003Cbr\\u003Eline2"); - } - - [Fact] - public async Task SendWelcomeEmailAsync_UserNameWithTokenSyntax_IsNotSubstituted() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendWelcomeEmailAsync("user@test.com", "{{footer}}"); - - _handler.LastRequestBody.Should().Contain("Welcome aboard, {{footer}}!"); - } - [Fact] public async Task SendVerificationCodeAsync_TestAccount_SkipsSend() { @@ -404,19 +221,6 @@ await _sut.SendWaitlistConfirmationAsync( _handler.LastRequestBody.Should().NotContain("{{"); } - [Fact] - public async Task SendWaitlistConfirmationAsync_Portuguese_SendsPortugueseSubject() - { - _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); - - await _sut.SendWaitlistConfirmationAsync( - "user@test.com", - "https://api.useorbit.org/api/waitlist/confirm?token=abc.def", - "pt-BR"); - - _handler.LastRequestBody.Should().Contain("Confirme sua vaga na lista de espera"); - } - /// /// Fake HttpMessageHandler for intercepting HTTP calls. /// diff --git a/tests/Orbit.Infrastructure.Tests/Services/SesEmailServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/SesEmailServiceTests.cs new file mode 100644 index 000000000..3f0a38f87 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/SesEmailServiceTests.cs @@ -0,0 +1,98 @@ +using Amazon.SimpleEmailV2; +using Amazon.SimpleEmailV2.Model; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public sealed class SesEmailServiceTests +{ + private readonly IAmazonSimpleEmailServiceV2 _client = Substitute.For(); + private readonly List _requests = []; + private readonly SesEmailService _service; + + public SesEmailServiceTests() + { + _client.SendEmailAsync(Arg.Do(_requests.Add), Arg.Any()) + .Returns(Task.FromResult(new SendEmailResponse())); + _service = new SesEmailService(_client, + Options.Create(new SesSettings { SupportEmail = "support@useorbit.org", MarketingRetryBaseDelayMs = 1 }), + Options.Create(new FrontendSettings { BaseUrl = "https://app.useorbit.org" }), + NullLogger.Instance); + } + + [Theory] + [InlineData("welcome", "Welcome to Orbit")] + [InlineData("verification", "verification")] + [InlineData("deletion", "deletion")] + [InlineData("api-key", "API key")] + [InlineData("waitlist", "waitlist")] + public async Task TransactionalEmailsUseExpectedSesRequest(string kind, string subjectFragment) + { + const string to = "recipient@example.com"; + switch (kind) + { + case "welcome": await _service.SendWelcomeEmailAsync(to, "Taylor"); break; + case "verification": await _service.SendVerificationCodeAsync(to, "123456"); break; + case "deletion": await _service.SendAccountDeletionCodeAsync(to, "123456"); break; + case "api-key": await _service.SendApiKeyCreationCodeAsync(to, "123456"); break; + case "waitlist": await _service.SendWaitlistConfirmationAsync(to, "https://useorbit.org/confirm"); break; + } + + _requests.Should().ContainSingle(); + var request = _requests[0]; + request.FromEmailAddress.Should().Be("Orbit "); + request.Destination.ToAddresses.Should().ContainSingle().Which.Should().Be(to); + request.ConfigurationSetName.Should().Be("orbit-transactional"); + request.Content.Simple.Subject.Data.Should().ContainEquivalentOf(subjectFragment); + request.Content.Simple.Body.Html.Data.Should().NotBeNullOrWhiteSpace(); + request.Content.Simple.Body.Text.Data.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task SupportEmailUsesReplyToAndSupportDestination() + { + await _service.SendSupportEmailAsync("Taylor", "taylor@example.com", "Help", "Question"); + var request = _requests.Should().ContainSingle().Which; + request.FromEmailAddress.Should().Be("Orbit "); + request.Destination.ToAddresses.Should().ContainSingle().Which.Should().Be("support@useorbit.org"); + request.ConfigurationSetName.Should().Be("orbit-transactional"); + request.Content.Simple.Subject.Data.Should().Be("[Orbit Support] Help"); + request.ReplyToAddresses.Should().ContainSingle().Which.Should().Be("taylor@example.com"); + } + + [Fact] + public async Task MarketingEmailUsesMarketingIdentityAndUnsubscribeHeaders() + { + const string url = "https://api.useorbit.org/api/marketing/unsubscribe?token=abc"; + await _service.SendMarketingEmailAsync("recipient@example.com", "News", "

Update

", "en", url); + var request = _requests.Should().ContainSingle().Which; + request.FromEmailAddress.Should().Be("Orbit "); + request.Destination.ToAddresses.Should().ContainSingle().Which.Should().Be("recipient@example.com"); + request.ConfigurationSetName.Should().Be("orbit-marketing"); + request.Content.Simple.Subject.Data.Should().Be("News"); + request.Content.Simple.Body.Html.Data.Should().Contain(url); + request.Content.Simple.Headers.Should().ContainEquivalentOf(new MessageHeader + { + Name = "List-Unsubscribe", Value = $"<{url}>" + }); + request.Content.Simple.Headers.Should().ContainEquivalentOf(new MessageHeader + { + Name = "List-Unsubscribe-Post", Value = "List-Unsubscribe=One-Click" + }); + } + + [Fact] + public async Task MarketingThrottlingRetriesThenStops() + { + _client.SendEmailAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new TooManyRequestsException("throttled"))); + await _service.SendMarketingEmailAsync("recipient@example.com", "News", "

Update

", "en", "https://api.useorbit.org/u"); + await _client.Received(5).SendEmailAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/SesEventProcessorTests.cs b/tests/Orbit.Infrastructure.Tests/Services/SesEventProcessorTests.cs new file mode 100644 index 000000000..3bb4cdbd9 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/SesEventProcessorTests.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public sealed class SesEventProcessorTests : IDisposable +{ + private const string Topic = "arn:aws:sns:us-east-2:713285551626:orbit-ses-events"; + private const string CertificateUrl = "https://sns.us-east-2.amazonaws.com/SimpleNotificationService-123abc.pem"; + private const string SubscribeUrl = "https://sns.us-east-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn%3Aaws%3Asns%3Aus-east-2%3A713285551626%3Aorbit-ses-events&Token=abc"; + private readonly RSA _key = RSA.Create(2048); + private readonly X509Certificate2 _certificate; + private readonly IGenericRepository _contacts = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly List _added = []; + private readonly List _fetchedUrls = []; + private readonly SesEventProcessor _processor; + + public SesEventProcessorTests() + { + var request = new CertificateRequest("CN=Test SNS", _key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.DigitalSignature, true)); + _certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddHours(1)); + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(call => new HttpClient(new StubHandler( + _certificate.ExportCertificatePem(), _fetchedUrls))); + _contacts.AddAsync(Arg.Do(_added.Add), Arg.Any()) + .Returns(Task.CompletedTask); + _unitOfWork.ExecuteInTransactionAsync(Arg.Any>(), Arg.Any()) + .Returns(call => ((Func)call[0]!)(CancellationToken.None)); + var settings = Options.Create(new SesSettings { TopicArn = Topic }); + var verifier = new SnsMessageVerifier(factory, settings, new MemoryCache(new MemoryCacheOptions()), _certificate); + _processor = new SesEventProcessor(verifier, factory, _contacts, _unitOfWork, + NullLogger.Instance); + } + + [Theory] + [InlineData("Bounce", "Permanent", true)] + [InlineData("Complaint", "", true)] + [InlineData("Bounce", "Transient", false)] + public async Task SignedEventsSuppressOnlyPermanentBounceAndComplaint(string eventName, string bounceType, bool suppressed) + { + var content = eventName == "Bounce" + ? JsonSerializer.Serialize(new { eventType = eventName, bounce = new { bounceType, bouncedRecipients = new[] { new { emailAddress = "Person@Example.com" } } } }) + : JsonSerializer.Serialize(new { eventType = eventName, complaint = new { complainedRecipients = new[] { new { emailAddress = "Person@Example.com" } } } }); + (await _processor.ProcessAsync(Signed("Notification", content), CancellationToken.None)).Should().BeTrue(); + if (suppressed) + { + var contact = _added.Should().ContainSingle().Which; + contact.Email.Should().Be("person@example.com"); + contact.SuppressedAtUtc.Should().NotBeNull(); + contact.UnsubscribedAtUtc.Should().NotBeNull(); + } + else + _added.Should().BeEmpty(); + } + + [Fact] + public async Task SignedSubscriptionConfirmationFetchesSubscribeUrl() + { + (await _processor.ProcessAsync(Signed("SubscriptionConfirmation", "Confirm"), CancellationToken.None)).Should().BeTrue(); + _fetchedUrls.Should().Contain(SubscribeUrl); + } + + [Fact] + public async Task VersionOneSignatureIsAccepted() + { + var content = JsonSerializer.Serialize(new { eventType = "Bounce", bounce = new { bounceType = "Permanent", bouncedRecipients = new[] { new { emailAddress = "person@example.com" } } } }); + (await _processor.ProcessAsync(Signed("Notification", content, "1"), CancellationToken.None)).Should().BeTrue(); + _added.Should().ContainSingle(); + } + + [Fact] + public async Task WrongTopicIsRejectedBeforeFetchingCertificateOrConfirming() + { + var payload = Signed("SubscriptionConfirmation", "Confirm").Replace(Topic, "arn:aws:sns:us-east-2:713285551626:other"); + (await _processor.ProcessAsync(payload, CancellationToken.None)).Should().BeFalse(); + _fetchedUrls.Should().BeEmpty(); + } + + [Fact] + public async Task RepeatedSignedEnvelopesFetchCertificateOnce() + { + var payload = Signed("Notification", JsonSerializer.Serialize(new { eventType = "Delivery" })); + + (await _processor.ProcessAsync(payload, CancellationToken.None)).Should().BeTrue(); + (await _processor.ProcessAsync(payload, CancellationToken.None)).Should().BeTrue(); + + _fetchedUrls.Should().ContainSingle().Which.Should().Be(CertificateUrl); + } + + [Fact] + public async Task TamperedSignatureIsRejected() + { + var payload = Signed("Notification", "Message").Replace("\"Message\":\"Message\"", "\"Message\":\"Tampered\""); + (await _processor.ProcessAsync(payload, CancellationToken.None)).Should().BeFalse(); + _added.Should().BeEmpty(); + } + + [Fact] + public async Task CertificateOnAnotherHostIsRejectedBeforeFetch() + { + var payload = Signed("Notification", "Message").Replace(CertificateUrl, + "https://sns.us-east-2.amazonaws.com.evil.example/SimpleNotificationService-123abc.pem"); + (await _processor.ProcessAsync(payload, CancellationToken.None)).Should().BeFalse(); + _fetchedUrls.Should().BeEmpty(); + } + + private string Signed(string type, string message, string version = "2") + { + var fields = new Dictionary + { + ["Type"] = type, + ["MessageId"] = "165545c9-2a5c-472c-8df2-7ff2be2b3b1b", + ["TopicArn"] = Topic, + ["Message"] = message, + ["Timestamp"] = "2026-09-27T00:00:00.000Z", + ["SignatureVersion"] = version, + ["SigningCertURL"] = CertificateUrl, + }; + if (type == "SubscriptionConfirmation") + { + fields["SubscribeURL"] = SubscribeUrl; + fields["Token"] = "abc"; + } + var order = type == "Notification" + ? new[] { "Message", "MessageId", "Timestamp", "TopicArn", "Type" } + : new[] { "Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type" }; + var canonical = string.Concat(order.Select(name => $"{name}\n{fields[name]}\n")); + fields["Signature"] = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(canonical), version == "2" ? HashAlgorithmName.SHA256 : HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1)); + return JsonSerializer.Serialize(fields); + } + + public void Dispose() + { + _certificate.Dispose(); + _key.Dispose(); + } + + private sealed class StubHandler(string certificatePem, List fetchedUrls) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.ToString(); + fetchedUrls.Add(url); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(url == CertificateUrl ? certificatePem : "confirmed") + }); + } + } +}