Feat/s3 storage #1
@@ -0,0 +1,67 @@
|
|||||||
|
# Email Attachments — Implementation Plan
|
||||||
|
|
||||||
|
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · Extends the SMTP provider + mail plumbing.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let an external email carry attachments (e.g. an invoice PDF). The kernel already STORES attachments
|
||||||
|
as message parts (`contentRef` + mime + size); the media `StoragePort` holds the bytes. The one gap is
|
||||||
|
the **email envelope** — `SmtpProvider` (and the payload) don't carry attachments. Close that.
|
||||||
|
|
||||||
|
## Design (locked)
|
||||||
|
|
||||||
|
- **Payload carries REFS, not bytes:** the EMAIL payload gains
|
||||||
|
`attachments?: [{ filename, contentRef, mimeType? }]`. Refs keep the outbound-command ledger small
|
||||||
|
(and keep T8's PII redaction cheap) — bytes are fetched at send time.
|
||||||
|
- **Resolver seam:** `AttachmentResolver = (contentRef) => Promise<{ filename?; content: Buffer; contentType? } | null>`.
|
||||||
|
`SmtpProvider` takes an optional resolver; on send it resolves each ref and attaches
|
||||||
|
(nodemailer `attachments: [{ filename, content, contentType }]`).
|
||||||
|
- **Fail closed on a missing attachment:** if a declared attachment can't be resolved, the send is
|
||||||
|
`FAILED` (so it retries) — NOT sent without it. A receipt/invoice missing its file is worse than a
|
||||||
|
retry. (No resolver wired at all + attachments present → also FAILED, same reasoning.)
|
||||||
|
- **Wiring:** `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`;
|
||||||
|
`CapabilityProviderRegistry` `@Optional() @Inject(STORAGE_PORT)` → builds the resolver from
|
||||||
|
`storage.get(contentRef)` → passes it to `SmtpProvider`. `@Optional` so contexts without storage
|
||||||
|
still boot (attachments simply can't resolve → FAILED if any are declared).
|
||||||
|
- **Scope:** SMTP path only. The HTTP relay `EmailProvider` attachment support is a separate follow-up
|
||||||
|
(it would base64 the bytes into the relay POST). INTERNAL/mirror attachments already work via message
|
||||||
|
parts and are not this plan.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
src/capability/smtp.provider.ts # attachments in EmailPayload + resolve+attach in send()
|
||||||
|
src/capability/smtp.provider.spec.ts # attach resolved bytes; missing → FAILED
|
||||||
|
src/capability/capability.registry.ts # inject STORAGE_PORT → resolver → SmtpProvider
|
||||||
|
src/capability/capability.module.ts # import MediaModule
|
||||||
|
src/media/media.module.ts # export STORAGE_PORT
|
||||||
|
src/templates/templated-sender.ts # accept + pass `attachments`
|
||||||
|
src/mail/mail.service.ts # accept + pass `attachments` (external send)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tasks (TDD)
|
||||||
|
|
||||||
|
**T1 — SmtpProvider attaches / fails closed**
|
||||||
|
- Inject a stub resolver. Tests: two refs → nodemailer `attachments` has both (filename + content +
|
||||||
|
contentType); a ref the resolver returns `null` for → outcome `FAILED`, nothing sent; no attachments
|
||||||
|
in payload → unchanged (plain send still SENT).
|
||||||
|
|
||||||
|
**T2 — registry wires the resolver from STORAGE_PORT**
|
||||||
|
- `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`; registry injects it
|
||||||
|
`@Optional`. Test: with a fake storage bound, `forChannel('EMAIL')` SMTP resolves an attachment;
|
||||||
|
without storage, the registry still constructs (attachments would FAIL, but boot is fine).
|
||||||
|
|
||||||
|
**T3 — pass-through: TemplatedSender + MailService**
|
||||||
|
- `sendTemplated`/`sendExternalWithMirror` accept `attachments` and place them in the EMAIL payload.
|
||||||
|
Tests: the outbound command's payload carries the attachment refs.
|
||||||
|
|
||||||
|
**T4 — gate + real send**
|
||||||
|
- Full suite + boundary + build. Manual: a real Ethereal send with a small attachment → SENT, and the
|
||||||
|
Ethereal message shows the attachment.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- **Fail-closed is deliberate** — don't silently send an invoice email without the invoice.
|
||||||
|
- **Payload holds refs, not bytes** — so the ledger and T8 redaction stay small; the resolver reads
|
||||||
|
bytes only at send time.
|
||||||
|
- **`@Optional` storage** — a context without `STORAGE_PORT` boots fine but can't send attachments;
|
||||||
|
that's correct (fail-closed), not a silent drop.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MediaModule } from '../media/media.module';
|
||||||
import { CapabilityProviderRegistry } from './capability.registry';
|
import { CapabilityProviderRegistry } from './capability.registry';
|
||||||
import { CapabilityBroker } from './capability.broker';
|
import { CapabilityBroker } from './capability.broker';
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ import { CapabilityBroker } from './capability.broker';
|
|||||||
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
|
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [MediaModule],
|
||||||
providers: [CapabilityProviderRegistry, CapabilityBroker],
|
providers: [CapabilityProviderRegistry, CapabilityBroker],
|
||||||
exports: [CapabilityBroker, CapabilityProviderRegistry],
|
exports: [CapabilityBroker, CapabilityProviderRegistry],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common';
|
||||||
import type { CapabilityProvider } from '@insignia/iios-contracts';
|
import type { CapabilityProvider } from '@insignia/iios-contracts';
|
||||||
import { SandboxProvider } from './sandbox.provider';
|
import { SandboxProvider } from './sandbox.provider';
|
||||||
import { HttpProvider } from './http.provider';
|
import { HttpProvider } from './http.provider';
|
||||||
import { EmailProvider } from './email.provider';
|
import { EmailProvider } from './email.provider';
|
||||||
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv } from './smtp.provider';
|
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
|
||||||
|
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
|
||||||
|
|
||||||
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
|||||||
export class CapabilityProviderRegistry {
|
export class CapabilityProviderRegistry {
|
||||||
private readonly byChannel = new Map<string, CapabilityProvider>();
|
private readonly byChannel = new Map<string, CapabilityProvider>();
|
||||||
|
|
||||||
constructor() {
|
constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) {
|
||||||
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
||||||
for (const ch of DEFAULT_CHANNELS) {
|
for (const ch of DEFAULT_CHANNELS) {
|
||||||
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
||||||
@@ -28,9 +29,13 @@ export class CapabilityProviderRegistry {
|
|||||||
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
|
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
|
||||||
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
|
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
|
||||||
}
|
}
|
||||||
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last).
|
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments
|
||||||
|
// resolve through the media StoragePort when one is bound (else attachments FAIL closed).
|
||||||
const smtp = smtpIdentityFromEnv();
|
const smtp = smtpIdentityFromEnv();
|
||||||
if (smtp) this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined));
|
if (smtp) {
|
||||||
|
const resolver = this.storage ? storageResolver(this.storage) : undefined;
|
||||||
|
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
register(provider: CapabilityProvider): void {
|
register(provider: CapabilityProvider): void {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, type MailTransport, type SmtpIdentity } from './smtp.provider';
|
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, storageResolver, type MailTransport, type SmtpIdentity } from './smtp.provider';
|
||||||
import type { CapabilityRequest } from '@insignia/iios-contracts';
|
import type { CapabilityRequest } from '@insignia/iios-contracts';
|
||||||
|
|
||||||
const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' };
|
const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' };
|
||||||
@@ -88,3 +88,48 @@ describe('SmtpProvider.send — failure + fallback', () => {
|
|||||||
expect(res.errorCode).toBe('ETIMEDOUT');
|
expect(res.errorCode).toBe('ETIMEDOUT');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SmtpProvider.send — attachments', () => {
|
||||||
|
const resolver = (map: Record<string, { content: Buffer; contentType?: string; filename?: string }>) =>
|
||||||
|
async (ref: string) => map[ref] ?? null;
|
||||||
|
|
||||||
|
it('resolves attachment refs to bytes and attaches them', async () => {
|
||||||
|
const t = stub();
|
||||||
|
const res = await new SmtpProvider(ID, undefined, t.make, resolver({ 'obj/1': { content: Buffer.from('PDFDATA'), contentType: 'application/pdf' } }))
|
||||||
|
.send(req({ subject: 'Invoice', attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }] }));
|
||||||
|
expect(res.outcome).toBe('SENT');
|
||||||
|
expect(t.calls[0].mail.attachments).toEqual([{ filename: 'invoice.pdf', content: Buffer.from('PDFDATA'), contentType: 'application/pdf' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAILS closed when a declared attachment cannot be resolved (never sends without it)', async () => {
|
||||||
|
const t = stub();
|
||||||
|
const res = await new SmtpProvider(ID, undefined, t.make, resolver({}))
|
||||||
|
.send(req({ subject: 'Invoice', attachments: [{ contentRef: 'missing' }] }));
|
||||||
|
expect(res.outcome).toBe('FAILED');
|
||||||
|
expect(res.errorCode).toBe('ATTACHMENT_UNRESOLVED');
|
||||||
|
expect(t.calls).toHaveLength(0); // nothing sent
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAILS when attachments are requested but no resolver is wired', async () => {
|
||||||
|
const t = stub();
|
||||||
|
const res = await new SmtpProvider(ID, undefined, t.make) // no resolver
|
||||||
|
.send(req({ subject: 'x', attachments: [{ contentRef: 'obj/1' }] }));
|
||||||
|
expect(res).toMatchObject({ outcome: 'FAILED', errorCode: 'NO_ATTACHMENT_RESOLVER' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a plain send with no attachments is unaffected', async () => {
|
||||||
|
const t = stub();
|
||||||
|
const res = await new SmtpProvider(ID, undefined, t.make, resolver({})).send(req({ subject: 'x', text: 'y' }));
|
||||||
|
expect(res.outcome).toBe('SENT');
|
||||||
|
expect(t.calls[0].mail.attachments).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
|
||||||
|
it('maps storage bytes into an attachment; missing ref → null', async () => {
|
||||||
|
const storage = { get: async (k: string) => (k === 'obj/1' ? { data: Buffer.from('X'), mime: 'application/pdf' } : null) };
|
||||||
|
const r = storageResolver(storage);
|
||||||
|
expect(await r('obj/1')).toEqual({ content: Buffer.from('X'), contentType: 'application/pdf' });
|
||||||
|
expect(await r('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,23 @@ export interface SmtpIdentity {
|
|||||||
from: string;
|
from: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MailAttachment {
|
||||||
|
filename: string;
|
||||||
|
content: Buffer;
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch an attachment's bytes by its opaque contentRef (backed by the media StoragePort). */
|
||||||
|
export type AttachmentResolver = (contentRef: string) => Promise<{ filename?: string; content: Buffer; contentType?: string } | null>;
|
||||||
|
|
||||||
|
/** Build an AttachmentResolver over the media StoragePort (contentRef = the storage object key). */
|
||||||
|
export function storageResolver(storage: { get(key: string): Promise<{ data: Buffer; mime: string } | null> }): AttachmentResolver {
|
||||||
|
return async (contentRef) => {
|
||||||
|
const o = await storage.get(contentRef);
|
||||||
|
return o ? { content: o.data, contentType: o.mime } : null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The subset of a mail transport this provider needs — lets tests inject a stub (no live server). */
|
/** The subset of a mail transport this provider needs — lets tests inject a stub (no live server). */
|
||||||
export interface MailTransport {
|
export interface MailTransport {
|
||||||
sendMail(mail: {
|
sendMail(mail: {
|
||||||
@@ -22,6 +39,7 @@ export interface MailTransport {
|
|||||||
html?: string;
|
html?: string;
|
||||||
inReplyTo?: string;
|
inReplyTo?: string;
|
||||||
references?: string;
|
references?: string;
|
||||||
|
attachments?: MailAttachment[];
|
||||||
}): Promise<{ messageId: string; accepted?: unknown[] }>;
|
}): Promise<{ messageId: string; accepted?: unknown[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +89,8 @@ interface EmailPayload {
|
|||||||
text?: string;
|
text?: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
inReplyTo?: string;
|
inReplyTo?: string;
|
||||||
|
/** Attachment REFS (not bytes) — resolved to bytes at send time via the injected resolver. */
|
||||||
|
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,6 +110,7 @@ export class SmtpProvider implements CapabilityProvider {
|
|||||||
private readonly primary: SmtpIdentity,
|
private readonly primary: SmtpIdentity,
|
||||||
private readonly fallback?: SmtpIdentity,
|
private readonly fallback?: SmtpIdentity,
|
||||||
makeTransport?: (id: SmtpIdentity) => MailTransport,
|
makeTransport?: (id: SmtpIdentity) => MailTransport,
|
||||||
|
private readonly resolveAttachment?: AttachmentResolver,
|
||||||
) {
|
) {
|
||||||
this.makeTransport = makeTransport ?? defaultTransport;
|
this.makeTransport = makeTransport ?? defaultTransport;
|
||||||
}
|
}
|
||||||
@@ -98,6 +119,20 @@ export class SmtpProvider implements CapabilityProvider {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const p = (req.payload ?? {}) as EmailPayload;
|
const p = (req.payload ?? {}) as EmailPayload;
|
||||||
|
|
||||||
|
// Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing
|
||||||
|
// its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch.
|
||||||
|
let attachments: MailAttachment[] | undefined;
|
||||||
|
if (p.attachments && p.attachments.length > 0) {
|
||||||
|
if (!this.resolveAttachment) return this.failed('NO_ATTACHMENT_RESOLVER', started);
|
||||||
|
const out: MailAttachment[] = [];
|
||||||
|
for (const a of p.attachments) {
|
||||||
|
const r = await this.resolveAttachment(a.contentRef).catch(() => null);
|
||||||
|
if (!r) return this.failed('ATTACHMENT_UNRESOLVED', started);
|
||||||
|
out.push({ filename: a.filename ?? r.filename ?? 'attachment', content: r.content, ...(a.mimeType ?? r.contentType ? { contentType: a.mimeType ?? r.contentType } : {}) });
|
||||||
|
}
|
||||||
|
attachments = out;
|
||||||
|
}
|
||||||
|
|
||||||
const attempt = async (id: SmtpIdentity): Promise<{ messageId: string }> =>
|
const attempt = async (id: SmtpIdentity): Promise<{ messageId: string }> =>
|
||||||
this.makeTransport(id).sendMail({
|
this.makeTransport(id).sendMail({
|
||||||
from: id.from,
|
from: id.from,
|
||||||
@@ -106,6 +141,7 @@ export class SmtpProvider implements CapabilityProvider {
|
|||||||
text: p.text,
|
text: p.text,
|
||||||
html: p.html,
|
html: p.html,
|
||||||
...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}),
|
...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}),
|
||||||
|
...(attachments ? { attachments } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -124,6 +160,10 @@ export class SmtpProvider implements CapabilityProvider {
|
|||||||
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err), latencyMs: Date.now() - started };
|
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err), latencyMs: Date.now() - started };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private failed(errorCode: string, started: number): ProviderResult {
|
||||||
|
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode, latencyMs: Date.now() - started };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True for connect/auth/timeout errors (server never accepted); false once the server responded 2xx. */
|
/** True for connect/auth/timeout errors (server never accepted); false once the server responded 2xx. */
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class MailController {
|
|||||||
...(body.locale ? { locale: body.locale } : {}),
|
...(body.locale ? { locale: body.locale } : {}),
|
||||||
idempotencyKey: body.idempotencyKey,
|
idempotencyKey: body.idempotencyKey,
|
||||||
...(body.purpose ? { purpose: body.purpose } : {}),
|
...(body.purpose ? { purpose: body.purpose } : {}),
|
||||||
|
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
|
||||||
...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}),
|
...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
import { IsArray, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
import { InlineTemplateDto } from '../templates/template.dto';
|
import { InlineTemplateDto } from '../templates/template.dto';
|
||||||
|
|
||||||
|
/** An email attachment reference — bytes live in the media store under `contentRef`. */
|
||||||
|
export class AttachmentDto {
|
||||||
|
@IsOptional() @IsString() filename?: string;
|
||||||
|
@IsString() @IsNotEmpty() contentRef!: string;
|
||||||
|
@IsOptional() @IsString() mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
|
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
|
||||||
export class MailInternalDto {
|
export class MailInternalDto {
|
||||||
@IsOptional() @IsString() @IsNotEmpty() key?: string;
|
@IsOptional() @IsString() @IsNotEmpty() key?: string;
|
||||||
@@ -25,6 +32,7 @@ export class MailSendDto {
|
|||||||
@IsOptional() @IsString() locale?: string;
|
@IsOptional() @IsString() locale?: string;
|
||||||
@IsString() @IsNotEmpty() idempotencyKey!: string;
|
@IsString() @IsNotEmpty() idempotencyKey!: string;
|
||||||
@IsOptional() @IsString() purpose?: string;
|
@IsOptional() @IsString() purpose?: string;
|
||||||
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
|
||||||
/** The registered recipient to mirror to; omit for a pre-registration send (email only). */
|
/** The registered recipient to mirror to; omit for a pre-registration send (email only). */
|
||||||
@IsOptional() @IsString() mirrorToUserId?: string;
|
@IsOptional() @IsString() mirrorToUserId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface SendExternalInput {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
|
/** Attachment refs (bytes resolved at send time). */
|
||||||
|
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
|
||||||
/** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */
|
/** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */
|
||||||
mirrorToUserId?: string;
|
mirrorToUserId?: string;
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,7 @@ export class MailService {
|
|||||||
const command = await this.sender.sendTemplated({
|
const command = await this.sender.sendTemplated({
|
||||||
source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {},
|
source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {},
|
||||||
...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}),
|
...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}),
|
||||||
|
...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mirror only for a registered recipient (timing rule: no inbox exists pre-registration).
|
// Mirror only for a registered recipient (timing rule: no inbox exists pre-registration).
|
||||||
|
|||||||
@@ -11,5 +11,7 @@ import { STORAGE_PORT } from './storage.port';
|
|||||||
controllers: [MediaController],
|
controllers: [MediaController],
|
||||||
// Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter.
|
// Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter.
|
||||||
providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }],
|
providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }],
|
||||||
|
// Exported so the capability layer can resolve email attachment bytes at send time.
|
||||||
|
exports: [STORAGE_PORT],
|
||||||
})
|
})
|
||||||
export class MediaModule {}
|
export class MediaModule {}
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ describe('TemplatedSender.sendTemplated', () => {
|
|||||||
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
|
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries attachment refs into the EMAIL command payload', async () => {
|
||||||
|
await seedReceipt();
|
||||||
|
const cmd = await sender().sendTemplated({
|
||||||
|
source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com',
|
||||||
|
vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'att:1',
|
||||||
|
attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }],
|
||||||
|
});
|
||||||
|
expect((cmd.payload as { attachments: unknown[] }).attachments).toEqual([{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('shapes an SMS send as text only', async () => {
|
it('shapes an SMS send as text only', async () => {
|
||||||
await prisma.iiosMessageTemplate.create({
|
await prisma.iiosMessageTemplate.create({
|
||||||
data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] },
|
data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] },
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { isInlineSource, type TemplateSource } from './template.model';
|
|||||||
/** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */
|
/** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */
|
||||||
export type ExternalChannel = 'EMAIL' | 'SMS';
|
export type ExternalChannel = 'EMAIL' | 'SMS';
|
||||||
|
|
||||||
|
/** An email attachment REF (bytes resolved by the provider at send time). */
|
||||||
|
export interface AttachmentRef { filename?: string; contentRef: string; mimeType?: string }
|
||||||
|
|
||||||
export interface SendTemplatedInput {
|
export interface SendTemplatedInput {
|
||||||
source: TemplateSource;
|
source: TemplateSource;
|
||||||
channel: ExternalChannel;
|
channel: ExternalChannel;
|
||||||
@@ -19,6 +22,7 @@ export interface SendTemplatedInput {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
|
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
|
attachments?: AttachmentRef[]; // EMAIL only
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +52,7 @@ export class TemplatedSender {
|
|||||||
return this.outbound.send(
|
return this.outbound.send(
|
||||||
input.channel,
|
input.channel,
|
||||||
input.target,
|
input.target,
|
||||||
this.toPayload(input.channel, content),
|
this.toPayload(input.channel, content, input.attachments),
|
||||||
input.idempotencyKey,
|
input.idempotencyKey,
|
||||||
input.scopeId,
|
input.scopeId,
|
||||||
input.purpose,
|
input.purpose,
|
||||||
@@ -57,8 +61,10 @@ export class TemplatedSender {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Shape rendered content into the channel's egress envelope (EmailProvider reads subject/text/html). */
|
/** Shape rendered content into the channel's egress envelope (EmailProvider reads subject/text/html). */
|
||||||
private toPayload(channel: ExternalChannel, content: RenderedContent): Record<string, unknown> {
|
private toPayload(channel: ExternalChannel, content: RenderedContent, attachments?: AttachmentRef[]): Record<string, unknown> {
|
||||||
if (channel === 'EMAIL') return { subject: content.subject, html: content.html, text: content.text };
|
if (channel === 'EMAIL') {
|
||||||
|
return { subject: content.subject, html: content.html, text: content.text, ...(attachments && attachments.length > 0 ? { attachments } : {}) };
|
||||||
|
}
|
||||||
return { text: content.text ?? content.subject ?? '' }; // SMS: text only
|
return { text: content.text ?? content.subject ?? '' }; // SMS: text only
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user