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

@@ -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 }));
}