mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
feat(emails): arbitrary-recipient send + Emailable on managed domains (#1681)
## Summary
Two related email changes:
### 1. Send to arbitrary email addresses
The `sendEmail` SDK function and `POST /api/v1/emails/send-email` now
accept a third recipient selector, **`emails: string[]`**, mutually
exclusive (XOR) with `user_ids` and `all_users`. These recipients don't
have to belong to a user — they're mapped to the existing internal
`custom-emails` recipient type (no associated user object, can't
unsubscribe, so intended for transactional mail).
- Response `results` items are now `{ user_id?, email? }`; the `emails`
path returns `{ email }`.
- Validation message updated to `Exactly one of user_ids, all_users, or
emails must be provided`.
- Recipient addresses are validated with the shared `emailSchema`.
### 2. Run Emailable on managed custom domains
`LowLevelEmailConfig.type` is widened `'shared' | 'standard'` →
`'shared' | 'managed' | 'standard'`, and `getEmailConfig` returns
`'managed'` for the managed-Resend branch. The Emailable deliverability
check now runs for **shared** and **managed**, and is skipped for
**`standard`** (a customer's own SMTP server or Resend API key).
**Why:** managed domains send through our single Resend *account*
(per-domain scoped keys, but shared account → account-level bounce
penalties are collateral across managed customers), so we own that
reputation. With a custom SMTP server or the customer's own Resend key,
the customer owns their deliverability, so we don't second-guess their
recipients or spend Emailable checks on their volume. The shared
dev-email wrapper remains `shared`-only.
## Tests
- `send-email.test.ts`: added arbitrary-recipient send
(single/multiple), empty-array, and validation cases (both-selectors,
no-selector, invalid email); updated the exactly-one-of snapshot.
**24/24 pass.**
- New `emails/deliverability-gating.test.ts`: shared & managed skip an
undeliverable address (`LIKELY_NOT_DELIVERABLE`), custom SMTP still
sends. **3/3 pass.**
> Note: `email-queue.test.ts` has 2 pre-existing snapshot failures (a
`margin:0rem`→`margin:0` CSS normalization in rendered email HTML) that
are unrelated to this change — confirmed by reproducing them on `dev`
with these changes stashed.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds support for sending emails to arbitrary addresses and enables
Emailable checks for managed domains to protect shared sending
reputation. The API/SDK now accept `emails: string[]` as a recipient
selector, and deliverability checks run on shared and managed sending.
- New Features
- API/SDK: `emails: string[]` recipient selector (XOR with
`user_ids`/`all_users`) in `sendEmail` and POST
/api/v1/emails/send-email. Results return `{ email }` for this path;
addresses validated via `emailSchema`. Recipients map to
transactional-only “custom-emails” (no user, no unsubscribe).
- Deliverability: email config adds `'managed'`; `getEmailConfig`
returns it for managed Resend domains. Emailable check runs for `shared`
and `managed`; skipped for `standard` (custom SMTP or customer Resend
key).
<sup>Written for commit e48cf2ddda.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1681?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Emails can now be sent to arbitrary email addresses, not just existing
users.
* Email sending APIs and client options now accept an email list as a
recipient choice.
* **Bug Fixes**
* Improved recipient validation so exactly one target type must be
chosen.
* Fixed response results to consistently show either a user ID or an
email address.
* Corrected deliverability checks for managed email setups and updated
skip behavior for undeliverable addresses.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
4cd766e406
commit
81433e5181
@ -1,17 +1,20 @@
|
||||
import { getEmailDraft, themeModeToTemplateThemeId } from "@/lib/email-drafts";
|
||||
import { createTemplateComponentFromHtml } from "@/lib/email-rendering";
|
||||
import { sendEmailToMany } from "@/lib/emails";
|
||||
import { EmailOutboxRecipient, sendEmailToMany } from "@/lib/emails";
|
||||
import { getNotificationCategoryByName } from "@/lib/notification-categories";
|
||||
import { getPrismaClientForTenancy } from "@/prisma-client";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { KnownErrors } from "@hexclave/shared";
|
||||
import { adaptSchema, jsonSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupBoolean, yupNumber, yupObject, yupRecord, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields";
|
||||
import { adaptSchema, emailSchema, jsonSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupBoolean, yupNumber, yupObject, yupRecord, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
import { StatusError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
|
||||
const bodyBase = yupObject({
|
||||
user_ids: yupArray(yupString().defined()).optional(),
|
||||
all_users: yupBoolean().oneOf([true]).optional(),
|
||||
emails: yupArray(emailSchema.defined()).optional().meta({
|
||||
openapiField: { description: "Send the email to arbitrary email addresses that don't have to belong to a user. Recipients have no associated user object and cannot unsubscribe, so this should only be used for transactional emails." }
|
||||
}),
|
||||
subject: yupString().optional(),
|
||||
notification_category_name: yupString().optional(),
|
||||
theme_id: templateThemeIdSchema.nullable().meta({
|
||||
@ -28,7 +31,7 @@ const bodyBase = yupObject({
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
summary: "Send email",
|
||||
description: "Send an email to a list of users. The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.",
|
||||
description: "Send an email to a list of users (user_ids), all users (all_users), or arbitrary email addresses (emails). The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.",
|
||||
tags: ["Emails"],
|
||||
},
|
||||
request: yupObject({
|
||||
@ -55,7 +58,8 @@ export const POST = createSmartRouteHandler({
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
results: yupArray(yupObject({
|
||||
user_id: yupString().defined(),
|
||||
user_id: yupString().optional(),
|
||||
email: yupString().optional(),
|
||||
})).defined(),
|
||||
}).defined(),
|
||||
}),
|
||||
@ -63,8 +67,9 @@ export const POST = createSmartRouteHandler({
|
||||
if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) {
|
||||
throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
|
||||
}
|
||||
if ((body.user_ids && body.all_users) || (!body.user_ids && !body.all_users)) {
|
||||
throw new KnownErrors.SchemaError("Exactly one of user_ids or all_users must be provided");
|
||||
const providedRecipientSelectors = [body.user_ids, body.all_users, body.emails].filter(selector => selector !== undefined);
|
||||
if (providedRecipientSelectors.length !== 1) {
|
||||
throw new KnownErrors.SchemaError("Exactly one of user_ids, all_users, or emails must be provided");
|
||||
}
|
||||
|
||||
// We have this check in the email queue step as well, but to give the user a better error message already in the send-email endpoint we already do it here
|
||||
@ -125,33 +130,46 @@ export const POST = createSmartRouteHandler({
|
||||
throw new KnownErrors.SchemaError("Either template_id, html, or draft_id must be provided");
|
||||
}
|
||||
|
||||
const requestedUserIds = body.all_users ? (await prisma.projectUser.findMany({
|
||||
where: {
|
||||
tenancyId: auth.tenancy.id,
|
||||
},
|
||||
select: {
|
||||
projectUserId: true,
|
||||
},
|
||||
})).map(user => user.projectUserId) : body.user_ids ?? throwErr("user_ids must be provided if all_users is false");
|
||||
let recipients: EmailOutboxRecipient[];
|
||||
let results: { user_id?: string, email?: string }[];
|
||||
|
||||
// Sanity check that the user IDs are valid so the user gets an error here instead of only once the email is rendered.
|
||||
if (!body.all_users && body.user_ids) {
|
||||
const uniqueUserIds = [...new Set(body.user_ids)];
|
||||
const users = await prisma.projectUser.findMany({
|
||||
if (body.emails) {
|
||||
// Send to arbitrary email addresses that don't have to belong to a user. These have no associated user object,
|
||||
// so they can't unsubscribe and there's nothing to sanity-check against the project's users.
|
||||
recipients = body.emails.map(email => ({ type: "custom-emails", emails: [email] }));
|
||||
results = body.emails.map(email => ({ email }));
|
||||
} else {
|
||||
const requestedUserIds = body.all_users ? (await prisma.projectUser.findMany({
|
||||
where: {
|
||||
tenancyId: auth.tenancy.id,
|
||||
projectUserId: { in: uniqueUserIds },
|
||||
},
|
||||
select: {
|
||||
projectUserId: true,
|
||||
},
|
||||
});
|
||||
if (users.length !== uniqueUserIds.length) {
|
||||
const foundUserIds = new Set(users.map(u => u.projectUserId));
|
||||
const missingUserId = uniqueUserIds.find(id => !foundUserIds.has(id));
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
throw new KnownErrors.UserIdDoesNotExist(missingUserId!);
|
||||
})).map(user => user.projectUserId) : body.user_ids ?? throwErr("user_ids must be provided if all_users is false");
|
||||
|
||||
// Sanity check that the user IDs are valid so the user gets an error here instead of only once the email is rendered.
|
||||
if (!body.all_users && body.user_ids) {
|
||||
const uniqueUserIds = [...new Set(body.user_ids)];
|
||||
const users = await prisma.projectUser.findMany({
|
||||
where: {
|
||||
tenancyId: auth.tenancy.id,
|
||||
projectUserId: { in: uniqueUserIds },
|
||||
},
|
||||
select: {
|
||||
projectUserId: true,
|
||||
},
|
||||
});
|
||||
if (users.length !== uniqueUserIds.length) {
|
||||
const foundUserIds = new Set(users.map(u => u.projectUserId));
|
||||
const missingUserId = uniqueUserIds.find(id => !foundUserIds.has(id));
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
throw new KnownErrors.UserIdDoesNotExist(missingUserId!);
|
||||
}
|
||||
}
|
||||
|
||||
recipients = requestedUserIds.map(userId => ({ type: "user-primary-email", userId }));
|
||||
results = requestedUserIds.map(userId => ({ user_id: userId }));
|
||||
}
|
||||
|
||||
const scheduledAt = body.scheduled_at_millis ? new Date(body.scheduled_at_millis) : new Date();
|
||||
@ -159,7 +177,7 @@ export const POST = createSmartRouteHandler({
|
||||
await sendEmailToMany({
|
||||
createdWith: createdWith,
|
||||
tenancy: auth.tenancy,
|
||||
recipients: requestedUserIds.map(userId => ({ type: "user-primary-email", userId })),
|
||||
recipients: recipients,
|
||||
tsxSource: tsxSource,
|
||||
extraVariables: variables,
|
||||
themeId: selectedThemeId === null ? null : (selectedThemeId === undefined ? auth.tenancy.config.emails.selectedThemeId : selectedThemeId),
|
||||
@ -187,7 +205,7 @@ export const POST = createSmartRouteHandler({
|
||||
statusCode: 200,
|
||||
bodyType: 'json',
|
||||
body: {
|
||||
results: requestedUserIds.map(userId => ({ user_id: userId })),
|
||||
results: results,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@ -65,10 +65,13 @@ const emailQueueFirstRunKey = Symbol.for("__hexclave_email_queue_first_run_compl
|
||||
async function verifyEmailDeliverability(
|
||||
email: string,
|
||||
shouldSkipDeliverabilityCheck: boolean,
|
||||
emailConfigType: "shared" | "standard"
|
||||
emailConfigType: "shared" | "managed" | "standard"
|
||||
): Promise<EmailableCheckResult> {
|
||||
// Skip deliverability check if requested or using non-shared email config
|
||||
if (shouldSkipDeliverabilityCheck || emailConfigType !== "shared") {
|
||||
// We run the Emailable deliverability check whenever the email goes out through infrastructure whose sending
|
||||
// reputation we own: our shared server ("shared") and custom domains we provision & send on the user's behalf
|
||||
// ("managed", Resend under our account). We skip it for "standard" (the user's own SMTP server or Resend API key),
|
||||
// where the user owns their own deliverability, and whenever the caller explicitly opts out.
|
||||
if (shouldSkipDeliverabilityCheck || (emailConfigType !== "shared" && emailConfigType !== "managed")) {
|
||||
return { status: "deliverable", emailableScore: null };
|
||||
}
|
||||
|
||||
|
||||
@ -34,7 +34,11 @@ export type LowLevelEmailConfig = {
|
||||
senderEmail: string,
|
||||
senderName: string,
|
||||
secure: boolean,
|
||||
type: 'shared' | 'standard',
|
||||
// 'shared': Hexclave's shared email server. 'managed': a custom domain we provision & send through on the user's
|
||||
// behalf (Resend under our account). 'standard': the user's own SMTP server or Resend API key. We run the Emailable
|
||||
// deliverability check for 'shared' and 'managed' (where bad recipients hurt our own sending reputation), but not
|
||||
// for 'standard' (the user owns their own deliverability there).
|
||||
type: 'shared' | 'managed' | 'standard',
|
||||
}
|
||||
|
||||
export type LowLevelSendEmailOptions = {
|
||||
|
||||
@ -161,7 +161,7 @@ export async function getEmailConfig(tenancy: Tenancy): Promise<LowLevelEmailCon
|
||||
senderEmail: `${projectEmailConfig.managedSenderLocalPart}@${projectEmailConfig.managedSubdomain}`,
|
||||
senderName: tenancy.project.display_name,
|
||||
secure: true,
|
||||
type: "standard",
|
||||
type: "managed",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,120 @@
|
||||
import { wait } from "@hexclave/shared/dist/utils/promises";
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../../../../../helpers";
|
||||
import { withPortPrefix } from "../../../../../helpers/ports";
|
||||
import { Project, niceBackendFetch, waitForOutboxEmailWithStatus } from "../../../../backend-helpers";
|
||||
|
||||
// The Emailable wrapper always reports this exact domain as undeliverable, regardless of API key, so we can exercise
|
||||
// the deliverability gate deterministically without hitting the real Emailable API.
|
||||
const NOT_DELIVERABLE_EMAIL = "recipient@emailable-not-deliverable.example.com";
|
||||
|
||||
const customSmtpConfig = {
|
||||
type: "standard",
|
||||
host: "localhost",
|
||||
port: Number(withPortPrefix("29")),
|
||||
username: "test",
|
||||
password: "test",
|
||||
sender_name: "Test Project",
|
||||
sender_email: "test@example.com",
|
||||
} as const;
|
||||
|
||||
async function sendToNotDeliverableAddress(subject: string) {
|
||||
return await niceBackendFetch("/api/v1/emails/send-email", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
emails: [NOT_DELIVERABLE_EMAIL],
|
||||
html: "<p>Deliverability gating test</p>",
|
||||
subject,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Sets up and applies a managed custom domain on the current project using the mock Resend onboarding flow.
|
||||
async function applyManagedEmailDomain(options: { subdomain: string, senderLocalPart: string }) {
|
||||
const setupResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/setup", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
subdomain: options.subdomain,
|
||||
sender_local_part: options.senderLocalPart,
|
||||
},
|
||||
});
|
||||
if (setupResponse.status !== 200) {
|
||||
throw new Error(`Managed onboarding setup failed: ${JSON.stringify(setupResponse.body)}`);
|
||||
}
|
||||
const domainId = setupResponse.body.domain_id as string;
|
||||
|
||||
// The mock onboarding flips the domain to "verified" asynchronously, mirroring the real Resend webhook.
|
||||
const deadline = performance.now() + 10_000;
|
||||
while (performance.now() < deadline) {
|
||||
const checkResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/check", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
domain_id: domainId,
|
||||
subdomain: options.subdomain,
|
||||
sender_local_part: options.senderLocalPart,
|
||||
},
|
||||
});
|
||||
if (checkResponse.status === 200 && checkResponse.body.status === "verified") {
|
||||
break;
|
||||
}
|
||||
await wait(250);
|
||||
}
|
||||
|
||||
const applyResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/apply", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
domain_id: domainId,
|
||||
},
|
||||
});
|
||||
if (applyResponse.status !== 200 || applyResponse.body.status !== "applied") {
|
||||
throw new Error(`Managed onboarding apply failed: ${JSON.stringify(applyResponse.body)}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe("emailable deliverability gating", () => {
|
||||
it("runs the deliverability check on the shared email server (undeliverable address is skipped)", async ({ expect }) => {
|
||||
// A fresh project defaults to the shared email server.
|
||||
await Project.createAndSwitch({ display_name: "Shared Deliverability Project" });
|
||||
|
||||
const subject = "Shared Deliverability Gating Test";
|
||||
const response = await sendToNotDeliverableAddress(subject);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const emails = await waitForOutboxEmailWithStatus(subject, "skipped");
|
||||
expect(emails[0].skipped_reason).toBe("LIKELY_NOT_DELIVERABLE");
|
||||
});
|
||||
|
||||
it("runs the deliverability check on a managed custom domain (undeliverable address is skipped)", async ({ expect }) => {
|
||||
await Project.createAndSwitch({ display_name: "Managed Deliverability Project" });
|
||||
await applyManagedEmailDomain({ subdomain: "mail.example.com", senderLocalPart: "noreply" });
|
||||
|
||||
const subject = "Managed Deliverability Gating Test";
|
||||
const response = await sendToNotDeliverableAddress(subject);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const emails = await waitForOutboxEmailWithStatus(subject, "skipped");
|
||||
expect(emails[0].skipped_reason).toBe("LIKELY_NOT_DELIVERABLE");
|
||||
});
|
||||
|
||||
it("does NOT run the deliverability check on a custom SMTP server (undeliverable address is still sent)", async ({ expect }) => {
|
||||
// A custom SMTP server (and likewise a custom Resend API key) is the user's own infrastructure, so we leave
|
||||
// deliverability to them and never call Emailable. The email therefore proceeds to send instead of being skipped.
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Custom SMTP Deliverability Project",
|
||||
config: {
|
||||
email_config: customSmtpConfig,
|
||||
},
|
||||
});
|
||||
|
||||
const subject = "Custom SMTP Deliverability Gating Test";
|
||||
const response = await sendToNotDeliverableAddress(subject);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const emails = await waitForOutboxEmailWithStatus(subject, "sent");
|
||||
expect(emails[0].status).toBe("sent");
|
||||
});
|
||||
});
|
||||
@ -542,8 +542,8 @@ describe("all users", () => {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "SCHEMA_ERROR",
|
||||
"details": { "message": "Exactly one of user_ids or all_users must be provided" },
|
||||
"error": "Exactly one of user_ids or all_users must be provided",
|
||||
"details": { "message": "Exactly one of user_ids, all_users, or emails must be provided" },
|
||||
"error": "Exactly one of user_ids, all_users, or emails must be provided",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "SCHEMA_ERROR",
|
||||
@ -770,3 +770,190 @@ describe("notification categories", () => {
|
||||
await backendContext.value.mailbox.waitForMessagesWithSubject("Default Category Test Subject");
|
||||
});
|
||||
});
|
||||
|
||||
describe("arbitrary email addresses", () => {
|
||||
it("should send to an arbitrary email address that does not belong to a user", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Arbitrary Emails Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const mailbox = await bumpEmailAddress();
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
emails: [mailbox.emailAddress],
|
||||
html: "<p>Hello arbitrary recipient</p>",
|
||||
subject: "Arbitrary Email Subject",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
// The response echoes back the email addresses (no user_id, since there is no user).
|
||||
expect(response.body.results).toEqual([{ email: mailbox.emailAddress }]);
|
||||
|
||||
const messages = await mailbox.waitForMessagesWithSubject("Arbitrary Email Subject");
|
||||
expect(messages.length).toBeGreaterThanOrEqual(1);
|
||||
expect(messages[0].body?.html ?? "").toContain("Hello arbitrary recipient");
|
||||
});
|
||||
|
||||
it("should send to multiple arbitrary email addresses", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Multiple Arbitrary Emails Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const mailbox1 = await bumpEmailAddress();
|
||||
const mailbox2 = await bumpEmailAddress();
|
||||
|
||||
const subject = "Multiple Arbitrary Emails Subject";
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
emails: [mailbox1.emailAddress, mailbox2.emailAddress],
|
||||
html: "<p>Broadcast to arbitrary recipients</p>",
|
||||
subject,
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.results).toEqual([
|
||||
{ email: mailbox1.emailAddress },
|
||||
{ email: mailbox2.emailAddress },
|
||||
]);
|
||||
|
||||
await mailbox1.waitForMessagesWithSubject(subject);
|
||||
await mailbox2.waitForMessagesWithSubject(subject);
|
||||
});
|
||||
|
||||
it("should return 200 with empty results when emails is an empty array", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Empty Emails Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
emails: [],
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "results": [] },
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 400 when both user_ids and emails are provided", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Both user_ids and emails Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const user = await User.create();
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_ids: [user.userId],
|
||||
emails: ["someone@example.com"],
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "SCHEMA_ERROR",
|
||||
"details": { "message": "Exactly one of user_ids, all_users, or emails must be provided" },
|
||||
"error": "Exactly one of user_ids, all_users, or emails must be provided",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "SCHEMA_ERROR",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 400 when none of user_ids, all_users, or emails are provided", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test No Recipient Selector Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "SCHEMA_ERROR",
|
||||
"details": { "message": "Exactly one of user_ids, all_users, or emails must be provided" },
|
||||
"error": "Exactly one of user_ids, all_users, or emails must be provided",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "SCHEMA_ERROR",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 400 when an invalid email address is provided", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Invalid Email Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
emails: ["not-an-email"],
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe("SCHEMA_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
@ -949,6 +949,7 @@ export class HexclaveServerInterface extends HexclaveClientInterface {
|
||||
async sendEmail(options: {
|
||||
userIds?: string[],
|
||||
allUsers?: true,
|
||||
emails?: string[],
|
||||
themeId?: string | null | false,
|
||||
html?: string,
|
||||
subject?: string,
|
||||
@ -968,6 +969,7 @@ export class HexclaveServerInterface extends HexclaveClientInterface {
|
||||
body: JSON.stringify({
|
||||
user_ids: options.userIds,
|
||||
all_users: options.allUsers,
|
||||
emails: options.emails,
|
||||
theme_id: options.themeId,
|
||||
html: options.html,
|
||||
subject: options.subject,
|
||||
|
||||
@ -241,7 +241,10 @@ type SendEmailOptionsBase = {
|
||||
export type SendEmailOptions = SendEmailOptionsBase
|
||||
& XOR<[
|
||||
{ userIds: string[] },
|
||||
{ allUsers: true }
|
||||
{ allUsers: true },
|
||||
// Send to arbitrary email addresses that don't have to belong to a user. These recipients have no associated user
|
||||
// object and cannot unsubscribe, so this should only be used for transactional emails.
|
||||
{ emails: string[] }
|
||||
]>
|
||||
& XOR<[
|
||||
{ html: string },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user