Skip to content
Merged

Test #714

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f99277e
feat(measurement-jobs): free-tier card gate (setup mode) + instant ba…
sweetmantech Jun 16, 2026
158a1d4
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
5fa5e3a
fix(songstats-backfill): backoff on 429 + defer instead of churn (cha…
sweetmantech Jun 16, 2026
1e9deac
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
5f45946
refactor(songstats): remove local quota ledger + budget gate (chat#17…
sweetmantech Jun 16, 2026
5472795
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
f24d4c7
feat: POST /api/catalogs (create + materialize from valuation snapsho…
sweetmantech Jun 18, 2026
ace0b01
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
a520a1d
fix: LEFT-join artists in catalog-songs read (materialized tracks wer…
sweetmantech Jun 18, 2026
76b5739
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
709ea0c
feat: add X (Twitter) + LinkedIn to the Composio connector whitelist …
sweetmantech Jun 18, 2026
8a3c3cb
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
66cc2fe
chore: remove unused ALLOWED_ARTIST_CONNECTORS from api (chat#1793) (…
sweetmantech Jun 18, 2026
79c22da
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
6fc10cc
fix: enrich valuation-captured songs (artists + notes) so they render…
sweetmantech Jun 18, 2026
0e42d02
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
2fa7029
fix(tasks): let admins fetch any task by id alone (cross-account read…
sweetmantech Jun 19, 2026
bd2ae10
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 19, 2026
cdb34d0
feat(connectors): POST /api/connectors/files — stage images for Linke…
sweetmantech Jun 20, 2026
acae4d8
Merge main into test (sync #692 Songstats work)
sweetmantech Jun 23, 2026
2a7af68
feat(artists): account_id override for DELETE /api/artists/{id} (#693)
sweetmantech Jun 23, 2026
8c962ef
Merge main into test (sync #696)
sweetmantech Jun 23, 2026
8b6a3bb
feat(chats): admins (RECOUP_ORG) can access any chat — read + write (…
sweetmantech Jun 23, 2026
03e0ef5
Enforce account_api_keys.expires_at in x-api-key auth (chat#1813) (#700)
sweetmantech Jun 24, 2026
bff29ae
Merge branch 'main' into test
sweetmantech Jun 24, 2026
78bd71b
refactor(chat): extract shared buildRunAgentInput (chat#1813) (#701)
sweetmantech Jun 24, 2026
2209b7d
Re-point POST /api/chat/generate onto runAgentWorkflow + ephemeral ke…
sweetmantech Jun 24, 2026
af7eaa5
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 24, 2026
b84e4a1
Merge remote-tracking branch 'origin/main' into HEAD
sweetmantech Jun 24, 2026
4c09cf0
Retire OpenClaw prompt_sandbox → run-sandbox-command bridge (chat#181…
sweetmantech Jun 24, 2026
f1ade64
Merge remote-tracking branch 'origin/main' into HEAD
sweetmantech Jun 24, 2026
4ecca44
feat: POST /api/emails + route ephemeral key to RECOUP_API_KEY (#1815…
sweetmantech Jun 25, 2026
27f0532
Merge origin/main into test (sync after #709 test→main promotion)
sweetmantech Jun 25, 2026
6d9e74e
fix(skills): install the renamed global skills into sandboxes (chat#1…
sweetmantech Jun 25, 2026
a463e59
Merge origin/main into test (sync after #713 test→main promotion)
sweetmantech Jun 25, 2026
18d20f7
feat(emails): make `to` and `subject` optional on POST /api/emails (#…
sweetmantech Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/emails/__tests__/firstMeaningfulLine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { firstMeaningfulLine } from "../firstMeaningfulLine";

describe("firstMeaningfulLine", () => {
it("returns the first non-empty line", () => {
expect(firstMeaningfulLine("\n\nHello there\nmore")).toBe("Hello there");
});

it("strips a leading Markdown heading", () => {
expect(firstMeaningfulLine("# Pulse Report\n\nbody")).toBe("Pulse Report");
expect(firstMeaningfulLine("### Deep heading")).toBe("Deep heading");
});

it("returns empty string for empty/undefined/whitespace input", () => {
expect(firstMeaningfulLine()).toBe("");
expect(firstMeaningfulLine("")).toBe("");
expect(firstMeaningfulLine(" \n \n")).toBe("");
});
});
39 changes: 39 additions & 0 deletions lib/emails/__tests__/resolveEmailSubject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { resolveEmailSubject, DEFAULT_EMAIL_SUBJECT } from "../resolveEmailSubject";

describe("resolveEmailSubject", () => {
it("returns the provided subject when non-empty", () => {
expect(resolveEmailSubject({ subject: "Weekly report", text: "# Pulse" })).toBe(
"Weekly report",
);
});

it("trims the provided subject", () => {
expect(resolveEmailSubject({ subject: " Hi " })).toBe("Hi");
});

it("derives from the text body's first heading (stripping leading #)", () => {
expect(resolveEmailSubject({ text: "# Pulse Report\n\nbody here" })).toBe("Pulse Report");
});

it("derives from the first non-empty line of plain text", () => {
expect(resolveEmailSubject({ subject: "", text: "\n\nHello there\nmore" })).toBe("Hello there");
});

it("derives from html when no text (tags stripped)", () => {
expect(resolveEmailSubject({ html: "<h1>Launch day</h1><p>details</p>" })).toBe("Launch day");
});

it("falls back to the default when the body is empty", () => {
expect(resolveEmailSubject({})).toBe(DEFAULT_EMAIL_SUBJECT);
expect(resolveEmailSubject({ subject: " ", text: " ", html: "" })).toBe(
DEFAULT_EMAIL_SUBJECT,
);
});

it("caps a very long derived subject", () => {
const long = "x".repeat(300);
const result = resolveEmailSubject({ text: long });
expect(result.length).toBeLessThanOrEqual(120);
});
});
21 changes: 21 additions & 0 deletions lib/emails/__tests__/stripHtml.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { stripHtml } from "../stripHtml";

describe("stripHtml", () => {
it("strips tags and breaks blocks onto their own lines", () => {
expect(stripHtml("<h1>Launch day</h1><p>details</p>")).toBe("Launch day\n details");
});

it("turns <br> into a newline", () => {
expect(stripHtml("line one<br/>line two")).toBe("line one\nline two");
});

it("collapses runs of spaces/tabs and trims", () => {
expect(stripHtml("<span> a\t\t b </span>")).toBe("a b");
});

it("returns empty string for empty/undefined input", () => {
expect(stripHtml()).toBe("");
expect(stripHtml("")).toBe("");
});
});
78 changes: 70 additions & 8 deletions lib/emails/__tests__/validateSendEmailBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { validateSendEmailBody } from "../validateSendEmailBody";

const mockValidateAuthContext = vi.fn();
const mockAssertRecipientsAllowed = vi.fn();
const mockSelectAccountEmails = vi.fn();

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args),
Expand All @@ -13,6 +14,10 @@ vi.mock("@/lib/emails/assertRecipientsAllowed", () => ({
assertRecipientsAllowed: (...args: unknown[]) => mockAssertRecipientsAllowed(...args),
}));

vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({
default: (...args: unknown[]) => mockSelectAccountEmails(...args),
}));

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));
Expand All @@ -38,6 +43,7 @@ describe("validateSendEmailBody", () => {
authToken: "test-api-key",
});
mockAssertRecipientsAllowed.mockResolvedValue({ allowed: true });
mockSelectAccountEmails.mockResolvedValue([{ email: "owner@example.com" }]);
});

describe("recipient restriction", () => {
Expand Down Expand Up @@ -74,6 +80,29 @@ describe("validateSendEmailBody", () => {
});
});

describe("subject defaulting", () => {
it("defaults a missing subject to the body's first heading", async () => {
const request = createRequest(
{ to: ["d@example.com"], text: "# Pulse Report\n\nbody" },
{ "x-api-key": "k" },
);
const result = await validateSendEmailBody(request);
expect(result).not.toBeInstanceOf(NextResponse);
if (!(result instanceof NextResponse)) {
expect(result.subject).toBe("Pulse Report");
}
});

it("defaults to `Message from Recoup` when subject and body are empty", async () => {
const request = createRequest({ to: ["d@example.com"] }, { "x-api-key": "k" });
const result = await validateSendEmailBody(request);
expect(result).not.toBeInstanceOf(NextResponse);
if (!(result instanceof NextResponse)) {
expect(result.subject).toBe("Message from Recoup");
}
});
});

describe("successful validation", () => {
it("returns validated data with to + subject + text", async () => {
const request = createRequest(
Expand Down Expand Up @@ -128,14 +157,52 @@ describe("validateSendEmailBody", () => {
});
});

describe("validation errors (400)", () => {
it("rejects a missing 'to'", async () => {
describe("default recipient (omitted 'to')", () => {
it("defaults 'to' to the account's own email when omitted", async () => {
mockSelectAccountEmails.mockResolvedValue([{ email: "owner@example.com" }]);
const request = createRequest({ subject: "s", text: "hi" }, { "x-api-key": "k" });
const result = await validateSendEmailBody(request);

expect(result).not.toBeInstanceOf(NextResponse);
if (!(result instanceof NextResponse)) {
expect(result.to).toEqual(["owner@example.com"]);
}
expect(mockSelectAccountEmails).toHaveBeenCalledWith({ accountIds: "account-123" });
});

it("includes every account email when the account has more than one", async () => {
mockSelectAccountEmails.mockResolvedValue([
{ email: "owner@example.com" },
{ email: "owner.alt@example.com" },
]);
const request = createRequest({ subject: "s" }, { "x-api-key": "k" });
const result = await validateSendEmailBody(request);

expect(result).not.toBeInstanceOf(NextResponse);
if (!(result instanceof NextResponse)) {
expect(result.to).toEqual(["owner@example.com", "owner.alt@example.com"]);
}
});

it("returns 400 when 'to' is omitted and the account has no email on file", async () => {
mockSelectAccountEmails.mockResolvedValue([]);
const request = createRequest({ subject: "s" }, { "x-api-key": "k" });
const result = await validateSendEmailBody(request);
expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) expect(result.status).toBe(400);
});

it("does not resolve account emails when 'to' is provided", async () => {
const request = createRequest(
{ to: ["dest@example.com"], subject: "s" },
{ "x-api-key": "k" },
);
await validateSendEmailBody(request);
expect(mockSelectAccountEmails).not.toHaveBeenCalled();
});
});

describe("validation errors (400)", () => {
it("rejects an empty 'to' array", async () => {
const request = createRequest({ to: [], subject: "s" }, { "x-api-key": "k" });
const result = await validateSendEmailBody(request);
Expand All @@ -150,12 +217,7 @@ describe("validateSendEmailBody", () => {
if (result instanceof NextResponse) expect(result.status).toBe(400);
});

it("rejects a missing subject", async () => {
const request = createRequest({ to: ["d@example.com"] }, { "x-api-key": "k" });
const result = await validateSendEmailBody(request);
expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) expect(result.status).toBe(400);
});
// subject is now optional (defaults from the body) — covered by "subject defaulting" above.
});

describe("auth", () => {
Expand Down
14 changes: 14 additions & 0 deletions lib/emails/firstMeaningfulLine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* First non-empty line of a string, with any leading Markdown heading `#`s
* stripped. Returns "" when there is no meaningful line.
*
* @param value - The string to scan (e.g. a Markdown/plain-text email body).
*/
export function firstMeaningfulLine(value?: string): string {
if (!value) return "";
for (const line of value.split("\n")) {
const cleaned = line.replace(/^\s*#+\s*/, "").trim();
if (cleaned) return cleaned;
}
return "";
}
33 changes: 33 additions & 0 deletions lib/emails/resolveEmailSubject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { firstMeaningfulLine } from "@/lib/emails/firstMeaningfulLine";
import { stripHtml } from "@/lib/emails/stripHtml";

/** Fallback subject when none is provided and the body has no usable first line. */
export const DEFAULT_EMAIL_SUBJECT = "Message from Recoup";

/** Max length for a subject derived from the body (full provided subjects pass through). */
const MAX_DERIVED_SUBJECT_LENGTH = 120;

/**
* Resolve the subject for an outbound email. Resend requires a non-empty
* subject, but `POST /api/emails` lets the caller omit it (recoupable/chat#1815):
* use the provided subject when present, else derive one from the body's first
* heading/line (text preferred, then HTML), else fall back to a generic default.
* Mirrors the documented contract in docs#252.
*/
export function resolveEmailSubject({
subject,
text,
html,
}: {
subject?: string;
text?: string;
html?: string;
}): string {
const provided = subject?.trim();
if (provided) return provided;

const derived = firstMeaningfulLine(text) || firstMeaningfulLine(stripHtml(html));
if (derived) return derived.slice(0, MAX_DERIVED_SUBJECT_LENGTH);

return DEFAULT_EMAIL_SUBJECT;
}
15 changes: 15 additions & 0 deletions lib/emails/stripHtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Strip HTML tags to plain text, breaking at block boundaries so the first
* block stays on its own line (so callers can pull the first line/heading).
*
* @param html - The HTML string to flatten.
*/
export function stripHtml(html?: string): string {
if (!html) return "";
return html
.replace(/<\/(h[1-6]|p|div|li|tr)\s*>/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/[ \t]+/g, " ")
.trim();
}
50 changes: 43 additions & 7 deletions lib/emails/validateSendEmailBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { assertRecipientsAllowed } from "@/lib/emails/assertRecipientsAllowed";
import { resolveEmailSubject } from "@/lib/emails/resolveEmailSubject";
import { safeParseJson } from "@/lib/networking/safeParseJson";
import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails";
import { z } from "zod";

export const sendEmailBodySchema = z.object({
to: z
.array(z.string().email("each 'to' entry must be a valid email"))
.min(1, "to must include at least one recipient"),
.min(1, "to must include at least one recipient")
.optional(),
cc: z.array(z.string().email("each 'cc' entry must be a valid email")).default([]).optional(),
subject: z.string({ message: "subject is required" }).min(1, "subject cannot be empty"),
subject: z.string().optional(),
text: z.string().optional(),
html: z.string().default("").optional(),
headers: z.record(z.string(), z.string()).default({}).optional(),
Expand All @@ -20,13 +23,23 @@ export const sendEmailBodySchema = z.object({

export type SendEmailBody = z.infer<typeof sendEmailBodySchema>;

export type ValidatedSendEmailRequest = SendEmailBody & { accountId: string };
export type ValidatedSendEmailRequest = Omit<SendEmailBody, "to" | "subject"> & {
to: string[];
subject: string;
accountId: string;
};

/**
* Validates POST /api/emails: parses the body, authenticates via
* validateAuthContext (x-api-key or Bearer), then enforces the recipient
* restriction (without a payment method on file, `to`/`cc` are limited to the
* account's own email). Takes an explicit `to` recipient list.
* validateAuthContext (x-api-key or Bearer), resolves the recipients, then
* enforces the recipient restriction (without a payment method on file,
* `to`/`cc` are limited to the account's own email).
*
* `to` is optional: when omitted, the email defaults to the authenticated
* account's own email address(es) (via `account_emails`), so a caller can
* "email me this" without restating their address. `subject` is optional too:
* when omitted it defaults to the body's first heading/line, else
* `Message from Recoup`. Returns the resolved `to` + `subject`.
*
* @param request - The NextRequest object
* @returns A NextResponse with an error if validation/auth/recipients fail, or the validated request data.
Expand Down Expand Up @@ -60,9 +73,24 @@ export async function validateSendEmailBody(
return authContext;
}

let to = result.data.to ?? [];
if (to.length === 0) {
const ownRows = await selectAccountEmails({ accountIds: authContext.accountId });
to = ownRows.map(row => row.email);
if (to.length === 0) {
return NextResponse.json(
{
status: "error",
error: "No email address found for the authenticated account.",
},
{ status: 400, headers: getCorsHeaders() },
);
}
}

const recipientCheck = await assertRecipientsAllowed({
accountId: authContext.accountId,
recipients: [...result.data.to, ...(result.data.cc ?? [])],
recipients: [...to, ...(result.data.cc ?? [])],
});
if (recipientCheck.allowed === false) {
return NextResponse.json(
Expand All @@ -75,8 +103,16 @@ export async function validateSendEmailBody(
);
}

const subject = resolveEmailSubject({
subject: result.data.subject,
text: result.data.text,
html: result.data.html,
});

return {
...result.data,
to,
subject,
accountId: authContext.accountId,
};
}
Loading