Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ EMAIL_PROVIDER=development
EMAIL_ALLOW_PRODUCTION_SEND=
EMAIL_FROM_ADDRESS=noreply@mail.example.com
EMAIL_FROM_NAME=PostKit
# Per-tenant sender overrides (JSON). Keys are tenantId -> environment -> sender fields.
# TENANT_EMAIL_CONFIG_BY_ID={"inkads":{"production":{"fromAddress":"noreply@mail.inkads.example.com","fromDisplayName":"InkAds","replyTo":"support@inkads.example.com"}}}
# Maps provider account ids to env var names whose values are Key Vault-resolved tokens.
# TENANT_PROVIDER_ACCOUNT_SECRETS={"inkads":"FORWARD_EMAIL_TOKEN_INKADS"}
CONTACT_INBOX_ADDRESS=
# Optional per-host contact profiles (JSON object).
# CONTACT_EMAIL_PROFILES_BY_HOST={"example.com":{"fromAddress":"noreply@mail.example.com","fromName":"Example","contactInboxAddress":"hello@example.com"}}
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/config/app-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const APP_CONFIGURATION_ENVIRONMENT_KEYS: Readonly<Record<string, string>
'app:email:allowProductionSend': 'EMAIL_ALLOW_PRODUCTION_SEND',
'app:email:fromAddress': 'EMAIL_FROM_ADDRESS',
'app:email:fromName': 'EMAIL_FROM_NAME',
'app:email:tenantConfigById': 'TENANT_EMAIL_CONFIG_BY_ID',
'app:email:tenantProviderAccountSecrets': 'TENANT_PROVIDER_ACCOUNT_SECRETS',
'app:email:contactInboxAddress': 'CONTACT_INBOX_ADDRESS',
'app:email:profilesByHost': 'CONTACT_EMAIL_PROFILES_BY_HOST',
'app:email:rateLimitPerMin': 'CONTACT_RATE_LIMIT_PER_MIN',
Expand Down
32 changes: 22 additions & 10 deletions apps/api/src/functions/send.security.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type TenantContext,
} from '@singleton-sd/post-kit-types';
import { ApiKeyTenantResolver, type TenantKeyMap } from '../tenant';
import type { ResolvedTenantEmailConfig } from '../tenant/tenant-email-config';
import type { TemplateStore } from '../templates';
import { createSendHandler } from './send';

Expand Down Expand Up @@ -97,6 +98,17 @@ function tenantScopedStore(
};
}

function stubTenantSender(
config: Partial<ResolvedTenantEmailConfig> = {},
): Pick<Parameters<typeof createSendHandler>[0], 'resolveTenantEmailConfig'> {
return {
resolveTenantEmailConfig: async () => ({
fromAddress: 'noreply@example.com',
...config,
}),
};
}

function fakeProvider(capture?: EmailSendRequest[]): EmailProvider {
return {
name: 'development',
Expand Down Expand Up @@ -134,7 +146,7 @@ describe('sendHandler — cross-tenant isolation', () => {
tenantResolver: new ApiKeyTenantResolver(KEY_MAP),
templateStore: store,
emailProvider: fakeProvider(sent),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

const response = await handler(
Expand All @@ -160,7 +172,7 @@ describe('sendHandler — cross-tenant isolation', () => {
tenantResolver: new ApiKeyTenantResolver(KEY_MAP),
templateStore: store,
emailProvider: fakeProvider(sent),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

const response = await handler(
Expand All @@ -183,7 +195,7 @@ describe('sendHandler — cross-tenant isolation', () => {
tenantResolver: new ApiKeyTenantResolver(KEY_MAP),
templateStore: store,
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

const response = await handler(
Expand Down Expand Up @@ -239,7 +251,7 @@ describe('sendHandler — tenant spoofing has no effect', () => {
tenantResolver: new ApiKeyTenantResolver(KEY_MAP),
templateStore: store,
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

const response = await handler(fakeRequest(options), fakeContext());
Expand All @@ -261,7 +273,7 @@ describe('sendHandler — tenant spoofing has no effect', () => {
tenantResolver: new ApiKeyTenantResolver(KEY_MAP),
templateStore: store,
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

const response = await handler(
Expand Down Expand Up @@ -291,7 +303,7 @@ describe('sendHandler — environment isolation', () => {
tenantResolver: new ApiKeyTenantResolver(KEY_MAP),
templateStore: store,
emailProvider: fakeProvider(sent),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

const response = await handler(
Expand Down Expand Up @@ -342,7 +354,7 @@ describe('sendHandler — unsafe template keys are rejected before storage acces
tenantResolver: new ApiKeyTenantResolver(KEY_MAP),
templateStore: store,
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

const response = await handler(
Expand Down Expand Up @@ -384,7 +396,7 @@ describe('sendHandler — hostile variable values cannot inject markup', () => {
tenantResolver: new ApiKeyTenantResolver(KEY_MAP),
templateStore: store,
emailProvider: fakeProvider(sent),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

const response = await handler(
Expand Down Expand Up @@ -424,7 +436,7 @@ describe('sendHandler — hostile variable values cannot inject markup', () => {
tenantResolver: new ApiKeyTenantResolver(KEY_MAP),
templateStore: store,
emailProvider: fakeProvider(sent),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

await handler(
Expand Down Expand Up @@ -453,7 +465,7 @@ describe('sendHandler — malformed and oversized bodies produce stable typed er
tenantResolver: new ApiKeyTenantResolver(KEY_MAP),
templateStore: store,
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});
const response = await handler(fakeRequest(options), fakeContext());
return { response, calls };
Expand Down
43 changes: 27 additions & 16 deletions apps/api/src/functions/send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type TenantContext,
} from '@singleton-sd/post-kit-types';
import { TenantResolverError, type TenantResolver } from '../tenant';
import type { ResolvedTenantEmailConfig } from '../tenant/tenant-email-config';
import { TemplateStoreError, type TemplateStore } from '../templates';
import { createLogger } from '../telemetry';
import { createSendHandler } from './send';
Expand Down Expand Up @@ -71,6 +72,17 @@ function fakeStore(result: CompiledTemplate | TemplateStoreError): TemplateStore
};
}

function stubTenantSender(
config: Partial<ResolvedTenantEmailConfig> = {},
): Pick<Parameters<typeof createSendHandler>[0], 'resolveTenantEmailConfig'> {
return {
resolveTenantEmailConfig: async () => ({
fromAddress: 'noreply@example.com',
fromDisplayName: 'PostKit',
...config,
}),
};
}
function fakeProvider(capture?: EmailSendRequest[]): EmailProvider {
return {
name: 'development',
Expand All @@ -89,8 +101,7 @@ describe('sendHandler', () => {
tenantResolver: fakeResolver(),
templateStore: fakeStore(COMPILED),
emailProvider: fakeProvider(sent),
fromAddress: () => 'noreply@example.com',
fromName: () => 'PostKit',
...stubTenantSender(),
});

const response = await handler(
Expand All @@ -117,7 +128,7 @@ describe('sendHandler', () => {
tenantResolver: fakeResolver(),
templateStore: fakeStore(COMPILED),
emailProvider: fakeProvider(sent),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

await handler(
Expand All @@ -140,7 +151,7 @@ describe('sendHandler', () => {
tenantResolver: fakeResolver(false),
templateStore: fakeStore(COMPILED),
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});
const response = await handler(fakeRequest({ json: {} }), fakeContext());
assert.equal(response.status, 401);
Expand All @@ -157,7 +168,7 @@ describe('sendHandler', () => {
},
templateStore: fakeStore(COMPILED),
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});
const response = await handler(fakeRequest({ json: validBody() }), fakeContext());
assert.equal(response.status, 403);
Expand All @@ -171,7 +182,7 @@ describe('sendHandler', () => {
new TemplateStoreError('missing', PostKitErrorCode.TEMPLATE_NOT_FOUND),
),
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});
const response = await handler(fakeRequest({ json: validBody() }), fakeContext());
assert.equal(response.status, 404);
Expand All @@ -183,7 +194,7 @@ describe('sendHandler', () => {
tenantResolver: fakeResolver(),
templateStore: fakeStore(new TemplateStoreError('bad', PostKitErrorCode.INVALID_TEMPLATE)),
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});
const response = await handler(fakeRequest({ json: validBody() }), fakeContext());
assert.equal(response.status, 400);
Expand All @@ -195,7 +206,7 @@ describe('sendHandler', () => {
tenantResolver: fakeResolver(),
templateStore: fakeStore(COMPILED),
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});
const response = await handler(
fakeRequest({
Expand All @@ -212,7 +223,7 @@ describe('sendHandler', () => {
tenantResolver: fakeResolver(),
templateStore: fakeStore(COMPILED),
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});
const response = await handler(
fakeRequest({
Expand All @@ -235,7 +246,7 @@ describe('sendHandler', () => {
},
},
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});
const response = await handler(
fakeRequest({
Expand Down Expand Up @@ -265,7 +276,7 @@ describe('sendHandler', () => {
templateStore: fakeStore(withBrandingVar),
emailProvider: fakeProvider(sent),
resolveBranding: async () => ({ companyName: 'InkAds' }),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});

const response = await handler(
Expand All @@ -290,7 +301,7 @@ describe('sendHandler', () => {
tenantResolver: fakeResolver(),
templateStore: fakeStore(COMPILED),
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
createLogger: (correlationId) => createLogger(correlationId, (line) => lines.push(line)),
});

Expand Down Expand Up @@ -326,7 +337,7 @@ describe('sendHandler', () => {
tenantResolver: fakeResolver(),
templateStore: fakeStore(COMPILED),
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
createLogger: (correlationId) => createLogger(correlationId, (line) => lines.push(line)),
});

Expand Down Expand Up @@ -358,7 +369,7 @@ describe('sendHandler', () => {
tenantResolver: fakeResolver(),
templateStore: fakeStore(COMPILED),
emailProvider: fakeProvider(),
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
createLogger: (correlationId) => createLogger(correlationId, (line) => lines.push(line)),
});

Expand Down Expand Up @@ -397,7 +408,7 @@ describe('sendHandler', () => {
});
},
},
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
createLogger: (correlationId) => createLogger(correlationId, (line) => lines.push(line)),
});

Expand Down Expand Up @@ -430,7 +441,7 @@ describe('sendHandler', () => {
});
},
},
fromAddress: () => 'noreply@example.com',
...stubTenantSender(),
});
const response = await handler(fakeRequest({ json: validBody() }), fakeContext());
assert.equal(response.status, 502);
Expand Down
Loading
Loading