Skip to content

Commit b1b1a82

Browse files
committed
Implement sending email with nodemailer
1 parent 50987e3 commit b1b1a82

File tree

6 files changed

+164
-4
lines changed

6 files changed

+164
-4
lines changed

apps/mailer/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/*.env

apps/mailer/docker-compose.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,17 @@ services:
1919
target: send
2020
environment:
2121
- PORT=8889
22+
env_file:
23+
- send.env
2224
ports:
2325
- "8889:8889"
2426
develop:
2527
watch:
2628
- action: sync+restart
2729
path: ./dist
28-
target: /deploypkg
30+
target: /deploypkg
31+
32+
smtp-server:
33+
image: rnwood/smtp4dev:3.8.7-ci20250525101@sha256:729d01654329fb80379935bb8b9f1e13146851c163a44577ef380c7e3a939e3e
34+
ports:
35+
- "8000:80"

apps/mailer/send.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
SMTP_SERVER=smtp-server
2+
SMTP_PORT=25
3+
SMTP_USER=myuser
4+
SMTP_PASSWORD=mypass

apps/mailer/src/send.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,42 @@
1-
import nodemailer from "nodemailer";
21
import { FissionContext, FissionCallback } from "./utils";
2+
import { getTransporter } from "./smtp-utils";
33

44
module.exports = async function ({request, response}: FissionContext, cb: FissionCallback) {
5-
response.status(200).json({});
5+
if (request.method !== "POST") {
6+
response.status(405).json({
7+
error: "Only POST is supported",
8+
});
9+
return;
10+
}
11+
12+
if (!request.body?.data) {
13+
response.status(400).json({
14+
error: "Missing request.body.data",
15+
});
16+
return;
17+
}
18+
19+
const transporter = await getTransporter();
20+
21+
try {
22+
const result = await transporter.sendMail({
23+
from: request.body.data.from,
24+
to: request.body.data.to,
25+
replyTo: request.body.data.reply_to,
26+
subject: request.body.data.subject,
27+
text: request.body.data.text,
28+
html: request.body.data.html,
29+
})
30+
31+
response.status(200).json({
32+
"transporter.isIdle": transporter.isIdle(),
33+
"result": result,
34+
});
35+
} catch (e) {
36+
console.error(e);
37+
response.status(500).json({
38+
error: "Failed to send email: " + e,
39+
});
40+
return;
41+
}
642
}

apps/mailer/src/smtp-utils.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { fissionGetConfig, fissionGetSecret } from "./utils";
2+
import nodemailer from "nodemailer";
3+
import SMTPPool from "nodemailer/lib/smtp-pool";
4+
5+
let transporterPromise: Promise<nodemailer.Transporter<SMTPPool.SentMessageInfo, SMTPPool.Options>> | null = null;
6+
7+
export function getTransporter(): Promise<nodemailer.Transporter<SMTPPool.SentMessageInfo, SMTPPool.Options>> {
8+
if (transporterPromise) {
9+
return transporterPromise;
10+
}
11+
12+
transporterPromise = (async () => {
13+
const SMTP_SERVER = await fissionGetConfig("SMTP_SERVER");
14+
const SMTP_PORT = await fissionGetConfig("SMTP_PORT");
15+
const SMTP_USER = await fissionGetConfig("SMTP_USER");
16+
const SMTP_PASSWORD = await fissionGetSecret("SMTP_PASSWORD");
17+
18+
if (!SMTP_SERVER || !SMTP_PORT || !SMTP_USER || !SMTP_PASSWORD) {
19+
throw new Error("SMTP_SERVER, SMTP_PORT, SMTP_USER, and SMTP_PASSWORD are required");
20+
}
21+
22+
/**
23+
* One shared transporter for your whole process.
24+
* The transporter will automatically open up to `maxConnections`
25+
* sockets and keep them warm.
26+
*/
27+
const transporter = nodemailer.createTransport({
28+
host: SMTP_SERVER,
29+
port: Number(SMTP_PORT),
30+
secure: Number(SMTP_PORT) === 465,
31+
pool: true, // ♻️ enable connection pooling
32+
maxConnections: 5, // optional – defaults to 5
33+
maxMessages: 100, // optional – defaults to 100
34+
auth: {
35+
user: SMTP_USER,
36+
pass: SMTP_PASSWORD,
37+
},
38+
});
39+
40+
return transporter;
41+
})();
42+
43+
return transporterPromise;
44+
}

apps/mailer/src/utils.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import express from "express";
2+
import fsPromise from "fs/promises"
3+
4+
const FISSION_CONFIGS_BASE_PATH = "/configs/fission-default/mailer-configs"
5+
const FISSION_SECRETS_BASE_PATH = "/secrets/fission-default/mailer-secrets"
26

37
// Context type for Fission serverless functions
48
// https://github.com/fission/environments/blob/c679ff68371ec779f87e855987157a52f05ea8dd/nodejs/server.js#L138-L142
@@ -9,4 +13,68 @@ export type FissionContext = {
913

1014
// Callback type for Fission serverless functions
1115
// https://github.com/fission/environments/blob/c679ff68371ec779f87e855987157a52f05ea8dd/nodejs/server.js#L144-L152
12-
export type FissionCallback = (status: number, body: any, headers?: Record<string, string>) => void
16+
export type FissionCallback = (status: number, body: any, headers?: Record<string, string>) => void
17+
18+
// Cache for storing configuration values
19+
const configCache = new Map<string, string>();
20+
21+
export async function fissionGetConfig(name: string, useCache: boolean = true): Promise<string | null> {
22+
// Check environment variables first (highest priority, always fresh)
23+
if (process.env[name]) {
24+
return process.env[name]!;
25+
}
26+
27+
// Check cache if enabled
28+
if (useCache && configCache.has(name)) {
29+
return configCache.get(name)!;
30+
}
31+
32+
// Check if file exists
33+
try {
34+
await fsPromise.access(`${FISSION_CONFIGS_BASE_PATH}/${name}`);
35+
} catch (e) {
36+
return null;
37+
}
38+
39+
// Read and cache the file content
40+
const content = await fsPromise.readFile(`${FISSION_CONFIGS_BASE_PATH}/${name}`, 'utf8');
41+
42+
// Update cache
43+
if (useCache) {
44+
configCache.set(name, content);
45+
}
46+
47+
return content;
48+
}
49+
50+
// Cache for storing secret values
51+
const secretCache = new Map<string, string>();
52+
53+
export async function fissionGetSecret(name: string, useCache: boolean = true): Promise<string | null> {
54+
// Check environment variables first (highest priority, always fresh)
55+
if (process.env[name]) {
56+
return process.env[name]!;
57+
}
58+
59+
// Check cache if enabled
60+
if (useCache && secretCache.has(name)) {
61+
return secretCache.get(name)!;
62+
}
63+
64+
// Check if file exists
65+
try {
66+
await fsPromise.access(`${FISSION_SECRETS_BASE_PATH}/${name}`);
67+
} catch (e) {
68+
return null;
69+
}
70+
71+
// Read and cache the secret content
72+
const content = await fsPromise.readFile(`${FISSION_SECRETS_BASE_PATH}/${name}`, 'utf8');
73+
74+
// Update cache
75+
if (useCache) {
76+
secretCache.set(name, content);
77+
}
78+
79+
return content;
80+
}

0 commit comments

Comments
 (0)