feat(integrations): add S3 and SMTP adapters

Port the legacy server/src/lib/s3.js and mailer.js (eden.md §4/§5) into
packages/integrations behind an adapter/port pattern, isolated from
domain logic per Master Prompt §13.

- s3/client.ts: @aws-sdk/client-s3 wrapper (uploadObject, getObjectStream,
  deleteObject, getClient/getBucket for future multipart Backup uploads).
  Lazily-initialized singleton client, forcePathStyle from
  S3_FORCE_PATH_STYLE (default true). Throws a clear "S3 não configurado
  (S3_ENDPOINT ausente)" whenever S3_ENDPOINT is unset — storage failures
  must be loud, never a silent no-op.

- smtp/mailer.ts: nodemailer wrapper (sendMail). Lazily-initialized
  singleton transporter, secure:true auto-detected on port 465, from
  defaults to SMTP_FROM or SMTP_USER. Deliberately fail-soft: if
  SMTP_HOST is unset, or if sending fails, logs and returns normally —
  no business flow ever fails because of email, matching the legacy's
  behavior exactly.

@eden/config's loadEnv()/env-schema is still a placeholder in this
worktree, so both adapters read process.env directly using the exact
var names already reserved in the target schema; @eden/config is added
as a workspace dependency so this can be swapped for a real loadEnv()
call later without touching callers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:38:41 -03:00
parent 44510bd019
commit 95090ba24b
5 changed files with 555 additions and 4 deletions

View File

@@ -1,7 +1,8 @@
/**
* External adapters (Focus NFe, SaperX, Control iD, S3, SMTP) — each
* isolated behind a port per Master Prompt §13. Empty placeholder; the
* first real adapter (S3 or SMTP) lands with the rest of Fase 1's
* platform bootstrap.
* isolated behind a port per Master Prompt §13. S3 and SMTP adapters land
* first (platform bootstrap); the others follow per-domain as their
* modules are built.
*/
export const INTEGRATIONS_PACKAGE_VERSION = "0.1.0";
export * from "./s3/client.js";
export * from "./smtp/mailer.js";

View File

@@ -0,0 +1,137 @@
/**
* S3 adapter — port of the legacy `server/src/lib/s3.js` (eden.md §5
* "Armazenamento S3"). Compatible with any S3-like endpoint (MinIO, AWS S3),
* not exclusive to AWS. Storage failures are never swallowed: every
* function throws a clear error when S3 isn't configured, unlike the SMTP
* adapter (see `../smtp/mailer.ts`) which fails silently by design.
*
* NOTE: `@eden/config`'s `loadEnv()`/env-schema is still a placeholder in
* this worktree (only `CONFIG_PACKAGE_VERSION` is exported so far), so this
* module reads `process.env` directly using the exact var names already
* reserved for S3 in the target env schema (S3_ENDPOINT, S3_REGION,
* S3_FORCE_PATH_STYLE, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET).
* `@eden/config` is still declared as a dependency so this can be swapped
* for a real `loadEnv()` call once that lands, without touching callers.
*/
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { Readable } from "node:stream";
const NOT_CONFIGURED_MESSAGE = "S3 não configurado (S3_ENDPOINT ausente)";
interface S3Config {
endpoint: string;
region: string;
forcePathStyle: boolean;
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
bucket: string | undefined;
}
let client: S3Client | undefined;
let config: S3Config | undefined;
function loadConfig(): S3Config | undefined {
const endpoint = process.env.S3_ENDPOINT;
if (!endpoint) {
return undefined;
}
return {
endpoint,
region: process.env.S3_REGION ?? "us-east-1",
// Path-style by default (typical of MinIO/self-hosted); set the env
// var to exactly "false" to switch to virtual-hosted style (AWS S3).
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== "false",
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
bucket: process.env.S3_BUCKET,
};
}
function requireConfig(): S3Config {
if (!config) {
config = loadConfig();
}
if (!config) {
throw new Error(NOT_CONFIGURED_MESSAGE);
}
return config;
}
/**
* Raw S3 client, exported for callers (e.g. a future Backup module) that
* need multipart streaming upload via `@aws-sdk/lib-storage`'s `Upload`
* class directly instead of `uploadObject` (which buffers the whole file
* in memory — fine for small files, not for a Postgres dump).
*/
export function getClient(): S3Client {
const cfg = requireConfig();
if (!client) {
client = new S3Client({
endpoint: cfg.endpoint,
region: cfg.region,
forcePathStyle: cfg.forcePathStyle,
...(cfg.accessKeyId && cfg.secretAccessKey
? {
credentials: {
accessKeyId: cfg.accessKeyId,
secretAccessKey: cfg.secretAccessKey,
},
}
: {}),
});
}
return client;
}
/** Single bucket used for all data (no bucket-per-data-type). */
export function getBucket(): string {
const cfg = requireConfig();
return cfg.bucket ?? "";
}
/**
* Simple PUT, whole buffer in memory — adequate for small/medium files
* (images, PDFs, attachments), not for large streaming uploads.
*/
export async function uploadObject(
key: string,
buffer: Buffer,
mimeType: string,
): Promise<void> {
const s3 = getClient();
const bucket = getBucket();
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: mimeType,
}),
);
}
/**
* GET, returning the object body as a Node stream — used both to serve a
* download and to pipe directly to an HTTP response. On Node.js, the SDK
* v3 response `.Body` is already a `Readable` (with extra helper mixins);
* this just types it correctly for callers.
*/
export async function getObjectStream(key: string): Promise<Readable> {
const s3 = getClient();
const bucket = getBucket();
const response = await s3.send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
);
return response.Body as Readable;
}
export async function deleteObject(key: string): Promise<void> {
const s3 = getClient();
const bucket = getBucket();
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
}

View File

@@ -0,0 +1,82 @@
/**
* SMTP adapter — port of the legacy `server/src/lib/mailer.js` (eden.md §4
* "Envio de e-mail"). Generic SMTP via `nodemailer`, not a direct
* integration with SES/SendGrid/etc.
*
* Deliberately fail-soft, unlike the S3 adapter (`../s3/client.ts`): no
* business flow may ever fail because of email. If SMTP isn't configured,
* or if sending fails, `sendMail` logs and returns normally — it never
* throws into the caller.
*
* NOTE: `@eden/config`'s `loadEnv()`/env-schema is still a placeholder in
* this worktree, so this module reads `process.env` directly using the
* exact var names already reserved for SMTP in the target env schema
* (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM).
*/
import nodemailer, { type Transporter } from "nodemailer";
export interface SendMailInput {
to: string;
subject: string;
html: string;
from?: string;
}
let transporter: Transporter | undefined;
let transporterBuilt = false;
function buildTransporter(): Transporter | undefined {
const host = process.env.SMTP_HOST;
if (!host) {
return undefined;
}
const port = Number(process.env.SMTP_PORT ?? "465");
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
return nodemailer.createTransport({
host,
port,
// secure: true automatically when the port is 465 (implicit SMTPS).
secure: port === 465,
...(user ? { auth: { user, pass } } : {}),
});
}
function getTransporter(): Transporter | undefined {
// Built once, lazily, on first use — not rebuilt afterwards even if env
// vars change at runtime (simplification acceptable for this MVP).
if (!transporterBuilt) {
transporter = buildTransporter();
transporterBuilt = true;
}
return transporter;
}
function defaultFrom(): string | undefined {
return process.env.SMTP_FROM ?? process.env.SMTP_USER;
}
export async function sendMail({
to,
subject,
html,
from,
}: SendMailInput): Promise<void> {
const t = getTransporter();
if (!t) {
console.log(
`[mailer] SMTP não configurado — e-mail para ${to} não enviado`,
);
return;
}
try {
await t.sendMail({
from: from ?? defaultFrom(),
to,
subject,
html,
});
} catch (err) {
console.error(`[mailer] falha ao enviar e-mail para ${to}:`, err);
}
}