Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 3.1.0

- Add `app email list|get` for filtered, cursor-paginated production message
diagnostics and authorized retained content inspection.
- Add `app dev email list|get|inject` for provider-free capture and synthetic
inbound testing, including file-backed text/HTML and bounded attachments.
- Vendor the application-email manifest, control-plane, and bundler contracts
so aliases, system-only inbound handlers, and email-capable bundles validate
locally before a development session or deployment.

## 3.0.1

- Include the conventional `tests/opencloud.e2e.js` source in deterministic
Expand Down
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ offline source bundle, but cannot connect to or deploy through OpenCloud.

## Install a pinned release

OpenCloud application skills pin an exact CLI release. To install `v3.0.1` in
OpenCloud application skills pin an exact CLI release. To install `v3.1.0` in
an isolated task directory:

```bash
OPENCLOUD_CLI_VERSION="v3.0.1"
OPENCLOUD_CLI_PACKAGE="opencloud-cli-3.0.1.tgz"
OPENCLOUD_CLI_VERSION="v3.1.0"
OPENCLOUD_CLI_PACKAGE="opencloud-cli-3.1.0.tgz"
OPENCLOUD_CLI_DIR="$(mktemp -d)"

curl -fsSLo "$OPENCLOUD_CLI_DIR/$OPENCLOUD_CLI_PACKAGE" \
Expand Down Expand Up @@ -167,6 +167,11 @@ Use the stable capability preview and isolated migration-replayed database befor
--values '{"title":"Preview item"}'
"$OPENCLOUD_CLI" app dev data . items updateById \
--id "$ITEM_ID" --values '{"title":"Updated preview item"}'
"$OPENCLOUD_CLI" app dev email inject . \
--to support --from customer@example.test \
--subject "Test request" --text "Please acknowledge this message."
"$OPENCLOUD_CLI" app dev email list .
"$OPENCLOUD_CLI" app dev email get . "$MESSAGE_ID"
"$OPENCLOUD_CLI" app dev invoke . function-name --body '{"example":true}'
"$OPENCLOUD_CLI" app dev requests .
"$OPENCLOUD_CLI" app dev verify . --parallelism 5
Expand All @@ -193,6 +198,24 @@ verification, prints the live HTTPS URL, and removes the dev environment only
after success. If deployment or verification fails, dev remains available for
repair.

## Application email

Inspect retained production message metadata with cursor, alias, direction,
and date filters, then fetch one authorized message's normalized text/HTML,
safe headers, and attachment metadata:

```bash
"$OPENCLOUD_CLI" app email list "$APP_ID" \
--alias support --direction inbound --limit 25
"$OPENCLOUD_CLI" app email get "$APP_ID" "$MESSAGE_ID"
```

Pass the returned `nextCursor` back through `--cursor` for the next page. Raw
MIME and attachment bytes are never returned. Development Function sends are
captured instead of delivered; `app dev email inject` accepts only reserved
`.test` sender and Reply-To addresses, and body/attachment file paths resolve
relative to the app directory.

## Agent Feed and alert rules

Read the stable app health, signal, alert, and recent-event contract without
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencloud/cli",
"version": "3.0.1",
"version": "3.1.0",
"description": "Versioned command-line client for building, deploying, and verifying OpenCloud applications",
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion src/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe("bundle builder", () => {
);
await writeFile(
path.join(root, "functions", "process", "index.ts"),
"import './shared.ts';",
'import { defineFunction, schema } from "@opencloud/server"; import "./shared.ts"; export default defineFunction({ input: schema.object({}), handler: () => ({ ok: true }) });',
);
await writeFile(
path.join(root, "functions", "process", "shared.ts"),
Expand Down
119 changes: 119 additions & 0 deletions src/email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
collectOption,
devEmailCaptureLimit,
devEmailInjectionRequest,
emailHistoryQuery,
} from "./email.js";

const temporaryDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true }),
),
);
});

describe("email history filters", () => {
it("normalizes filters through the shared platform schema", () => {
expect(
emailHistoryQuery({
cursor: "next-page",
limit: "25",
alias: "support",
direction: "inbound",
from: "2026-01-01T00:00:00.000Z",
to: "2026-01-31T00:00:00.000Z",
}),
).toEqual({
cursor: "next-page",
limit: 25,
alias: "support",
direction: "inbound",
from: "2026-01-01T00:00:00.000Z",
to: "2026-01-31T00:00:00.000Z",
});
});

it("rejects invalid limits, aliases, and date ranges", () => {
expect(() => emailHistoryQuery({ limit: "0" })).toThrow();
expect(() => emailHistoryQuery({ alias: "Support" })).toThrow();
expect(() =>
emailHistoryQuery({
from: "2026-02-01T00:00:00.000Z",
to: "2026-01-01T00:00:00.000Z",
}),
).toThrow(/after from/);
});
});

describe("development email", () => {
it("validates capture limits and repeated options", () => {
expect(devEmailCaptureLimit("200")).toBe(200);
expect(() => devEmailCaptureLimit("201")).toThrow(/between 1 and 200/);
expect(collectOption("X-Two: 2", ["X-One: 1"])).toEqual([
"X-One: 1",
"X-Two: 2",
]);
});

it("loads body and attachment files relative to the app directory", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "opencloud-email-"));
temporaryDirectories.push(directory);
await writeFile(path.join(directory, "body.txt"), "Please acknowledge.");
await writeFile(path.join(directory, "receipt.pdf"), "synthetic-pdf");

await expect(
devEmailInjectionRequest(
{
to: "support",
from: "customer@example.test",
fromName: "Synthetic Customer",
subject: "Question",
textFile: "body.txt",
replyTo: "reply@example.test",
headers: ["X-Test-Case: round-trip"],
attachments: ["receipt.pdf"],
},
(value) => path.resolve(directory, value),
),
).resolves.toEqual({
to: "support",
from: "customer@example.test",
fromName: "Synthetic Customer",
subject: "Question",
text: "Please acknowledge.",
replyTo: "reply@example.test",
headers: ["X-Test-Case: round-trip"],
attachments: [
{
name: "receipt.pdf",
contentType: "application/pdf",
contentBase64: Buffer.from("synthetic-pdf").toString("base64"),
},
],
});
});

it("rejects real senders and ambiguous body sources", async () => {
await expect(
devEmailInjectionRequest({
to: "support",
from: "customer@example.com",
}),
).rejects.toThrow(/reserved \.test address/);
await expect(
devEmailInjectionRequest({
to: "support",
from: "customer@example.test",
text: "inline",
textFile: "body.txt",
}),
).rejects.toThrow(/cannot be used together/);
});
});
152 changes: 152 additions & 0 deletions src/email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { ZodType } from "zod";
import {
appEmailHistoryQuerySchema,
injectDevEmailRequestSchema,
} from "@opencloud/contracts";

export interface EmailHistoryOptions {
cursor?: string | undefined;
limit?: string | number | undefined;
alias?: string | undefined;
direction?: "outbound" | "inbound" | undefined;
from?: string | undefined;
to?: string | undefined;
}

export interface DevEmailInjectionOptions {
to: string;
from: string;
fromName?: string | undefined;
subject?: string | undefined;
text?: string | undefined;
textFile?: string | undefined;
html?: string | undefined;
htmlFile?: string | undefined;
replyTo?: string | undefined;
headers?: string[] | undefined;
attachments?: string[] | undefined;
}

export function emailHistoryQuery(options: EmailHistoryOptions) {
return parseOrThrow(
appEmailHistoryQuerySchema,
{
limit: options.limit ?? 100,
...(options.cursor ? { cursor: options.cursor } : {}),
...(options.alias ? { alias: options.alias } : {}),
...(options.direction ? { direction: options.direction } : {}),
...(options.from ? { from: options.from } : {}),
...(options.to ? { to: options.to } : {}),
},
"email history filters",
);
}

export function devEmailCaptureLimit(value: string | number | undefined) {
const limit = Number(value ?? 100);
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
throw new Error("--limit must be an integer between 1 and 200");
}
return limit;
}

export async function devEmailInjectionRequest(
options: DevEmailInjectionOptions,
resolvePath: (value: string) => string = (value) => path.resolve(value),
) {
const text = await contentValue(
options.text,
options.textFile,
"--text",
"--text-file",
resolvePath,
);
const html = await contentValue(
options.html,
options.htmlFile,
"--html",
"--html-file",
resolvePath,
);
const attachments = await Promise.all(
(options.attachments ?? []).map(async (value) => {
const filePath = resolvePath(value);
const contentBase64 = (await readFile(filePath)).toString("base64");
return {
name: path.basename(filePath),
contentType: attachmentContentType(filePath),
contentBase64,
};
}),
);
return parseOrThrow(
injectDevEmailRequestSchema,
{
to: options.to,
from: options.from,
...(options.fromName ? { fromName: options.fromName } : {}),
...(options.subject ? { subject: options.subject } : {}),
...(text !== undefined ? { text } : {}),
...(html !== undefined ? { html } : {}),
...(options.replyTo ? { replyTo: options.replyTo } : {}),
headers: options.headers ?? [],
attachments,
},
"development email",
);
}

export function collectOption(value: string, previous: string[] = []) {
return [...previous, value];
}

function attachmentContentType(filePath: string): string {
const extension = path.extname(filePath).toLowerCase();
return (
{
".csv": "text/csv",
".gif": "image/gif",
".htm": "text/html",
".html": "text/html",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".json": "application/json",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain",
".webp": "image/webp",
".xml": "application/xml",
".zip": "application/zip",
} as Record<string, string>
)[extension] ?? "application/octet-stream";
}

async function contentValue(
inline: string | undefined,
file: string | undefined,
inlineFlag: string,
fileFlag: string,
resolvePath: (value: string) => string,
): Promise<string | undefined> {
if (inline !== undefined && file !== undefined) {
throw new Error(`${inlineFlag} and ${fileFlag} cannot be used together`);
}
return file === undefined ? inline : readFile(resolvePath(file), "utf8");
}

function parseOrThrow<T>(
schema: ZodType<T>,
value: unknown,
label: string,
): T {
const parsed = schema.safeParse(value);
if (parsed.success) return parsed.data;
const issue = parsed.error.issues[0];
const location = issue?.path.length ? ` at ${issue.path.join(".")}` : "";
throw new Error(
`Invalid ${label}${location}: ${issue?.message ?? "invalid input"}`,
);
}
Loading
Loading