d28c873d40
Email can now carry attachments (e.g. an invoice PDF). The kernel already stored
attachments as message parts + the media StoragePort holds the bytes; the gap was
the email envelope.
- EMAIL payload carries attachment REFS ({filename, contentRef, mimeType}), not
bytes — the ledger + T8 PII redaction stay small; bytes are fetched at send time.
- SmtpProvider takes an AttachmentResolver; resolves each ref via storage and attaches
(nodemailer). FAILS CLOSED if a declared attachment can't be resolved (or no resolver
is wired) — never send a receipt/invoice missing its file; a FAILED command retries.
- CapabilityProviderRegistry injects the resolver from STORAGE_PORT (@Optional);
MediaModule exports STORAGE_PORT, CapabilityModule imports MediaModule. No cycle.
- TemplatedSender + MailService + the /v1/mail/send DTO pass attachments through.
Verified: 6 new unit tests (attach, fail-closed x2, plain-unaffected, resolver,
pass-through) + a REAL Ethereal SMTP send WITH a PDF attachment (SENT). Full suite
294/294, boundary + build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
7.5 KiB
TypeScript
186 lines
7.5 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { createTransport } from 'nodemailer';
|
|
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
|
|
|
|
/** One SMTP sending identity (a mailbox + how to reach its server). */
|
|
export interface SmtpIdentity {
|
|
host: string;
|
|
port: number;
|
|
secure: boolean;
|
|
user: string;
|
|
pass: 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). */
|
|
export interface MailTransport {
|
|
sendMail(mail: {
|
|
from: string;
|
|
to: string;
|
|
subject?: string;
|
|
text?: string;
|
|
html?: string;
|
|
inReplyTo?: string;
|
|
references?: string;
|
|
attachments?: MailAttachment[];
|
|
}): Promise<{ messageId: string; accepted?: unknown[] }>;
|
|
}
|
|
|
|
const bool = (v: string | undefined): boolean => v === 'true' || v === '1';
|
|
|
|
/** Build the primary SMTP identity from env, or null if the required trio is incomplete. */
|
|
export function smtpIdentityFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null {
|
|
return identityFrom(env, '');
|
|
}
|
|
|
|
/** Build the optional fallback identity (accounts@ → ceo@), or null if not configured. */
|
|
export function smtpFallbackFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null {
|
|
const fb = identityFrom(env, 'FALLBACK_');
|
|
if (fb) return fb;
|
|
// Fallback may reuse the primary host/port and only override the mailbox identity.
|
|
const host = env.IIOS_SMTP_HOST;
|
|
const user = env.IIOS_SMTP_FALLBACK_USER;
|
|
const pass = env.IIOS_SMTP_FALLBACK_PASS;
|
|
if (!host || !user || !pass) return null;
|
|
return {
|
|
host,
|
|
port: Number(env.IIOS_SMTP_PORT ?? 587),
|
|
secure: bool(env.IIOS_SMTP_SECURE),
|
|
user,
|
|
pass,
|
|
from: env.IIOS_SMTP_FALLBACK_FROM ?? user,
|
|
};
|
|
}
|
|
|
|
function identityFrom(env: NodeJS.ProcessEnv, prefix: string): SmtpIdentity | null {
|
|
const host = env[`IIOS_SMTP_${prefix}HOST`] ?? (prefix ? undefined : env.IIOS_SMTP_HOST);
|
|
const user = env[`IIOS_SMTP_${prefix}USER`];
|
|
const pass = env[`IIOS_SMTP_${prefix}PASS`];
|
|
if (!host || !user || !pass) return null;
|
|
return {
|
|
host,
|
|
port: Number(env[`IIOS_SMTP_${prefix}PORT`] ?? env.IIOS_SMTP_PORT ?? 587),
|
|
secure: bool(env[`IIOS_SMTP_${prefix}SECURE`] ?? env.IIOS_SMTP_SECURE),
|
|
user,
|
|
pass,
|
|
from: env[`IIOS_SMTP_${prefix}FROM`] ?? user,
|
|
};
|
|
}
|
|
|
|
interface EmailPayload {
|
|
subject?: string;
|
|
text?: string;
|
|
html?: 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 }>;
|
|
}
|
|
|
|
/**
|
|
* SMTP egress provider (nodemailer). Bound for EMAIL when the SMTP env trio is set (else the sandbox
|
|
* stays). A transport failure surfaces as FAILED, never thrown. On a PRE-acceptance failure it retries
|
|
* once via the fallback identity (accounts@ → ceo@); a post-acceptance failure is NOT retried, so a
|
|
* message the server already accepted can't be double-delivered.
|
|
*/
|
|
export class SmtpProvider implements CapabilityProvider {
|
|
readonly name = 'smtp';
|
|
readonly channelTypes = ['EMAIL'];
|
|
readonly capabilities = { canSend: true };
|
|
|
|
private readonly makeTransport: (id: SmtpIdentity) => MailTransport;
|
|
|
|
constructor(
|
|
private readonly primary: SmtpIdentity,
|
|
private readonly fallback?: SmtpIdentity,
|
|
makeTransport?: (id: SmtpIdentity) => MailTransport,
|
|
private readonly resolveAttachment?: AttachmentResolver,
|
|
) {
|
|
this.makeTransport = makeTransport ?? defaultTransport;
|
|
}
|
|
|
|
async send(req: CapabilityRequest): Promise<ProviderResult> {
|
|
const started = Date.now();
|
|
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 }> =>
|
|
this.makeTransport(id).sendMail({
|
|
from: id.from,
|
|
to: req.target,
|
|
subject: p.subject ?? '(no subject)',
|
|
text: p.text,
|
|
html: p.html,
|
|
...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}),
|
|
...(attachments ? { attachments } : {}),
|
|
});
|
|
|
|
try {
|
|
const info = await attempt(this.primary);
|
|
return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started };
|
|
} catch (err) {
|
|
// Retry via the fallback identity ONLY if the primary never got the message accepted.
|
|
if (this.fallback && isPreAcceptanceFailure(err)) {
|
|
try {
|
|
const info = await attempt(this.fallback);
|
|
return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started };
|
|
} catch (err2) {
|
|
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), 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. */
|
|
function isPreAcceptanceFailure(err: unknown): boolean {
|
|
const e = err as { code?: string; responseCode?: number };
|
|
const preCodes = ['ECONNECTION', 'ETIMEDOUT', 'ECONNREFUSED', 'EDNS', 'EAUTH', 'ESOCKET', 'EENVELOPE'];
|
|
if (e.code && preCodes.includes(e.code)) return true;
|
|
// A responseCode present means the server spoke — treat 5xx after acceptance as terminal (no retry).
|
|
return e.responseCode == null && e.code == null;
|
|
}
|
|
|
|
function codeOf(err: unknown): string {
|
|
const e = err as { code?: string; message?: string };
|
|
return (e.code ?? e.message ?? 'SMTP_ERROR').slice(0, 60);
|
|
}
|
|
|
|
function defaultTransport(id: SmtpIdentity): MailTransport {
|
|
return createTransport({ host: id.host, port: id.port, secure: id.secure, auth: { user: id.user, pass: id.pass } }) as unknown as MailTransport;
|
|
}
|