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:
@@ -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";
|
||||
|
||||
137
packages/integrations/src/s3/client.ts
Normal file
137
packages/integrations/src/s3/client.ts
Normal 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 }));
|
||||
}
|
||||
82
packages/integrations/src/smtp/mailer.ts
Normal file
82
packages/integrations/src/smtp/mailer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user