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
1 change: 1 addition & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `MAILGUN_DOMAIN` - Mailgun sending domain. Used only when `VERCEL_TARGET_ENV` is `production` or `staging`. [SERVER]
- `NEVERBOUNCE_API_KEY` - NeverBounce API key for email verification. In staging, only the effective internal sink is verified. `[SECRET]`
- `STAGING_EMAIL_REDIRECT_TO` - Required when `VERCEL_TARGET_ENV=staging`. Must contain exactly one valid address in the `kilocode.ai` domain; every staging message is redirected there with a staging subject prefix and safe Reply-To. [SERVER]
- `LOCAL_EMAIL_OPEN_BROWSER` - Set to `false` to stop locally captured emails from opening in a browser tab. Defaults to opening each capture. Local development only. [SERVER]

When `VERCEL_TARGET_ENV` is absent in local development or a script process, transactional messages are captured as owner-only clickable HTML under `dev/logs/emails/` instead of being sent. Automated tests (including `IS_IN_AUTOMATED_TEST`) and non-production Vercel targets suppress provider delivery and report successful no-op delivery. A production-mode process without `VERCEL_TARGET_ENV` fails delivery as a configuration error so retryable email markers are not consumed as successful sends.

Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/lib/email-local-outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ jest.mock('node:child_process', () => ({ execFile: jest.fn() }));

const execFileMock = jest.mocked(execFile);
let temporaryDirectory: string;
const originalOpenBrowser = process.env.LOCAL_EMAIL_OPEN_BROWSER;

beforeEach(async () => {
temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'kilo-email-outbox-'));
delete process.env.LOCAL_EMAIL_OPEN_BROWSER;
});

afterEach(async () => {
execFileMock.mockReset();
if (originalOpenBrowser === undefined) {
delete process.env.LOCAL_EMAIL_OPEN_BROWSER;
} else {
process.env.LOCAL_EMAIL_OPEN_BROWSER = originalOpenBrowser;
}
await rm(temporaryDirectory, { recursive: true, force: true });
});

Expand Down Expand Up @@ -74,6 +81,22 @@ describe('local email outbox', () => {
).resolves.toEqual(expect.stringMatching(/\.html$/));
});

it('does not open the captured email when LOCAL_EMAIL_OPEN_BROWSER is false', async () => {
process.env.LOCAL_EMAIL_OPEN_BROWSER = 'false';

const filePath = await writeEmailToLocalOutbox(
{
to: 'developer@example.com',
subject: 'Local test',
html: '<p>Body</p>',
},
temporaryDirectory
);

expect(filePath).toMatch(/\.html$/);
expect(execFileMock).not.toHaveBeenCalled();
});

it('escapes metadata before adding it to the captured HTML', async () => {
const filePath = await writeEmailToLocalOutbox(
{
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/lib/email-local-outbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export async function writeEmailToLocalOutbox(
const filePath = path.join(directory, `${timestamp}-${randomUUID()}.html`);
const html = addDevelopmentBanner(params.html, developmentBanner(params));
await writeFile(filePath, html, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
openEmail(filePath);
if (process.env.LOCAL_EMAIL_OPEN_BROWSER !== 'false') {
openEmail(filePath);
}
return filePath;
}
Loading