diff --git a/AGENTS.md b/AGENTS.md index 12969531c..ceafdca98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - Command for running tests: `pnpm test`. - The project uses shadcn for building UI, so stick to its conventions and design. - In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. +- Prefer constants in `packages/common-models` over string literals. - For admin/dashboard empty states in `apps/web`, prefer reusing `apps/web/components/admin/empty-state.tsx` instead of creating one-off placeholder UIs. - When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button. - Check the name field inside each package's package.json to confirm the right name—skip the top-level one. diff --git a/apps/docs-new/content/docs/self-hosting/meta.json b/apps/docs-new/content/docs/self-hosting/meta.json index 6fdac060d..f5e0519b0 100644 --- a/apps/docs-new/content/docs/self-hosting/meta.json +++ b/apps/docs-new/content/docs/self-hosting/meta.json @@ -1,5 +1,10 @@ { "title": "Self hosting", "icon": "ServerCog", - "pages": ["introduction", "cloud-vs-self-hosting", "self-host"] + "pages": [ + "introduction", + "cloud-vs-self-hosting", + "self-host", + "reply-by-email" + ] } diff --git a/apps/docs-new/content/docs/self-hosting/reply-by-email.mdx b/apps/docs-new/content/docs/self-hosting/reply-by-email.mdx new file mode 100644 index 000000000..1b9dd92ac --- /dev/null +++ b/apps/docs-new/content/docs/self-hosting/reply-by-email.mdx @@ -0,0 +1,172 @@ +--- +title: Configure reply by email +description: Configure Amazon SES, Postmark, or Mailgun so discussion notification emails can receive replies. +--- + +CourseLit can put a per-recipient `Reply-To` address on Community and product-discussion notifications. A reply sent to that address is added to the correct discussion as the notification recipient. + +This requires an active queue worker and one inbound-email provider. The providers supported out of the box are **Amazon SES**, **Postmark**, and **Mailgun**. Choose one provider for each inbound reply domain. + +## Before you configure a provider + +1. Use a dedicated subdomain, such as `replies.example.com`. Do not point the MX records for your primary school domain at this feature. +2. Set the same `INBOUND_EMAIL_DOMAIN=replies.example.com` value in both the `app` and `queue` services. The queue service mints the reply address; the app service accepts inbound callbacks. +3. Enable the `queue` service in the supplied Docker Compose file. If the queue does not receive `INBOUND_EMAIL_DOMAIN`, outgoing emails intentionally have no reply token or `Reply-To` header. +4. Generate provider secrets with a password manager or `openssl rand -base64 32`. Keep them out of source control and webhook URLs except where a provider requires HTTP Basic authentication. + +The webhook endpoint is always `https:///api/inbound-email/`. Your reverse proxy must expose this HTTPS path without adding tenant headers or requiring dashboard authentication. Outbound SMTP and inbound reply processing are independent, so they may use different providers. CourseLit validates the provider callback before it reads a reply. Attachments and HTML reply parsing are not supported; CourseLit stores up to 5,000 characters of the plain-text reply. + +## Environment variables + +Set the common value in **both** services, then set only the values for your chosen provider in the `app` service. + +```dotenv +# app and queue +INBOUND_EMAIL_DOMAIN=replies.example.com + +# app only — Postmark +INBOUND_EMAIL_WEBHOOK_SECRET=replace-with-a-random-secret + +# app only — Mailgun +MAILGUN_WEBHOOK_SIGNING_KEY=copy-from-mailgun-webhook-signing-key + +# app only — Amazon SES +INBOUND_EMAIL_SES_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:courselit-inbound +INBOUND_EMAIL_SES_BUCKET=courselit-inbound-email +INBOUND_EMAIL_SES_REGION=us-east-1 +INBOUND_EMAIL_SES_OBJECT_PREFIX=inbound +``` + +`INBOUND_EMAIL_SES_OBJECT_PREFIX` is optional. If set, it must exactly match the S3 key prefix configured in the SES receipt rule, without leading or trailing slashes. + +## Amazon SES + +Amazon SES is a first-class option and is the recommended route for an AWS-hosted CourseLit deployment. Follow the [SES receiving setup guide](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-setting-up.html) in an AWS Region that supports receiving email. + +First, verify `replies.example.com` in SES and publish the MX record that SES gives you. Its value is region-specific, for example `inbound-smtp.us-east-1.amazonaws.com`. Use the same AWS Region for the SES receipt rule, SNS topic, and S3 bucket. + +### Create the SES receipt rule + +1. In **Amazon SES → Email receiving**, create a receipt rule set. + + ![Create an Amazon SES receipt rule set](/assets/self-host/aws-ses/create-rule-set.png) + +2. Open the new rule set and choose **Create rule**. + + ![Create a receipt rule in the rule set](/assets/self-host/aws-ses/create-rule.png) + +3. Give the rule a descriptive name, leave it enabled, and enable spam and virus scanning. Requiring TLS is optional. + + ![Configure the Amazon SES receipt rule](/assets/self-host/aws-ses/rule-wizard-step-1.png) + +4. Add `replies.example.com` as the recipient condition. Using the dedicated subdomain accepts every generated `reply+` address beneath it. + + ![Set the reply subdomain as the recipient condition](/assets/self-host/aws-ses/rule-wizard-step-2.png) + +5. Add a **Deliver to Amazon S3 bucket** action. Select an existing bucket or create a private bucket for inbound messages. If you set an object key prefix, copy the exact value to `INBOUND_EMAIL_SES_OBJECT_PREFIX` without leading or trailing slashes. + + ![Create the S3 bucket for inbound email](/assets/self-host/aws-ses/rule-wizard-step-create-bucket-name.png) + +6. In the same S3 action, select an SNS topic or create a **Standard** topic. Copy its ARN to `INBOUND_EMAIL_SES_TOPIC_ARN`. Do not add a separate direct SNS action: the S3 action's SNS notification tells CourseLit which stored MIME message to fetch without imposing SNS's smaller mail-content limit. + + ![Create the SNS topic used by the S3 action](/assets/self-host/aws-ses/rule-wizard-step-create-topic-name.png) + +7. Review the rule and create it. + + ![Review and create the Amazon SES receipt rule](/assets/self-host/aws-ses/rule-wizard-step-4.png) + +8. Return to the rule set and choose **Set as active**. SES evaluates only the active receipt rule set, even when an individual rule says **Enabled**. + + ![Set the Amazon SES receipt rule set as active](/assets/self-host/aws-ses/set-ruleset-active.png) + + Confirm that the rule set now appears under **Active rule set**. + + ![Active Amazon SES receipt rule set](/assets/self-host/aws-ses/email-receiving-activated-ruleset.png) + +### Subscribe CourseLit to the SNS topic + +1. In Amazon SNS, open the topic attached to the SES S3 action. + + ![Select the inbound email SNS topic](/assets/self-host/aws-ses/sns-topic-select.png) + +2. Configure the topic to use **SNS SignatureVersion 2**. CourseLit requires the SHA-256 signature version and rejects SNS's legacy SignatureVersion 1 default. The SNS topic console does not expose this setting, so run the following in AWS CloudShell or with the AWS CLI (replace the placeholders): + + ```bash + aws sns set-topic-attributes \ + --region \ + --topic-arn arn:aws:sns::: \ + --attribute-name SignatureVersion \ + --attribute-value 2 + ``` + + See [AWS's signature-version guidance](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message-configure-message-signature.html). + +3. On the topic's **Subscriptions** tab, choose **Create subscription**. + + ![Create an HTTPS subscription for the SNS topic](/assets/self-host/aws-ses/sns-topic-create-subscription.png) + +4. Select **HTTPS** and enter `https:///api/inbound-email/ses`. Leave **Enable raw message delivery** off. + + ![Add the CourseLit inbound email HTTPS endpoint](/assets/self-host/aws-ses/sns-topic-add-https-url.png) + +5. The subscription briefly shows **Pending confirmation**. CourseLit verifies the SNS signature and configured topic ARN, then confirms a valid request automatically. The app must be publicly reachable and have `INBOUND_EMAIL_SES_TOPIC_ARN` set before you create or re-request confirmation. + + ![SNS subscription pending confirmation](/assets/self-host/aws-ses/sns-topic-pending-confirmation.png) + + Refresh the page and verify that its status becomes **Confirmed**. + + ![Confirmed CourseLit SNS subscription](/assets/self-host/aws-ses/sns-subscription-confirmed.png) + +Finally, give the CourseLit **web/app runtime** permission to read the stored messages, and add a short S3 lifecycle expiry for the raw MIME prefix. CourseLit reads each object only while processing the webhook and does not need to retain raw email. The queue service does not need S3 permission. SES needs separate permission to write the selected prefix and publish to the topic; use the policies AWS generates in the SES and SNS consoles where possible. + +The app checks SNS Signature Version 2, the AWS signing-certificate host, and the configured topic ARN before it reads the S3 object. It does not store message attachments. + +### Grant the web service access to the SES bucket + +Use a workload IAM role rather than static AWS access keys. Attach this least-privilege policy to the role, replacing the bucket name and narrowing the object path to your configured prefix when one is used: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ReadInboundReplyEmails", + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::courselit-inbound-email/*" + } + ] +} +``` + +- **EC2 / Docker Compose:** create an IAM role with the **EC2** trusted entity and this policy. In the EC2 console, select the instance, then choose **Actions → Security → Modify IAM role** and attach the role. The AWS SDK in the web container uses the instance role's temporary credentials automatically. +- **ECS / Fargate:** create an IAM role with the **Elastic Container Service Task** trusted entity and this policy. Assign it as the **task role** for the web task definition. Do not use the task execution role, and do not assign this bucket-read permission to the queue task. + +Do not set `AWS_ACCESS_KEY_ID` or `AWS_SECRET_ACCESS_KEY` in production when using a workload role. If the SES bucket uses SSE-KMS, also grant the web role `kms:Decrypt` for that KMS key. + +## Postmark + +1. Create a Postmark inbound server and add its MX records for `replies.example.com` as described in [Postmark's inbound setup guide](https://postmarkapp.com/developer/user-guide/inbound/configure-an-inbound-server). +2. Set its webhook URL to `https://courselit:@/api/inbound-email/postmark`. +3. Set `INBOUND_EMAIL_WEBHOOK_SECRET` in the app service to that same secret. The username may be any non-empty value; `courselit` is used above for clarity. +4. Send a test message to the provider-generated inbound address. Postmark retries non-2xx responses, so do not disable the provider's retries. + +CourseLit uses Postmark's `StrippedTextReply` when supplied, and otherwise removes quoted text from the plain-text body. + +## Mailgun + +1. Add `replies.example.com` as a Mailgun receiving domain and publish the MX records Mailgun provides. +2. Create a Mailgun route that forwards matching mail to `https:///api/inbound-email/mailgun` using the `forward()` action. See [Mailgun's receiving-routes documentation](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/receive-http). +3. Copy Mailgun's webhook signing key to `MAILGUN_WEBHOOK_SIGNING_KEY` in the app service. +4. Keep the route payload as `multipart/form-data` or form data; CourseLit verifies Mailgun's timestamp, token, and HMAC signature before processing it. + +## Verify the setup + +1. Restart the app and queue services after changing environment variables. +2. Trigger a Community or product-discussion notification. Inspect the delivered message headers: its `Reply-To` should be `reply+@replies.example.com`. +3. Reply from the same email address that received the notification. The reply should appear in the discussion and participants should receive the usual notifications. +4. Try a different sender address. CourseLit accepts the webhook but silently discards that reply, so forwarded reply addresses cannot impersonate the original recipient. + +Provider retries are idempotent: a received provider message ID is retained for 30 days, so retry delivery does not create duplicate comments. + +To disable reply by email safely, remove `INBOUND_EMAIL_DOMAIN` from the queue service and restart it. New notifications will no longer include a `Reply-To` token; leave the app endpoint configuration in place until outstanding messages have expired or remove the provider route after the 30-day reply-token window. diff --git a/apps/docs-new/content/docs/self-hosting/self-host.mdx b/apps/docs-new/content/docs/self-hosting/self-host.mdx index 95fd29dff..1a6abc43e 100644 --- a/apps/docs-new/content/docs/self-hosting/self-host.mdx +++ b/apps/docs-new/content/docs/self-hosting/self-host.mdx @@ -119,6 +119,10 @@ To self-host it, follow these steps. 6. That's it! You now have a fully functioning LMS powered by CourseLit and MediaLit. +### Enabling replies to discussion emails + +To let members reply directly to Community and product-discussion notification emails, configure an inbound-email provider and enable the queue service. See [Configure reply by email](/self-hosting/reply-by-email) for Amazon SES, Postmark, and Mailgun setup instructions. + ## Hosted version If this is too technical for you to handle, CourseLit's hosted version is available at [CourseLit.app](https://courselit.app). diff --git a/apps/docs-new/public/assets/self-host/aws-ses/create-rule-set.png b/apps/docs-new/public/assets/self-host/aws-ses/create-rule-set.png new file mode 100644 index 000000000..99bb0e4d5 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/create-rule-set.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/create-rule.png b/apps/docs-new/public/assets/self-host/aws-ses/create-rule.png new file mode 100644 index 000000000..6dbe2f690 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/create-rule.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/email-receiving-activated-ruleset.png b/apps/docs-new/public/assets/self-host/aws-ses/email-receiving-activated-ruleset.png new file mode 100644 index 000000000..ffe09df25 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/email-receiving-activated-ruleset.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-1.png b/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-1.png new file mode 100644 index 000000000..e0b90ceb2 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-1.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-2.png b/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-2.png new file mode 100644 index 000000000..f2f1e2db7 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-2.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-4.png b/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-4.png new file mode 100644 index 000000000..3911ae222 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-4.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-create-bucket-name.png b/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-create-bucket-name.png new file mode 100644 index 000000000..fab351378 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-create-bucket-name.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-create-topic-name.png b/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-create-topic-name.png new file mode 100644 index 000000000..c0203b0f0 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/rule-wizard-step-create-topic-name.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/set-ruleset-active.png b/apps/docs-new/public/assets/self-host/aws-ses/set-ruleset-active.png new file mode 100644 index 000000000..38162e7f4 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/set-ruleset-active.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/sns-subscription-confirmed.png b/apps/docs-new/public/assets/self-host/aws-ses/sns-subscription-confirmed.png new file mode 100644 index 000000000..15ab25b11 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/sns-subscription-confirmed.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-add-https-url.png b/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-add-https-url.png new file mode 100644 index 000000000..b181b4cf2 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-add-https-url.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-create-subscription.png b/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-create-subscription.png new file mode 100644 index 000000000..935861cf0 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-create-subscription.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-pending-confirmation.png b/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-pending-confirmation.png new file mode 100644 index 000000000..258cc1246 Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-pending-confirmation.png differ diff --git a/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-select.png b/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-select.png new file mode 100644 index 000000000..4924c197e Binary files /dev/null and b/apps/docs-new/public/assets/self-host/aws-ses/sns-topic-select.png differ diff --git a/apps/docs/src/config.ts b/apps/docs/src/config.ts index 202d22035..4ea74d670 100644 --- a/apps/docs/src/config.ts +++ b/apps/docs/src/config.ts @@ -152,6 +152,10 @@ export const SIDEBAR: Sidebar = { "Self hosting": [ { text: "Why self host?", link: "en/self-hosting/introduction" }, { text: "Self hosting guide", link: "en/self-hosting/self-host" }, + { + text: "Configure reply by email", + link: "en/self-hosting/reply-by-email", + }, ], }, }; diff --git a/apps/docs/src/pages/en/self-hosting/reply-by-email.md b/apps/docs/src/pages/en/self-hosting/reply-by-email.md new file mode 100644 index 000000000..2de5cacf4 --- /dev/null +++ b/apps/docs/src/pages/en/self-hosting/reply-by-email.md @@ -0,0 +1,116 @@ +--- +title: Configure reply by email +description: Configure Amazon SES, Postmark, or Mailgun so discussion notification emails can receive replies. +layout: ../../../layouts/MainLayout.astro +--- + +CourseLit can put a per-recipient `Reply-To` address on Community and product-discussion notifications. A reply sent to that address is added to the correct discussion as the notification recipient. + +This requires an active queue worker and one inbound-email provider. The providers supported out of the box are **Amazon SES**, **Postmark**, and **Mailgun**. Choose one provider for each inbound reply domain. + +## Before you configure a provider + +1. Use a dedicated subdomain, such as `replies.example.com`. Do not point the MX records for your primary school domain at this feature. +2. Set the same `INBOUND_EMAIL_DOMAIN=replies.example.com` value in both the `app` and `queue` services. The queue service mints the reply address; the app service accepts inbound callbacks. +3. Enable the `queue` service in the supplied Docker Compose file. If the queue does not receive `INBOUND_EMAIL_DOMAIN`, outgoing emails intentionally have no reply token or `Reply-To` header. +4. Generate provider secrets with a password manager or `openssl rand -base64 32`. Keep them out of source control and webhook URLs except where a provider requires HTTP Basic authentication. + +The webhook endpoint is always `https:///api/inbound-email/`. Your reverse proxy must expose this HTTPS path without adding tenant headers or requiring dashboard authentication. Outbound SMTP and inbound reply processing are independent, so they may use different providers. CourseLit validates the provider callback before it reads a reply. Attachments and HTML reply parsing are not supported; CourseLit stores up to 5,000 characters of the plain-text reply. + +## Environment variables + +Set the common value in **both** services, then set only the values for your chosen provider in the `app` service. + +```dotenv +# app and queue +INBOUND_EMAIL_DOMAIN=replies.example.com + +# app only — Postmark +INBOUND_EMAIL_WEBHOOK_SECRET=replace-with-a-random-secret + +# app only — Mailgun +MAILGUN_WEBHOOK_SIGNING_KEY=copy-from-mailgun-webhook-signing-key + +# app only — Amazon SES +INBOUND_EMAIL_SES_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:courselit-inbound +INBOUND_EMAIL_SES_BUCKET=courselit-inbound-email +INBOUND_EMAIL_SES_REGION=us-east-1 +INBOUND_EMAIL_SES_OBJECT_PREFIX=inbound +``` + +`INBOUND_EMAIL_SES_OBJECT_PREFIX` is optional. If set, it must exactly match the S3 key prefix configured in the SES receipt rule, without leading or trailing slashes. + +## Amazon SES + +Amazon SES is a first-class option and is the recommended route for an AWS-hosted CourseLit deployment. Follow the [SES receiving setup guide](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-setting-up.html) in an AWS Region that supports receiving email. + +1. Verify `replies.example.com` in SES and publish the MX record that SES gives you. Its value is region-specific, for example `inbound-smtp.us-east-1.amazonaws.com`. +2. Create an SNS topic for inbound notifications and set its ARN as `INBOUND_EMAIL_SES_TOPIC_ARN`. +3. Configure the topic to use **SNS SignatureVersion 2**. CourseLit requires the SHA-256 signature version and rejects SNS's legacy SignatureVersion 1 default. The SNS topic console does not expose this setting, so run the following in AWS CloudShell or with the AWS CLI (replace the placeholders): + + ```bash + aws sns set-topic-attributes \ + --region \ + --topic-arn arn:aws:sns::: \ + --attribute-name SignatureVersion \ + --attribute-value 2 + ``` + + See [AWS's signature-version guidance](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message-configure-message-signature.html). + +4. Add an HTTPS subscription to that topic: `https:///api/inbound-email/ses`. CourseLit verifies the SNS signature and expected topic before confirming the subscription. +5. Create an active SES receipt rule for the reply subdomain. Add an **S3 action** that writes to `INBOUND_EMAIL_SES_BUCKET` with the optional `INBOUND_EMAIL_SES_OBJECT_PREFIX`, and configure that action to publish to the SNS topic. Do not use the direct SNS mail-content action: it has a much smaller message limit. +6. Give the CourseLit **web/app runtime** an AWS IAM role with `s3:GetObject` restricted to that bucket and prefix. The queue service does not need this S3 permission. SES needs separate permission to write the selected prefix and publish to the topic; use the policies AWS generates in the SES/SNS consoles where possible. +7. Add a short S3 lifecycle expiry for the raw MIME prefix. CourseLit reads each object only while processing the webhook and does not need to retain raw email. + +The app checks SNS Signature Version 2, the AWS signing-certificate host, and the configured topic ARN before it reads the S3 object. It does not store message attachments. + +### Grant the web service access to the SES bucket + +Use a workload IAM role rather than static AWS access keys. Attach this least-privilege policy to the role, replacing the bucket name and narrowing the object path to your configured prefix when one is used: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ReadInboundReplyEmails", + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::courselit-inbound-email/*" + } + ] +} +``` + +- **EC2 / Docker Compose:** create an IAM role with the **EC2** trusted entity and this policy. In the EC2 console, select the instance, then choose **Actions → Security → Modify IAM role** and attach the role. The AWS SDK in the web container uses the instance role's temporary credentials automatically. +- **ECS / Fargate:** create an IAM role with the **Elastic Container Service Task** trusted entity and this policy. Assign it as the **task role** for the web task definition. Do not use the task execution role, and do not assign this bucket-read permission to the queue task. + +Do not set `AWS_ACCESS_KEY_ID` or `AWS_SECRET_ACCESS_KEY` in production when using a workload role. If the SES bucket uses SSE-KMS, also grant the web role `kms:Decrypt` for that KMS key. + +## Postmark + +1. Create a Postmark inbound server and add its MX records for `replies.example.com` as described in [Postmark's inbound setup guide](https://postmarkapp.com/developer/user-guide/inbound/configure-an-inbound-server). +2. Set its webhook URL to `https://courselit:@/api/inbound-email/postmark`. +3. Set `INBOUND_EMAIL_WEBHOOK_SECRET` in the app service to that same secret. The username may be any non-empty value; `courselit` is used above for clarity. +4. Send a test message to the provider-generated inbound address. Postmark retries non-2xx responses, so do not disable the provider's retries. + +CourseLit uses Postmark's `StrippedTextReply` when supplied, and otherwise removes quoted text from the plain-text body. + +## Mailgun + +1. Add `replies.example.com` as a Mailgun receiving domain and publish the MX records Mailgun provides. +2. Create a Mailgun route that forwards matching mail to `https:///api/inbound-email/mailgun` using the `forward()` action. See [Mailgun's receiving-routes documentation](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/receive-http). +3. Copy Mailgun's webhook signing key to `MAILGUN_WEBHOOK_SIGNING_KEY` in the app service. +4. Keep the route payload as `multipart/form-data` or form data; CourseLit verifies Mailgun's timestamp, token, and HMAC signature before processing it. + +## Verify the setup + +1. Restart the app and queue services after changing environment variables. +2. Trigger a Community or product-discussion notification. Inspect the delivered message headers: its `Reply-To` should be `reply+@replies.example.com`. +3. Reply from the same email address that received the notification. The reply should appear in the discussion and participants should receive the usual notifications. +4. Try a different sender address. CourseLit accepts the webhook but silently discards that reply, so forwarded reply addresses cannot impersonate the original recipient. + +Provider retries are idempotent: a received provider message ID is retained for 30 days, so retry delivery does not create duplicate comments. + +To disable reply by email safely, remove `INBOUND_EMAIL_DOMAIN` from the queue service and restart it. New notifications will no longer include a `Reply-To` token; leave the app endpoint configuration in place until outstanding messages have expired or remove the provider route after the 30-day reply-token window. diff --git a/apps/docs/src/pages/en/self-hosting/self-host.md b/apps/docs/src/pages/en/self-hosting/self-host.md index 2a68ad7bf..cc7a4f1ee 100644 --- a/apps/docs/src/pages/en/self-hosting/self-host.md +++ b/apps/docs/src/pages/en/self-hosting/self-host.md @@ -131,6 +131,10 @@ To self host, follow the following steps. 6. That's it! You now have a fully functioning LMS powered by CourseLit and MediaLit. +### Enabling replies to discussion emails + +To let members reply directly to Community and product-discussion notification emails, configure an inbound-email provider and enable the queue service. See [Configure reply by email](./reply-by-email) for Amazon SES, Postmark, and Mailgun setup instructions. + ## Hosted version If this is too technical for you to handle, CourseLit's hosted version is available at [CourseLit.app](https://courselit.app). diff --git a/apps/queue/.env b/apps/queue/.env.example similarity index 80% rename from apps/queue/.env rename to apps/queue/.env.example index 7df280850..875c285e4 100644 --- a/apps/queue/.env +++ b/apps/queue/.env.example @@ -7,6 +7,11 @@ EMAIL_PASS=email_pass EMAIL_HOST=email_host EMAIL_FROM=no-reply@example.com +# Reply by email: enables reply tokens and Reply-To headers for conversation +# emails. Set the same dedicated inbound domain in the web service, where the +# provider webhook is processed (for example, replies.example.com). +# INBOUND_EMAIL_DOMAIN=replies.example.com + # MongoDB connection string for storing queue data and job information DB_CONNECTION_STRING=mongodb://db.string diff --git a/apps/queue/README.md b/apps/queue/README.md index 7f1a85855..80bf2650e 100644 --- a/apps/queue/README.md +++ b/apps/queue/README.md @@ -28,6 +28,7 @@ The following environment variables are used by the queue service: - `SEQUENCE_BOUNCE_LIMIT` - Maximum number of bounces allowed for email sequences (default: `3`) - `PROTOCOL` - Protocol used for generating site URLs (default: `https`) - `DOMAIN` - Base domain name for generating site URLs +- `INBOUND_EMAIL_DOMAIN` - Dedicated inbound email domain. When set, conversation notifications receive an opaque reply token in their `Reply-To` address. Inbound processing requires a supported provider adapter in the web app. ## Running the app diff --git a/apps/queue/src/domain/model/email-reply-token.ts b/apps/queue/src/domain/model/email-reply-token.ts new file mode 100644 index 000000000..6fa5d630e --- /dev/null +++ b/apps/queue/src/domain/model/email-reply-token.ts @@ -0,0 +1,14 @@ +import { + EmailReplyTokenSchema, + InternalEmailReplyToken, +} from "@courselit/orm-models"; +import mongoose, { Model } from "mongoose"; + +const EmailReplyTokenModel = + (mongoose.models.EmailReplyToken as Model) || + mongoose.model( + "EmailReplyToken", + EmailReplyTokenSchema, + ); + +export default EmailReplyTokenModel; diff --git a/apps/queue/src/notifications/services/__tests__/email-reply-token.test.ts b/apps/queue/src/notifications/services/__tests__/email-reply-token.test.ts new file mode 100644 index 000000000..1e2b96202 --- /dev/null +++ b/apps/queue/src/notifications/services/__tests__/email-reply-token.test.ts @@ -0,0 +1,216 @@ +/** + * @jest-environment node + */ + +import mongoose from "mongoose"; +import EmailReplyTokenModel from "../../../domain/model/email-reply-token"; +import { + EMAIL_REPLY_TOKEN_TTL_MS, + buildReplyToAddress, + isReplyByEmailEnabled, + mintReplyToken, +} from "../email-reply-token"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe("email reply tokens", () => { + const domainId = new mongoose.Types.ObjectId(); + + beforeAll(async () => { + await EmailReplyTokenModel.syncIndexes(); + }); + + beforeEach(async () => { + delete process.env.INBOUND_EMAIL_DOMAIN; + await EmailReplyTokenModel.deleteMany({}); + }); + + afterAll(async () => { + delete process.env.INBOUND_EMAIL_DOMAIN; + await EmailReplyTokenModel.deleteMany({}); + }); + + it("mints one reusable community token for concurrent requests at the same thread position", async () => { + const context = { + community: { + communityId: "community-1", + postId: "post-1", + parentCommentId: "comment-1", + parentReplyId: "reply-1", + }, + }; + + const tokens = await Promise.all( + Array.from({ length: 5 }, () => + mintReplyToken({ + domainId, + userId: "user-1", + context, + }), + ), + ); + + expect(new Set(tokens).size).toBe(1); + expect(tokens[0]).toMatch(/^[A-Za-z0-9_-]{27}$/); + + const records = await EmailReplyTokenModel.find({}).lean(); + expect(records).toHaveLength(1); + expect(records[0]).toEqual( + expect.objectContaining({ + domain: domainId, + token: tokens[0], + userId: "user-1", + kind: "community", + community: expect.objectContaining(context.community), + }), + ); + expect(records[0].product).toBeUndefined(); + expect(records[0].contextKey).toEqual(expect.any(String)); + expect(records[0].contextKey).not.toContain("community-1"); + expect(records[0].expiresAt.getTime()).toBeGreaterThanOrEqual( + Date.now() + EMAIL_REPLY_TOKEN_TTL_MS - 5_000, + ); + }); + + it("slides expiry by 30 days without replacing an existing token", async () => { + const context = { + community: { + communityId: "community-1", + postId: "post-1", + }, + }; + const originalToken = await mintReplyToken({ + domainId, + userId: "user-1", + context, + }); + const staleExpiry = new Date(Date.now() - DAY_MS); + await EmailReplyTokenModel.updateOne( + { token: originalToken }, + { $set: { expiresAt: staleExpiry } }, + ); + + const reusedToken = await mintReplyToken({ + domainId, + userId: "user-1", + context, + }); + const record = await EmailReplyTokenModel.findOne({ + token: originalToken, + }).lean(); + + expect(reusedToken).toBe(originalToken); + expect(record?.expiresAt.getTime()).toBeGreaterThan( + staleExpiry.getTime(), + ); + expect(record?.expiresAt.getTime()).toBeGreaterThanOrEqual( + Date.now() + 30 * DAY_MS - 5_000, + ); + }); + + it("stores product coordinates and mints a different token for a different thread position", async () => { + const firstToken = await mintReplyToken({ + domainId, + userId: "user-1", + context: { + product: { + productId: "product-1", + entityType: "lesson", + entityId: "lesson-1", + commentId: "comment-1", + }, + }, + }); + const secondToken = await mintReplyToken({ + domainId, + userId: "user-1", + context: { + product: { + productId: "product-1", + entityType: "lesson", + entityId: "lesson-1", + commentId: "comment-1", + parentReplyId: "reply-1", + }, + }, + }); + + expect(secondToken).not.toBe(firstToken); + const record = await EmailReplyTokenModel.findOne({ + token: secondToken, + }).lean(); + expect(record).toEqual( + expect.objectContaining({ + kind: "product", + product: expect.objectContaining({ + productId: "product-1", + entityType: "lesson", + entityId: "lesson-1", + commentId: "comment-1", + parentReplyId: "reply-1", + }), + }), + ); + expect(record?.community).toBeUndefined(); + }); + + it.each([ + {}, + { + community: { communityId: "community-1", postId: "post-1" }, + product: { + productId: "product-1", + entityType: "lesson", + entityId: "lesson-1", + commentId: "comment-1", + }, + }, + ])("rejects an ambiguous reply context", async (context) => { + await expect( + mintReplyToken({ + domainId, + userId: "user-1", + context: context as any, + }), + ).rejects.toThrow("exactly one reply context"); + + expect(await EmailReplyTokenModel.countDocuments()).toBe(0); + }); + + it("enables reply-by-email only when a domain is configured", () => { + expect(isReplyByEmailEnabled()).toBe(false); + + process.env.INBOUND_EMAIL_DOMAIN = "replies.example.com"; + expect(isReplyByEmailEnabled()).toBe(true); + }); + + it("builds a normalized, local-part-safe Reply-To address", () => { + process.env.INBOUND_EMAIL_DOMAIN = " Replies.Example.COM. "; + + expect(buildReplyToAddress("a".repeat(27))).toBe( + `reply+${"a".repeat(27)}@replies.example.com`, + ); + }); + + it.each([ + "replies.example.com\r\nBcc: attacker@example.com", + "https://replies.example.com", + "reply@replies.example.com", + "-replies.example.com", + "replies..example.com", + ])("rejects an unsafe inbound email domain: %s", (domain) => { + process.env.INBOUND_EMAIL_DOMAIN = domain; + + expect(() => buildReplyToAddress("a".repeat(27))).toThrow( + "INBOUND_EMAIL_DOMAIN", + ); + }); + + it("rejects a token that is unsafe for an email local-part", () => { + process.env.INBOUND_EMAIL_DOMAIN = "replies.example.com"; + + expect(() => + buildReplyToAddress("token\r\nBcc: attacker@example.com"), + ).toThrow("reply token"); + }); +}); diff --git a/apps/queue/src/notifications/services/channels/__tests__/email.test.ts b/apps/queue/src/notifications/services/channels/__tests__/email.test.ts index cd7c6abdc..d88b9ee91 100644 --- a/apps/queue/src/notifications/services/channels/__tests__/email.test.ts +++ b/apps/queue/src/notifications/services/channels/__tests__/email.test.ts @@ -6,6 +6,11 @@ import { Constants } from "@courselit/common-models"; import { getNotificationEmailContent } from "@courselit/common-logic"; import { JSDOM } from "jsdom"; import { addMailJob } from "../../../../domain/handler"; +import { + buildReplyToAddress, + isReplyByEmailEnabled, + mintReplyToken, +} from "../../email-reply-token"; import { EmailChannel } from "../email"; jest.mock("@courselit/common-logic", () => ({ @@ -16,9 +21,18 @@ jest.mock("../../../../domain/handler", () => ({ addMailJob: jest.fn(), })); +jest.mock("../../email-reply-token", () => ({ + buildReplyToAddress: jest.fn(), + isReplyByEmailEnabled: jest.fn(), + mintReplyToken: jest.fn(), +})); + const mockedGetNotificationEmailContent = getNotificationEmailContent as jest.Mock; const mockedAddMailJob = addMailJob as jest.Mock; +const mockedBuildReplyToAddress = buildReplyToAddress as jest.Mock; +const mockedIsReplyByEmailEnabled = isReplyByEmailEnabled as jest.Mock; +const mockedMintReplyToken = mintReplyToken as jest.Mock; function makePayload(overrides: Partial = {}): any { return { @@ -76,6 +90,13 @@ describe("EmailChannel", () => { process.env.DOMAIN = "courselit.test"; process.env.MULTITENANT = "true"; process.env.EMAIL_FROM = "hello@courselit.test"; + delete process.env.INBOUND_EMAIL_DOMAIN; + + mockedIsReplyByEmailEnabled.mockReturnValue(false); + mockedMintReplyToken.mockResolvedValue("reply-token"); + mockedBuildReplyToAddress.mockReturnValue( + "reply+reply-token@replies.courselit.test", + ); mockedGetNotificationEmailContent.mockResolvedValue({ subject: @@ -352,4 +373,84 @@ describe("EmailChannel", () => { expect(visibleText).toContain("A new comment"); expect(visibleText).not.toContain("Earlier comment"); }); + + it("adds Reply-To and a reply hint for an enabled conversation notification", async () => { + const replyContext = { + community: { + communityId: "community-id", + postId: "post-id", + parentCommentId: "comment-id", + }, + }; + mockedIsReplyByEmailEnabled.mockReturnValue(true); + mockedGetNotificationEmailContent.mockResolvedValue({ + subject: "Test Instructor commented on a post", + message: "Test Instructor commented on a post", + href: "https://school.courselit.test/community/post", + threadTitle: "A discussion title", + commentText: "A new comment", + conversationLabel: "New comment", + replyContext, + }); + + await new EmailChannel().send(makePayload()); + + expect(mockedMintReplyToken).toHaveBeenCalledWith({ + domainId: "domain-id", + userId: "recipient-id", + context: replyContext, + }); + expect(mockedBuildReplyToAddress).toHaveBeenCalledWith("reply-token"); + const mail = mockedAddMailJob.mock.calls[0][0]; + expect(mail.headers).toEqual( + expect.objectContaining({ + "Reply-To": "reply+reply-token@replies.courselit.test", + "List-Unsubscribe": + "", + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }), + ); + expect( + getVisibleEmailText(getVisibleEmailDocument(mail.body)), + ).toContain("You can reply to this email to respond directly"); + }); + + it("does not mint a reply token for a non-conversation notification", async () => { + mockedIsReplyByEmailEnabled.mockReturnValue(true); + + await new EmailChannel().send(makePayload()); + + expect(mockedMintReplyToken).not.toHaveBeenCalled(); + expect(mockedBuildReplyToAddress).not.toHaveBeenCalled(); + const mail = mockedAddMailJob.mock.calls[0][0]; + expect(mail.headers).not.toHaveProperty("Reply-To"); + expect( + getVisibleEmailText(getVisibleEmailDocument(mail.body)), + ).not.toContain("You can reply to this email to respond directly"); + }); + + it("does not queue an email when reply token minting fails", async () => { + mockedIsReplyByEmailEnabled.mockReturnValue(true); + mockedMintReplyToken.mockRejectedValue( + new Error("database unavailable"), + ); + mockedGetNotificationEmailContent.mockResolvedValue({ + subject: "Test Instructor commented on a post", + message: "Test Instructor commented on a post", + href: "https://school.courselit.test/community/post", + commentText: "A new comment", + conversationLabel: "New comment", + replyContext: { + community: { + communityId: "community-id", + postId: "post-id", + }, + }, + }); + + await expect(new EmailChannel().send(makePayload())).rejects.toThrow( + "database unavailable", + ); + expect(mockedAddMailJob).not.toHaveBeenCalled(); + }); }); diff --git a/apps/queue/src/notifications/services/channels/email.ts b/apps/queue/src/notifications/services/channels/email.ts index 92c0d0b9e..9bbb38698 100644 --- a/apps/queue/src/notifications/services/channels/email.ts +++ b/apps/queue/src/notifications/services/channels/email.ts @@ -8,6 +8,11 @@ import { ChannelPayload, NotificationChannel } from "./types"; import { getDomainId } from "../../../observability/posthog"; import { buildNotificationEmailTemplate } from "./notification-email-template"; import UserModel from "../../../domain/model/user"; +import { + buildReplyToAddress, + isReplyByEmailEnabled, + mintReplyToken, +} from "../email-reply-token"; function getActorAvatarUrl(actor: ChannelPayload["actor"]) { return actor?.avatar?.file || actor?.avatar?.thumbnail || undefined; @@ -54,6 +59,16 @@ export class EmailChannel implements NotificationChannel { return; } + let replyToAddress: string | undefined; + if (notificationDetails.replyContext && isReplyByEmailEnabled()) { + const replyToken = await mintReplyToken({ + domainId: payload.domain._id, + userId: payload.recipient.userId, + context: notificationDetails.replyContext, + }); + replyToAddress = buildReplyToAddress(replyToken); + } + const unsubscribeUrl = getUnsubLink( payload.domain, payload.recipient.unsubscribeToken, @@ -72,6 +87,7 @@ export class EmailChannel implements NotificationChannel { threadTitle: notificationDetails.threadTitle, conversationLabel: notificationDetails.conversationLabel, isConversation: Boolean(notificationDetails.replyContext), + showReplyByEmailHint: Boolean(replyToAddress), hideCourseLitBranding: payload.domain.settings?.hideCourseLitBranding, }), @@ -89,6 +105,7 @@ export class EmailChannel implements NotificationChannel { headers: { "List-Unsubscribe": `<${unsubscribeUrl}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + ...(replyToAddress && { "Reply-To": replyToAddress }), }, }); } diff --git a/apps/queue/src/notifications/services/email-reply-token.ts b/apps/queue/src/notifications/services/email-reply-token.ts new file mode 100644 index 000000000..a1b059291 --- /dev/null +++ b/apps/queue/src/notifications/services/email-reply-token.ts @@ -0,0 +1,208 @@ +import { Constants } from "@courselit/common-models"; +import type { + EmailReplyTokenKind, + ReplyByEmailContext, +} from "@courselit/common-models"; +import { createHash, randomBytes } from "node:crypto"; +import type { Types } from "mongoose"; +import EmailReplyTokenModel from "../../domain/model/email-reply-token"; + +export const EMAIL_REPLY_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +interface MintReplyTokenOptions { + domainId: string | Types.ObjectId; + userId: string; + context: ReplyByEmailContext; +} + +interface NormalizedReplyContext { + kind: EmailReplyTokenKind; + community?: ReplyByEmailContext["community"]; + product?: ReplyByEmailContext["product"]; +} + +function assertNonEmptyStrings(values: Array) { + if (values.some((value) => !value?.trim())) { + throw new Error("Reply context contains an empty identifier"); + } +} + +function normalizeReplyContext( + context: ReplyByEmailContext, +): NormalizedReplyContext { + const hasCommunity = Boolean(context.community); + const hasProduct = Boolean(context.product); + + if (hasCommunity === hasProduct) { + throw new Error("Expected exactly one reply context"); + } + + if (context.community) { + assertNonEmptyStrings([ + context.community.communityId, + context.community.postId, + ]); + if ( + context.community.parentReplyId && + !context.community.parentCommentId + ) { + throw new Error( + "A community parent reply requires a parent comment", + ); + } + + return { + kind: Constants.EmailReplyTokenKind.COMMUNITY, + community: { ...context.community }, + }; + } + + assertNonEmptyStrings([ + context.product?.productId, + context.product?.entityType, + context.product?.entityId, + context.product?.commentId, + ]); + return { + kind: Constants.EmailReplyTokenKind.PRODUCT, + product: { ...context.product! }, + }; +} + +function buildContextKey(context: NormalizedReplyContext) { + const coordinates = context.community + ? [ + context.kind, + context.community.communityId, + context.community.postId, + context.community.parentCommentId || "", + context.community.parentReplyId || "", + ] + : [ + context.kind, + context.product!.productId, + context.product!.entityType, + context.product!.entityId, + context.product!.commentId, + context.product!.parentReplyId || "", + ]; + + return createHash("sha256") + .update(JSON.stringify(coordinates)) + .digest("hex"); +} + +function generateReplyToken() { + return randomBytes(20).toString("base64url"); +} + +function isDuplicateKeyError(error: unknown) { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === 11000 + ); +} + +export async function mintReplyToken({ + domainId, + userId, + context, +}: MintReplyTokenOptions): Promise { + if (!userId.trim()) { + throw new Error("A recipient user is required to mint a reply token"); + } + + const normalizedContext = normalizeReplyContext(context); + const contextKey = buildContextKey(normalizedContext); + const expiresAt = new Date(Date.now() + EMAIL_REPLY_TOKEN_TTL_MS); + const update = { + $set: { + kind: normalizedContext.kind, + community: normalizedContext.community, + product: normalizedContext.product, + expiresAt, + }, + $setOnInsert: { + token: generateReplyToken(), + }, + }; + const filter = { domain: domainId, userId, contextKey }; + + try { + const record = await EmailReplyTokenModel.findOneAndUpdate( + filter, + update, + { + new: true, + upsert: true, + runValidators: true, + setDefaultsOnInsert: true, + }, + ).lean(); + + return record.token; + } catch (error) { + if (!isDuplicateKeyError(error)) { + throw error; + } + + const record = await EmailReplyTokenModel.findOneAndUpdate( + filter, + update, + { new: true, runValidators: true }, + ).lean(); + + if (!record) { + throw error; + } + + return record.token; + } +} + +export function isReplyByEmailEnabled() { + return Boolean(process.env.INBOUND_EMAIL_DOMAIN?.trim()); +} + +function getInboundEmailDomain() { + const domain = process.env.INBOUND_EMAIL_DOMAIN?.trim().toLowerCase(); + if (!domain) { + throw new Error("INBOUND_EMAIL_DOMAIN is not configured"); + } + + const normalizedDomain = domain.endsWith(".") + ? domain.slice(0, -1) + : domain; + const labels = normalizedDomain.split("."); + const isValid = + normalizedDomain.length <= 253 && + labels.length >= 2 && + labels.every( + (label) => + label.length >= 1 && + label.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label), + ); + + if (!isValid) { + throw new Error("INBOUND_EMAIL_DOMAIN is not a valid email domain"); + } + + return normalizedDomain; +} + +export function buildReplyToAddress(token: string) { + if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) { + throw new Error("Invalid reply token for an email local-part"); + } + + const address = `reply+${token}@${getInboundEmailDomain()}`; + const localPart = address.slice(0, address.indexOf("@")); + if (Buffer.byteLength(localPart, "utf8") > 64) { + throw new Error("Reply-To local-part exceeds 64 octets"); + } + + return address; +} diff --git a/apps/web/.env b/apps/web/.env.example similarity index 51% rename from apps/web/.env rename to apps/web/.env.example index a93bfae81..56882ea07 100644 --- a/apps/web/.env +++ b/apps/web/.env.example @@ -7,6 +7,25 @@ # EMAIL_HOST=email_host # EMAIL_FROM=email_from +# Reply by email +# Set this dedicated inbound domain in both the web and queue services. +# The domain's MX records must point to your chosen inbound provider. +# INBOUND_EMAIL_DOMAIN=replies.example.com +# +# Configure exactly one provider below in the web service: +# Postmark +# INBOUND_EMAIL_WEBHOOK_SECRET=replace_with_a_random_secret +# +# Mailgun +# MAILGUN_WEBHOOK_SIGNING_KEY=copy_from_mailgun +# +# Amazon SES (the web runtime also needs s3:GetObject access to this bucket, +# preferably through its workload IAM role rather than static credentials) +# INBOUND_EMAIL_SES_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:courselit-inbound +# INBOUND_EMAIL_SES_BUCKET=courselit-inbound-email +# INBOUND_EMAIL_SES_REGION=us-east-1 +# INBOUND_EMAIL_SES_OBJECT_PREFIX=inbound + # For storing files # Usage guide: https://medialit.cloud/blog/apps-and-api-key/Pul-Qpsc4EtMj0iFefj5V # MEDIALIT_APIKEY=medialit_apikey @@ -30,4 +49,4 @@ # CACHE_DIR=/tmp # If using standalone Mongo (no replica set) -# DB_TRANSACTION=false \ No newline at end of file +# DB_TRANSACTION=false diff --git a/apps/web/__mocks__/email-reply-parser.ts b/apps/web/__mocks__/email-reply-parser.ts new file mode 100644 index 000000000..c4dbfd968 --- /dev/null +++ b/apps/web/__mocks__/email-reply-parser.ts @@ -0,0 +1,9 @@ +export default class EmailReplyParser { + parseReply(text: string) { + if (/^On .+wrote:/.test(text)) { + return ""; + } + + return text.split(/\r?\n\r?\nOn .+/)[0]; + } +} diff --git a/apps/web/__tests__/proxy.test.ts b/apps/web/__tests__/proxy.test.ts new file mode 100644 index 000000000..81d7c6f22 --- /dev/null +++ b/apps/web/__tests__/proxy.test.ts @@ -0,0 +1,35 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { getBackendAddress } from "@/app/actions"; +import { proxy } from "../proxy"; + +jest.mock("@/app/actions", () => ({ + getBackendAddress: jest.fn(), +})); +jest.mock("@/auth", () => ({ + auth: { + api: { + getSession: jest.fn(), + }, + }, +})); + +describe("proxy inbound-email bypass", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("does not derive a tenant from the webhook host", async () => { + const response = await proxy( + new NextRequest( + "https://courselit.example/api/inbound-email/postmark", + ), + ); + + expect(getBackendAddress).not.toHaveBeenCalled(); + expect(response.headers.get("x-middleware-next")).toBe("1"); + }); +}); diff --git a/apps/web/app/api/inbound-email/[provider]/__tests__/route.test.ts b/apps/web/app/api/inbound-email/[provider]/__tests__/route.test.ts new file mode 100644 index 000000000..d5a159e88 --- /dev/null +++ b/apps/web/app/api/inbound-email/[provider]/__tests__/route.test.ts @@ -0,0 +1,158 @@ +/** + * @jest-environment node + */ + +import { InboundEmailError } from "@/lib/inbound-email/errors"; +import { getInboundEmailAdapter } from "@/lib/inbound-email/providers"; +import { confirmSnsSubscription } from "@/lib/inbound-email/providers/ses"; +import { processInboundEmail } from "@/lib/inbound-email/process-inbound-email"; +import type { InboundEmailProvider } from "@/lib/inbound-email/types"; + +jest.mock("@/lib/inbound-email/providers", () => ({ + getInboundEmailAdapter: jest.fn(), +})); +jest.mock("@/lib/inbound-email/providers/ses", () => ({ + confirmSnsSubscription: jest.fn(), +})); +jest.mock("@/lib/inbound-email/process-inbound-email", () => ({ + processInboundEmail: jest.fn(), +})); +jest.mock("@/services/logger", () => ({ + error: jest.fn(), + info: jest.fn(), +})); + +const adapter = { + provider: "postmark" as InboundEmailProvider, + verify: jest.fn(), + parse: jest.fn(), +}; + +function request(body = "{}") { + return new Request("https://courselit.example/api/inbound-email/postmark", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Basic dGVzdDp0ZXN0", + }, + body, + }); +} + +function context(provider = "postmark") { + return { params: Promise.resolve({ provider }) }; +} + +describe("POST /api/inbound-email/[provider]", () => { + beforeEach(() => { + jest.clearAllMocks(); + (getInboundEmailAdapter as jest.Mock).mockReturnValue(adapter); + adapter.verify.mockResolvedValue(undefined); + adapter.parse.mockResolvedValue({ + kind: "email", + email: { + from: "member@example.com", + to: ["reply+token@replies.example.com"], + textBody: "Reply", + messageId: "provider-message-id", + }, + }); + (processInboundEmail as jest.Mock).mockResolvedValue({ ok: true }); + }); + + it("returns 404 for an unsupported provider", async () => { + (getInboundEmailAdapter as jest.Mock).mockReturnValue(undefined); + const { POST } = await import("../route"); + + const response = await POST(request(), context("unsupported")); + + expect(response.status).toBe(404); + expect(adapter.verify).not.toHaveBeenCalled(); + }); + + it("returns 401 before processing an unauthenticated provider webhook", async () => { + adapter.verify.mockRejectedValue( + new InboundEmailError("authentication", "Invalid signature"), + ); + const { POST } = await import("../route"); + + const response = await POST(request(), context()); + + expect(response.status).toBe(401); + expect(processInboundEmail).not.toHaveBeenCalled(); + }); + + it("processes an authenticated normalized email and acknowledges it", async () => { + const { POST } = await import("../route"); + + const response = await POST(request('{"event":"inbound"}'), context()); + + expect(response.status).toBe(200); + expect(adapter.verify).toHaveBeenCalledWith( + expect.objectContaining({ rawBody: '{"event":"inbound"}' }), + ); + expect(processInboundEmail).toHaveBeenCalledWith({ + provider: "postmark", + email: { + from: "member@example.com", + to: ["reply+token@replies.example.com"], + textBody: "Reply", + messageId: "provider-message-id", + }, + }); + }); + + it("acknowledges terminal application rejections without retrying providers", async () => { + (processInboundEmail as jest.Mock).mockResolvedValue({ + ok: false, + reason: "sender_mismatch", + }); + const { POST } = await import("../route"); + + const response = await POST(request(), context()); + + expect(response.status).toBe(200); + }); + + it("returns a retryable failure for transient provider infrastructure errors", async () => { + adapter.parse.mockRejectedValue( + new InboundEmailError("transient", "S3 object unavailable"), + ); + const { POST } = await import("../route"); + + const response = await POST(request(), context()); + + expect(response.status).toBe(503); + }); + + it("requests a retry while the same provider message is still processing", async () => { + (processInboundEmail as jest.Mock).mockResolvedValue({ + ok: false, + reason: "processing_in_progress", + retryable: true, + }); + const { POST } = await import("../route"); + + const response = await POST(request(), context()); + + expect(response.status).toBe(503); + }); + + it("confirms an authenticated SES subscription before acknowledging it", async () => { + adapter.provider = "ses"; + adapter.parse.mockResolvedValue({ + kind: "subscription_confirmation", + subscribeUrl: + "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription", + }); + const { POST } = await import("../route"); + + const response = await POST(request(), context("ses")); + + expect(response.status).toBe(200); + expect(confirmSnsSubscription).toHaveBeenCalledWith( + "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription", + ); + expect(processInboundEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/inbound-email/[provider]/route.ts b/apps/web/app/api/inbound-email/[provider]/route.ts new file mode 100644 index 000000000..4f2af10e1 --- /dev/null +++ b/apps/web/app/api/inbound-email/[provider]/route.ts @@ -0,0 +1,122 @@ +import { InboundEmailError } from "@/lib/inbound-email/errors"; +import { getInboundEmailAdapter } from "@/lib/inbound-email/providers"; +import { confirmSnsSubscription } from "@/lib/inbound-email/providers/ses"; +import { processInboundEmail } from "@/lib/inbound-email/process-inbound-email"; +import { error, info } from "@/services/logger"; + +export const runtime = "nodejs"; + +const MAX_INBOUND_WEBHOOK_BYTES = 2 * 1024 * 1024; + +function getInboundErrorKind(error: unknown) { + if ( + error instanceof InboundEmailError || + (typeof error === "object" && + error !== null && + "kind" in error && + typeof error.kind === "string") + ) { + return error.kind; + } + + return undefined; +} + +function errorStatus(error: unknown, isAuthenticated: boolean) { + const kind = getInboundErrorKind(error); + if (!kind) { + return 500; + } + + if (kind === "authentication") { + return 401; + } + if (kind === "configuration" || kind === "transient") { + return 503; + } + + return isAuthenticated ? 200 : 400; +} + +function getProviderFromParams(params: { provider: string }) { + return params.provider.toLowerCase(); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ provider: string }> }, +) { + const { provider: providerParam } = await params; + const provider = getProviderFromParams({ provider: providerParam }); + const adapter = getInboundEmailAdapter(provider); + if (!adapter) { + return Response.json({ ok: false }, { status: 404 }); + } + + const advertisedSize = Number(request.headers.get("content-length")); + if ( + Number.isFinite(advertisedSize) && + advertisedSize > MAX_INBOUND_WEBHOOK_BYTES + ) { + return Response.json({ ok: false }, { status: 413 }); + } + + let isAuthenticated = false; + try { + // Provider verification must receive the original body, before JSON or + // form parsing changes any bytes used by its authentication scheme. + const rawBody = await request.text(); + if (Buffer.byteLength(rawBody) > MAX_INBOUND_WEBHOOK_BYTES) { + return Response.json({ ok: false }, { status: 413 }); + } + + const input = { + rawBody, + headers: request.headers, + searchParams: new URL(request.url).searchParams, + contentType: request.headers.get("content-type") || "", + }; + await adapter.verify(input); + isAuthenticated = true; + + const parsed = await adapter.parse(input); + if (parsed.kind === "subscription_confirmation") { + if (adapter.provider !== "ses") { + return Response.json({ ok: false }, { status: 400 }); + } + + await confirmSnsSubscription(parsed.subscribeUrl); + await info("Inbound SES SNS subscription confirmed", { provider }); + return Response.json({ ok: true }); + } + if (parsed.kind === "unsubscribe_confirmation") { + await info("Inbound SES SNS subscription removed", { provider }); + return Response.json({ ok: true }); + } + + const result = await processInboundEmail({ + provider: adapter.provider, + email: parsed.email, + }); + if (!result.ok) { + await info("Inbound email rejected", { + provider, + reason: result.reason, + }); + if (result.retryable) { + return Response.json({ ok: false }, { status: 503 }); + } + } + + // A rejection after authentication is terminal (for example, an + // expired token or a sender mismatch), so providers must not retry it. + return Response.json({ ok: result.ok }); + } catch (caught) { + const status = errorStatus(caught, isAuthenticated); + await error("Inbound email processing failed", { + provider, + errorKind: getInboundErrorKind(caught) || "unknown", + }); + return Response.json({ ok: false }, { status }); + } +} diff --git a/apps/web/graphql/courses/__tests__/logic.test.ts b/apps/web/graphql/courses/__tests__/logic.test.ts index b118fe2a9..0d88ed124 100644 --- a/apps/web/graphql/courses/__tests__/logic.test.ts +++ b/apps/web/graphql/courses/__tests__/logic.test.ts @@ -939,6 +939,21 @@ describe("product discussion comment and reply logic", () => { expect(secondReply.commentId).toBe(comment.commentId); expect(secondReply.parentReplyId).toBe(firstReply.replyId); + firstReply.deleted = true; + await firstReply.save(); + + await expect( + createDiscussionReply({ + ctx, + productId: courseId, + entityType: CommonConstants.ProductDiscussionEntityType.LESSON, + entityId: lessonId, + commentId: comment.commentId, + parentReplyId: firstReply.replyId, + content: doc("Reply under deleted parent"), + }), + ).rejects.toThrow(responses.item_not_found); + const comments = await listDiscussionComments({ ctx, productId: courseId, diff --git a/apps/web/graphql/product-discussions/logic.ts b/apps/web/graphql/product-discussions/logic.ts index f84886ea1..a6db462c7 100644 --- a/apps/web/graphql/product-discussions/logic.ts +++ b/apps/web/graphql/product-discussions/logic.ts @@ -209,6 +209,7 @@ export async function createDiscussionReply({ entityId, commentId, replyId: parentReplyId, + deleted: false, }).select("_id"); if (!parentReply) { throw new Error(responses.item_not_found); diff --git a/apps/web/graphql/users/__tests__/delete-user.test.ts b/apps/web/graphql/users/__tests__/delete-user.test.ts index 00e180f35..721129466 100644 --- a/apps/web/graphql/users/__tests__/delete-user.test.ts +++ b/apps/web/graphql/users/__tests__/delete-user.test.ts @@ -18,6 +18,8 @@ import NotificationPreferenceModel from "@models/NotificationPreference"; import MailRequestStatusModel from "@models/MailRequestStatus"; import LessonEvaluationModel from "@models/LessonEvaluation"; import DownloadLinkModel from "@models/DownloadLink"; +import EmailReplyTokenModel from "@models/EmailReplyToken"; +import InboundEmailReceiptModel from "@models/InboundEmailReceipt"; import CommunityReportModel from "@models/CommunityReport"; import CertificateModel from "@models/Certificate"; import ActivityModel from "@models/Activity"; @@ -146,6 +148,8 @@ describe("deleteUser - Comprehensive Test Suite", () => { MailRequestStatusModel.deleteMany({ domain: testDomain._id }), LessonEvaluationModel.deleteMany({ domain: testDomain._id }), DownloadLinkModel.deleteMany({ domain: testDomain._id }), + EmailReplyTokenModel.deleteMany({ domain: testDomain._id }), + InboundEmailReceiptModel.deleteMany({ domain: testDomain._id }), CommunityReportModel.deleteMany({ domain: testDomain._id }), CertificateModel.deleteMany({ domain: testDomain._id }), ActivityModel.deleteMany({ domain: testDomain._id }), @@ -666,6 +670,47 @@ describe("deleteUser - Comprehensive Test Suite", () => { expect(links).toHaveLength(0); }); + it("should delete email reply tokens", async () => { + await EmailReplyTokenModel.create({ + domain: testDomain._id, + token: "email-reply-token", + userId: targetUser.userId, + kind: "community", + community: { + communityId: "community-123", + postId: "post-123", + }, + contextKey: + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + expiresAt: new Date(Date.now() + 60_000), + }); + + await deleteUser(targetUser.userId, mockCtx); + + const tokens = await EmailReplyTokenModel.find({ + userId: targetUser.userId, + }); + expect(tokens).toHaveLength(0); + }); + + it("should delete inbound email receipts", async () => { + await InboundEmailReceiptModel.create({ + domain: testDomain._id, + userId: targetUser.userId, + provider: "postmark", + messageId: "inbound-message-id", + status: "accepted", + expiresAt: new Date(Date.now() + 60_000), + }); + + await deleteUser(targetUser.userId, mockCtx); + + const receipts = await InboundEmailReceiptModel.find({ + userId: targetUser.userId, + }); + expect(receipts).toHaveLength(0); + }); + it("should delete community reports", async () => { await CommunityReportModel.create({ domain: testDomain._id, diff --git a/apps/web/graphql/users/helpers.ts b/apps/web/graphql/users/helpers.ts index 3396df582..913823c24 100644 --- a/apps/web/graphql/users/helpers.ts +++ b/apps/web/graphql/users/helpers.ts @@ -26,6 +26,8 @@ import NotificationPreferenceModel from "@models/NotificationPreference"; import MailRequestStatusModel from "@models/MailRequestStatus"; import LessonEvaluationModel from "@models/LessonEvaluation"; import DownloadLinkModel from "@models/DownloadLink"; +import EmailReplyTokenModel from "@models/EmailReplyToken"; +import InboundEmailReceiptModel from "@models/InboundEmailReceipt"; import CommunityReportModel from "@models/CommunityReport"; import CertificateModel from "@models/Certificate"; import ActivityModel from "@models/Activity"; @@ -295,6 +297,14 @@ export async function cleanupPersonalData( domain: ctx.subdomain._id, userId: userToDelete.userId, }), + EmailReplyTokenModel.deleteMany({ + domain: ctx.subdomain._id, + userId: userToDelete.userId, + }), + InboundEmailReceiptModel.deleteMany({ + domain: ctx.subdomain._id, + userId: userToDelete.userId, + }), CommunityReportModel.deleteMany({ domain: ctx.subdomain._id, userId: userToDelete.userId, diff --git a/apps/web/jest.client.config.ts b/apps/web/jest.client.config.ts index de2640aec..dce37095b 100644 --- a/apps/web/jest.client.config.ts +++ b/apps/web/jest.client.config.ts @@ -14,6 +14,8 @@ const config = { // Exclude MongoDB tests - they will be handled by the MongoDB config ".*/graphql/.*/__tests__/.*\\.test\\.(ts|tsx)$", ".*/api/.*/__tests__/.*\\.test\\.(ts|tsx)$", + // Inbound-email tests use server-only models and middleware. + ".*/lib/inbound-email/(?:.*/)?__tests__/.*\\.test\\.(ts|tsx)$", ], // collectCoverage: true, // collectCoverageFrom: [ diff --git a/apps/web/jest.server.config.ts b/apps/web/jest.server.config.ts index 271af3c91..d86e99fc8 100644 --- a/apps/web/jest.server.config.ts +++ b/apps/web/jest.server.config.ts @@ -20,6 +20,7 @@ const config: Config = { "better-auth": "/__mocks__/better-auth.ts", "^slugify$": "/__mocks__/slugify.ts", "^@sindresorhus/slugify$": "/__mocks__/slugify.ts", + "^email-reply-parser$": "/__mocks__/email-reply-parser.ts", "@models/(.*)": "/models/$1", "@/auth": "/auth.ts", "@/ba-multitenant-adapter": "/ba-multitenant-adapter", @@ -51,6 +52,7 @@ const config: Config = { testMatch: [ "**/graphql/**/__tests__/**/*.test.ts", "**/app/**/__tests__/**/*.test.ts", + "**/lib/inbound-email/**/__tests__/**/*.test.ts", ], testPathIgnorePatterns: [ "/node_modules/", diff --git a/apps/web/lib/inbound-email/__tests__/extract-reply-text.test.ts b/apps/web/lib/inbound-email/__tests__/extract-reply-text.test.ts new file mode 100644 index 000000000..a3b097d9e --- /dev/null +++ b/apps/web/lib/inbound-email/__tests__/extract-reply-text.test.ts @@ -0,0 +1,18 @@ +import { extractReplyText } from "../extract-reply-text"; + +describe("extractReplyText", () => { + it("prefers stripped reply text and otherwise removes quoted text", async () => { + await expect( + extractReplyText({ + strippedReply: " Provider reply ", + textBody: "Ignored", + }), + ).resolves.toBe("Provider reply"); + await expect( + extractReplyText({ + textBody: + "My direct reply\n\nOn Tue, someone wrote:\n> Original discussion", + }), + ).resolves.toBe("My direct reply"); + }); +}); diff --git a/apps/web/lib/inbound-email/__tests__/process-inbound-email.test.ts b/apps/web/lib/inbound-email/__tests__/process-inbound-email.test.ts new file mode 100644 index 000000000..63fc51d75 --- /dev/null +++ b/apps/web/lib/inbound-email/__tests__/process-inbound-email.test.ts @@ -0,0 +1,264 @@ +/** + * @jest-environment node + */ + +import { + Constants, + type ProductDiscussionEntityType, +} from "@courselit/common-models"; +import { normalizeTextEditorContent } from "@courselit/utils"; +import { processInboundEmail } from "@/lib/inbound-email/process-inbound-email"; +import CommunityModel from "@models/Community"; +import CommunityCommentModel from "@models/CommunityComment"; +import CommunityPostModel from "@models/CommunityPost"; +import CommunityPostSubscriberModel from "@models/CommunityPostSubscriber"; +import DomainModel from "@models/Domain"; +import InboundEmailReceiptModel from "@models/InboundEmailReceipt"; +import EmailReplyTokenModel from "@models/EmailReplyToken"; +import MembershipModel from "@models/Membership"; +import RateLimitEventModel from "@models/RateLimitEvent"; +import UserModel from "@models/User"; +import { createDiscussionReply } from "@/graphql/product-discussions/logic"; + +jest.mock("@/services/queue"); +jest.mock("@/graphql/product-discussions/logic", () => ({ + createDiscussionReply: jest.fn(), +})); + +const replyToken = "test-inbound-reply-token"; + +describe("processInboundEmail", () => { + let domain: any; + let user: any; + let community: any; + let post: any; + const originalInboundDomain = process.env.INBOUND_EMAIL_DOMAIN; + + beforeEach(async () => { + process.env.INBOUND_EMAIL_DOMAIN = "replies.example.com"; + jest.clearAllMocks(); + + domain = await DomainModel.create({ + name: "inbound-email-test", + email: "owner@example.com", + }); + user = await UserModel.create({ + domain: domain._id, + userId: "inbound-member", + email: "member@example.com", + name: "Inbound Member", + active: true, + permissions: [], + }); + community = await CommunityModel.create({ + domain: domain._id, + communityId: "inbound-community", + name: "Inbound Community", + pageId: "inbound-community-page", + slug: "inbound-community", + enabled: true, + deleted: false, + categories: ["General"], + }); + post = await CommunityPostModel.create({ + domain: domain._id, + userId: "another-member", + communityId: community.communityId, + postId: "inbound-post", + title: "Inbound post", + content: "Post content", + category: "General", + }); + await MembershipModel.create({ + domain: domain._id, + membershipId: "inbound-membership", + userId: user.userId, + entityId: community.communityId, + entityType: Constants.MembershipEntityType.COMMUNITY, + paymentPlanId: "inbound-plan", + sessionId: "inbound-session", + status: Constants.MembershipStatus.ACTIVE, + role: Constants.MembershipRole.MODERATE, + }); + }); + + afterEach(async () => { + await Promise.all([ + EmailReplyTokenModel.deleteMany({}), + InboundEmailReceiptModel.deleteMany({}), + CommunityCommentModel.deleteMany({}), + CommunityPostModel.deleteMany({}), + CommunityPostSubscriberModel.deleteMany({}), + MembershipModel.deleteMany({}), + RateLimitEventModel.deleteMany({}), + CommunityModel.deleteMany({}), + UserModel.deleteMany({}), + DomainModel.deleteMany({}), + ]); + if (originalInboundDomain === undefined) { + delete process.env.INBOUND_EMAIL_DOMAIN; + } else { + process.env.INBOUND_EMAIL_DOMAIN = originalInboundDomain; + } + }); + + async function createCommunityToken( + expiresAt = new Date(Date.now() + 60000), + ) { + await EmailReplyTokenModel.create({ + domain: domain._id, + token: replyToken, + userId: user.userId, + kind: "community", + community: { + communityId: community.communityId, + postId: post.postId, + }, + contextKey: `community:${replyToken}:${expiresAt.getTime()}`, + expiresAt, + }); + } + + function email(overrides: Record = {}) { + return { + from: user.email, + to: [`reply+${replyToken}@replies.example.com`], + textBody: "Reply from email", + messageId: "inbound-message-id", + ...overrides, + }; + } + + function processEmail(input: ReturnType) { + return processInboundEmail({ provider: "postmark", email: input }); + } + + it("creates a top-level community comment through the existing mutation logic", async () => { + await createCommunityToken(); + + await expect(processEmail(email())).resolves.toEqual({ ok: true }); + await expect( + CommunityCommentModel.findOne({ + domain: domain._id, + communityId: community.communityId, + postId: post.postId, + userId: user.userId, + }).lean(), + ).resolves.toMatchObject({ content: "Reply from email" }); + }); + + it("rejects expired reply tokens", async () => { + await createCommunityToken(new Date(Date.now() - 1)); + + await expect(processEmail(email())).resolves.toEqual({ + ok: false, + reason: "invalid_token", + }); + }); + + it("rejects a sender that does not match the recipient's address", async () => { + await createCommunityToken(); + + await expect( + processEmail(email({ from: "forwarder@example.com" })), + ).resolves.toEqual({ ok: false, reason: "sender_mismatch" }); + }); + + it("rejects empty reply text after quote stripping", async () => { + await createCommunityToken(); + + await expect( + processEmail( + email({ + textBody: + "On yesterday, someone wrote:\n> Earlier discussion", + }), + ), + ).resolves.toEqual({ ok: false, reason: "empty_reply" }); + }); + + it("does not bypass the community membership permission", async () => { + await createCommunityToken(); + await MembershipModel.deleteMany({ + domain: domain._id, + userId: user.userId, + }); + + await expect(processEmail(email())).resolves.toEqual({ + ok: false, + reason: "creation_failed", + }); + await expect(CommunityCommentModel.countDocuments({})).resolves.toBe(0); + }); + + it("marks transient rate-limit failures retryable", async () => { + await createCommunityToken(); + const countDocuments = jest + .spyOn(RateLimitEventModel, "countDocuments") + .mockRejectedValueOnce(new Error("database unavailable")); + + await expect(processEmail(email())).resolves.toEqual({ + ok: false, + reason: "creation_failed", + retryable: true, + }); + expect(countDocuments).toHaveBeenCalled(); + countDocuments.mockRestore(); + }); + + it("creates a product-discussion reply with normalized rich text", async () => { + await EmailReplyTokenModel.create({ + domain: domain._id, + token: replyToken, + userId: user.userId, + kind: "product", + product: { + productId: "course-id", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-id", + commentId: "comment-id", + parentReplyId: "parent-reply-id", + }, + contextKey: "product:inbound-token", + expiresAt: new Date(Date.now() + 60000), + }); + + await expect( + processEmail(email({ textBody: "First line\nSecond line" })), + ).resolves.toEqual({ ok: true }); + expect(createDiscussionReply).toHaveBeenCalledWith({ + ctx: expect.objectContaining({ + user: expect.objectContaining({ userId: user.userId }), + subdomain: expect.objectContaining({ _id: domain._id }), + address: "", + }), + productId: "course-id", + entityType: Constants.ProductDiscussionEntityType + .LESSON as ProductDiscussionEntityType, + entityId: "lesson-id", + commentId: "comment-id", + parentReplyId: "parent-reply-id", + content: normalizeTextEditorContent("First line\nSecond line"), + }); + }); + + it("acknowledges a duplicated provider message without creating another reply", async () => { + await createCommunityToken(); + + await expect(processEmail(email())).resolves.toEqual({ ok: true }); + await expect(processEmail(email())).resolves.toEqual({ ok: true }); + await expect( + CommunityCommentModel.countDocuments({ + domain: domain._id, + communityId: community.communityId, + postId: post.postId, + }), + ).resolves.toBe(1); + await expect( + InboundEmailReceiptModel.findOne({ + provider: "postmark", + messageId: "inbound-message-id", + }).lean(), + ).resolves.toMatchObject({ status: "accepted" }); + }); +}); diff --git a/apps/web/lib/inbound-email/errors.ts b/apps/web/lib/inbound-email/errors.ts new file mode 100644 index 000000000..9a085719c --- /dev/null +++ b/apps/web/lib/inbound-email/errors.ts @@ -0,0 +1,15 @@ +export type InboundEmailErrorKind = + | "authentication" + | "configuration" + | "invalid" + | "transient"; + +export class InboundEmailError extends Error { + constructor( + public readonly kind: InboundEmailErrorKind, + message: string, + ) { + super(message); + this.name = "InboundEmailError"; + } +} diff --git a/apps/web/lib/inbound-email/extract-reply-text.ts b/apps/web/lib/inbound-email/extract-reply-text.ts new file mode 100644 index 000000000..6ccc32c30 --- /dev/null +++ b/apps/web/lib/inbound-email/extract-reply-text.ts @@ -0,0 +1,18 @@ +export const MAX_INBOUND_REPLY_TEXT_LENGTH = 5000; + +export async function extractReplyText({ + strippedReply, + textBody, +}: { + strippedReply?: string; + textBody: string; +}): Promise { + if (strippedReply?.trim()) { + return strippedReply.trim().slice(0, MAX_INBOUND_REPLY_TEXT_LENGTH); + } + + const { default: EmailReplyParser } = await import("email-reply-parser"); + const candidate = new EmailReplyParser().parseReply(textBody); + + return candidate.trim().slice(0, MAX_INBOUND_REPLY_TEXT_LENGTH); +} diff --git a/apps/web/lib/inbound-email/process-inbound-email.ts b/apps/web/lib/inbound-email/process-inbound-email.ts new file mode 100644 index 000000000..b1d3c7034 --- /dev/null +++ b/apps/web/lib/inbound-email/process-inbound-email.ts @@ -0,0 +1,398 @@ +import { postComment } from "@/graphql/communities/logic"; +import { createDiscussionReply } from "@/graphql/product-discussions/logic"; +import { assertRateLimit } from "@/lib/assert-rate-limit"; +import DomainModel from "@models/Domain"; +import InboundEmailReceiptModel from "@models/InboundEmailReceipt"; +import EmailReplyTokenModel from "@models/EmailReplyToken"; +import UserModel from "@models/User"; +import { normalizeTextEditorContent } from "@courselit/utils"; +import { responses } from "@/config/strings"; +import { randomUUID } from "crypto"; +import { InboundEmailError } from "./errors"; +import { extractReplyText } from "./extract-reply-text"; +import type { InboundEmailProcessingInput } from "./types"; +import { Constants } from "@courselit/common-models"; + +const INBOUND_EMAIL_RATE_LIMITS = { + repliesPerMinute: { window: 60 * 1000, limit: 5 }, + repliesPerDay: { window: 24 * 60 * 60 * 1000, limit: 50 }, +} as const; + +const REPLY_TOKEN_PATTERN = /^[A-Za-z0-9_-]{20,64}$/; +const INBOUND_EMAIL_RECEIPT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +const INBOUND_EMAIL_PROCESSING_LEASE_MS = 5 * 60 * 1000; // 5 minutes +const TERMINAL_REPLY_ERRORS = new Set([ + responses.action_not_allowed, + responses.drip_not_released, + responses.invalid_input, + responses.item_not_found, + responses.not_enrolled, + responses.request_not_authenticated, +]); + +export type InboundEmailProcessingResult = + | { ok: true } + | { + ok: false; + reason: + | "reply_by_email_disabled" + | "no_reply_address" + | "invalid_token" + | "sender_mismatch" + | "empty_reply" + | "creation_failed" + | "processing_in_progress"; + retryable?: boolean; + }; + +type ReceiptClaim = + | { status: "claimed"; processingId: string } + | { status: "duplicate" } + | { status: "in_progress" }; + +function getInboundEmailDomain() { + const configured = process.env.INBOUND_EMAIL_DOMAIN?.trim().toLowerCase(); + if (!configured) { + return undefined; + } + + const domain = configured.endsWith(".") + ? configured.slice(0, -1) + : configured; + const labels = domain.split("."); + const valid = + domain.length <= 253 && + labels.length >= 2 && + labels.every( + (label) => + label.length >= 1 && + label.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label), + ); + + return valid ? domain : undefined; +} + +function findReplyToken(to: string[], inboundDomain: string) { + for (const recipient of to) { + const address = recipient.trim(); + const separator = address.lastIndexOf("@"); + if (separator <= 0 || separator === address.length - 1) { + continue; + } + + const localPart = address.slice(0, separator); + const domain = address.slice(separator + 1).toLowerCase(); + if ( + !localPart.toLowerCase().startsWith("reply+") || + domain !== inboundDomain + ) { + continue; + } + + const token = localPart.slice("reply+".length); + if (REPLY_TOKEN_PATTERN.test(token)) { + return token; + } + } + + return undefined; +} + +async function assertInboundEmailRateLimit({ + domain, + userId, +}: { + domain: Parameters[0]["domain"]; + userId: string; +}) { + const common = { + domain, + userId, + scope: "inbound_email", + action: "reply:create", + subjectId: "reply-by-email", + }; + + await assertRateLimit({ + ...common, + window: INBOUND_EMAIL_RATE_LIMITS.repliesPerDay.window, + limit: INBOUND_EMAIL_RATE_LIMITS.repliesPerDay.limit, + record: false, + }); + await assertRateLimit({ + ...common, + window: INBOUND_EMAIL_RATE_LIMITS.repliesPerMinute.window, + limit: INBOUND_EMAIL_RATE_LIMITS.repliesPerMinute.limit, + }); +} + +function isDuplicateKeyError(error: unknown) { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === 11000 + ); +} + +function isTerminalReplyError(error: unknown) { + return error instanceof Error && TERMINAL_REPLY_ERRORS.has(error.message); +} + +async function claimInboundEmailReceipt({ + provider, + messageId, + domain, + userId, +}: { + provider: InboundEmailProcessingInput["provider"]; + messageId: string; + domain: Parameters[0]["domain"]; + userId: string; +}): Promise { + const now = new Date(); + const processingId = randomUUID(); + const expiresAt = new Date(now.getTime() + INBOUND_EMAIL_RECEIPT_TTL_MS); + const processingExpiresAt = new Date( + now.getTime() + INBOUND_EMAIL_PROCESSING_LEASE_MS, + ); + + try { + await InboundEmailReceiptModel.create({ + domain, + userId, + provider, + messageId, + status: Constants.InboundEmailReceiptStatus.PROCESSING, + processingId, + processingExpiresAt, + expiresAt, + }); + return { status: "claimed", processingId }; + } catch (caught) { + if (!isDuplicateKeyError(caught)) { + throw new InboundEmailError( + "transient", + "Unable to claim inbound email receipt", + ); + } + } + + const existing = await InboundEmailReceiptModel.findOne({ + provider, + messageId, + }).lean(); + if (!existing) { + throw new InboundEmailError( + "transient", + "Inbound email receipt could not be read", + ); + } + if (existing.status === Constants.InboundEmailReceiptStatus.ACCEPTED) { + return { status: "duplicate" }; + } + if ( + existing.processingExpiresAt && + existing.processingExpiresAt.getTime() > now.getTime() + ) { + return { status: "in_progress" }; + } + + const reclaimed = await InboundEmailReceiptModel.updateOne( + { + _id: existing._id, + status: Constants.InboundEmailReceiptStatus.PROCESSING, + $or: [ + { processingExpiresAt: { $lte: now } }, + { processingExpiresAt: { $exists: false } }, + ], + }, + { + $set: { + domain, + userId, + processingId, + processingExpiresAt, + expiresAt, + }, + }, + ); + return reclaimed.modifiedCount === 1 + ? { status: "claimed", processingId } + : { status: "in_progress" }; +} + +async function releaseInboundEmailReceipt( + provider: InboundEmailProcessingInput["provider"], + messageId: string, + processingId: string, +) { + await InboundEmailReceiptModel.deleteOne({ + provider, + messageId, + status: Constants.InboundEmailReceiptStatus.PROCESSING, + processingId, + }); +} + +async function acceptInboundEmailReceipt( + provider: InboundEmailProcessingInput["provider"], + messageId: string, + processingId: string, +) { + const accepted = await InboundEmailReceiptModel.updateOne( + { + provider, + messageId, + status: Constants.InboundEmailReceiptStatus.PROCESSING, + processingId, + }, + { + $set: { status: "accepted" }, + $unset: { processingId: "", processingExpiresAt: "" }, + }, + ); + if (accepted.modifiedCount !== 1) { + throw new InboundEmailError( + "transient", + "Unable to accept inbound email receipt", + ); + } +} + +export async function processInboundEmail({ + provider, + email, +}: InboundEmailProcessingInput): Promise { + const inboundDomain = getInboundEmailDomain(); + if (!inboundDomain) { + return { ok: false, reason: "reply_by_email_disabled" }; + } + + const token = findReplyToken(email.to, inboundDomain); + if (!token) { + return { ok: false, reason: "no_reply_address" }; + } + + const replyToken = await EmailReplyTokenModel.findOne({ + token, + expiresAt: { $gt: new Date() }, + }).lean(); + if (!replyToken) { + return { ok: false, reason: "invalid_token" }; + } + + const [domain, user] = await Promise.all([ + DomainModel.findOne({ _id: replyToken.domain, deleted: false }), + UserModel.findOne({ + domain: replyToken.domain, + userId: replyToken.userId, + active: true, + }), + ]); + if (!domain || !user) { + return { ok: false, reason: "invalid_token" }; + } + + if (email.from.trim().toLowerCase() !== user.email.trim().toLowerCase()) { + return { ok: false, reason: "sender_mismatch" }; + } + + const replyText = await extractReplyText(email); + if (!replyText) { + return { ok: false, reason: "empty_reply" }; + } + + let processingId: string | undefined; + if (email.messageId) { + const receipt = await claimInboundEmailReceipt({ + provider, + messageId: email.messageId, + domain: domain._id, + userId: user.userId, + }); + if (receipt.status === "duplicate") { + return { ok: true }; + } + if (receipt.status === "in_progress") { + return { + ok: false, + reason: "processing_in_progress", + retryable: true, + }; + } + processingId = receipt.processingId; + } + + try { + await assertInboundEmailRateLimit({ + domain: domain._id, + userId: user.userId, + }); + + const ctx = { + user, + subdomain: domain, + address: "", + }; + if ( + replyToken.kind === Constants.EmailReplyTokenKind.COMMUNITY && + replyToken.community + ) { + await postComment({ + ctx, + communityId: replyToken.community.communityId, + postId: replyToken.community.postId, + content: replyText, + parentCommentId: replyToken.community.parentCommentId, + parentReplyId: replyToken.community.parentReplyId, + }); + } else if ( + replyToken.kind === Constants.EmailReplyTokenKind.PRODUCT && + replyToken.product + ) { + await createDiscussionReply({ + ctx, + productId: replyToken.product.productId, + entityType: replyToken.product.entityType, + entityId: replyToken.product.entityId, + commentId: replyToken.product.commentId, + parentReplyId: replyToken.product.parentReplyId, + content: normalizeTextEditorContent(replyText), + }); + } else { + if (processingId && email.messageId) { + await releaseInboundEmailReceipt( + provider, + email.messageId, + processingId, + ); + } + return { ok: false, reason: "invalid_token" }; + } + } catch (caught) { + if (processingId && email.messageId) { + await releaseInboundEmailReceipt( + provider, + email.messageId, + processingId, + ); + } + if (isTerminalReplyError(caught)) { + return { ok: false, reason: "creation_failed" }; + } + + return { ok: false, reason: "creation_failed", retryable: true }; + } + + if (processingId && email.messageId) { + await acceptInboundEmailReceipt( + provider, + email.messageId, + processingId, + ); + } + + return { ok: true }; +} diff --git a/apps/web/lib/inbound-email/provider-utils.ts b/apps/web/lib/inbound-email/provider-utils.ts new file mode 100644 index 000000000..616d5da91 --- /dev/null +++ b/apps/web/lib/inbound-email/provider-utils.ts @@ -0,0 +1,110 @@ +import { timingSafeEqual } from "node:crypto"; +import { InboundEmailError } from "./errors"; + +const SIMPLE_EMAIL_ADDRESS = /^[^\s<>@,]+@[^\s<>@,]+$/; +const textEncoder = new TextEncoder(); + +export function timingSafeEqualStrings(left: string, right: string): boolean { + const leftBytes = textEncoder.encode(left); + const rightBytes = textEncoder.encode(right); + + return ( + leftBytes.length === rightBytes.length && + timingSafeEqual(leftBytes, rightBytes) + ); +} + +export function parseEmailAddress(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const candidate = value.match(/<([^<>]+)>/)?.[1]?.trim() || value.trim(); + const separator = candidate.lastIndexOf("@"); + if (separator <= 0 || separator === candidate.length - 1) { + return undefined; + } + + // The domain is case-insensitive, while the Reply-To local-part embeds a + // base64url token whose uppercase characters are significant. + const email = `${candidate.slice(0, separator)}@${candidate + .slice(separator + 1) + .toLowerCase()}`; + + return SIMPLE_EMAIL_ADDRESS.test(email) ? email : undefined; +} + +export function parseEmailAddresses(values: unknown[]): string[] { + return values + .map(parseEmailAddress) + .filter((value): value is string => Boolean(value)); +} + +export function parseJsonBody(rawBody: string): Record { + try { + const value = JSON.parse(rawBody); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected a JSON object"); + } + + return value as Record; + } catch { + throw new InboundEmailError("invalid", "Invalid JSON webhook body"); + } +} + +export async function parseFormBody({ + rawBody, + contentType, +}: { + rawBody: string; + contentType: string; +}): Promise> { + if (contentType.startsWith("application/x-www-form-urlencoded")) { + return Object.fromEntries(new URLSearchParams(rawBody).entries()); + } + + if (!contentType.startsWith("multipart/form-data")) { + throw new InboundEmailError( + "invalid", + "Expected a form-encoded webhook body", + ); + } + + try { + const formData = await new Response(rawBody, { + headers: { "content-type": contentType }, + }).formData(); + const fields: Record = {}; + + formData.forEach((value, key) => { + if (typeof value === "string" && fields[key] === undefined) { + fields[key] = value; + } + }); + + return fields; + } catch { + throw new InboundEmailError( + "invalid", + "Invalid multipart webhook body", + ); + } +} + +export function getString( + object: Record, + key: string, +): string | undefined { + const value = object[key]; + return typeof value === "string" ? value : undefined; +} + +export function parseMessageId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const messageId = value.trim().replace(/^<|>$/g, ""); + return messageId || undefined; +} diff --git a/apps/web/lib/inbound-email/providers/__tests__/providers.test.ts b/apps/web/lib/inbound-email/providers/__tests__/providers.test.ts new file mode 100644 index 000000000..9287453ac --- /dev/null +++ b/apps/web/lib/inbound-email/providers/__tests__/providers.test.ts @@ -0,0 +1,170 @@ +/** + * @jest-environment node + */ + +import { createHmac } from "node:crypto"; +import { mailgunAdapter } from "@/lib/inbound-email/providers/mailgun"; +import { postmarkAdapter } from "@/lib/inbound-email/providers/postmark"; + +function verificationInput({ + rawBody, + contentType = "application/json", + authorization, +}: { + rawBody: string; + contentType?: string; + authorization?: string; +}) { + const headers = new Headers({ "content-type": contentType }); + if (authorization) { + headers.set("authorization", authorization); + } + + return { + rawBody, + headers, + searchParams: new URL("https://example.test").searchParams, + contentType, + }; +} + +describe("inbound email provider adapters", () => { + const originalWebhookSecret = process.env.INBOUND_EMAIL_WEBHOOK_SECRET; + const originalMailgunSigningKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY; + + beforeEach(() => { + process.env.INBOUND_EMAIL_WEBHOOK_SECRET = "webhook-secret"; + process.env.MAILGUN_WEBHOOK_SIGNING_KEY = "mailgun-signing-key"; + }); + + afterEach(() => { + if (originalWebhookSecret === undefined) { + delete process.env.INBOUND_EMAIL_WEBHOOK_SECRET; + } else { + process.env.INBOUND_EMAIL_WEBHOOK_SECRET = originalWebhookSecret; + } + + if (originalMailgunSigningKey === undefined) { + delete process.env.MAILGUN_WEBHOOK_SIGNING_KEY; + } else { + process.env.MAILGUN_WEBHOOK_SIGNING_KEY = originalMailgunSigningKey; + } + }); + + it("uses Postmark's parsed addresses and stripped reply after basic authentication", async () => { + const rawBody = JSON.stringify({ + FromFull: { + Email: "member@example.com", + }, + ToFull: [ + { + Email: "reply+CaseSensitiveToken@REPLIES.example.com", + }, + ], + Subject: "Re: A discussion", + TextBody: + "My reply\n\nOn yesterday, someone wrote:\n> Earlier text", + StrippedTextReply: "My reply", + MessageID: "postmark-message-id", + }); + const authorization = `Basic ${Buffer.from( + "courselit:webhook-secret", + ).toString("base64")}`; + const input = verificationInput({ rawBody, authorization }); + + await expect(postmarkAdapter.verify(input)).resolves.toBeUndefined(); + await expect(postmarkAdapter.parse(input)).resolves.toEqual({ + kind: "email", + email: { + from: "member@example.com", + to: ["reply+CaseSensitiveToken@replies.example.com"], + subject: "Re: A discussion", + textBody: + "My reply\n\nOn yesterday, someone wrote:\n> Earlier text", + strippedReply: "My reply", + messageId: "postmark-message-id", + }, + }); + }); + + it("rejects a Postmark request with an invalid shared secret", async () => { + const input = verificationInput({ + rawBody: "{}", + authorization: `Basic ${Buffer.from("courselit:not-it").toString( + "base64", + )}`, + }); + + await expect(postmarkAdapter.verify(input)).rejects.toMatchObject({ + kind: "authentication", + }); + }); + + it("verifies a fresh Mailgun HMAC and normalizes its form payload", async () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + const token = "mailgun-token"; + const signature = createHmac( + "sha256", + process.env.MAILGUN_WEBHOOK_SIGNING_KEY!, + ) + .update(`${timestamp}${token}`) + .digest("hex"); + const rawBody = new URLSearchParams({ + timestamp, + token, + signature, + recipient: "reply+token@replies.example.com", + from: "Member ", + subject: "Re: A discussion", + "body-plain": + "My reply\n\nOn yesterday, someone wrote:\n> Earlier text", + "stripped-text": "My reply", + "message-headers": JSON.stringify([ + ["Message-Id", ""], + ]), + }).toString(); + const input = verificationInput({ + rawBody, + contentType: "application/x-www-form-urlencoded", + }); + + await expect(mailgunAdapter.verify(input)).resolves.toBeUndefined(); + await expect(mailgunAdapter.parse(input)).resolves.toEqual({ + kind: "email", + email: { + from: "member@example.com", + to: ["reply+token@replies.example.com"], + subject: "Re: A discussion", + textBody: + "My reply\n\nOn yesterday, someone wrote:\n> Earlier text", + strippedReply: "My reply", + messageId: "mailgun-message-id@example.com", + }, + }); + }); + + it("rejects stale Mailgun webhook signatures", async () => { + const timestamp = String(Math.floor(Date.now() / 1000) - 301); + const token = "mailgun-token"; + const signature = createHmac( + "sha256", + process.env.MAILGUN_WEBHOOK_SIGNING_KEY!, + ) + .update(`${timestamp}${token}`) + .digest("hex"); + const rawBody = new URLSearchParams({ + timestamp, + token, + signature, + }).toString(); + + await expect( + mailgunAdapter.verify( + verificationInput({ + rawBody, + contentType: "application/x-www-form-urlencoded", + }), + ), + ).rejects.toMatchObject({ kind: "authentication" }); + }); +}); diff --git a/apps/web/lib/inbound-email/providers/__tests__/ses.test.ts b/apps/web/lib/inbound-email/providers/__tests__/ses.test.ts new file mode 100644 index 000000000..4c34a7742 --- /dev/null +++ b/apps/web/lib/inbound-email/providers/__tests__/ses.test.ts @@ -0,0 +1,248 @@ +/** + * @jest-environment node + */ + +import { generateKeyPairSync, sign } from "node:crypto"; +import { + buildSnsSigningString, + confirmSnsSubscription, + createSesInboundEmailAdapter, + type SnsMessage, + verifySnsMessage, +} from "@/lib/inbound-email/providers/ses"; + +const textEncoder = new TextEncoder(); + +function snsEnvelope(overrides: Record = {}): SnsMessage { + return { + Type: "Notification", + MessageId: "sns-message-id", + TopicArn: "arn:aws:sns:us-east-1:123456789012:courselit-replies", + Subject: "Amazon SES Email Receipt Notification", + Message: JSON.stringify({ notificationType: "Received" }), + Timestamp: "2026-07-19T12:00:00.000Z", + SignatureVersion: "2", + SigningCertURL: + "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-test.pem", + Signature: "example-signature", + ...overrides, + } as SnsMessage; +} + +function input(rawBody: string) { + return { + rawBody, + headers: new Headers({ "content-type": "application/json" }), + searchParams: new URL("https://example.test").searchParams, + contentType: "application/json", + }; +} + +describe("Amazon SES inbound adapter", () => { + const originalTopic = process.env.INBOUND_EMAIL_SES_TOPIC_ARN; + const originalBucket = process.env.INBOUND_EMAIL_SES_BUCKET; + const originalRegion = process.env.INBOUND_EMAIL_SES_REGION; + const originalPrefix = process.env.INBOUND_EMAIL_SES_OBJECT_PREFIX; + + beforeEach(() => { + process.env.INBOUND_EMAIL_SES_TOPIC_ARN = + "arn:aws:sns:us-east-1:123456789012:courselit-replies"; + process.env.INBOUND_EMAIL_SES_BUCKET = "courselit-inbound"; + process.env.INBOUND_EMAIL_SES_REGION = "us-east-1"; + process.env.INBOUND_EMAIL_SES_OBJECT_PREFIX = "replies"; + }); + + afterEach(() => { + for (const [name, value] of Object.entries({ + INBOUND_EMAIL_SES_TOPIC_ARN: originalTopic, + INBOUND_EMAIL_SES_BUCKET: originalBucket, + INBOUND_EMAIL_SES_REGION: originalRegion, + INBOUND_EMAIL_SES_OBJECT_PREFIX: originalPrefix, + })) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + }); + + it("constructs SNS's ordered, no-trailing-newline signing string", () => { + expect(buildSnsSigningString(snsEnvelope())).toBe( + [ + "Message", + JSON.stringify({ notificationType: "Received" }), + "MessageId", + "sns-message-id", + "Subject", + "Amazon SES Email Receipt Notification", + "Timestamp", + "2026-07-19T12:00:00.000Z", + "TopicArn", + "arn:aws:sns:us-east-1:123456789012:courselit-replies", + "Type", + "Notification", + ].join("\n"), + ); + }); + + it("accepts a valid SNS signature from the expected topic", async () => { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + const unsignedEnvelope = snsEnvelope(); + const signature = sign( + "RSA-SHA256", + textEncoder.encode(buildSnsSigningString(unsignedEnvelope)), + privateKey, + ).toString("base64"); + const envelope = { ...unsignedEnvelope, Signature: signature }; + + await expect( + verifySnsMessage(envelope, { + getSigningKey: async () => publicKey, + }), + ).resolves.toBeUndefined(); + }); + + it("accepts an SNS subscription confirmation signed with a final delimiter", async () => { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + const unsignedEnvelope = snsEnvelope({ + Type: "SubscriptionConfirmation", + Token: "subscription-token", + SubscribeURL: + "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription", + }); + const signature = sign( + "RSA-SHA256", + textEncoder.encode(`${buildSnsSigningString(unsignedEnvelope)}\n`), + privateKey, + ).toString("base64"); + + await expect( + verifySnsMessage( + { ...unsignedEnvelope, Signature: signature }, + { getSigningKey: async () => publicKey }, + ), + ).resolves.toBeUndefined(); + }); + + it("rejects an SNS message from an unexpected topic before reading S3", async () => { + const adapter = createSesInboundEmailAdapter({ + verifySnsMessage: async () => undefined, + getS3Object: async () => + Uint8Array.from(Buffer.from("should not be read")), + }); + const message = { + notificationType: "Received", + receipt: { + recipients: ["reply+token@replies.example.com"], + action: { + type: "S3", + bucketName: "courselit-inbound", + objectKey: "replies/message.eml", + }, + }, + mail: { messageId: "ses-message-id" }, + }; + const envelope = snsEnvelope({ + TopicArn: "arn:aws:sns:us-east-1:123456789012:another-topic", + Message: JSON.stringify(message), + }); + + await expect( + adapter.parse(input(JSON.stringify(envelope))), + ).rejects.toMatchObject({ + kind: "authentication", + }); + }); + + it("loads only the configured S3 object prefix and normalizes the MIME email", async () => { + const getS3Object = jest + .fn() + .mockResolvedValue( + Uint8Array.from( + Buffer.from( + [ + "From: Member ", + "To: reply+token@replies.example.com", + "Subject: Re: A discussion", + "Message-ID: ", + "Content-Type: text/plain; charset=utf-8", + "", + "My direct reply", + ].join("\r\n"), + ), + ), + ); + const adapter = createSesInboundEmailAdapter({ + verifySnsMessage: async () => undefined, + getS3Object, + }); + const message = { + notificationType: "Received", + receipt: { + recipients: ["reply+token@replies.example.com"], + action: { + type: "S3", + bucketName: "courselit-inbound", + objectKey: "replies/2026/message.eml", + }, + }, + mail: { messageId: "ses-message-id" }, + }; + const envelope = snsEnvelope({ Message: JSON.stringify(message) }); + + await expect( + adapter.parse(input(JSON.stringify(envelope))), + ).resolves.toEqual({ + kind: "email", + email: { + from: "member@example.com", + to: ["reply+token@replies.example.com"], + subject: "Re: A discussion", + textBody: "My direct reply", + messageId: "ses-message-id", + }, + }); + expect(getS3Object).toHaveBeenCalledWith({ + bucket: "courselit-inbound", + key: "replies/2026/message.eml", + region: "us-east-1", + }); + }); + + it("returns a subscription confirmation only after SNS verification", async () => { + const verify = jest.fn().mockResolvedValue(undefined); + const adapter = createSesInboundEmailAdapter({ + verifySnsMessage: verify, + getS3Object: async () => Uint8Array.from(Buffer.from("unused")), + }); + const envelope = snsEnvelope({ + Type: "SubscriptionConfirmation", + Token: "subscription-token", + SubscribeURL: + "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription", + }); + + await adapter.verify(input(JSON.stringify(envelope))); + await expect( + adapter.parse(input(JSON.stringify(envelope))), + ).resolves.toEqual({ + kind: "subscription_confirmation", + subscribeUrl: + "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription", + }); + expect(verify).toHaveBeenCalledTimes(1); + }); + + it("rejects a subscription confirmation URL outside the expected SNS action", async () => { + await expect( + confirmSnsSubscription( + "https://sns.us-east-1.amazonaws.com/?Action=Publish", + ), + ).rejects.toMatchObject({ kind: "authentication" }); + }); +}); diff --git a/apps/web/lib/inbound-email/providers/index.ts b/apps/web/lib/inbound-email/providers/index.ts new file mode 100644 index 000000000..bec89852e --- /dev/null +++ b/apps/web/lib/inbound-email/providers/index.ts @@ -0,0 +1,14 @@ +import { mailgunAdapter } from "./mailgun"; +import { postmarkAdapter } from "./postmark"; +import { sesAdapter } from "./ses"; +import type { InboundEmailAdapter, InboundEmailProvider } from "../types"; + +const adapters: Record = { + ses: sesAdapter, + postmark: postmarkAdapter, + mailgun: mailgunAdapter, +}; + +export function getInboundEmailAdapter(provider: string) { + return adapters[provider as InboundEmailProvider]; +} diff --git a/apps/web/lib/inbound-email/providers/mailgun.ts b/apps/web/lib/inbound-email/providers/mailgun.ts new file mode 100644 index 000000000..7291184b7 --- /dev/null +++ b/apps/web/lib/inbound-email/providers/mailgun.ts @@ -0,0 +1,118 @@ +import { createHmac } from "node:crypto"; +import { InboundEmailError } from "../errors"; +import { + parseEmailAddress, + parseFormBody, + parseMessageId, + timingSafeEqualStrings, +} from "../provider-utils"; +import type { + InboundEmailAdapter, + InboundEmailRequest, + ParsedInboundEmail, +} from "../types"; + +const MAILGUN_SIGNATURE_MAX_AGE_SECONDS = 5 * 60; + +async function getMailgunFields(input: InboundEmailRequest) { + return parseFormBody({ + rawBody: input.rawBody, + contentType: input.contentType, + }); +} + +function hasValidMailgunSignature({ + timestamp, + token, + signature, +}: Record) { + const signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY; + if (!signingKey?.trim()) { + throw new InboundEmailError( + "configuration", + "Mailgun webhook signing key is not configured", + ); + } + + if (!/^\d+$/.test(timestamp || "") || !token || !signature) { + return false; + } + + const age = Math.abs(Date.now() - Number(timestamp) * 1000); + if (age > MAILGUN_SIGNATURE_MAX_AGE_SECONDS * 1000) { + return false; + } + + const expected = createHmac("sha256", signingKey) + .update(`${timestamp}${token}`) + .digest("hex"); + return ( + /^[a-f0-9]{64}$/i.test(signature) && + timingSafeEqualStrings(signature.toLowerCase(), expected) + ); +} + +function getMailgunMessageId(fields: Record) { + const rawHeaders = fields["message-headers"]; + if (!rawHeaders) { + return undefined; + } + + try { + const headers = JSON.parse(rawHeaders); + if (!Array.isArray(headers)) { + return undefined; + } + + const messageId = headers.find( + (header) => + Array.isArray(header) && + typeof header[0] === "string" && + header[0].toLowerCase() === "message-id", + )?.[1]; + + return parseMessageId(messageId); + } catch { + return undefined; + } +} + +export const mailgunAdapter: InboundEmailAdapter = { + provider: "mailgun", + + async verify(input: InboundEmailRequest) { + const fields = await getMailgunFields(input); + if (!hasValidMailgunSignature(fields)) { + throw new InboundEmailError( + "authentication", + "Invalid Mailgun webhook signature", + ); + } + }, + + async parse(input: InboundEmailRequest): Promise { + const fields = await getMailgunFields(input); + const from = parseEmailAddress(fields.from); + const recipient = parseEmailAddress(fields.recipient); + const textBody = fields["body-plain"]; + + if (!from || !recipient || textBody === undefined) { + throw new InboundEmailError( + "invalid", + "Mailgun payload is missing required email fields", + ); + } + + return { + kind: "email", + email: { + from, + to: [recipient], + subject: fields.subject, + textBody, + strippedReply: fields["stripped-text"], + messageId: getMailgunMessageId(fields), + }, + }; + }, +}; diff --git a/apps/web/lib/inbound-email/providers/postmark.ts b/apps/web/lib/inbound-email/providers/postmark.ts new file mode 100644 index 000000000..c4922e95a --- /dev/null +++ b/apps/web/lib/inbound-email/providers/postmark.ts @@ -0,0 +1,102 @@ +import { InboundEmailError } from "../errors"; +import { + getString, + parseEmailAddress, + parseEmailAddresses, + parseJsonBody, + parseMessageId, + timingSafeEqualStrings, +} from "../provider-utils"; +import type { + InboundEmailAdapter, + InboundEmailRequest, + ParsedInboundEmail, +} from "../types"; + +function hasValidPostmarkAuthentication(headers: Headers) { + const secret = process.env.INBOUND_EMAIL_WEBHOOK_SECRET; + const authorization = headers.get("authorization"); + if (!secret?.trim()) { + throw new InboundEmailError( + "configuration", + "Postmark inbound webhook authentication is not configured", + ); + } + + if (!authorization?.startsWith("Basic ")) { + return false; + } + + let usernameAndPassword: string; + try { + usernameAndPassword = Buffer.from( + authorization.slice("Basic ".length), + "base64", + ).toString("utf8"); + } catch { + return false; + } + + const separator = usernameAndPassword.indexOf(":"); + if (separator <= 0) { + return false; + } + + const suppliedSecret = usernameAndPassword.slice(separator + 1); + return timingSafeEqualStrings(suppliedSecret, secret); +} + +function getPostmarkRecipients(body: Record) { + const toFull = body.ToFull; + if (!Array.isArray(toFull)) { + return []; + } + + return parseEmailAddresses( + toFull.map((recipient) => + recipient && typeof recipient === "object" + ? (recipient as Record).Email + : undefined, + ), + ); +} + +export const postmarkAdapter: InboundEmailAdapter = { + provider: "postmark", + + async verify(input: InboundEmailRequest) { + if (!hasValidPostmarkAuthentication(input.headers)) { + throw new InboundEmailError( + "authentication", + "Invalid Postmark inbound webhook authentication", + ); + } + }, + + async parse(input: InboundEmailRequest): Promise { + const body = parseJsonBody(input.rawBody); + const fromFull = body.FromFull as Record | undefined; + const from = parseEmailAddress(fromFull?.Email); + const to = getPostmarkRecipients(body); + const textBody = getString(body, "TextBody"); + + if (!from || !to.length || textBody === undefined) { + throw new InboundEmailError( + "invalid", + "Postmark payload is missing required email fields", + ); + } + + return { + kind: "email", + email: { + from, + to, + subject: getString(body, "Subject"), + textBody, + strippedReply: getString(body, "StrippedTextReply"), + messageId: parseMessageId(getString(body, "MessageID")), + }, + }; + }, +}; diff --git a/apps/web/lib/inbound-email/providers/ses.ts b/apps/web/lib/inbound-email/providers/ses.ts new file mode 100644 index 000000000..b8135e672 --- /dev/null +++ b/apps/web/lib/inbound-email/providers/ses.ts @@ -0,0 +1,584 @@ +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { simpleParser } from "mailparser"; +import { type KeyObject, verify, X509Certificate } from "node:crypto"; +import { InboundEmailError } from "../errors"; +import { + parseEmailAddress, + parseEmailAddresses, + parseJsonBody, + parseMessageId, + timingSafeEqualStrings, +} from "../provider-utils"; +import type { + InboundEmailAdapter, + InboundEmailRequest, + NormalizedInboundEmail, + ParsedInboundEmail, +} from "../types"; + +const MAX_SNS_SIGNING_CERTIFICATE_BYTES = 64 * 1024; +const MAX_SES_S3_EMAIL_BYTES = 40 * 1024 * 1024; +const textEncoder = new TextEncoder(); + +type SnsMessageType = + | "Notification" + | "SubscriptionConfirmation" + | "UnsubscribeConfirmation"; + +export interface SnsMessage { + Type: SnsMessageType; + MessageId: string; + TopicArn: string; + Message: string; + Timestamp: string; + SignatureVersion: string; + SigningCertURL: string; + Signature: string; + Subject?: string; + Token?: string; + SubscribeURL?: string; +} + +interface SesS3Notification { + notificationType: string; + receipt: { + recipients?: unknown; + action?: { + type?: unknown; + bucketName?: unknown; + objectKey?: unknown; + }; + }; + mail: { + messageId?: unknown; + }; +} + +interface SesS3ObjectOptions { + bucket: string; + key: string; + region: string; +} + +interface SesInboundEmailAdapterDependencies { + verifySnsMessage?: typeof verifySnsMessage; + getS3Object?: (options: SesS3ObjectOptions) => Promise; +} + +interface SnsSignatureVerificationOptions { + getSigningKey?: (signingCertUrl: string) => Promise; +} + +interface SesInboundConfiguration { + topicArn: string; + bucket: string; + region: string; + objectPrefix?: string; +} + +const s3Clients = new Map(); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function getRequiredString( + value: Record, + key: string, +): string { + const field = value[key]; + if (typeof field !== "string" || !field) { + throw new InboundEmailError("invalid", `SNS message is missing ${key}`); + } + + return field; +} + +function getExpectedSesTopicArn() { + const topicArn = process.env.INBOUND_EMAIL_SES_TOPIC_ARN?.trim(); + if (!topicArn) { + throw new InboundEmailError( + "configuration", + "INBOUND_EMAIL_SES_TOPIC_ARN is not configured", + ); + } + + return topicArn; +} + +function getSesInboundConfiguration(): SesInboundConfiguration { + const bucket = process.env.INBOUND_EMAIL_SES_BUCKET?.trim(); + const region = process.env.INBOUND_EMAIL_SES_REGION?.trim(); + if (!bucket || !region) { + throw new InboundEmailError( + "configuration", + "SES inbound S3 bucket and region must be configured", + ); + } + + const objectPrefix = + process.env.INBOUND_EMAIL_SES_OBJECT_PREFIX?.trim().replace( + /^\/+|\/+$/g, + "", + ); + + return { + topicArn: getExpectedSesTopicArn(), + bucket, + region, + objectPrefix: objectPrefix || undefined, + }; +} + +function assertExpectedSesTopic(topicArn: string) { + if (!timingSafeEqualStrings(getExpectedSesTopicArn(), topicArn)) { + throw new InboundEmailError( + "authentication", + "SNS message was not published by the expected topic", + ); + } +} + +function assertTrustedSnsEndpoint(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new InboundEmailError( + "authentication", + "SNS signing certificate URL is invalid", + ); + } + + const trustedHost = /^sns\.[a-z0-9-]+\.amazonaws\.com(?:\.cn)?$/.test( + url.hostname, + ); + if ( + url.protocol !== "https:" || + url.port || + url.username || + url.password || + !trustedHost + ) { + throw new InboundEmailError( + "authentication", + "SNS endpoint URL is not trusted", + ); + } + + return url; +} + +function assertTrustedSnsCertificateUrl(value: string): URL { + const url = assertTrustedSnsEndpoint(value); + if (!url.pathname.endsWith(".pem")) { + throw new InboundEmailError( + "authentication", + "SNS signing certificate URL is not trusted", + ); + } + + return url; +} + +function assertTrustedSnsSubscriptionUrl(value: string): URL { + const url = assertTrustedSnsEndpoint(value); + if (url.searchParams.get("Action") !== "ConfirmSubscription") { + throw new InboundEmailError( + "authentication", + "SNS subscription confirmation URL is not trusted", + ); + } + + return url; +} + +async function getSnsSigningKey(signingCertUrl: string): Promise { + const url = assertTrustedSnsCertificateUrl(signingCertUrl); + let response: Response; + try { + response = await fetch(url, { + redirect: "error", + signal: AbortSignal.timeout(5000), + }); + } catch { + throw new InboundEmailError( + "transient", + "Unable to retrieve the SNS signing certificate", + ); + } + + if (!response.ok) { + throw new InboundEmailError( + "transient", + "Unable to retrieve the SNS signing certificate", + ); + } + + const certificate = await response.text(); + if (Buffer.byteLength(certificate) > MAX_SNS_SIGNING_CERTIFICATE_BYTES) { + throw new InboundEmailError( + "authentication", + "SNS signing certificate is unexpectedly large", + ); + } + + try { + return new X509Certificate(certificate).publicKey; + } catch { + throw new InboundEmailError( + "authentication", + "SNS signing certificate is invalid", + ); + } +} + +export function parseSnsMessage(rawBody: string): SnsMessage { + const body = parseJsonBody(rawBody); + const type = getRequiredString(body, "Type") as SnsMessageType; + if ( + type !== "Notification" && + type !== "SubscriptionConfirmation" && + type !== "UnsubscribeConfirmation" + ) { + throw new InboundEmailError("invalid", "Unsupported SNS message type"); + } + + const message: SnsMessage = { + Type: type, + MessageId: getRequiredString(body, "MessageId"), + TopicArn: getRequiredString(body, "TopicArn"), + Message: getRequiredString(body, "Message"), + Timestamp: getRequiredString(body, "Timestamp"), + SignatureVersion: getRequiredString(body, "SignatureVersion"), + SigningCertURL: getRequiredString(body, "SigningCertURL"), + Signature: getRequiredString(body, "Signature"), + }; + if (typeof body.Subject === "string") { + message.Subject = body.Subject; + } + if (typeof body.Token === "string") { + message.Token = body.Token; + } + if (typeof body.SubscribeURL === "string") { + message.SubscribeURL = body.SubscribeURL; + } + + if (type === "SubscriptionConfirmation" && !message.SubscribeURL) { + throw new InboundEmailError( + "invalid", + "SNS subscription confirmation is missing SubscribeURL", + ); + } + + return message; +} + +export function buildSnsSigningString(message: SnsMessage): string { + const fields = + message.Type === "Notification" + ? [ + "Message", + "MessageId", + "Subject", + "Timestamp", + "TopicArn", + "Type", + ] + : [ + "Message", + "MessageId", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type", + ]; + + return fields + .flatMap((field) => { + const value = message[field as keyof SnsMessage]; + return field === "Subject" && value === undefined + ? [] + : [field, String(value ?? "")]; + }) + .join("\n"); +} + +export async function verifySnsMessage( + message: SnsMessage, + { getSigningKey = getSnsSigningKey }: SnsSignatureVerificationOptions = {}, +): Promise { + assertExpectedSesTopic(message.TopicArn); + if (message.SignatureVersion !== "2") { + throw new InboundEmailError( + "authentication", + "SNS SignatureVersion 2 is required", + ); + } + + let signature: Uint8Array; + try { + signature = Uint8Array.from(Buffer.from(message.Signature, "base64")); + } catch { + throw new InboundEmailError( + "authentication", + "SNS signature is invalid", + ); + } + + if (!signature.length) { + throw new InboundEmailError( + "authentication", + "SNS signature is invalid", + ); + } + + const signingKey = await getSigningKey(message.SigningCertURL); + const signingString = buildSnsSigningString(message); + const valid = + verify( + "RSA-SHA256", + textEncoder.encode(signingString), + signingKey, + signature, + ) || + // Some SNS deliveries use the legacy final-delimiter form. It is safe + // to accept only because the same AWS certificate still verifies it. + verify( + "RSA-SHA256", + textEncoder.encode(`${signingString}\n`), + signingKey, + signature, + ); + if (!valid) { + throw new InboundEmailError( + "authentication", + "SNS message signature is invalid", + ); + } +} + +function getS3Client(region: string) { + let client = s3Clients.get(region); + if (!client) { + client = new S3Client({ region }); + s3Clients.set(region, client); + } + + return client; +} + +async function getS3Object({ + bucket, + key, + region, +}: SesS3ObjectOptions): Promise { + try { + const result = await getS3Client(region).send( + new GetObjectCommand({ Bucket: bucket, Key: key }), + ); + if ( + !result.Body || + typeof result.Body.transformToByteArray !== "function" + ) { + throw new Error("S3 object had no readable body"); + } + if ( + result.ContentLength !== undefined && + result.ContentLength > MAX_SES_S3_EMAIL_BYTES + ) { + throw new InboundEmailError( + "invalid", + "SES email object exceeds the supported size", + ); + } + + const body = await result.Body.transformToByteArray(); + if (body.byteLength > MAX_SES_S3_EMAIL_BYTES) { + throw new InboundEmailError( + "invalid", + "SES email object exceeds the supported size", + ); + } + + return body; + } catch (error) { + if (error instanceof InboundEmailError) { + throw error; + } + + throw new InboundEmailError( + "transient", + "Unable to retrieve the SES email object", + ); + } +} + +function parseSesS3Notification(rawMessage: string): SesS3Notification { + const value = parseJsonBody(rawMessage); + if (!isRecord(value.receipt) || !isRecord(value.mail)) { + throw new InboundEmailError( + "invalid", + "SES notification is missing receipt data", + ); + } + + return { + notificationType: + typeof value.notificationType === "string" + ? value.notificationType + : "", + receipt: { + recipients: value.receipt.recipients, + action: isRecord(value.receipt.action) + ? value.receipt.action + : undefined, + }, + mail: { + messageId: value.mail.messageId, + }, + }; +} + +function getObjectLocation( + notification: SesS3Notification, + config: SesInboundConfiguration, +) { + const action = notification.receipt.action; + const bucket = action?.bucketName; + const key = action?.objectKey; + const expectedPrefix = config.objectPrefix; + const hasExpectedPrefix = + !expectedPrefix || + (typeof key === "string" && + (key === expectedPrefix || key.startsWith(`${expectedPrefix}/`))); + + if ( + notification.notificationType !== "Received" || + action?.type !== "S3" || + bucket !== config.bucket || + typeof key !== "string" || + !key || + !hasExpectedPrefix + ) { + throw new InboundEmailError( + "invalid", + "SES notification does not reference the configured S3 object", + ); + } + + return { bucket, key, region: config.region }; +} + +async function parseMimeEmail({ + content, + recipients, + messageId, +}: { + content: Uint8Array; + recipients: unknown; + messageId: unknown; +}): Promise { + let parsed; + try { + parsed = await simpleParser(Buffer.from(content)); + } catch { + throw new InboundEmailError("invalid", "SES email MIME is invalid"); + } + + const from = parseEmailAddress(parsed.from?.value[0]?.address); + const to = Array.from( + new Set( + parseEmailAddresses([ + ...(Array.isArray(recipients) ? recipients : []), + ...(parsed.to?.value.map((recipient) => recipient.address) || + []), + ...(parsed.cc?.value.map((recipient) => recipient.address) || + []), + ]), + ), + ); + if (!from || !to.length) { + throw new InboundEmailError( + "invalid", + "SES email is missing required address headers", + ); + } + + return { + from, + to, + subject: parsed.subject || undefined, + textBody: parsed.text || "", + messageId: + parseMessageId(messageId) || parseMessageId(parsed.messageId), + }; +} + +export function createSesInboundEmailAdapter( + dependencies: SesInboundEmailAdapterDependencies = {}, +): InboundEmailAdapter { + const verifyMessage = dependencies.verifySnsMessage || verifySnsMessage; + const loadS3Object = dependencies.getS3Object || getS3Object; + + return { + provider: "ses", + + async verify(input: InboundEmailRequest) { + await verifyMessage(parseSnsMessage(input.rawBody)); + }, + + async parse(input: InboundEmailRequest): Promise { + const envelope = parseSnsMessage(input.rawBody); + assertExpectedSesTopic(envelope.TopicArn); + if (envelope.Type === "SubscriptionConfirmation") { + return { + kind: "subscription_confirmation", + subscribeUrl: envelope.SubscribeURL!, + }; + } + if (envelope.Type === "UnsubscribeConfirmation") { + return { kind: "unsubscribe_confirmation" }; + } + + const config = getSesInboundConfiguration(); + const notification = parseSesS3Notification(envelope.Message); + const location = getObjectLocation(notification, config); + const content = await loadS3Object(location); + + return { + kind: "email", + email: await parseMimeEmail({ + content, + recipients: notification.receipt.recipients, + messageId: notification.mail.messageId, + }), + }; + }, + }; +} + +export const sesAdapter = createSesInboundEmailAdapter(); + +export async function confirmSnsSubscription(subscribeUrl: string) { + const url = assertTrustedSnsSubscriptionUrl(subscribeUrl); + try { + const response = await fetch(url, { + redirect: "error", + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + throw new Error("SNS subscription confirmation failed"); + } + } catch (error) { + if (error instanceof InboundEmailError) { + throw error; + } + + throw new InboundEmailError( + "transient", + "Unable to confirm the SNS subscription", + ); + } +} diff --git a/apps/web/lib/inbound-email/types.ts b/apps/web/lib/inbound-email/types.ts new file mode 100644 index 000000000..2a925a3eb --- /dev/null +++ b/apps/web/lib/inbound-email/types.ts @@ -0,0 +1,43 @@ +import type { InboundEmailProvider as SharedInboundEmailProvider } from "@courselit/common-models"; + +export type InboundEmailProvider = SharedInboundEmailProvider; + +export interface NormalizedInboundEmail { + to: string[]; + from: string; + subject?: string; + textBody: string; + strippedReply?: string; + messageId?: string; +} + +export interface InboundEmailProcessingInput { + provider: InboundEmailProvider; + email: NormalizedInboundEmail; +} + +export interface InboundEmailRequest { + rawBody: string; + headers: Headers; + searchParams: URLSearchParams; + contentType: string; +} + +export type ParsedInboundEmail = + | { + kind: "email"; + email: NormalizedInboundEmail; + } + | { + kind: "subscription_confirmation"; + subscribeUrl: string; + } + | { + kind: "unsubscribe_confirmation"; + }; + +export interface InboundEmailAdapter { + provider: InboundEmailProvider; + verify(input: InboundEmailRequest): Promise; + parse(input: InboundEmailRequest): Promise; +} diff --git a/apps/web/models/EmailReplyToken.ts b/apps/web/models/EmailReplyToken.ts new file mode 100644 index 000000000..6fa5d630e --- /dev/null +++ b/apps/web/models/EmailReplyToken.ts @@ -0,0 +1,14 @@ +import { + EmailReplyTokenSchema, + InternalEmailReplyToken, +} from "@courselit/orm-models"; +import mongoose, { Model } from "mongoose"; + +const EmailReplyTokenModel = + (mongoose.models.EmailReplyToken as Model) || + mongoose.model( + "EmailReplyToken", + EmailReplyTokenSchema, + ); + +export default EmailReplyTokenModel; diff --git a/apps/web/models/InboundEmailReceipt.ts b/apps/web/models/InboundEmailReceipt.ts new file mode 100644 index 000000000..1f06017e4 --- /dev/null +++ b/apps/web/models/InboundEmailReceipt.ts @@ -0,0 +1,16 @@ +import { + InboundEmailReceiptSchema, + InternalInboundEmailReceipt, +} from "@courselit/orm-models"; +import mongoose, { Model } from "mongoose"; + +const InboundEmailReceiptModel = + (mongoose.models.InboundEmailReceipt as + | Model + | undefined) || + mongoose.model( + "InboundEmailReceipt", + InboundEmailReceiptSchema, + ); + +export default InboundEmailReceiptModel; diff --git a/apps/web/package.json b/apps/web/package.json index 60d21cdfc..f3b238dec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ ] }, "dependencies": { + "@aws-sdk/client-s3": "^3.1091.0", "@better-auth/sso": "1.6.11", "@courselit/common-logic": "workspace:^", "@courselit/common-models": "workspace:^", @@ -77,11 +78,13 @@ "color-convert": "^3.1.0", "cookie": "^0.4.2", "date-fns": "^4.1.0", + "email-reply-parser": "^2.3.9", "graphql": "^16.10.0", "graphql-type-json": "^0.3.2", "jsdom": "^26.1.0", "lodash.debounce": "^4.0.8", "lucide-react": "^0.553.0", + "mailparser": "^3.9.14", "medialit": "0.2.0", "mongodb": "^6.21.0", "mongoose": "^8.22.1", @@ -107,18 +110,19 @@ "zod": "^3.24.1" }, "devDependencies": { - "autoprefixer": "^10.4.21", "@eslint/eslintrc": "^3.3.1", "@shelf/jest-mongodb": "^5.2.2", "@types/adm-zip": "^0.5.7", "@types/bcryptjs": "^2.4.2", "@types/cookie": "^0.4.1", + "@types/mailparser": "^3.4.6", "@types/mongodb": "^4.0.7", "@types/node": "17.0.21", "@types/nodemailer": "^6.4.4", "@types/pug": "^2.0.6", "@types/react": "19.2.4", "@types/xml2js": "^0.4.14", + "autoprefixer": "^10.4.21", "eslint": "^9.12.0", "eslint-config-next": "^16.2.9", "eslint-config-prettier": "^9.0.0", diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 6a034470f..2e76f4288 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -14,6 +14,16 @@ export async function proxy(request: NextRequest) { ); } + // Inbound email is addressed to a shared reply domain. Its tenant is + // resolved from the opaque reply token, not the request host. + if (request.nextUrl.pathname.startsWith("/api/inbound-email/")) { + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); + } + if (request.nextUrl.pathname.startsWith("/course/")) { requestHeaders.set( COURSE_VIEWER_CURRENT_URL_HEADER, diff --git a/apps/web/services/logger.ts b/apps/web/services/logger.ts index 9883d91f5..a0b06b587 100644 --- a/apps/web/services/logger.ts +++ b/apps/web/services/logger.ts @@ -14,7 +14,7 @@ export const info = async ( metadata, }); } else { - console.log(severityError, message, metadata); // eslint-disable-line no-console + console.log(severityInfo, message, metadata); // eslint-disable-line no-console } }; @@ -29,7 +29,7 @@ export const warn = async ( metadata, }); } else { - console.warn(severityError, message, metadata); + console.warn(severityWarn, message, metadata); } }; diff --git a/deployment/docker/docker-compose.yml b/deployment/docker/docker-compose.yml index ec5a5b4d8..1781149c3 100644 --- a/deployment/docker/docker-compose.yml +++ b/deployment/docker/docker-compose.yml @@ -50,22 +50,33 @@ services: # CourseLit uses MediaLit (our another open-source software) to manage files on # a AWS S3 compatible storage. MediaLit is available as a hosted service # at https://medialit.cloud. You can self host it as well. - # - # Uncomment the following lines to use MediaLit as a hosted service. + # + # Uncomment the following lines to use MediaLit as a hosted service. # The MEDIALIT_APIKEY can be obtained by signing up at https://medialit.cloud. # - MEDIALIT_APIKEY=${MEDIALIT_APIKEY} # - MEDIALIT_SERVER=https://api.medialit.cloud - # + # # Uncomment the following lines to use MediaLit as a self hosted service. # The MEDIALIT_APIKEY can be obtained by running the medialit service locally and # checking the logs for the API key. # - MEDIALIT_APIKEY=${MEDIALIT_APIKEY} # - MEDIALIT_SERVER=http://medialit - # + # # Google reCAPTCHA v3 is used to prevent abuse of the login functionality. # Uncomment the following lines to use reCAPTCHA. # - RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} # - RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY} + + # Reply by email (requires the queue service too). Set the same reply + # domain in the app and queue services. Then configure exactly one + # inbound provider as documented in apps/docs. + # - INBOUND_EMAIL_DOMAIN=${INBOUND_EMAIL_DOMAIN} + # - INBOUND_EMAIL_WEBHOOK_SECRET=${INBOUND_EMAIL_WEBHOOK_SECRET} # Postmark + # - MAILGUN_WEBHOOK_SIGNING_KEY=${MAILGUN_WEBHOOK_SIGNING_KEY} # Mailgun + # - INBOUND_EMAIL_SES_TOPIC_ARN=${INBOUND_EMAIL_SES_TOPIC_ARN} # Amazon SES + # - INBOUND_EMAIL_SES_BUCKET=${INBOUND_EMAIL_SES_BUCKET} + # - INBOUND_EMAIL_SES_REGION=${INBOUND_EMAIL_SES_REGION} + # - INBOUND_EMAIL_SES_OBJECT_PREFIX=${INBOUND_EMAIL_SES_OBJECT_PREFIX} expose: - "${PORT:-80}" @@ -116,6 +127,9 @@ services: # - EMAIL_USER=${EMAIL_USER?'EMAIL_USER environment variable is not defined'} # - EMAIL_PASS=${EMAIL_PASS?'EMAIL_PASS environment variable is not defined'} # - EMAIL_HOST=${EMAIL_HOST?'EMAIL_HOST environment variable is not defined'} + # # Optional: enables reply tokens and Reply-To headers for conversation emails. + # # The configured subdomain must be routed to a supported inbound provider. + # - INBOUND_EMAIL_DOMAIN=${INBOUND_EMAIL_DOMAIN} # # The following secret is used by CourseLit app to sign jobs sent to the queue. # - COURSELIT_JWT_SECRET=${COURSELIT_JWT_SECRET?'COURSELIT_JWT_SECRET environment variable is not defined'} # - REDIS_HOST=redis @@ -159,16 +173,16 @@ services: # - ENABLE_TRUST_PROXY=${ENABLE_TRUST_PROXY} # # CloudFront configuration - # - ACCESS_PRIVATE_BUCKET_VIA_CLOUDFRONT=${ACCESS_PRIVATE_BUCKET_VIA_CLOUDFRONT} - # - CLOUDFRONT_KEY_PAIR_ID=${CLOUDFRONT_KEY_PAIR_ID} + # - ACCESS_PRIVATE_BUCKET_VIA_CLOUDFRONT=${ACCESS_PRIVATE_BUCKET_VIA_CLOUDFRONT} + # - CLOUDFRONT_KEY_PAIR_ID=${CLOUDFRONT_KEY_PAIR_ID} # - CLOUDFRONT_PRIVATE_KEY=${CLOUDFRONT_PRIVATE_KEY} # - CDN_MAX_AGE=${CDN_MAX_AGE} - # # To make MediaLit work on local. This allows the medialit service + # # To make MediaLit work on local. This allows the medialit service # # to be accessed from the host machine. # - HOSTNAME_OVERRIDE=localhost:8000 # ports: # - "8000:80" # depends_on: # - mongo - # restart: on-failure \ No newline at end of file + # restart: on-failure diff --git a/docs/user-deletion.md b/docs/user-deletion.md index c7b7eab49..f05f17a8c 100644 --- a/docs/user-deletion.md +++ b/docs/user-deletion.md @@ -137,6 +137,8 @@ cleanupPersonalData(userToDelete, ctx) | |-- Mail request statuses | |-- Lesson evaluations | |-- Download links +| |-- Email reply tokens +| |-- Inbound email receipts | |-- Community reports | |-- Certificates | |-- Activity logs @@ -187,6 +189,8 @@ deleteUser | Certificates | Delete | `CertificateModel.deleteMany()` | [x] Compliant | | Evaluations | Delete | `LessonEvaluationModel.deleteMany()` | [x] Compliant | | Downloads | Delete | `DownloadLinkModel.deleteMany()` | [x] Compliant | +| Email reply tokens | Delete | `EmailReplyTokenModel.deleteMany()` | [x] Compliant | +| Inbound email receipts | Delete | `InboundEmailReceiptModel.deleteMany()` | [x] Compliant | | Reports | Delete | `CommunityReportModel.deleteMany()` | [x] Compliant | | Posts/Comments | Delete | `deleteCommunityPosts()` | [x] Compliant | | Community reactions | Delete | `CommunityReactionModel.deleteMany()` by `userId` / owned posts; hard content delete purges entity rows; soft-delete retains until hard/user/post delete | [x] Compliant | diff --git a/docs/wip/community-replies-from-email.md b/docs/wip/community-replies-from-email.md index 54d90e9e8..f86047d05 100644 --- a/docs/wip/community-replies-from-email.md +++ b/docs/wip/community-replies-from-email.md @@ -2,20 +2,28 @@ ## Context -GitHub issue [#811](https://github.com/codelitdev/courselit/issues/811): notification emails for Community Discussions and Product Discussions are today one-line summaries ("X commented on your post 'Y' in Z") with a "View notification" button — no comment content, no thread context. The issue asks for: +GitHub issue [#811](https://github.com/codelitdev/courselit/issues/811): before Phase 1, notification emails for Community Discussions and Product Discussions were one-line summaries ("X commented on your post 'Y' in Z") with a "View notification" button — no comment content or thread context. The issue asks for: 1. **Richer notification emails** — author, thread title, parent-comment context, the comment content itself, and a clear CTA. -2. **Reply-by-email** — reply tokens embedded in the Reply-To address on a dedicated reply domain; inbound provider webhooks (vendor-agnostic: SES, Postmark, Mailgun, SendGrid) post replies back into the right thread, attributed to the right user, quoted text stripped, participants notified as usual. +2. **Reply-by-email** — reply tokens embedded in the Reply-To address on a dedicated reply domain; inbound provider adapters for Amazon SES, Postmark, Mailgun, and later SendGrid post replies back into the right thread, attributed to the right user, quoted text stripped, participants notified as usual. MVP out of scope: attachments, rich HTML reply parsing, email reactions, advanced threading. +## Implementation status + +- **Phase 1 is implemented and verified.** Conversation emails now include the actor, activity label, thread title, relevant thread context, new content, and a discussion CTA. The in-app notification channel remains compact and unchanged. +- **Phase 2 is implemented and browser-verified.** Conversation emails conditionally mint reusable opaque reply tokens, add a validated `Reply-To` header, and show the direct-reply hint when `INBOUND_EMAIL_DOMAIN` is configured. The feature remains disabled when the variable is absent. +- **Phases 3–4 are implemented.** Amazon SES, Postmark, and Mailgun callbacks are authenticated, normalized, and processed through the existing discussion mutations. Operational configuration guides are published in both documentation sites. SendGrid remains a separate Phase 3b follow-up. + ## Verified architecture facts - **Pipeline**: `recordActivity()` (`apps/web/lib/record-activity.ts`) → JWT POST to queue app → `dispatch-notification` BullMQ worker (`apps/queue/src/notifications/worker/dispatch-notification.ts`) fans out per `metadata.forUserIds` gated by `NotificationPreferenceModel` → `AppChannel` / `EmailChannel` → `mail` queue → nodemailer SMTP. -- **`EmailChannel`** (`apps/queue/src/notifications/services/channels/email.ts`): calls `getNotificationMessageAndHref()` (`packages/common-logic/src/utils/get-notification-message-and-href.ts`), builds email via `buildNotificationEmailTemplate()` (`apps/queue/src/notifications/services/channels/notification-email-template.ts`), passes arbitrary `headers` to `addMailJob` (already sets `List-Unsubscribe`) — so `Reply-To` needs no mail-pipeline changes. -- **The queue already re-fetches entities at send time** via `createNotificationEntityResolver()` (`packages/common-logic/src/utils/notification-entity-resolver.ts`, lazy mongoose models from `@courselit/orm-models`); `getComment()` already returns comment content, postId, communityId, and embedded replies with content. Verified. +- **`EmailChannel`** (`apps/queue/src/notifications/services/channels/email.ts`): calls `getNotificationEmailContent()` (`packages/common-logic/src/utils/get-notification-email-content.ts`), which delegates compact subject/URL generation to `getNotificationMessageAndHref()` as the single source of truth. It builds email via `buildNotificationEmailTemplate()` (`apps/queue/src/notifications/services/channels/notification-email-template.ts`) and passes arbitrary `headers` to `addMailJob` (already sets `List-Unsubscribe`) — so `Reply-To` needs no mail-pipeline changes. +- **The queue re-fetches entities at send time** via `createNotificationEntityResolver()` (`packages/common-logic/src/utils/notification-entity-resolver.ts`, lazy mongoose models from `@courselit/orm-models`). Community posts/comments and product-discussion comments/replies are filtered with `deleted: false`, and deleted embedded community replies are removed before email content is derived. +- **Resolver calls are cached per email.** `getNotificationEmailContent()` wraps the resolver in a request-scoped promise cache keyed by entity type, domain, and ID. Subject/URL generation and rich-body generation therefore share fetched entities without introducing a cross-notification cache or stale data. - **Community**: comment/reply content is a **plain string**; `postComment()` (`apps/web/graphql/communities/logic.ts:1686`) handles both comment and reply, enforcing membership + `MembershipRole.COMMENT`, auto-subscribe, and `recordActivity`. -- **Product discussions** (separate collections): content is `TextEditorContent`; `createDiscussionComment()`/`createDiscussionReply()` (`apps/web/graphql/product-discussions/logic.ts`) enforce enrollment, content validation, and rate limits. Converters already exist and are verified: `normalizeTextEditorContent` / `extractTextFromTextEditorContent` in `@courselit/utils`. +- **Product discussions** (separate collections): content is `TextEditorContent`; `createDiscussionComment()`/`createDiscussionReply()` (`apps/web/graphql/product-discussions/logic.ts`) enforce enrollment, content validation, and rate limits. `extractTextFromTextEditorContent()` in `@courselit/utils` now preserves document paragraphs (`\n\n`), hard breaks (`\n`), and list boundaries by default. Presentation consumers retain those boundaries; validation/previews trim boundary whitespace; duplicate-content fingerprints normalize Unicode and collapse whitespace so formatting changes cannot bypass rate limiting. +- **Reply coordinates are shared types.** `ReplyByEmailContext` lives in `packages/common-models/src/email-reply-context.ts`; the product coordinate uses `ProductDiscussionEntityType` rather than an unbounded string. - Both create-functions need only a synthesized `GQLContext` `{ user, subdomain, address }` (`apps/web/models/GQLContext.ts`) — an inbound handler reuses them wholesale, inheriting all permission checks, rate limits, subscriptions, and notification fan-out. - **Constraint**: RFC 5321 caps the email local-part at 64 octets → a JWT cannot fit in Reply-To → opaque DB token (precedent: `DownloadLinkSchema` with TTL in orm-models). - Tenant for inbound webhooks must come from the token (providers post to one shared URL; the `proxy.ts` domain header is host-based). @@ -25,68 +33,114 @@ MVP out of scope: attachments, rich HTML reply parsing, email reactions, advance ## Phase 1 — Richer emails (Part A, independently shippable — tracked as [#825](https://github.com/codelitdev/courselit/issues/825)) +**Status: implemented.** + **Decision: re-fetch content in the queue worker (thin metadata)** — the resolver pattern already exists and fetches comment content; zero changes to `recordActivity` callers, no payload bloat in Redis, no stale content. The APP channel stays byte-for-byte unchanged. **Constraint: content richness is per-channel.** The in-app notification panel must stay compact (current one-liners with `truncate(20)` titles) to avoid bloating the UI — only emails get the full content blocks. This falls out of the design: the panel/APP channel keeps `getNotificationMessageAndHref()` as-is; only `EmailChannel` adopts `getNotificationEmailContent()`. -1. **Extend `NotificationEntityResolver`** (`packages/common-logic/src/utils/notification-entity-resolver.ts`) with optional `getDiscussionComment(commentId, domainId)` and `getDiscussionReply(replyId, domainId)` using the same lazy-model pattern with product-discussion schemas from orm-models. Also add `content: 1` to the `getPost()` projection (community post `content` is `TextEditorContent | string` — handle both when extracting text). -2. **New `packages/common-logic/src/utils/get-notification-email-content.ts`** (export from the barrel): - - Returns `NotificationEmailContent { subject, message, href, commentText?, parentText?, parentAuthorName?, threadTitle?, replyContext? }`. +1. **Extended `NotificationEntityResolver`** (`packages/common-logic/src/utils/notification-entity-resolver.ts`) with optional `getDiscussionComment(commentId, domainId)` and `getDiscussionReply(replyId, domainId)` using the same lazy-model pattern with product-discussion schemas from orm-models. The community post projection includes `content`; post, comment, and reply lookups suppress soft-deleted content. +2. **Added `packages/common-logic/src/utils/get-notification-email-content.ts`** (exported from the barrel): + - Returns `NotificationEmailContent { subject, message, href, commentText?, parentText?, parentAuthorName?, parentLabel?, threadTitle?, conversationLabel?, replyContext? }`. - Internally calls `getNotificationMessageAndHref()` for `message`/`href` (single source of truth). - Populates extras only for the five conversation activity types: - - `COMMUNITY_POST_CREATED` — post body excerpt (string or via `extractTextFromTextEditorContent`) + post title; `replyContext.community = { communityId, postId }` (no `parentCommentId` — an email reply becomes a top-level comment on the post). - - `COMMUNITY_COMMENT_CREATED` — comment content + post title; `replyContext.community = { communityId, postId, parentCommentId: entityId }`. - - `COMMUNITY_REPLY_CREATED` — reply content, parent (reply or comment) excerpt; `replyContext.community = { …, parentCommentId: commentId, parentReplyId: replyId }`. - - `COURSE_DISCUSSION_COMMENT_CREATED` (eventType comment_created | reply_created) — content via `extractTextFromTextEditorContent`; course title; `replyContext.product = { productId, entityType, entityId, commentId, parentReplyId? }`. - - Caps: commentText ~1000 chars, parentText ~200 chars. Optional `resolveUserName(userId)` callback for parent-author names (queue passes a UserModel lookup). + - `COMMUNITY_POST_CREATED` — `New post`, post body excerpt (plain string or `TextEditorContent`) + post title; `replyContext.community = { communityId, postId }` (no `parentCommentId` — an email reply becomes a top-level comment on the post). + - `COMMUNITY_COMMENT_CREATED` — `New comment`, comment content + post title, plus the original post excerpt and author in a gray context block labeled `Original post`; `replyContext.community = { communityId, postId, parentCommentId: entityId }`. + - `COMMUNITY_REPLY_CREATED` / `COMMUNITY_COMMENT_REPLIED` — `New reply`, reply content plus the active parent reply/comment excerpt and author in a gray context block labeled `Earlier comment`; `replyContext.community = { …, parentCommentId: commentId, parentReplyId: replyId }`. + - `COURSE_DISCUSSION_COMMENT_CREATED` (`eventType: comment_created | reply_created`) — `New comment` or `New reply`, rich-text content + course title. Replies include their active parent comment/reply context. `replyContext.product = { productId, entityType, entityId, commentId, parentReplyId? }`. + - Exact caps: `commentText` 1000 characters and `parentText` 200 characters. Optional `resolveUserName(userId)` resolves context-author names; the queue passes a domain-scoped `UserModel` lookup. - All other activity types: output identical to today. - - `ReplyByEmailContext` = thread coordinates consumed by Phase 2 token minting. -3. **Extend the template** (`apps/queue/src/notifications/services/channels/notification-email-template.ts`): parent-context block (small gray "In reply to X: …"), comment-body block (15px, line breaks preserved — verify markdown hard-break behavior in `renderEmailToHtml`), CTA text "View discussion" for conversation types, and a "You can reply to this email to respond directly" footer only when reply-by-email is active. All content through the existing `encodePlainTextForMarkdown`. -4. **Wire `EmailChannel`** (`apps/queue/src/notifications/services/channels/email.ts:30`): swap `getNotificationMessageAndHref` → `getNotificationEmailContent`; pass new fields to the template; subject = `content.subject`. + - `ReplyByEmailContext` contains thread coordinates consumed by Phase 2 token minting. +3. **Extended the template** (`apps/queue/src/notifications/services/channels/notification-email-template.ts`): actor/activity header, 18px thread title, gray context block with an explicit label (`Original post` or `Earlier comment`), 16px new-content block, preserved line breaks, and CTA text `View discussion` for conversation types. Dynamic content is encoded through `encodePlainTextForMarkdown`. The reply-by-email hint exists behind `showReplyByEmailHint` but is not enabled until Phase 2 supplies a working `Reply-To` address. +4. **Wired `EmailChannel`** (`apps/queue/src/notifications/services/channels/email.ts`): replaced its direct `getNotificationMessageAndHref()` call with `getNotificationEmailContent()`, passes the rich content/context fields to the template, resolves context-author names within the current domain, and uses `content.subject` as the email subject. - **Subject strategy**: keep `subject = one-line message` (actor + action + truncated title — good inbox scanability). Alternative `Re: ""` for mail-client threading noted; can revisit. +**Supporting rich-text refactor:** `extractTextFromTextEditorContent()` is now the single structure-preserving plain-text extractor used by emails, moderation previews, validation, and discussion fingerprints. Fingerprints deliberately canonicalize the extracted output (`NFKC`, trim, lowercase, collapse whitespace), keeping duplicate detection insensitive to paragraph-only formatting differences while avoiding accidental `Hello` + `world` → `Helloworld` collisions. + ## Phase 2 — Reply tokens + Reply-To (Part B) -**Decision: opaque DB token** (an HMAC `id.sig` scheme can't fit the thread coordinates in 64 octets anyway). 20 random bytes → 32-char string; `reply+<token>` ≈ 38 octets. +**Status: implemented and browser-verified.** + +**Decision: opaque DB token** (an HMAC `id.sig` scheme can't fit the thread coordinates in 64 octets anyway). 20 random bytes encoded as unpadded base64url → 27 characters; `reply+<token>` is 33 octets, below the RFC 5321 64-octet local-part limit. + +**Provider boundary:** Phase 2 is provider-neutral: it mints the token and emits the `Reply-To` address, but does not ingest the resulting email. Phase 3 initially ships Amazon SES, Postmark, and Mailgun adapters so a Phase 2 address has a complete inbound path. SendGrid follows as Phase 3b. 5. **New schema `packages/orm-models/src/models/email-reply-token.ts`** (mirror `download-link.ts`, export from index): - `{ domain, token (unique), userId, kind: "community"|"product", community?: {communityId, postId, parentCommentId?, parentReplyId?}, product?: {productId, entityType, entityId, commentId, parentReplyId?}, contextKey, expiresAt (TTL index), createdAt }` (`parentCommentId` absent = reply targets the post itself as a top-level comment). - - Unique index `{ domain, userId, contextKey }` (precomputed context string) → minting is an idempotent upsert. + - Unique index `{ domain, userId, contextKey }` (the context key is a SHA-256 digest of canonical thread coordinates) → minting is an idempotent upsert without duplicating readable coordinates in the index. + - A TTL index expires unused records automatically. User deletion also removes that user's tokens immediately; the web deletion regression suite covers this lifecycle path. - Model registrations: `apps/queue/src/domain/model/email-reply-token.ts` and `apps/web/models/EmailReplyToken.ts`. 6. **Minting service `apps/queue/src/notifications/services/email-reply-token.ts`**: - - `mintReplyToken({ domainId, userId, context })` — upsert, sliding 30-day `expiresAt`, returns token. - - `isReplyByEmailEnabled()` = `!!process.env.INBOUND_EMAIL_DOMAIN`; `buildReplyToAddress(token)` = `` `reply+${token}@${INBOUND_EMAIL_DOMAIN}` ``. + - `mintReplyToken({ domainId, userId, context })` — validates exactly one complete community/product context, performs a concurrency-safe upsert, slides `expiresAt` by 30 days, and returns the existing token for the same recipient and thread position. + - `isReplyByEmailEnabled()` = `!!process.env.INBOUND_EMAIL_DOMAIN`; `buildReplyToAddress(token)` validates both the base64url-safe token and a normalized ASCII email domain before returning `` `reply+${token}@${INBOUND_EMAIL_DOMAIN}` ``. Invalid or header-injection-shaped configuration fails the notification job instead of emitting an unsafe header. - In `EmailChannel.send`: when `replyContext` exists and feature enabled, add `"Reply-To"` to the existing headers object. Env absent → no Reply-To, everything else identical (self-hosters unaffected). Tokens are multi-use (standard reply-address semantics). -7. **Env vars**: `INBOUND_EMAIL_DOMAIN` (queue + web; presence = feature on), `INBOUND_EMAIL_WEBHOOK_SECRET` (web), `MAILGUN_WEBHOOK_SIGNING_KEY` (web, optional). TTL is a constant (30 d), not env. +7. **Env/configuration**: + - Phase 2: `INBOUND_EMAIL_DOMAIN` is read by the queue (presence enables token minting and the `Reply-To` header). Phase 3 also supplies the same value to the web app so inbound recipients can be matched. The queue environment template and Docker Compose example include the optional queue setting. TTL is a constant (30 d), not env. + - Phase 3 common: `INBOUND_EMAIL_WEBHOOK_SECRET` for providers protected by a shared endpoint secret. + - Amazon SES: `INBOUND_EMAIL_SES_TOPIC_ARN`, `INBOUND_EMAIL_SES_BUCKET`, `INBOUND_EMAIL_SES_REGION`, and optional `INBOUND_EMAIL_SES_OBJECT_PREFIX`. Prefer the workload's IAM role over static AWS credentials; the web workload needs `s3:GetObject` for the inbound bucket. The S3 bucket must have a short lifecycle expiration so raw MIME objects do not accumulate. + - Mailgun: `MAILGUN_WEBHOOK_SIGNING_KEY` for HMAC verification. ## Phase 3 — Inbound endpoint + provider abstraction (Part C) -8. **Adapter layer, new dir `apps/web/lib/inbound-email/`**: +**Status: implemented for Amazon SES, Postmark, and Mailgun.** + +8. **Adapter layer, `apps/web/lib/inbound-email/`**: - `types.ts` — `NormalizedInboundEmail { to[], from, subject?, textBody, strippedReply?, messageId? }`; `InboundEmailAdapter { provider, verify({rawBody, headers, searchParams}), parse({rawBody, contentType}) }`. - - `providers/postmark.ts` (JSON; `StrippedTextReply`), `providers/mailgun.ts` (form-encoded; `stripped-text`; HMAC of timestamp+token with signing key + 5-min freshness = replay protection), `providers/sendgrid.ts` (multipart, phase 3b), `providers/index.ts` registry. - - **SES deferred** (needs SNS confirmation handling + S3 body fetch; the interface accommodates it later). Order: Postmark → Mailgun → SendGrid → SES. - - `extract-reply-text.ts` — provider `strippedReply` when present, else `email-reply-parser` npm fallback on `textBody`; trim + 5000-char cap. (Add dep to `apps/web/package.json`.) -9. **Processing pipeline `apps/web/lib/inbound-email/process-inbound-email.ts`** — returns `{ok:true} | {ok:false, reason}`: + - Initial adapters: `providers/ses.ts`, `providers/postmark.ts`, and `providers/mailgun.ts`; `providers/sendgrid.ts` follows in Phase 3b. `providers/index.ts` is the registry. + - **Amazon SES (first-class because the production stack is hosted on AWS):** follow the [AWS email-receiving prerequisites](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-setting-up.html): verify the reply subdomain in an SES email-receiving Region, point its MX record at that Region's SES inbound endpoint, and grant SES access to the AWS resources used by the receipt rule. Add an active receipt rule scoped to the reply subdomain. The rule stores raw MIME in S3 and publishes the S3 receipt notification to SNS. SNS subscribes `POST /api/inbound-email/ses`. The adapter validates the SNS signature and expected `TopicArn`, safely handles `SubscriptionConfirmation`, reads the expected bucket/object key using the workload IAM role, parses the MIME message, and normalizes it. S3 is preferred over putting content directly in an SNS action because direct SNS email content is limited to 150 KB, while the S3 action supports messages up to 40 MB. + - **Postmark:** JSON payload with `StrippedTextReply`; protect the configured webhook URL with the shared endpoint secret. + - **Mailgun:** form/multipart payload with `stripped-text`; verify the HMAC of timestamp + token with the signing key and enforce 5-minute freshness for replay protection. + - **SendGrid (Phase 3b):** multipart Inbound Parse payload with raw-body ECDSA signature verification. + - `extract-reply-text.ts` — use a provider's stripped reply when present; otherwise use `email-reply-parser` on `textBody`. SES raw MIME additionally needs a MIME parser before this step. Trim and cap accepted reply text at 5000 characters. (Add the required dependencies to `apps/web/package.json`.) +9. **Processing pipeline `apps/web/lib/inbound-email/process-inbound-email.ts`**: 1. Find `reply+<token>@$INBOUND_EMAIL_DOMAIN` among recipients → else `no_reply_address`. 2. Token lookup + expiry check → `invalid_token`. 3. Load Domain (from token) + `User.findOne({ domain, userId: token.userId, active: true })`. 4. **Sender check**: parsed From must equal `user.email` (case-insensitive) → `sender_mismatch` (token possession alone is insufficient — emails get forwarded). 5. Extract reply text → `empty_reply` if blank. - 6. Rate limit via existing `assertRateLimit` (`apps/web/lib/assert-rate-limit.ts`), key `inbound-email:<domain>:<userId>`. - 7. Synthesize `ctx = { user, subdomain: domain, address: "" }`; dispatch: + 6. If the provider supplied a message ID, atomically lease an `InboundEmailReceipt` before creating content. Accepted receipts are retained for 30 days; duplicate delivery is acknowledged without a second comment, while an active five-minute lease returns a retryable response. Receipts contain no message body and are removed when their user is deleted. + 7. Rate limit via existing `assertRateLimit` (`apps/web/lib/assert-rate-limit.ts`), scoped to `inbound_email` / `reply:create`, with 5 replies per minute and 50 per day. + 8. Synthesize `ctx = { user, subdomain: domain, address: "" }`; dispatch: - community → `postComment({ ctx, communityId, postId, content: replyText, parentCommentId?, parentReplyId? })` (no `parentCommentId` in token → top-level comment on the post) - product → `createDiscussionReply({ ctx, productId, entityType, entityId, commentId, parentReplyId, content: normalizeTextEditorContent(replyText) })` - All checks (membership, enrollment, deleted content, rate limits) re-enforced inside; `recordActivity` inside → participants re-notified as usual. - 8. Thrown errors → logged rejection. **Policy: silent drop with logging, no bounce emails** (bounces leak info and can loop); courtesy-failure email is a future enhancement. + 9. Terminal rejections are logged and silently dropped. **Policy: no bounce emails** (bounces leak information and can loop); courtesy-failure email is a future enhancement. 10. **Route `apps/web/app/api/inbound-email/[provider]/route.ts`**: - `POST`; `await req.text()` first (raw body for HMAC — deliberately better than the payment-webhook precedent, which skips real verification). - - Check `INBOUND_EMAIL_WEBHOOK_SECRET` (query param / basic auth) → 401 on mismatch; unknown provider → 404. - - `adapter.verify()` → `adapter.parse()` → `processInboundEmail()`. - - **Always 200 after auth passes** (even on rejection) so providers don't retry-loop; log reasons via `@/services/logger`. Ignores the `domain` header — tenant comes from the token. Confirm `proxy.ts` passes this path through for the apex hostname. + - Unknown provider → 404. Authenticate using the provider's strongest available mechanism: SNS Signature Version 2 plus expected topic for SES, Mailgun HMAC, and HTTP Basic authentication backed by the shared endpoint secret for Postmark. + - `adapter.verify()` → provider-specific retrieval/parsing → `processInboundEmail()`. SES control messages are handled before normalized-email processing; a subscription is confirmed only after its signature and expected topic are validated. + - Return 200 after successful processing, duplicate delivery, or a terminal application rejection so providers do not retry-loop. Return 503 for a transient infrastructure error, including an unavailable S3 object or a concurrently leased receipt. Log outcomes via `@/services/logger`. Ignore the `domain` request header — tenant comes from the reply token. `proxy.ts` explicitly bypasses host-based tenant resolution for this path. ## Phase 4 — Docs -11. Document env vars + provider setup (reply-domain MX → provider inbound → webhook URL) in `docs/` and the self-hosting env reference. +**Status: implemented.** + +11. **Self-hosting documentation is a release gate.** The same change adds equivalent Amazon SES, Postmark, and Mailgun configuration guides to both active documentation sites. + - Legacy Astro docs: add `apps/docs/src/pages/en/self-hosting/reply-by-email.md`, add it to the `Self hosting` navigation in `apps/docs/src/config.ts`, and link it from `apps/docs/src/pages/en/self-hosting/self-host.md`. + - Fumadocs site: add `apps/docs-new/content/docs/self-hosting/reply-by-email.mdx`, add it to `apps/docs-new/content/docs/self-hosting/meta.json`, and link it from `apps/docs-new/content/docs/self-hosting/self-host.mdx`. + - The Docker Compose example identifies service placement for all inbound variables. The queue-side switch remains documented in `apps/queue/.env` and `apps/queue/README.md`. +12. **The guides cover common reply-by-email configuration**: + - State the CourseLit version in which each provider became available. Initial support is Amazon SES, Postmark, and Mailgun; add SendGrid instructions only when Phase 3b ships. + - Explain that outbound SMTP and inbound reply processing are independent and may use different providers. + - Require a dedicated inbound subdomain such as `replies.example.com`; warn self-hosters not to replace the MX records of a root domain already used for normal mail. + - Document `INBOUND_EMAIL_DOMAIN`, which services need each environment variable (web, queue, or both), the public HTTPS provider endpoint, reverse-proxy requirements, container restart steps, and how to disable the feature safely. + - Include an end-to-end test: trigger a real conversation notification, confirm its `Reply-To`, reply from the notified user's address, verify one new thread entry, and inspect web/queue logs for rejection reasons. + - Include troubleshooting for DNS propagation, an unverified provider domain, invalid webhook signatures/secrets, sender mismatch, expired tokens, empty stripped replies, inaccessible S3 objects, and missing membership/enrollment permissions. + - Never instruct users to log or paste real reply tokens, webhook secrets, signing keys, or AWS credentials. +13. **Amazon SES documentation** uses the official [email-receiving prerequisites](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-setting-up.html), [S3 receipt action](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-s3.html), and [SNS signature verification](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html): + - Choose a Region that supports SES email receiving; verify the inbound subdomain and publish its regional inbound MX record. + - Create the active receipt rule scoped to the reply subdomain. Store raw MIME in the expected S3 bucket/prefix and publish the S3 action notification to the expected SNS topic. + - Subscribe `https://<courselit-host>/api/inbound-email/ses` to the topic and explain that CourseLit confirms the subscription only after signature and topic validation. + - Configure `INBOUND_EMAIL_SES_TOPIC_ARN`, `INBOUND_EMAIL_SES_BUCKET`, `INBOUND_EMAIL_SES_REGION`, and optional `INBOUND_EMAIL_SES_OBJECT_PREFIX`. + - Prefer an instance/task role granting the web workload least-privilege `s3:GetObject` access over static AWS keys. Document the permissions SES itself needs for S3/SNS and the same-Region constraints. + - Require a short S3 lifecycle expiration for raw inbound messages and document the direct-SNS 150 KB versus S3 40 MB rationale. +14. **Postmark documentation** uses its official [inbound server](https://postmarkapp.com/developer/user-guide/inbound/configure-an-inbound-server) guide: + - Create an Inbound Message Stream, configure the dedicated inbound domain and MX record, and set `https://<courselit-host>/api/inbound-email/postmark` as its inbound webhook. + - Configure the CourseLit shared webhook secret through the implemented Postmark authentication mechanism and explain how it maps to `INBOUND_EMAIL_WEBHOOK_SECRET` without exposing the value in screenshots or logs. +15. **Mailgun documentation** uses its official [domain verification](https://documentation.mailgun.com/docs/mailgun/user-manual/domains/domains-verify), [receiving route](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/routes), and [HTTP payload](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/receive-http) guides: + - Add and verify the dedicated receiving domain, publish both Mailgun MX records, and create a route that forwards matching recipients to `https://<courselit-host>/api/inbound-email/mailgun`. + - Configure `MAILGUN_WEBHOOK_SIGNING_KEY`; explain that CourseLit validates Mailgun's timestamp/token HMAC and rejects stale requests. +16. **Document SendGrid only with Phase 3b** using the official [Inbound Parse setup](https://www.twilio.com/docs/sendgrid/for-developers/parsing-email/setting-up-the-inbound-parse-webhook) and [security policy](https://www.twilio.com/docs/sendgrid/for-developers/parsing-email/securing-your-parse-webhooks) guides. Cover the dedicated hostname/MX record, destination URL, raw multipart handling, security-policy public key/configuration, and the corresponding CourseLit environment variables introduced by that adapter. --- @@ -96,19 +150,33 @@ MVP out of scope: attachments, rich HTML reply parsing, email reactions, advance | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Content enrichment location | Queue-side re-fetch via existing resolver (no metadata changes) | | Token format | Opaque DB token, 30-day sliding TTL, multi-use per (recipient, thread position) | -| Provider order | Postmark → Mailgun → SendGrid → SES (first two ship pre-stripped reply text) | +| Provider support | Initial: Amazon SES, Postmark, and Mailgun. Phase 3b: SendGrid. SES uses S3 + SNS and the AWS workload role; Postmark and Mailgun supply pre-stripped reply text. | | Subject line | Keep one-line summary as subject (vs `Re: "<title>"`) | | Per-channel sizing | Notification panel stays compact (unchanged one-liners); only email shows full content blocks | +| Thread-context labels | New comments show the original post as `Original post`; replies show their active parent as `Earlier comment` | +| Rich-text extraction | Preserve structural line breaks by default; normalize whitespace only in consumers such as duplicate-content fingerprinting | +| Entity lifecycle | Do not send conversation email content for soft-deleted posts/comments/replies; exclude deleted parents from context | | Rejection UX | Silent drop + server log; no bounce emails in MVP | | Rich treatment scope | 5 conversation activity types: `COMMUNITY_POST_CREATED` (post body excerpt; email reply → top-level comment), the 2 community comment/reply types, and `COURSE_DISCUSSION_COMMENT_CREATED` (comment + reply eventTypes) | ## Verification -- **Unit**: `getNotificationEmailContent` with a stub resolver (one test per conversation type + one non-conversation type asserting unchanged output); template block presence/absence tests; token-minting idempotency + disabled-env tests (queue jest); `process-inbound-email` tests modeled on `communities/__tests__/logic.test.ts` — happy paths (community + product), expired token, sender mismatch, empty reply, non-member; adapter parse/verify tests with fixture payloads (`__tests__/fixtures/{postmark,mailgun}.json`). -- **E2E local**: run web + queue with a dev SMTP inbox; post a comment in the UI → inspect rich email + Reply-To header; then simulate inbound: - ``` - curl -X POST "http://localhost:3000/api/inbound-email/postmark?secret=$INBOUND_EMAIL_WEBHOOK_SECRET" \ - -H 'Content-Type: application/json' -d @fixtures/postmark.json - ``` - (fixture `To` = minted `reply+<token>@…`, `From` = recipient email) → verify the reply appears in the thread and re-notification fires. -- Run existing suites in `apps/web` and `apps/queue` to catch regressions. +### Phase 1 — completed + +- `getNotificationEmailContent` tests cover the default database resolver, all conversation variants, deleted entities/parents, original-post context, structured rich-text boundaries, request-scoped resolver reuse, and unchanged non-conversation output. +- `EmailChannel`/template tests cover the actor/avatar header, safe text encoding, unsubscribe behavior, legacy non-conversation layout, `Original post` versus `Earlier comment` labels, preserved new-content line breaks, and the discussion CTA. +- Shared extractor/product-discussion tests cover paragraph and hard-break extraction plus duplicate detection across formatting-only differences. +- Latest full verification: **91 test suites and 783 tests passed**. Formatting, lint, relevant type checks, and `@courselit/orm-models` / `@courselit/queue` builds also passed. A standalone web `tsc --noEmit` reports four pre-existing errors in `apps/web/graphql/pages/__tests__/logic.test.ts` unrelated to this change. + +### Phase 2 — completed + +- Queue tests cover concurrency-safe, idempotent minting; 27-character base64url tokens; product and community coordinates; sliding expiry; disabled configuration; and token/domain header-injection validation. +- `EmailChannel` tests cover enabled conversation headers and reply hints, non-conversation omission, and failure behavior when token minting fails. The user-deletion regression suite confirms immediate token cleanup. +- **Browser E2E:** with `INBOUND_EMAIL_DOMAIN=replies.example.test`, a real community comment produced a Mailpit message containing the expected `Reply-To` address, `Original post` context, preserved paragraph break, reply hint, and `View discussion` CTA. The community page had no console errors; associated GraphQL requests returned 200. + +### Phase 3–4 — implementation coverage + +- Adapter tests cover Postmark HTTP Basic verification and `StrippedTextReply`, Mailgun fresh/stale HMAC signatures and quote stripping, plus SES SNS signature verification, unexpected topic rejection, S3 prefix and MIME parsing, and safe subscription confirmation. +- Processor tests cover community and product dispatch, expired tokens, sender mismatch, empty replies, membership enforcement, and idempotent provider redelivery. Route tests cover unknown providers, authentication, terminal rejection acknowledgement, retryable processing, and SES controls. A proxy test proves the shared webhook endpoint does not derive tenancy from its host. +- The endpoint is represented in the generated OpenAPI contract. Both self-hosting navigation trees link the equivalent provider guide, and the Docker Compose template lists the exact app/queue environment placement. +- Live-provider E2E remains a deployment acceptance step because it requires a real SES, Postmark, or Mailgun account and DNS. It should verify one new thread entry and the usual re-notification fan-out using the provider configured for the deployment. diff --git a/packages/common-models/src/constants.ts b/packages/common-models/src/constants.ts index 20b834a32..3e6ff7aa7 100644 --- a/packages/common-models/src/constants.ts +++ b/packages/common-models/src/constants.ts @@ -251,3 +251,16 @@ export const ProductDiscussionReportStatus = { ACCEPTED: "accepted", REJECTED: "rejected", } as const; +export const InboundEmailReceiptStatus = { + PROCESSING: "processing", + ACCEPTED: "accepted", +} as const; +export const InboundEmailProvider = { + SES: "ses", + POSTMARK: "postmark", + MAILGUN: "mailgun", +} as const; +export const EmailReplyTokenKind = { + COMMUNITY: "community", + PRODUCT: "product", +} as const; diff --git a/packages/common-models/src/email-reply-token-kind.ts b/packages/common-models/src/email-reply-token-kind.ts new file mode 100644 index 000000000..dbad738b8 --- /dev/null +++ b/packages/common-models/src/email-reply-token-kind.ts @@ -0,0 +1,4 @@ +import { EmailReplyTokenKind } from "./constants"; + +export type EmailReplyTokenKind = + (typeof EmailReplyTokenKind)[keyof typeof EmailReplyTokenKind]; diff --git a/packages/common-models/src/inbound-email-provider.ts b/packages/common-models/src/inbound-email-provider.ts new file mode 100644 index 000000000..790df7ba9 --- /dev/null +++ b/packages/common-models/src/inbound-email-provider.ts @@ -0,0 +1,4 @@ +import { InboundEmailProvider } from "./constants"; + +export type InboundEmailProvider = + (typeof InboundEmailProvider)[keyof typeof InboundEmailProvider]; diff --git a/packages/common-models/src/inbound-email-receipt-status.ts b/packages/common-models/src/inbound-email-receipt-status.ts new file mode 100644 index 000000000..2f91e0c00 --- /dev/null +++ b/packages/common-models/src/inbound-email-receipt-status.ts @@ -0,0 +1,4 @@ +import { InboundEmailReceiptStatus } from "./constants"; + +export type InboundEmailReceiptStatus = + (typeof InboundEmailReceiptStatus)[keyof typeof InboundEmailReceiptStatus]; diff --git a/packages/common-models/src/index.ts b/packages/common-models/src/index.ts index a371455d3..4f00e285e 100644 --- a/packages/common-models/src/index.ts +++ b/packages/common-models/src/index.ts @@ -71,6 +71,7 @@ export * from "./course"; export * from "./activity-type"; export * from "./email-event-action"; export type { ReplyByEmailContext } from "./email-reply-context"; +export type { EmailReplyTokenKind } from "./email-reply-token-kind"; export * from "./login-provider"; export * from "./features"; export * from "./product-discussion"; @@ -87,3 +88,5 @@ export type { CommunityReactionEntityType, CommunityReactionRecord, } from "./community-reaction"; +export type { InboundEmailReceiptStatus } from "./inbound-email-receipt-status"; +export type { InboundEmailProvider } from "./inbound-email-provider"; diff --git a/packages/orm-models/src/index.ts b/packages/orm-models/src/index.ts index 81e488c18..1351894ab 100644 --- a/packages/orm-models/src/index.ts +++ b/packages/orm-models/src/index.ts @@ -11,6 +11,8 @@ export * from "./models/rule"; export * from "./models/email"; export * from "./models/email-delivery"; export * from "./models/email-event"; +export * from "./models/email-reply-token"; +export * from "./models/inbound-email-receipt"; export * from "./models/subscriber"; export * from "./models/domain"; export * from "./models/site-info"; diff --git a/packages/orm-models/src/models/email-reply-token.ts b/packages/orm-models/src/models/email-reply-token.ts new file mode 100644 index 000000000..3f48a1230 --- /dev/null +++ b/packages/orm-models/src/models/email-reply-token.ts @@ -0,0 +1,72 @@ +import { Constants } from "@courselit/common-models"; +import type { + EmailReplyTokenKind, + ReplyByEmailContext, +} from "@courselit/common-models"; +import mongoose from "mongoose"; + +export interface InternalEmailReplyToken extends mongoose.Document { + domain: mongoose.Types.ObjectId; + token: string; + userId: string; + kind: EmailReplyTokenKind; + community?: ReplyByEmailContext["community"]; + product?: ReplyByEmailContext["product"]; + contextKey: string; + expiresAt: Date; + createdAt: Date; +} + +const CommunityReplyContextSchema = new mongoose.Schema( + { + communityId: { type: String, required: true }, + postId: { type: String, required: true }, + parentCommentId: String, + parentReplyId: String, + }, + { _id: false }, +); + +const ProductReplyContextSchema = new mongoose.Schema( + { + productId: { type: String, required: true }, + entityType: { + type: String, + required: true, + enum: Object.values(Constants.ProductDiscussionEntityType), + }, + entityId: { type: String, required: true }, + commentId: { type: String, required: true }, + parentReplyId: String, + }, + { _id: false }, +); + +export const EmailReplyTokenSchema = + new mongoose.Schema<InternalEmailReplyToken>( + { + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + token: { type: String, required: true }, + userId: { type: String, required: true }, + kind: { + type: String, + required: true, + enum: Object.values(Constants.EmailReplyTokenKind), + }, + community: CommunityReplyContextSchema, + product: ProductReplyContextSchema, + contextKey: { type: String, required: true }, + expiresAt: { type: Date, required: true }, + }, + { + timestamps: { createdAt: true, updatedAt: false }, + }, + ); + +EmailReplyTokenSchema.index({ token: 1 }, { unique: true }); +EmailReplyTokenSchema.index( + { domain: 1, userId: 1, contextKey: 1 }, + { unique: true }, +); +EmailReplyTokenSchema.index({ domain: 1, userId: 1 }); +EmailReplyTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); diff --git a/packages/orm-models/src/models/inbound-email-receipt.ts b/packages/orm-models/src/models/inbound-email-receipt.ts new file mode 100644 index 000000000..a17250071 --- /dev/null +++ b/packages/orm-models/src/models/inbound-email-receipt.ts @@ -0,0 +1,57 @@ +import mongoose from "mongoose"; +import { Constants } from "@courselit/common-models"; +import type { + InboundEmailProvider, + InboundEmailReceiptStatus, +} from "@courselit/common-models"; + +export const INBOUND_EMAIL_RECEIPT_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days + +export interface InternalInboundEmailReceipt extends mongoose.Document { + domain: mongoose.Types.ObjectId; + userId: string; + provider: InboundEmailProvider; + messageId: string; + status: InboundEmailReceiptStatus; + processingId?: string; + processingExpiresAt?: Date; + expiresAt: Date; + createdAt: Date; +} + +/** + * Records a provider message after it has been accepted. Providers retry + * webhook delivery, so this is deliberately separate from reply tokens and + * keyed by the provider's immutable message id. + */ +export const InboundEmailReceiptSchema = + new mongoose.Schema<InternalInboundEmailReceipt>( + { + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + userId: { type: String, required: true }, + provider: { + type: String, + required: true, + enum: Object.values(Constants.InboundEmailProvider), + }, + messageId: { type: String, required: true }, + status: { + type: String, + required: true, + enum: Object.values(Constants.InboundEmailReceiptStatus), + }, + processingId: String, + processingExpiresAt: Date, + expiresAt: { type: Date, required: true }, + }, + { + timestamps: { createdAt: true, updatedAt: false }, + }, + ); + +InboundEmailReceiptSchema.index( + { provider: 1, messageId: 1 }, + { unique: true }, +); +InboundEmailReceiptSchema.index({ domain: 1, userId: 1 }); +InboundEmailReceiptSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e7195baa..e6caf6fb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,10 +290,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.46.4 - version: 8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) apps/web: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1091.0 + version: 3.1091.0 '@better-auth/sso': specifier: 1.6.11 version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.2.9(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(better-call@1.3.5(zod@3.24.3)) @@ -447,6 +450,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + email-reply-parser: + specifier: ^2.3.9 + version: 2.3.9 graphql: specifier: ^16.10.0 version: 16.11.0 @@ -462,6 +468,9 @@ importers: lucide-react: specifier: ^0.553.0 version: 0.553.0(react@19.2.0) + mailparser: + specifier: ^3.9.14 + version: 3.9.14 medialit: specifier: 0.2.0 version: 0.2.0 @@ -547,6 +556,9 @@ importers: '@types/cookie': specifier: ^0.4.1 version: 0.4.1 + '@types/mailparser': + specifier: ^3.4.6 + version: 3.4.6 '@types/mongodb': specifier: ^4.0.7 version: 4.0.7(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) @@ -1492,10 +1504,18 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/checksums@3.1000.19': + resolution: {integrity: sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-cognito-identity@3.797.0': resolution: {integrity: sha512-IzpwVFYpLOV7Anm2CvihMb+qx/2OFjd47BWVOtMtMBDeYbygYKmTWQ2XdqWMlTwKub/gwzn0yRoXSeGuKl/yEQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-s3@3.1091.0': + resolution: {integrity: sha512-jvXBCWZdPEXACF7TEbLdwLgmrcWvAqSnIJeRbnnFTxLuPRAZtEbujVUU48i7PGnrOoO6Jc6HCw9hRqBECoKSww==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-sso@3.797.0': resolution: {integrity: sha512-9xuR918p7tShR67ZL+AOSbydpJxSHAOdXcQswxxWR/hKCF7tULX7tyL3gNo3l/ETp0CDcStvorOdH/nCbzEOjw==} engines: {node: '>=18.0.0'} @@ -1504,6 +1524,10 @@ packages: resolution: {integrity: sha512-tH8Sp7lCxISVoLnkyv4AouuXs2CDlMhTuesWa0lq2NX1f+DXsMwSBtN37ttZdpFMw3F8mWdsJt27X9h2Oq868A==} engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.976.0': + resolution: {integrity: sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-cognito-identity@3.797.0': resolution: {integrity: sha512-hE/fOgFIToJKuMz8hxUZ9PqqyG9xkjig4my6JxE+dcKxefoK3gT2ELrDjw/MOnjqeUorzJ8O5PjJxa1zzIzchw==} engines: {node: '>=18.0.0'} @@ -1512,30 +1536,62 @@ packages: resolution: {integrity: sha512-kQzGKm4IOYYO6vUrai2JocNwhJm4Aml2BsAV+tBhFhhkutE7khf9PUucoVjB78b0J48nF+kdSacqzY+gB81/Uw==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-env@3.972.60': + resolution: {integrity: sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.796.0': resolution: {integrity: sha512-wWOT6VAHIKOuHdKFGm1iyKvx7f6+Kc/YTzFWJPuT+l+CPlXR6ylP1UMIDsHHLKpMzsrh3CH77QDsjkhQrnKkfg==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-http@3.972.62': + resolution: {integrity: sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.797.0': resolution: {integrity: sha512-Zpj6pJ2hnebrhLDr+x61ArMUkjHG6mfJRfamHxeVTgZkhLcwHjC5aM4u9pWTVugIaPY+VBtgkKPbi3TRbHlt2g==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-ini@3.973.5': + resolution: {integrity: sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.67': + resolution: {integrity: sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.797.0': resolution: {integrity: sha512-xJSWvvnmzEfHbqbpN4F3E3mI9+zJ/VWLGiKOjzX1Inbspa5WqNn2GoMamolZR2TvvZS4F3Hp73TD1WoBzkIjuw==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-node@3.972.71': + resolution: {integrity: sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.796.0': resolution: {integrity: sha512-r4e8/4AdKn/qQbRVocW7oXkpoiuXdTv0qty8AASNLnbQnT1vjD1bvmP6kp4fbHPWgwY8I9h0Dqjp49uy9Bqyuw==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-process@3.972.60': + resolution: {integrity: sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.797.0': resolution: {integrity: sha512-VlyWnjTsTnBXqXcEW0nw3S7nj00n9fYwF6uU6HPO9t860yIySG01lNPAWTvAt3DfVL5SRS0GANriCZF6ohcMcQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-sso@3.973.4': + resolution: {integrity: sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.797.0': resolution: {integrity: sha512-DIb05FEmdOX7bNsqSVEAB3UkaDgrYHonQ2+gcBLqZ7LoDNnovHIlvC5jii93usgEStxITZstnzw+49keNEgVWw==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.66': + resolution: {integrity: sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-providers@3.797.0': resolution: {integrity: sha512-UBsPtUql1q22kMNKcUKhpRKlZXHW1ESbcrv0Z/BekwHEu8fuxgS8aRsfminUcLaBLPjBJjEy6AhY/rXUpX6lsg==} engines: {node: '>=18.0.0'} @@ -1552,6 +1608,10 @@ packages: resolution: {integrity: sha512-GLCzC8D0A0YDG5u3F5U03Vb9j5tcOEFhr8oc6PDk0k0vm5VwtZOE6LvK7hcCSoAB4HXyOUM0sQuXrbaAh9OwXA==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.65': + resolution: {integrity: sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.796.0': resolution: {integrity: sha512-IeNg+3jNWT37J45opi5Jx89hGF0lOnZjiNwlMp3rKq7PlOqy8kWq5J1Gxk0W3tIkPpuf68CtBs/QFrRXWOjsZw==} engines: {node: '>=18.0.0'} @@ -1560,10 +1620,22 @@ packages: resolution: {integrity: sha512-xCsRKdsv0GAg9E28fvYBdC3JR2xdtZ2o41MVknOs+pSFtMsZm3SsgxObN35p1OTMk/o/V0LORGVLnFQMlc5QiA==} engines: {node: '>=18.0.0'} + '@aws-sdk/nested-clients@3.997.34': + resolution: {integrity: sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.775.0': resolution: {integrity: sha512-40iH3LJjrQS3LKUJAl7Wj0bln7RFPEvUYKFxtP8a+oKFDO0F65F52xZxIJbPn6sHkxWDAnZlGgdjZXM3p2g5wQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.41': + resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1092.0': + resolution: {integrity: sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.797.0': resolution: {integrity: sha512-TLFkP4BBdkH2zCXhG3JjaYrRft25MMZ+6/YDz1C/ikq2Zk8krUbVoSmhtYMVz10JtxAPiQ++w0vI/qbz2JSDXg==} engines: {node: '>=18.0.0'} @@ -1572,6 +1644,10 @@ packages: resolution: {integrity: sha512-ZoGKwa4C9fC9Av6bdfqcW6Ix5ot05F/S4VxWR2nHuMv7hzfmAjTOcUiWT7UR4hM/U0whf84VhDtXN/DWAk52KA==} engines: {node: '>=18.0.0'} + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-endpoints@3.787.0': resolution: {integrity: sha512-fd3zkiOkwnbdbN0Xp9TsP5SWrmv0SpT70YEdbb8wAj2DWQwiCmFszaSs+YCvhoCdmlR3Wl9Spu0pGpSAGKeYvQ==} engines: {node: '>=18.0.0'} @@ -1592,6 +1668,14 @@ packages: aws-crt: optional: true + '@aws-sdk/xml-builder@3.972.36': + resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -1760,6 +1844,10 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.0': resolution: {integrity: sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==} engines: {node: '>=6.9.0'} @@ -2501,8 +2589,8 @@ packages: resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@8.57.1': @@ -2513,8 +2601,8 @@ packages: resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -2609,10 +2697,22 @@ packages: resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + '@humanfs/node@0.16.7': resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} engines: {node: '>=18.18.0'} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -4734,6 +4834,11 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@selderee/plugin-htmlparser2@0.12.0': + resolution: {integrity: sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==} + peerDependencies: + selderee: ~0.12.0 + '@shelf/jest-mongodb@5.2.2': resolution: {integrity: sha512-kQwMjswHVjxElsXhCwBxG1NncZK0GtHd8o+U/OQLx+noer4mc/mO5KD46NmmF3oRIjgfGtvf6WB2OyO1FTKAqA==} engines: {node: '>=22'} @@ -4797,24 +4902,20 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@smithy/abort-controller@4.0.2': - resolution: {integrity: sha512-Sl/78VDtgqKxN2+1qduaVE140XF+Xg+TafkncspwM4jFP/LHr76ZHmIY/y3V1M0mMLNk+Je6IGbzxy23RSToMw==} - engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.1.0': resolution: {integrity: sha512-8smPlwhga22pwl23fM5ew4T9vfLUCeFXlcqNOCD5M5h8VmNPNUE9j6bQSuRXpDSV11L/E/SwEBQuW8hr6+nS1A==} engines: {node: '>=18.0.0'} - '@smithy/core@3.2.0': - resolution: {integrity: sha512-k17bgQhVZ7YmUvA8at4af1TDpl0NDMBuBKJl8Yg0nrefwmValU+CnA5l/AriVdQNthU/33H3nK71HrLgqOPr1Q==} + '@smithy/core@3.29.6': + resolution: {integrity: sha512-TO3w25cdGWBeYqKNDaqH3v4O3jjMPpKwf39YlG5X5xhqWfpOWJbi5gQi1lrllukuwohdhY0TPB8jBEv6UC50Vg==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.0.2': - resolution: {integrity: sha512-32lVig6jCaWBHnY+OEQ6e6Vnt5vDHaLiydGrwYMW9tPqO688hPGTYRamYJ1EptxEC2rAwJrHWmPoKRBl4iTa8w==} + '@smithy/credential-provider-imds@4.4.11': + resolution: {integrity: sha512-6CUvZwS0tCcVCrcvh2TpwTXxmAkuY6JGNPeKODYRLjHtUUhFLGS3dNkNdRvT/ttJyqimqnhFMTS2nqp4pDZ7oQ==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.0.2': - resolution: {integrity: sha512-+9Dz8sakS9pe7f2cBocpJXdeVjMopUDLgZs1yWeu7h++WqSbjUYv/JAJwKwXw1HV6gq1jyWjxuyn24E2GhoEcQ==} + '@smithy/fetch-http-handler@5.6.8': + resolution: {integrity: sha512-AFuLou893FesRZeQcKMh87P9x4PF2/ksPOYLLI1ctW7WJxm55SWInFSHAhaNRBPBmbgZyUcCCDKepBX+1jZBBw==} engines: {node: '>=18.0.0'} '@smithy/hash-node@4.0.2': @@ -4857,8 +4958,8 @@ packages: resolution: {integrity: sha512-WgCkILRZfJwJ4Da92a6t3ozN/zcvYyJGUTmfGbgS/FkCcoCjl7G4FJaCDN1ySdvLvemnQeo25FdkyMSTSwulsw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.0.4': - resolution: {integrity: sha512-/mdqabuAT3o/ihBGjL94PUbTSPSRJ0eeVTdgADzow0wRJ0rN4A27EOrtlK56MYiO1fDvlO3jVTCxQtQmK9dZ1g==} + '@smithy/node-http-handler@4.9.8': + resolution: {integrity: sha512-ArSSIN4t1wLutcIkHzaL6N11J7xpZK7W3T0pFz9cep9zIpEr9x5+lhJRcVUgObGI3OIMbnROq7w8bwzx+Nkf8A==} engines: {node: '>=18.0.0'} '@smithy/property-provider@4.0.2': @@ -4869,10 +4970,6 @@ packages: resolution: {integrity: sha512-KxAOL1nUNw2JTYrtviRRjEnykIDhxc84qMBzxvu1MUfQfHTuBlCG7PA6EdVwqpJjH7glw7FqQoFxUJSyBQgu7g==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.0.2': - resolution: {integrity: sha512-NTOs0FwHw1vimmQM4ebh+wFQvOwkEf/kQL6bSM1Lock+Bv4I89B3hGYoUEPkmvYPkDKyp5UdXJYu+PoTQ3T31Q==} - engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.0.2': resolution: {integrity: sha512-v6w8wnmZcVXjfVLjxw8qF7OwESD9wnpjp0Dqry/Pod0/5vcEA3qxCr+BhbOHlxS8O+29eLpT3aagxXGwIoEk7Q==} engines: {node: '>=18.0.0'} @@ -4885,16 +4982,16 @@ packages: resolution: {integrity: sha512-J9/gTWBGVuFZ01oVA6vdb4DAjf1XbDhK6sLsu3OS9qmLrS6KB5ygpeHiM3miIbj1qgSJ96GYszXFWv6ErJ8QEw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.1.0': - resolution: {integrity: sha512-4t5WX60sL3zGJF/CtZsUQTs3UrZEDO2P7pEaElrekbLqkWPYkgqNW1oeiNYC6xXifBnT9dVBOnNQRvOE9riU9w==} + '@smithy/signature-v4@5.6.7': + resolution: {integrity: sha512-32PmEsuZV9lz7SZk3gJcm+EfIAoIVu83AJyEzgALpwmSqLvuacdAu0fvCVNMbDbegyk1S0lHUDrMWIfR47Micw==} engines: {node: '>=18.0.0'} '@smithy/smithy-client@4.2.0': resolution: {integrity: sha512-Qs65/w30pWV7LSFAez9DKy0Koaoh3iHhpcpCCJ4waj/iqwsuSzJna2+vYwq46yBaqO5ZbP9TjUsATUNxrKeBdw==} engines: {node: '>=18.0.0'} - '@smithy/types@4.2.0': - resolution: {integrity: sha512-7eMk09zQKCO+E/ivsjQv+fDlOupcFUCSC/L2YUPgwhvowVGWbPQHjEFcmjt7QQ4ra5lyowS92SV53Zc6XD4+fg==} + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} engines: {node: '>=18.0.0'} '@smithy/url-parser@4.0.2': @@ -4953,10 +5050,6 @@ packages: resolution: {integrity: sha512-Vj1TtwWnuWqdgQI6YTUF5hQ/0jmFiOYsc51CSMgj7QfyO+RF4EnT2HNjoviNlOOmgzgvf3f5yno+EiC4vrnaWQ==} engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@4.0.0': - resolution: {integrity: sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==} - engines: {node: '>=18.0.0'} - '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} @@ -5500,6 +5593,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@4.19.6': resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} @@ -5554,6 +5650,9 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/mailparser@3.4.6': + resolution: {integrity: sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -5904,6 +6003,9 @@ packages: resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + '@zone-eu/mailsplit@5.4.14': + resolution: {integrity: sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -5963,8 +6065,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -6324,8 +6426,8 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - bowser@2.11.0: - resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} boxen@6.2.1: resolution: {integrity: sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==} @@ -6871,6 +6973,10 @@ packages: resolution: {integrity: sha512-if3ZYdkD2dClhnXR5reKtG98cwyaRT1NeugQoAPTTfsOpV9kqyeiBF9Qa5RHjemb3KzD5ulqygv6ED3t5j9eJw==} engines: {node: '>=12.4.0'} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -7024,6 +7130,15 @@ packages: electron-to-chromium@1.5.142: resolution: {integrity: sha512-Ah2HgkTu/9RhTDNThBtzu2Wirdy4DC9b0sMT1pUhbkZQ5U/iwmE+PHZX1MpjD5IkJCc2wSghgGG/B04szAx07w==} + email-reply-parser@2.3.9: + resolution: {integrity: sha512-k5w4cjqLSZABTyxkFHQLnMKJgrK2l6mPptmOnQHlwIosw86S05Xnupy4PPdw8XdXv/kXzn2PAnLfFMNFVUH4YQ==} + engines: {node: '>= 22.0.0'} + peerDependencies: + re2: 1.x + peerDependenciesMeta: + re2: + optional: true + emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -7048,6 +7163,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-japanese@2.2.0: + resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} + engines: {node: '>=8.10.0'} + end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -7067,6 +7186,10 @@ packages: resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -7445,8 +7568,8 @@ packages: jiti: optional: true - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -8095,6 +8218,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-from-parse5@7.1.2: resolution: {integrity: sha512-Nz7FfPBuljzsN3tCQ4kCBKqdNhQE2l0Tn+X1ubgKBPRoiDIu1mL08Cfw4k7q71+Duyaw7DXDN+VTAp4Vh3oCOw==} @@ -8146,6 +8273,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -8183,6 +8314,10 @@ packages: html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-to-text@10.0.0: + resolution: {integrity: sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==} + engines: {node: '>=20.19.0'} + html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -8193,6 +8328,9 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} @@ -8245,8 +8383,8 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.0: - resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} identity-obj-proxy@3.0.0: @@ -8376,6 +8514,10 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-data-view@1.0.2: resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} engines: {node: '>= 0.4'} @@ -8750,6 +8892,10 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} @@ -8869,6 +9015,9 @@ packages: leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + leac@0.7.0: + resolution: {integrity: sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -8877,6 +9026,15 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.4.1: + resolution: {integrity: sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -8961,6 +9119,9 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} @@ -9144,6 +9305,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mailparser@3.9.14: + resolution: {integrity: sha512-3QD6TRXcyXtq2NCuyA2AEjqmallQkyxYmZI9GMCIvQDCaB9Uc034WUI1x8RUBYFnk5+p7h14JEz4O/lrxQmttw==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -9855,6 +10019,10 @@ packages: resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} engines: {node: '>=6.0.0'} + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -10036,6 +10204,9 @@ packages: parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + parseley@0.13.1: + resolution: {integrity: sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -10090,6 +10261,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peberminta@0.10.0: + resolution: {integrity: sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -10837,6 +11011,11 @@ packages: engines: {node: '>= 0.4'} hasBin: true + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + resolve@2.0.0-next.5: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true @@ -10982,6 +11161,9 @@ packages: selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + selderee@0.12.0: + resolution: {integrity: sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -11442,6 +11624,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -12545,6 +12731,14 @@ snapshots: tslib: 2.8.1 optional: true + '@aws-sdk/checksums@3.1000.19': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/client-cognito-identity@3.797.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -12561,8 +12755,8 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.775.0 '@aws-sdk/util-user-agent-node': 3.796.0 '@smithy/config-resolver': 4.1.0 - '@smithy/core': 3.2.0 - '@smithy/fetch-http-handler': 5.0.2 + '@smithy/core': 3.29.6 + '@smithy/fetch-http-handler': 5.6.8 '@smithy/hash-node': 4.0.2 '@smithy/invalid-dependency': 4.0.2 '@smithy/middleware-content-length': 4.0.2 @@ -12571,10 +12765,10 @@ snapshots: '@smithy/middleware-serde': 4.0.3 '@smithy/middleware-stack': 4.0.2 '@smithy/node-config-provider': 4.0.2 - '@smithy/node-http-handler': 4.0.4 + '@smithy/node-http-handler': 4.9.8 '@smithy/protocol-http': 5.1.0 '@smithy/smithy-client': 4.2.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/url-parser': 4.0.2 '@smithy/util-base64': 4.0.0 '@smithy/util-body-length-browser': 4.0.0 @@ -12590,6 +12784,20 @@ snapshots: - aws-crt optional: true + '@aws-sdk/client-s3@3.1091.0': + dependencies: + '@aws-sdk/checksums': 3.1000.19 + '@aws-sdk/core': 3.976.0 + '@aws-sdk/credential-provider-node': 3.972.71 + '@aws-sdk/middleware-sdk-s3': 3.972.65 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/fetch-http-handler': 5.6.8 + '@smithy/node-http-handler': 4.9.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/client-sso@3.797.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -12605,8 +12813,8 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.775.0 '@aws-sdk/util-user-agent-node': 3.796.0 '@smithy/config-resolver': 4.1.0 - '@smithy/core': 3.2.0 - '@smithy/fetch-http-handler': 5.0.2 + '@smithy/core': 3.29.6 + '@smithy/fetch-http-handler': 5.6.8 '@smithy/hash-node': 4.0.2 '@smithy/invalid-dependency': 4.0.2 '@smithy/middleware-content-length': 4.0.2 @@ -12615,10 +12823,10 @@ snapshots: '@smithy/middleware-serde': 4.0.3 '@smithy/middleware-stack': 4.0.2 '@smithy/node-config-provider': 4.0.2 - '@smithy/node-http-handler': 4.0.4 + '@smithy/node-http-handler': 4.9.8 '@smithy/protocol-http': 5.1.0 '@smithy/smithy-client': 4.2.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/url-parser': 4.0.2 '@smithy/util-base64': 4.0.0 '@smithy/util-body-length-browser': 4.0.0 @@ -12637,24 +12845,35 @@ snapshots: '@aws-sdk/core@3.796.0': dependencies: '@aws-sdk/types': 3.775.0 - '@smithy/core': 3.2.0 + '@smithy/core': 3.29.6 '@smithy/node-config-provider': 4.0.2 '@smithy/property-provider': 4.0.2 '@smithy/protocol-http': 5.1.0 - '@smithy/signature-v4': 5.1.0 + '@smithy/signature-v4': 5.6.7 '@smithy/smithy-client': 4.2.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/util-middleware': 4.0.2 fast-xml-parser: 4.5.4 tslib: 2.8.1 optional: true + '@aws-sdk/core@3.976.0': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.36 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.6 + '@smithy/signature-v4': 5.6.7 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-cognito-identity@3.797.0': dependencies: '@aws-sdk/client-cognito-identity': 3.797.0 '@aws-sdk/types': 3.775.0 '@smithy/property-provider': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -12665,24 +12884,42 @@ snapshots: '@aws-sdk/core': 3.796.0 '@aws-sdk/types': 3.775.0 '@smithy/property-provider': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-env@3.972.60': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.796.0': dependencies: '@aws-sdk/core': 3.796.0 '@aws-sdk/types': 3.775.0 - '@smithy/fetch-http-handler': 5.0.2 - '@smithy/node-http-handler': 4.0.4 + '@smithy/fetch-http-handler': 5.6.8 + '@smithy/node-http-handler': 4.9.8 '@smithy/property-provider': 4.0.2 '@smithy/protocol-http': 5.1.0 '@smithy/smithy-client': 4.2.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/util-stream': 4.2.0 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-http@3.972.62': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/fetch-http-handler': 5.6.8 + '@smithy/node-http-handler': 4.9.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.797.0': dependencies: '@aws-sdk/core': 3.796.0 @@ -12693,15 +12930,40 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.797.0 '@aws-sdk/nested-clients': 3.797.0 '@aws-sdk/types': 3.775.0 - '@smithy/credential-provider-imds': 4.0.2 + '@smithy/credential-provider-imds': 4.4.11 '@smithy/property-provider': 4.0.2 '@smithy/shared-ini-file-loader': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt optional: true + '@aws-sdk/credential-provider-ini@3.973.5': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/credential-provider-env': 3.972.60 + '@aws-sdk/credential-provider-http': 3.972.62 + '@aws-sdk/credential-provider-login': 3.972.67 + '@aws-sdk/credential-provider-process': 3.972.60 + '@aws-sdk/credential-provider-sso': 3.973.4 + '@aws-sdk/credential-provider-web-identity': 3.972.66 + '@aws-sdk/nested-clients': 3.997.34 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/credential-provider-imds': 4.4.11 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.67': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/nested-clients': 3.997.34 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.797.0': dependencies: '@aws-sdk/credential-provider-env': 3.796.0 @@ -12711,25 +12973,47 @@ snapshots: '@aws-sdk/credential-provider-sso': 3.797.0 '@aws-sdk/credential-provider-web-identity': 3.797.0 '@aws-sdk/types': 3.775.0 - '@smithy/credential-provider-imds': 4.0.2 + '@smithy/credential-provider-imds': 4.4.11 '@smithy/property-provider': 4.0.2 '@smithy/shared-ini-file-loader': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt optional: true + '@aws-sdk/credential-provider-node@3.972.71': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.60 + '@aws-sdk/credential-provider-http': 3.972.62 + '@aws-sdk/credential-provider-ini': 3.973.5 + '@aws-sdk/credential-provider-process': 3.972.60 + '@aws-sdk/credential-provider-sso': 3.973.4 + '@aws-sdk/credential-provider-web-identity': 3.972.66 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/credential-provider-imds': 4.4.11 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.796.0': dependencies: '@aws-sdk/core': 3.796.0 '@aws-sdk/types': 3.775.0 '@smithy/property-provider': 4.0.2 '@smithy/shared-ini-file-loader': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-process@3.972.60': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.797.0': dependencies: '@aws-sdk/client-sso': 3.797.0 @@ -12738,24 +13022,43 @@ snapshots: '@aws-sdk/types': 3.775.0 '@smithy/property-provider': 4.0.2 '@smithy/shared-ini-file-loader': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt optional: true + '@aws-sdk/credential-provider-sso@3.973.4': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/nested-clients': 3.997.34 + '@aws-sdk/token-providers': 3.1092.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.797.0': dependencies: '@aws-sdk/core': 3.796.0 '@aws-sdk/nested-clients': 3.797.0 '@aws-sdk/types': 3.775.0 '@smithy/property-provider': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt optional: true + '@aws-sdk/credential-provider-web-identity@3.972.66': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/nested-clients': 3.997.34 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-providers@3.797.0': dependencies: '@aws-sdk/client-cognito-identity': 3.797.0 @@ -12771,11 +13074,11 @@ snapshots: '@aws-sdk/nested-clients': 3.797.0 '@aws-sdk/types': 3.775.0 '@smithy/config-resolver': 4.1.0 - '@smithy/core': 3.2.0 - '@smithy/credential-provider-imds': 4.0.2 + '@smithy/core': 3.29.6 + '@smithy/credential-provider-imds': 4.4.11 '@smithy/node-config-provider': 4.0.2 '@smithy/property-provider': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -12785,14 +13088,14 @@ snapshots: dependencies: '@aws-sdk/types': 3.775.0 '@smithy/protocol-http': 5.1.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true '@aws-sdk/middleware-logger@3.775.0': dependencies: '@aws-sdk/types': 3.775.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true @@ -12800,18 +13103,27 @@ snapshots: dependencies: '@aws-sdk/types': 3.775.0 '@smithy/protocol-http': 5.1.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true + '@aws-sdk/middleware-sdk-s3@3.972.65': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.796.0': dependencies: '@aws-sdk/core': 3.796.0 '@aws-sdk/types': 3.775.0 '@aws-sdk/util-endpoints': 3.787.0 - '@smithy/core': 3.2.0 + '@smithy/core': 3.29.6 '@smithy/protocol-http': 5.1.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true @@ -12830,8 +13142,8 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.775.0 '@aws-sdk/util-user-agent-node': 3.796.0 '@smithy/config-resolver': 4.1.0 - '@smithy/core': 3.2.0 - '@smithy/fetch-http-handler': 5.0.2 + '@smithy/core': 3.29.6 + '@smithy/fetch-http-handler': 5.6.8 '@smithy/hash-node': 4.0.2 '@smithy/invalid-dependency': 4.0.2 '@smithy/middleware-content-length': 4.0.2 @@ -12840,10 +13152,10 @@ snapshots: '@smithy/middleware-serde': 4.0.3 '@smithy/middleware-stack': 4.0.2 '@smithy/node-config-provider': 4.0.2 - '@smithy/node-http-handler': 4.0.4 + '@smithy/node-http-handler': 4.9.8 '@smithy/protocol-http': 5.1.0 '@smithy/smithy-client': 4.2.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/url-parser': 4.0.2 '@smithy/util-base64': 4.0.0 '@smithy/util-body-length-browser': 4.0.0 @@ -12859,23 +13171,50 @@ snapshots: - aws-crt optional: true + '@aws-sdk/nested-clients@3.997.34': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/fetch-http-handler': 5.6.8 + '@smithy/node-http-handler': 4.9.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.775.0': dependencies: '@aws-sdk/types': 3.775.0 '@smithy/node-config-provider': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/util-config-provider': 4.0.0 '@smithy/util-middleware': 4.0.2 tslib: 2.8.1 optional: true + '@aws-sdk/signature-v4-multi-region@3.996.41': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1092.0': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/nested-clients': 3.997.34 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.797.0': dependencies: '@aws-sdk/nested-clients': 3.797.0 '@aws-sdk/types': 3.775.0 '@smithy/property-provider': 4.0.2 '@smithy/shared-ini-file-loader': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -12883,14 +13222,19 @@ snapshots: '@aws-sdk/types@3.775.0': dependencies: - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.787.0': dependencies: '@aws-sdk/types': 3.775.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/util-endpoints': 3.0.2 tslib: 2.8.1 optional: true @@ -12903,8 +13247,8 @@ snapshots: '@aws-sdk/util-user-agent-browser@3.775.0': dependencies: '@aws-sdk/types': 3.775.0 - '@smithy/types': 4.2.0 - bowser: 2.11.0 + '@smithy/types': 4.16.1 + bowser: 2.14.1 tslib: 2.8.1 optional: true @@ -12913,10 +13257,17 @@ snapshots: '@aws-sdk/middleware-user-agent': 3.796.0 '@aws-sdk/types': 3.775.0 '@smithy/node-config-provider': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true + '@aws-sdk/xml-builder@3.972.36': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -13104,6 +13455,9 @@ snapshots: '@babel/runtime@7.29.2': {} + '@babel/runtime@7.29.7': + optional: true + '@babel/template@7.27.0': dependencies: '@babel/code-frame': 7.26.2 @@ -13696,14 +14050,14 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.4(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.5(jiti@2.6.1))': dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.5(jiti@2.6.1) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.6.1))': dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.5(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -13762,15 +14116,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.6': dependencies: - ajv: 6.14.0 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -13780,7 +14134,7 @@ snapshots: '@eslint/js@9.39.1': {} - '@eslint/js@9.39.4': {} + '@eslint/js@9.39.5': {} '@eslint/object-schema@2.1.7': {} @@ -13874,11 +14228,23 @@ snapshots: '@humanfs/core@0.19.1': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + '@humanfs/node@0.16.7': dependencies: '@humanfs/core': 0.19.1 '@humanwhocodes/retry': 0.4.3 + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -14068,7 +14434,7 @@ snapshots: '@inquirer/external-editor@1.0.3(@types/node@26.0.0)': dependencies: chardet: 2.1.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.3 optionalDependencies: '@types/node': 26.0.0 @@ -16748,6 +17114,12 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 + '@selderee/plugin-htmlparser2@0.12.0(selderee@0.12.0)': + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + selderee: 0.12.0 + '@shelf/jest-mongodb@5.2.2(@aws-sdk/credential-providers@3.797.0)(jest-environment-node@29.7.0)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(socks@2.8.4)': dependencies: debug: 4.4.1 @@ -16837,54 +17209,35 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@smithy/abort-controller@4.0.2': - dependencies: - '@smithy/types': 4.2.0 - tslib: 2.8.1 - optional: true - '@smithy/config-resolver@4.1.0': dependencies: '@smithy/node-config-provider': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/util-config-provider': 4.0.0 '@smithy/util-middleware': 4.0.2 tslib: 2.8.1 optional: true - '@smithy/core@3.2.0': + '@smithy/core@3.29.6': dependencies: - '@smithy/middleware-serde': 4.0.3 - '@smithy/protocol-http': 5.1.0 - '@smithy/types': 4.2.0 - '@smithy/util-body-length-browser': 4.0.0 - '@smithy/util-middleware': 4.0.2 - '@smithy/util-stream': 4.2.0 - '@smithy/util-utf8': 4.0.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 - optional: true - '@smithy/credential-provider-imds@4.0.2': + '@smithy/credential-provider-imds@4.4.11': dependencies: - '@smithy/node-config-provider': 4.0.2 - '@smithy/property-provider': 4.0.2 - '@smithy/types': 4.2.0 - '@smithy/url-parser': 4.0.2 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 tslib: 2.8.1 - optional: true - '@smithy/fetch-http-handler@5.0.2': + '@smithy/fetch-http-handler@5.6.8': dependencies: - '@smithy/protocol-http': 5.1.0 - '@smithy/querystring-builder': 4.0.2 - '@smithy/types': 4.2.0 - '@smithy/util-base64': 4.0.0 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 tslib: 2.8.1 - optional: true '@smithy/hash-node@4.0.2': dependencies: - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/util-buffer-from': 4.0.0 '@smithy/util-utf8': 4.0.0 tslib: 2.8.1 @@ -16892,7 +17245,7 @@ snapshots: '@smithy/invalid-dependency@4.0.2': dependencies: - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true @@ -16909,17 +17262,17 @@ snapshots: '@smithy/middleware-content-length@4.0.2': dependencies: '@smithy/protocol-http': 5.1.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true '@smithy/middleware-endpoint@4.1.0': dependencies: - '@smithy/core': 3.2.0 + '@smithy/core': 3.29.6 '@smithy/middleware-serde': 4.0.3 '@smithy/node-config-provider': 4.0.2 '@smithy/shared-ini-file-loader': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/url-parser': 4.0.2 '@smithy/util-middleware': 4.0.2 tslib: 2.8.1 @@ -16931,7 +17284,7 @@ snapshots: '@smithy/protocol-http': 5.1.0 '@smithy/service-error-classification': 4.0.2 '@smithy/smithy-client': 4.2.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/util-middleware': 4.0.2 '@smithy/util-retry': 4.0.2 tslib: 2.8.1 @@ -16940,13 +17293,13 @@ snapshots: '@smithy/middleware-serde@4.0.3': dependencies: - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true '@smithy/middleware-stack@4.0.2': dependencies: - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true @@ -16954,87 +17307,70 @@ snapshots: dependencies: '@smithy/property-provider': 4.0.2 '@smithy/shared-ini-file-loader': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true - '@smithy/node-http-handler@4.0.4': + '@smithy/node-http-handler@4.9.8': dependencies: - '@smithy/abort-controller': 4.0.2 - '@smithy/protocol-http': 5.1.0 - '@smithy/querystring-builder': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 tslib: 2.8.1 - optional: true '@smithy/property-provider@4.0.2': dependencies: - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true '@smithy/protocol-http@5.1.0': dependencies: - '@smithy/types': 4.2.0 - tslib: 2.8.1 - optional: true - - '@smithy/querystring-builder@4.0.2': - dependencies: - '@smithy/types': 4.2.0 - '@smithy/util-uri-escape': 4.0.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true '@smithy/querystring-parser@4.0.2': dependencies: - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true '@smithy/service-error-classification@4.0.2': dependencies: - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 optional: true '@smithy/shared-ini-file-loader@4.0.2': dependencies: - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true - '@smithy/signature-v4@5.1.0': + '@smithy/signature-v4@5.6.7': dependencies: - '@smithy/is-array-buffer': 4.0.0 - '@smithy/protocol-http': 5.1.0 - '@smithy/types': 4.2.0 - '@smithy/util-hex-encoding': 4.0.0 - '@smithy/util-middleware': 4.0.2 - '@smithy/util-uri-escape': 4.0.0 - '@smithy/util-utf8': 4.0.0 + '@smithy/core': 3.29.6 + '@smithy/types': 4.16.1 tslib: 2.8.1 - optional: true '@smithy/smithy-client@4.2.0': dependencies: - '@smithy/core': 3.2.0 + '@smithy/core': 3.29.6 '@smithy/middleware-endpoint': 4.1.0 '@smithy/middleware-stack': 4.0.2 '@smithy/protocol-http': 5.1.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 '@smithy/util-stream': 4.2.0 tslib: 2.8.1 optional: true - '@smithy/types@4.2.0': + '@smithy/types@4.16.1': dependencies: tslib: 2.8.1 - optional: true '@smithy/url-parser@4.0.2': dependencies: '@smithy/querystring-parser': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true @@ -17076,26 +17412,26 @@ snapshots: dependencies: '@smithy/property-provider': 4.0.2 '@smithy/smithy-client': 4.2.0 - '@smithy/types': 4.2.0 - bowser: 2.11.0 + '@smithy/types': 4.16.1 + bowser: 2.14.1 tslib: 2.8.1 optional: true '@smithy/util-defaults-mode-node@4.0.8': dependencies: '@smithy/config-resolver': 4.1.0 - '@smithy/credential-provider-imds': 4.0.2 + '@smithy/credential-provider-imds': 4.4.11 '@smithy/node-config-provider': 4.0.2 '@smithy/property-provider': 4.0.2 '@smithy/smithy-client': 4.2.0 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true '@smithy/util-endpoints@3.0.2': dependencies: '@smithy/node-config-provider': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true @@ -17106,22 +17442,22 @@ snapshots: '@smithy/util-middleware@4.0.2': dependencies: - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true '@smithy/util-retry@4.0.2': dependencies: '@smithy/service-error-classification': 4.0.2 - '@smithy/types': 4.2.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 optional: true '@smithy/util-stream@4.2.0': dependencies: - '@smithy/fetch-http-handler': 5.0.2 - '@smithy/node-http-handler': 4.0.4 - '@smithy/types': 4.2.0 + '@smithy/fetch-http-handler': 5.6.8 + '@smithy/node-http-handler': 4.9.8 + '@smithy/types': 4.16.1 '@smithy/util-base64': 4.0.0 '@smithy/util-buffer-from': 4.0.0 '@smithy/util-hex-encoding': 4.0.0 @@ -17129,11 +17465,6 @@ snapshots: tslib: 2.8.1 optional: true - '@smithy/util-uri-escape@4.0.0': - dependencies: - tslib: 2.8.1 - optional: true - '@smithy/util-utf8@2.3.0': dependencies: '@smithy/util-buffer-from': 2.2.0 @@ -18014,6 +18345,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@4.19.6': dependencies: '@types/node': 20.19.0 @@ -18084,6 +18417,11 @@ snapshots: '@types/linkify-it@5.0.0': {} + '@types/mailparser@3.4.6': + dependencies: + '@types/node': 20.19.0 + iconv-lite: 0.6.3 + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -18315,15 +18653,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.46.4 - '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.46.4 - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.5(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -18394,14 +18732,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.46.4 '@typescript-eslint/types': 8.46.4 '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.46.4 debug: 4.4.1 - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.5(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -18502,13 +18840,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.46.4 '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.1 - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.5(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -18635,13 +18973,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.5(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.46.4 '@typescript-eslint/types': 8.46.4 '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.5(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -18725,6 +19063,12 @@ snapshots: '@xmldom/xmldom@0.8.13': {} + '@zone-eu/mailsplit@5.4.14': + dependencies: + libbase64: 1.3.0 + libmime: 5.4.1 + libqp: 2.1.1 + abab@2.0.6: {} abort-controller@3.0.0: @@ -18780,7 +19124,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@6.14.0: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -19147,9 +19491,9 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 cosmiconfig: 7.1.0 - resolve: 1.22.11 + resolve: 1.22.12 optional: true babel-plugin-module-resolver@4.1.0: @@ -19287,8 +19631,7 @@ snapshots: boolean@3.2.0: {} - bowser@2.11.0: - optional: true + bowser@2.14.1: {} boxen@6.2.1: dependencies: @@ -19813,6 +20156,8 @@ snapshots: deepmerge-ts@4.3.0: {} + deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} defaults@1.0.4: @@ -19940,6 +20285,8 @@ snapshots: electron-to-chromium@1.5.142: {} + email-reply-parser@2.3.9: {} + emittery@0.13.1: {} emmet@2.4.11: @@ -19957,6 +20304,8 @@ snapshots: encodeurl@2.0.0: {} + encoding-japanese@2.2.0: {} + end-of-stream@1.4.4: dependencies: once: 1.4.0 @@ -19975,6 +20324,8 @@ snapshots: entities@6.0.0: {} + entities@7.0.1: {} + environment@1.1.0: {} error-ex@1.3.2: @@ -20288,7 +20639,7 @@ snapshots: '@next/eslint-plugin-next': 16.2.9 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) @@ -20319,7 +20670,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -20334,14 +20685,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -20362,7 +20713,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -20565,21 +20916,21 @@ snapshots: transitivePeerDependencies: - supports-color - eslint@9.39.4(jiti@2.6.1): + eslint@9.39.5(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 + '@types/estree': 1.0.9 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -21328,6 +21679,11 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + optional: true + hast-util-from-parse5@7.1.2: dependencies: '@types/hast': 2.3.10 @@ -21501,6 +21857,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + he@1.2.0: {} + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -21529,6 +21887,14 @@ snapshots: html-escaper@3.0.3: {} + html-to-text@10.0.0: + dependencies: + '@selderee/plugin-htmlparser2': 0.12.0(selderee@0.12.0) + deepmerge-ts: 7.1.5 + dom-serializer: 2.0.0 + htmlparser2: 10.1.0 + selderee: 0.12.0 + html-to-text@9.0.5: dependencies: '@selderee/plugin-htmlparser2': 0.11.0 @@ -21541,6 +21907,13 @@ snapshots: html-void-elements@3.0.0: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -21603,7 +21976,7 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.0: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -21736,6 +22109,11 @@ snapshots: dependencies: hasown: 2.0.2 + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + optional: true + is-data-view@1.0.2: dependencies: call-bound: 1.0.4 @@ -22404,6 +22782,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + jsbn@1.1.0: {} jsdom@20.0.3: @@ -22560,6 +22942,8 @@ snapshots: leac@0.6.0: {} + leac@0.7.0: {} + leven@3.1.0: {} levn@0.4.1: @@ -22567,6 +22951,17 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libbase64@1.3.0: {} + + libmime@5.4.1: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libqp: 2.1.1 + + libqp@2.1.1: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -22626,6 +23021,10 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + linkifyjs@4.3.2: {} lint-staged@15.5.1: @@ -22816,6 +23215,19 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mailparser@3.9.14: + dependencies: + '@zone-eu/mailsplit': 5.4.14 + encoding-japanese: 2.2.0 + he: 1.2.0 + html-to-text: 10.0.0 + iconv-lite: 0.7.3 + libmime: 5.4.1 + linkify-it: 5.0.2 + nodemailer: 9.0.3 + punycode.js: 2.3.1 + tlds: 1.261.0 + make-dir@3.1.0: dependencies: semver: 6.3.1 @@ -24009,6 +24421,8 @@ snapshots: nodemailer@6.10.1: {} + nodemailer@9.0.3: {} + normalize-path@3.0.0: {} normalize-range@0.1.2: {} @@ -24218,6 +24632,11 @@ snapshots: leac: 0.6.0 peberminta: 0.9.0 + parseley@0.13.1: + dependencies: + leac: 0.7.0 + peberminta: 0.10.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -24251,6 +24670,8 @@ snapshots: pathe@2.0.3: {} + peberminta@0.10.0: {} + peberminta@0.9.0: {} pend@1.2.0: {} @@ -25284,6 +25705,14 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + optional: true + resolve@2.0.0-next.5: dependencies: is-core-module: 2.16.1 @@ -25474,6 +25903,10 @@ snapshots: dependencies: parseley: 0.12.1 + selderee@0.12.0: + dependencies: + parseley: 0.13.1 + semver-compare@1.0.0: {} semver@6.3.1: {} @@ -26179,6 +26612,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tlds@1.261.0: {} + tldts-core@6.1.86: {} tldts@6.1.86: @@ -26762,13 +27197,13 @@ snapshots: transitivePeerDependencies: - supports-color - typescript-eslint@8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color