@mailerport/service 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { createMailService } from "../dist/index.js";
3
+ const service=createMailService({host:process.env.HOST||"0.0.0.0"});
4
+ await service.start();
5
+ const shutdown=async()=>{await service.stop();process.exit(0);};
6
+ process.on("SIGTERM",shutdown);process.on("SIGINT",shutdown);
@@ -0,0 +1,19 @@
1
+ import { MailPortError, ERROR_CODES } from "@mailerport/core";
2
+
3
+ export function createDeliveryEventSource({ mail, operations, suppressHardBounces = true, suppressComplaints = true } = {}) {
4
+ return {
5
+ async consume(input) {
6
+ const type = input.type || input.event_type;
7
+ if (!["delivered", "bounced", "complained"].includes(type))
8
+ throw new MailPortError(ERROR_CODES.MAIL_DELIVERY_FAILED, `Unsupported delivery event: ${type}`);
9
+ const message = await mail.recordDeliveryEvent({ ...input, type });
10
+ if (!message) return null;
11
+ const shouldSuppress = (type === "bounced" && input.bounce_type === "hard" && suppressHardBounces) ||
12
+ (type === "complained" && suppressComplaints);
13
+ if (shouldSuppress && input.recipient) operations.addSuppression({ email: input.recipient,
14
+ application_id: message.application_id, tenant_id: message.tenant_id,
15
+ reason: type === "complained" ? "complaint" : "hard_bounce", source: "delivery_event" });
16
+ return message;
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,8 @@
1
+ import type { MailPort } from "@mailerport/sdk";
2
+ export interface MailServiceOptions { host?:string;port?:number;apiKey?:string;adminKey?:string;applicationId?:string;production?:boolean;transport?:string|Record<string,unknown>;outbox?:{filePath?:string};worker?:{enabled?:boolean;concurrency?:number;pollIntervalMs?:number;leaseMs?:number;maxAttempts?:number};identities?:Record<string,string>;templates?:Record<string,unknown>;testEndpointsEnabled?:boolean;environment?:Record<string,string|undefined> }
3
+ export interface MailService { mail:MailPort;operations:unknown;start():Promise<void>;stop():Promise<void> }
4
+ export function createMailService(options?:MailServiceOptions):MailService;
5
+ export function createMailPortService(mail:MailPort,options?:Record<string,unknown>):Pick<MailService,"start"|"stop">;
6
+ export class FileOperationsStore { constructor(options?:Record<string,unknown>); }
7
+ export function createDeliveryEventSource(options:Record<string,unknown>):{consume(event:Record<string,unknown>):Promise<unknown>};
8
+ export function validateProductionConfig(options?:Record<string,unknown>,environment?:Record<string,string|undefined>):{production:boolean};
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createMailService } from "./service.js";
2
+ export { createMailPortService } from "./remote-service.js";
3
+ export { FileOperationsStore } from "./operations.js";
4
+ export { createDeliveryEventSource } from "./delivery-events.js";
5
+ export { validateProductionConfig } from "./production-config.js";
@@ -0,0 +1,121 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto, { randomUUID } from "node:crypto";
4
+ import dns from "node:dns/promises";
5
+ import { MailPortError, ERROR_CODES } from "@mailerport/core";
6
+
7
+ const clone = (value) => structuredClone(value);
8
+ const now = () => new Date().toISOString();
9
+ const normalizeEmail = (email) => String(email).trim().toLowerCase();
10
+
11
+ export class FileOperationsStore {
12
+ constructor({ filePath, resolver = dns } = {}) {
13
+ this.filePath = filePath;
14
+ this.resolver = resolver;
15
+ this.state = { domains: [], identities: [], suppressions: [], keys: [] };
16
+ this.rate = new Map();
17
+ this.signingKeys = new Map();
18
+ this.#load();
19
+ }
20
+ #load() {
21
+ if (!this.filePath || !fs.existsSync(this.filePath)) return;
22
+ this.state = { ...this.state, ...JSON.parse(fs.readFileSync(this.filePath, "utf8")) };
23
+ let migratedSecret = false;
24
+ for (const domain of this.state.domains) {
25
+ if (domain.dkim?.private_key) { this.signingKeys.set(domain.domain, domain.dkim.private_key); delete domain.dkim.private_key; migratedSecret = true; }
26
+ }
27
+ if (migratedSecret) this.#save();
28
+ }
29
+ #save() {
30
+ if (!this.filePath) return;
31
+ fs.mkdirSync(path.dirname(path.resolve(this.filePath)), { recursive: true });
32
+ const temp = `${this.filePath}.${process.pid}.tmp`;
33
+ fs.writeFileSync(temp, JSON.stringify(this.state, null, 2), { mode: 0o600 });
34
+ fs.renameSync(temp, this.filePath);
35
+ }
36
+ createDomain(domainName) {
37
+ const domain = String(domainName).trim().toLowerCase();
38
+ const existing = this.state.domains.find((item) => item.domain === domain);
39
+ if (existing) return clone(existing);
40
+ const selector = `mailport-${crypto.randomBytes(6).toString("hex")}`;
41
+ let privateKey = this.signingKeys.get(domain);
42
+ let publicKey;
43
+ if (privateKey) {
44
+ publicKey = crypto.createPublicKey(privateKey).export({ type: "spki", format: "pem" });
45
+ } else {
46
+ const generated = crypto.generateKeyPairSync("rsa", { modulusLength: 2048,
47
+ publicKeyEncoding: { type: "spki", format: "pem" }, privateKeyEncoding: { type: "pkcs8", format: "pem" } });
48
+ publicKey = generated.publicKey; privateKey = generated.privateKey;
49
+ }
50
+ const publicValue = publicKey.replace(/-----[^-]+-----|\s/g, "");
51
+ const item = { domain, status: "pending", created_at: now(),
52
+ spf: { status: "pending", name: domain, record: "v=spf1 include:_spf.mailport.local ~all" },
53
+ dkim: { status: "pending", selector, name: `${selector}._domainkey.${domain}`,
54
+ record: `v=DKIM1; k=rsa; p=${publicValue}` },
55
+ dmarc: { status: "recommended", name: `_dmarc.${domain}`, record: "v=DMARC1; p=none; rua=mailto:dmarc@" + domain } };
56
+ this.signingKeys.set(domain, privateKey);
57
+ this.state.domains.push(item); this.#save(); return this.#publicDomain(item);
58
+ }
59
+ #publicDomain(item) { const value = clone(item); if (value?.dkim) delete value.dkim.private_key; return value; }
60
+ listDomains() { return this.state.domains.map((item) => this.#publicDomain(item)); }
61
+ getDomain(domain) { const item = this.state.domains.find((value) => value.domain === domain); return item ? this.#publicDomain(item) : null; }
62
+ deleteDomain(domain) { this.state.domains = this.state.domains.filter((item) => item.domain !== domain); this.#save(); }
63
+ async verifyDomain(domainName) {
64
+ const item = this.state.domains.find((value) => value.domain === domainName);
65
+ if (!item) return null;
66
+ item.status = "verifying"; this.#save();
67
+ const checks = async (name, record) => { try { return (await this.resolver.resolveTxt(name)).flat().join("").includes(record); } catch { return false; } };
68
+ const [spf, dkim] = await Promise.all([checks(item.spf.name, item.spf.record), checks(item.dkim.name, item.dkim.record)]);
69
+ item.spf.status = spf ? "verified" : "pending"; item.dkim.status = dkim ? "verified" : "pending";
70
+ item.status = spf && dkim ? "active" : "pending"; item.verified_at = item.status === "active" ? now() : null;
71
+ for (const identity of this.state.identities.filter((value) => value.domain === item.domain)) identity.status = item.status === "active" ? "active" : "pending";
72
+ this.#save(); return this.#publicDomain(item);
73
+ }
74
+ createIdentity({ identity, address, application_id = null, tenant_id = null }) {
75
+ const domain = normalizeEmail(address).split("@").at(-1);
76
+ if (!this.getDomain(domain)) throw new MailPortError(ERROR_CODES.MAIL_DOMAIN_NOT_VERIFIED, "Identity domain is not configured");
77
+ const item = { identity, address: normalizeEmail(address), domain, application_id, tenant_id,
78
+ status: this.getDomain(domain).status === "active" ? "active" : "pending", created_at: now() };
79
+ this.state.identities = this.state.identities.filter((value) => !(value.identity === identity && value.application_id === application_id));
80
+ this.state.identities.push(item); this.#save(); return clone(item);
81
+ }
82
+ listIdentities(principal) { return this.state.identities.filter((item) => !principal || principal.admin || item.application_id === principal.application_id).map(clone); }
83
+ signingForIdentity(identityName, principal) {
84
+ const identity = this.state.identities.find((item) => item.identity === identityName && item.status === "active" &&
85
+ (!item.application_id || item.application_id === principal.application_id));
86
+ const domain = identity && this.state.domains.find((item) => item.domain === identity.domain && item.status === "active");
87
+ const privateKey = domain && this.signingKeys.get(domain.domain);
88
+ return domain && privateKey ? { domain: domain.domain, selector: domain.dkim.selector, privateKey } : null;
89
+ }
90
+ setSigningKey(domain, privateKey) { this.signingKeys.set(domain, privateKey); }
91
+ signingReady() { return this.state.domains.filter((item) => item.status === "active").every((item) => this.signingKeys.has(item.domain)); }
92
+ addSuppression({ email, reason = "manual", source = "admin", application_id = null, tenant_id = null }) {
93
+ const item = { email: normalizeEmail(email), reason, source, application_id, tenant_id, created_at: now() };
94
+ this.state.suppressions = this.state.suppressions.filter((value) => !(value.email === item.email && value.application_id === application_id));
95
+ this.state.suppressions.push(item); this.#save(); return clone(item);
96
+ }
97
+ listSuppressions(principal) { return this.state.suppressions.filter((item) => !principal || principal.admin || item.application_id === principal.application_id).map(clone); }
98
+ isSuppressed(email, principal) { return this.state.suppressions.find((item) => item.email === normalizeEmail(email) &&
99
+ (!item.application_id || item.application_id === principal.application_id) && (!item.tenant_id || item.tenant_id === principal.tenant_id)); }
100
+ addKey({ token, key_id = `key_${randomUUID()}`, application_id, tenant_id = null, scopes = ["mail.send", "mail.read"] }) {
101
+ const item = { token_hash: crypto.createHash("sha256").update(token).digest("hex"), key_id, application_id, tenant_id,
102
+ scopes, status: "active", created_at: now(), last_used_at: null };
103
+ this.state.keys.push(item); this.#save(); return { ...clone(item), token_hash: undefined };
104
+ }
105
+ authenticate(token) {
106
+ const hash = crypto.createHash("sha256").update(token).digest("hex");
107
+ const key = this.state.keys.find((item) => item.token_hash === hash && item.status === "active");
108
+ if (!key) return null; key.last_used_at = now(); this.#save();
109
+ return { key_id: key.key_id, application_id: key.application_id, tenant_id: key.tenant_id, scopes: key.scopes };
110
+ }
111
+ enforceRate(principal, identity, recipients, limits = {}) {
112
+ const maximum = limits.messagesPerMinute || limits.messages_per_minute;
113
+ if (!maximum) return;
114
+ const minute = Math.floor(Date.now() / 60_000);
115
+ for (const dimension of [`app:${principal.application_id}`, `identity:${principal.application_id}:${identity}`,
116
+ ...recipients.map((email) => `recipient:${principal.application_id}:${normalizeEmail(email)}`)]) {
117
+ const key = `${minute}:${dimension}`; const count = (this.rate.get(key) || 0) + 1; this.rate.set(key, count);
118
+ if (count > maximum) throw new MailPortError(ERROR_CODES.MAIL_RATE_LIMITED, "Mail rate limit exceeded", { retry_after_ms: 60_000 - Date.now() % 60_000 });
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,22 @@
1
+ import path from "node:path";
2
+ import { MailPortError, ERROR_CODES } from "@mailerport/core";
3
+
4
+ export function validateProductionConfig(options = {}, environment = process.env) {
5
+ const production = options.production ?? environment.NODE_ENV === "production";
6
+ if (!production) return { production: false };
7
+ const apiKey = options.apiKey || environment.MAILPORT_API_KEY;
8
+ const outbox = options.outbox?.filePath || environment.MAILPORT_OUTBOX;
9
+ const transport = typeof options.transport === "string" ? options.transport : options.transport?.type || options.transport?.kind || environment.MAILPORT_TRANSPORT;
10
+ const missing = [];
11
+ if (!apiKey) missing.push("MAILPORT_API_KEY");
12
+ if (!outbox) missing.push("MAILPORT_OUTBOX");
13
+ if (!transport) missing.push("MAILPORT_TRANSPORT");
14
+ if (transport === "smtp") {
15
+ for (const name of ["MAILPORT_SMTP_HOST", "MAILPORT_SMTP_PORT", "MAILPORT_SMTP_USERNAME", "MAILPORT_SMTP_PASSWORD"])
16
+ if (!environment[name] && !options.transport?.[name.slice(14).toLowerCase()]) missing.push(name);
17
+ }
18
+ if (missing.length) throw new MailPortError(ERROR_CODES.MAIL_NOT_CONFIGURED, `Missing production configuration: ${missing.join(", ")}`);
19
+ if (/^(dev|test)(-|$)/i.test(apiKey)) throw new MailPortError(ERROR_CODES.MAIL_NOT_CONFIGURED, "Development API keys are forbidden in production");
20
+ if (!path.isAbsolute(outbox)) throw new MailPortError(ERROR_CODES.MAIL_NOT_CONFIGURED, "MAILPORT_OUTBOX must be an absolute persistent-storage path in production");
21
+ return { production: true, outbox, transport };
22
+ }
@@ -0,0 +1,254 @@
1
+ import http from "node:http";
2
+ import { URL } from "node:url";
3
+ import { MailPortError, ERROR_CODES } from "@mailerport/core";
4
+
5
+ function sendJson(res, status, body) {
6
+ res.statusCode = status;
7
+ res.setHeader("content-type", "application/json; charset=utf-8");
8
+ res.end(JSON.stringify(body));
9
+ }
10
+ function publicMessage(message) {
11
+ if (!message || typeof message !== "object") return message;
12
+ const copy = structuredClone(message); delete copy.dkim; return copy;
13
+ }
14
+
15
+ function parseQueryFilters(searchParams) {
16
+ const parsed = {};
17
+ for (const [key, value] of searchParams.entries()) {
18
+ if (value === "null") parsed[key] = null;
19
+ else if (value === "undefined") parsed[key] = undefined;
20
+ else parsed[key] = value;
21
+ }
22
+ return parsed;
23
+ }
24
+
25
+ function parseAuthorization(req) {
26
+ const header = req.headers.authorization;
27
+ if (typeof header !== "string" || !/^Bearer [^\s]+$/.test(header)) return null;
28
+ return header.slice(7);
29
+ }
30
+
31
+ async function readJsonBody(req, maxBodyBytes) {
32
+ let total = 0;
33
+ const chunks = [];
34
+ for await (const chunk of req) {
35
+ total += chunk.length;
36
+ if (total > maxBodyBytes) {
37
+ throw new MailPortError(ERROR_CODES.MAIL_MESSAGE_TOO_LARGE, "Request body too large");
38
+ }
39
+ chunks.push(chunk);
40
+ }
41
+ if (chunks.length === 0) return {};
42
+ try {
43
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
44
+ } catch {
45
+ throw new MailPortError(ERROR_CODES.MAIL_DELIVERY_FAILED, "Invalid JSON body");
46
+ }
47
+ }
48
+
49
+ function authorize(req, { apiKey, adminKey, operations, applicationId }) {
50
+ if (!apiKey && !adminKey && !operations) return { application_id: applicationId || "app", scopes: ["mail.send", "mail.read", "mail.test"] };
51
+ const token = parseAuthorization(req);
52
+ if (adminKey && token === adminKey) return { admin: true, application_id: null, scopes: ["*"] };
53
+ if (apiKey && token === apiKey) return { application_id: applicationId || "app", tenant_id: null, scopes: ["mail.send", "mail.read", "mail.test"] };
54
+ const principal = token && operations?.authenticate(token);
55
+ if (!principal) {
56
+ throw new MailPortError(ERROR_CODES.MAIL_UNAUTHORIZED, "Unauthorized");
57
+ }
58
+ return principal;
59
+ }
60
+ function requireScope(principal, scope) {
61
+ if (!principal.admin && !principal.scopes?.includes(scope)) throw new MailPortError(ERROR_CODES.MAIL_FORBIDDEN, `Missing scope: ${scope}`);
62
+ }
63
+ function requireAdmin(principal) {
64
+ if (!principal.admin) throw new MailPortError(ERROR_CODES.MAIL_FORBIDDEN, "Administrative credential required");
65
+ }
66
+
67
+ export function createMailPortService(
68
+ mail,
69
+ {
70
+ host = "127.0.0.1",
71
+ port = 8789,
72
+ apiKey,
73
+ maxBodyBytes = 256 * 1024,
74
+ testEndpointsEnabled = true,
75
+ adminKey, operations, rateLimits, applicationId, production, deliveryEvents,
76
+ } = {}
77
+ ) {
78
+ const server = http.createServer(async (req, res) => {
79
+ try {
80
+ const url = new URL(req.url, `http://${host}:${port}`);
81
+
82
+ if (req.method === "GET" && url.pathname === "/health") {
83
+ return sendJson(res, 200, { ok: true });
84
+ }
85
+ if (req.method === "GET" && url.pathname === "/ready") {
86
+ await mail.list();
87
+ if (production?.production && operations && !operations.signingReady())
88
+ return sendJson(res, 503, { ready: false, reason: "signing_configuration_unavailable" });
89
+ return sendJson(res, 200, { ready: true });
90
+ }
91
+
92
+ const principal = url.pathname.startsWith("/v1/")
93
+ ? authorize(req, { apiKey, adminKey, operations, applicationId }) : null;
94
+
95
+ if (req.method === "GET" && url.pathname === "/v1/status") {
96
+ const status = typeof mail.status === "function" ? await mail.status() : {};
97
+ const suppressions = operations?.listSuppressions({ admin: true }) || [];
98
+ return sendJson(res, 200, { service: "mailport", version: "1.0.0", ...status,
99
+ suppression: { suppressed: suppressions.length, hard_bounces: suppressions.filter((item) => item.reason === "hard_bounce").length,
100
+ complaints: suppressions.filter((item) => item.reason === "complaint").length } });
101
+ }
102
+
103
+ if (req.method === "POST" && url.pathname === "/v1/domains") {
104
+ requireAdmin(principal); const body = await readJsonBody(req, maxBodyBytes);
105
+ return sendJson(res, 201, operations.createDomain(body.domain));
106
+ }
107
+ if (req.method === "GET" && url.pathname === "/v1/domains") {
108
+ requireAdmin(principal); return sendJson(res, 200, operations.listDomains());
109
+ }
110
+ if (url.pathname.startsWith("/v1/domains/")) {
111
+ requireAdmin(principal);
112
+ const parts = url.pathname.split("/"); const domain = decodeURIComponent(parts[3]);
113
+ if (req.method === "POST" && parts[4] === "verify") return sendJson(res, 200, await operations.verifyDomain(domain));
114
+ if (req.method === "GET") { const item = operations.getDomain(domain); return item ? sendJson(res, 200, item) : sendJson(res, 404, { error: "not_found" }); }
115
+ if (req.method === "DELETE") { operations.deleteDomain(domain); return sendJson(res, 200, { deleted: true }); }
116
+ }
117
+ if (req.method === "POST" && url.pathname === "/v1/identities") {
118
+ requireAdmin(principal); return sendJson(res, 201, operations.createIdentity(await readJsonBody(req, maxBodyBytes)));
119
+ }
120
+ if (req.method === "GET" && url.pathname === "/v1/identities") {
121
+ requireAdmin(principal); return sendJson(res, 200, operations.listIdentities(principal));
122
+ }
123
+ if (req.method === "POST" && url.pathname === "/v1/suppressions") {
124
+ requireAdmin(principal); return sendJson(res, 201, operations.addSuppression(await readJsonBody(req, maxBodyBytes)));
125
+ }
126
+ if (req.method === "POST" && url.pathname === "/v1/delivery-events") {
127
+ requireAdmin(principal);
128
+ return sendJson(res, 202, publicMessage(await deliveryEvents.consume(await readJsonBody(req, maxBodyBytes))));
129
+ }
130
+ if (req.method === "GET" && url.pathname === "/v1/suppressions") {
131
+ requireAdmin(principal); return sendJson(res, 200, operations.listSuppressions(principal));
132
+ }
133
+ if (req.method === "GET" && /^\/v1\/messages\/[^/]+\/events$/.test(url.pathname)) {
134
+ requireScope(principal, "mail.read"); const id = decodeURIComponent(url.pathname.split("/")[3]);
135
+ const message = await mail.get(id);
136
+ if (!message) return sendJson(res, 404, { error: "not_found" });
137
+ if (!principal.admin && message.application_id !== principal.application_id) throw new MailPortError(ERROR_CODES.MAIL_FORBIDDEN, "Message belongs to another application");
138
+ return sendJson(res, 200, message.events || []);
139
+ }
140
+
141
+ if (req.method === "POST" && url.pathname === "/v1/messages") {
142
+ requireScope(principal, "mail.send");
143
+ const body = await readJsonBody(req, maxBodyBytes);
144
+ delete body.__applicationId; delete body.__tenantId; delete body.__identityAddress;
145
+ const payload = body.template ? body.payload || {} : body;
146
+ const identity = operations?.listIdentities(principal).find((item) => item.identity === payload.identity &&
147
+ (!item.tenant_id || item.tenant_id === principal.tenant_id));
148
+ if (identity && identity.status !== "active") throw new MailPortError(ERROR_CODES.MAIL_DOMAIN_NOT_VERIFIED, "Sending identity is not active");
149
+ const recipients = [...(Array.isArray(payload.to) ? payload.to : [payload.to]), ...(payload.cc || []), ...(payload.bcc || [])].filter(Boolean);
150
+ const suppressed = recipients.find((email) => operations?.isSuppressed(email, principal));
151
+ if (suppressed) throw new MailPortError(ERROR_CODES.MAIL_RECIPIENT_SUPPRESSED, "Recipient is suppressed", { recipient: suppressed });
152
+ operations?.enforceRate(principal, payload.identity, recipients, rateLimits);
153
+ payload.__applicationId = principal.application_id; payload.__tenantId = principal.tenant_id;
154
+ if (identity) payload.__identityAddress = identity.address;
155
+ if (identity) payload.__dkim = operations.signingForIdentity(identity.identity, principal);
156
+ const message = body.template
157
+ ? await mail.send(body.template, payload)
158
+ : await mail.send(body);
159
+ return sendJson(res, 200, publicMessage(message));
160
+ }
161
+
162
+ if (req.method === "GET" && url.pathname === "/v1/messages") {
163
+ requireScope(principal, "mail.read");
164
+ return sendJson(res, 200, (await mail.list(parseQueryFilters(url.searchParams))).filter((item) => principal.admin ||
165
+ (item.application_id === principal.application_id && (principal.tenant_id == null || item.tenant_id === principal.tenant_id))).map(publicMessage));
166
+ }
167
+
168
+ if (req.method === "GET" && url.pathname.startsWith("/v1/messages/")) {
169
+ const id = decodeURIComponent(url.pathname.split("/").pop());
170
+ const message = await mail.get(id);
171
+ if (!message) return sendJson(res, 404, { error: "not_found" });
172
+ requireScope(principal, "mail.read");
173
+ if (!principal.admin && (message.application_id !== principal.application_id ||
174
+ (principal.tenant_id != null && message.tenant_id !== principal.tenant_id))) throw new MailPortError(ERROR_CODES.MAIL_FORBIDDEN, "Message belongs to another application");
175
+ return sendJson(res, 200, publicMessage(message));
176
+ }
177
+
178
+ if (req.method === "GET" && url.pathname === "/v1/test/messages") {
179
+ requireScope(principal, "mail.test");
180
+ if (!testEndpointsEnabled) {
181
+ throw new MailPortError(
182
+ ERROR_CODES.MAIL_TEST_TRANSPORT_DISABLED,
183
+ "Test API is disabled"
184
+ );
185
+ }
186
+ return sendJson(res, 200, (await mail.test.list(parseQueryFilters(url.searchParams))).filter((item) =>
187
+ principal.admin || item.application_id === principal.application_id).map(publicMessage));
188
+ }
189
+
190
+ if (req.method === "GET" && url.pathname.startsWith("/v1/test/messages/")) {
191
+ requireScope(principal, "mail.test");
192
+ if (!testEndpointsEnabled) {
193
+ throw new MailPortError(
194
+ ERROR_CODES.MAIL_TEST_TRANSPORT_DISABLED,
195
+ "Test API is disabled"
196
+ );
197
+ }
198
+ const id = decodeURIComponent(url.pathname.split("/").pop());
199
+ const message = await mail.test.get(id);
200
+ if (!message) return sendJson(res, 404, { error: "not_found" });
201
+ if (!principal.admin && message.application_id !== principal.application_id) throw new MailPortError(ERROR_CODES.MAIL_FORBIDDEN, "Message belongs to another application");
202
+ return sendJson(res, 200, publicMessage(message));
203
+ }
204
+
205
+ if (req.method === "DELETE" && url.pathname === "/v1/test/messages") {
206
+ requireScope(principal, "mail.test");
207
+ if (!testEndpointsEnabled) {
208
+ throw new MailPortError(
209
+ ERROR_CODES.MAIL_TEST_TRANSPORT_DISABLED,
210
+ "Test API is disabled"
211
+ );
212
+ }
213
+ const filters = parseQueryFilters(url.searchParams);
214
+ if (!principal.admin || Object.keys(filters).length) await mail.test.clear({ ...filters, application_id: principal.application_id });
215
+ else await mail.test.clear();
216
+ return sendJson(res, 200, { cleared: true });
217
+ }
218
+
219
+ return sendJson(res, 404, { error: "not_found" });
220
+ } catch (error) {
221
+ if (error instanceof MailPortError) {
222
+ const status = error.code === ERROR_CODES.MAIL_UNAUTHORIZED ? 401 : error.code === ERROR_CODES.MAIL_FORBIDDEN ? 403 :
223
+ error.code === ERROR_CODES.MAIL_RATE_LIMITED ? 429 : 400;
224
+ return sendJson(res, status, { error: error.code, message: error.message, details: error.details });
225
+ }
226
+ return sendJson(res, 500, { error: "internal_error" });
227
+ }
228
+ });
229
+
230
+ return {
231
+ async start() {
232
+ await new Promise((resolve, reject) => {
233
+ const onError = (error) => {
234
+ server.off("error", onError);
235
+ reject(error);
236
+ };
237
+ server.once("error", onError);
238
+ server.listen(port, host, () => {
239
+ server.off("error", onError);
240
+ resolve();
241
+ });
242
+ });
243
+ },
244
+ async stop() {
245
+ if (typeof mail.stopWorker === "function") mail.stopWorker();
246
+ await new Promise((resolve, reject) =>
247
+ server.close((error) => (error ? reject(error) : resolve()))
248
+ );
249
+ if (typeof mail.close === "function") {
250
+ await mail.close();
251
+ }
252
+ },
253
+ };
254
+ }
@@ -0,0 +1,43 @@
1
+ import { createMailPort } from "@mailerport/sdk";
2
+ import { createSmtpTransport } from "@mailerport/smtp";
3
+ import { createMailPortService } from "./remote-service.js";
4
+ import { FileOperationsStore } from "./operations.js";
5
+ import { validateProductionConfig } from "./production-config.js";
6
+ import { createDeliveryEventSource } from "./delivery-events.js";
7
+
8
+ export function createMailService(options = {}) {
9
+ const environment = options.environment || process.env;
10
+ const production = validateProductionConfig(options, environment);
11
+ const worker = options.worker || {};
12
+ const transport = options.transport || { type: environment.MAILPORT_TRANSPORT || "local" };
13
+ let transportConfig = typeof transport === "string" ? transport : { ...transport, kind: transport.kind || transport.type };
14
+ if ((transportConfig === "smtp" || transportConfig.kind === "smtp" || transportConfig.type === "smtp")) {
15
+ transportConfig = createSmtpTransport({
16
+ ...(typeof transportConfig === "object" ? transportConfig : {}), host: transportConfig.host || environment.MAILPORT_SMTP_HOST,
17
+ port: Number(transportConfig.port || environment.MAILPORT_SMTP_PORT || 25), username: transportConfig.username || environment.MAILPORT_SMTP_USERNAME,
18
+ password: transportConfig.password || environment.MAILPORT_SMTP_PASSWORD, secure: transportConfig.secure ?? environment.MAILPORT_SMTP_SECURE === "true",
19
+ requireTLS: transportConfig.requireTLS ?? (environment.MAILPORT_SMTP_REQUIRE_TLS === "true" || environment.NODE_ENV === "production") });
20
+ }
21
+ const mail = createMailPort({
22
+ applicationId: options.applicationId || environment.MAILPORT_APPLICATION_ID || "app",
23
+ identities: options.identities || { system: "notifications@example.test" },
24
+ templates: options.templates || {}, environment,
25
+ transport: transportConfig,
26
+ testEndpointsEnabled: options.testEndpointsEnabled ?? transportConfig.kind !== "smtp",
27
+ outbox: { enabled: true, filePath: options.outbox?.filePath || environment.MAILPORT_OUTBOX || ".mailport/outbox.json",
28
+ workerEnabled: worker.enabled !== false, pollIntervalMs: worker.pollIntervalMs,
29
+ leaseMs: worker.leaseMs, maxAttempts: worker.maxAttempts, retryBaseMs: worker.retryBaseMs },
30
+ });
31
+ const operations = options.operationsStore || new FileOperationsStore({ filePath: options.operations?.filePath || environment.MAILPORT_OPERATIONS,
32
+ resolver: options.operations?.resolver });
33
+ for (const key of options.apiKeys || []) operations.addKey(key);
34
+ if (environment.MAILPORT_DKIM_DOMAIN && environment.MAILPORT_DKIM_PRIVATE_KEY)
35
+ operations.setSigningKey(environment.MAILPORT_DKIM_DOMAIN, environment.MAILPORT_DKIM_PRIVATE_KEY.replace(/\\n/g, "\n"));
36
+ const deliveryEvents = createDeliveryEventSource({ mail, operations, ...(options.deliveryEvents || {}) });
37
+ const server = createMailPortService(mail, { host: options.host, port: options.port || Number(environment.PORT) || 8789,
38
+ apiKey: options.apiKey || environment.MAILPORT_API_KEY,
39
+ adminKey: options.adminKey || environment.MAILPORT_ADMIN_KEY, operations, rateLimits: options.limits,
40
+ applicationId: options.applicationId || environment.MAILPORT_APPLICATION_ID || "app", production, deliveryEvents,
41
+ maxBodyBytes: options.maxBodyBytes, testEndpointsEnabled: options.testEndpointsEnabled ?? transportConfig.kind !== "smtp" });
42
+ return { ...server, mail, operations, deliveryEvents, production, config: options };
43
+ }
package/package.json ADDED
@@ -0,0 +1 @@
1
+ {"name":"@mailerport/service","version":"0.1.0","description":"Standalone MailPort transactional mail service","type":"module","bin":{"mailport-service":"./bin/mailport-service.js"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"types":"./dist/index.d.ts","files":["dist","bin"],"scripts":{"build":"node ../../scripts/build-package.js service","prepack":"npm run build"},"dependencies":{"@mailerport/core":"0.1.0","@mailerport/sdk":"0.1.0","@mailerport/smtp":"0.1.0"},"engines":{"node":">=20"},"license":"MIT","publishConfig":{"access":"public"}}