Skip to content

Commit 5731244

Browse files
committed
feat: add smtp action (#20)
1 parent bc4b27f commit 5731244

9 files changed

Lines changed: 265 additions & 0 deletions

File tree

actions/smtp-action/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.env
2+
node_modules/
3+
dist/

actions/smtp-action/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM node:24.10.0-alpine
2+
3+
WORKDIR /app
4+
5+
COPY package.json package-lock.json* ./
6+
7+
RUN npm install
8+
9+
COPY . .
10+
11+
RUN npm run build
12+
13+
CMD ["npm", "run", "start"]

actions/smtp-action/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@code0-tech/smtp-action",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"main": "dist/index.js",
7+
"scripts": {
8+
"dev": "tsx watch src/index.ts",
9+
"typecheck": "tsc --noEmit",
10+
"build": "vite build",
11+
"test": "vitest run",
12+
"start": "node dist/index.js"
13+
},
14+
"dependencies": {
15+
"@code0-tech/hercules": "^1.1.1",
16+
"nodemailer": "^6.9.16"
17+
},
18+
"devDependencies": {
19+
"@types/node": "^22.0.0",
20+
"@types/nodemailer": "^6.4.16",
21+
"tsx": "^4.0.0",
22+
"typescript": "^5.9.3",
23+
"vite": "^8.0.3",
24+
"vitest": "^4.1.10"
25+
}
26+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { DisplayMessage, Identifier, Name, Schema } from "@code0-tech/hercules";
2+
import { z } from "zod";
3+
4+
/**
5+
* SMTP_ATTACHMENT mirrors the shape of a nodemailer attachment object
6+
* (see https://nodemailer.com/message/attachments/). Only the fields that make
7+
* sense to build from a flow are exposed; `content` is always provided as a
8+
* string and interpreted according to `encoding` (e.g. "base64" for binary
9+
* files, "utf-8" for text).
10+
*/
11+
export const SmtpAttachmentSchema = z.object({
12+
filename: z.string().describe("The file name shown to the recipient, e.g. \"invoice.pdf\"."),
13+
content: z.string().describe("The attachment content as a string. Interpreted using the encoding field."),
14+
contentType: z.string().optional().describe("The MIME type of the attachment, e.g. \"application/pdf\". Derived from the filename when omitted."),
15+
encoding: z.string().optional().describe("How to decode content, e.g. \"base64\" or \"utf-8\". Defaults to utf-8."),
16+
});
17+
export type SmtpAttachment = z.infer<typeof SmtpAttachmentSchema>;
18+
19+
@Identifier("SMTP_ATTACHMENT")
20+
@Name({ code: "en-US", content: "Email attachment" })
21+
@DisplayMessage({ code: "en-US", content: "Email attachment ${filename}" })
22+
@Schema(SmtpAttachmentSchema)
23+
export class SmtpAttachmentDataType {}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { DisplayMessage, Identifier, Name, Schema } from "@code0-tech/hercules";
2+
import { z } from "zod";
3+
4+
/**
5+
* SMTP_SEND_RESULT is a normalized view of nodemailer's SentMessageInfo
6+
* (see https://nodemailer.com/usage/#sending-mail). It captures the message id
7+
* assigned by the SMTP server together with the accepted/rejected recipient
8+
* lists and the raw server response so flows can branch on delivery outcome.
9+
*/
10+
export const SmtpEnvelopeSchema = z.object({
11+
from: z.string().describe("The envelope MAIL FROM address, empty when not set."),
12+
to: z.array(z.string()).describe("The envelope RCPT TO addresses."),
13+
});
14+
export type SmtpEnvelope = z.infer<typeof SmtpEnvelopeSchema>;
15+
16+
export const SmtpSendResultSchema = z.object({
17+
messageId: z.string().describe("The Message-ID assigned to the sent message."),
18+
accepted: z.array(z.string()).describe("Recipient addresses the SMTP server accepted."),
19+
rejected: z.array(z.string()).describe("Recipient addresses the SMTP server rejected."),
20+
response: z.string().describe("The last SMTP response string from the server."),
21+
envelope: SmtpEnvelopeSchema.describe("The SMTP envelope actually used for delivery."),
22+
});
23+
export type SmtpSendResult = z.infer<typeof SmtpSendResultSchema>;
24+
25+
@Identifier("SMTP_ENVELOPE")
26+
@Name({ code: "en-US", content: "Email envelope" })
27+
@DisplayMessage({ code: "en-US", content: "Email envelope" })
28+
@Schema(SmtpEnvelopeSchema)
29+
export class SmtpEnvelopeDataType {}
30+
31+
@Identifier("SMTP_SEND_RESULT")
32+
@Name({ code: "en-US", content: "Email send result" })
33+
@DisplayMessage({ code: "en-US", content: "Email send result" })
34+
@Schema(SmtpSendResultSchema)
35+
export class SmtpSendResultDataType {}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {
2+
Description,
3+
DisplayIcon,
4+
DisplayMessage,
5+
Documentation,
6+
Identifier,
7+
Name,
8+
Parameter,
9+
Signature,
10+
} from "@code0-tech/hercules";
11+
import { SmtpAttachment } from "../../data_types/smtpAttachment.js";
12+
13+
@Identifier("createAttachment")
14+
@DisplayIcon("tabler:paperclip")
15+
@Signature("(Filename: string, Content: string, ContentType?: string, Encoding?: string): SMTP_ATTACHMENT")
16+
@Name({ code: "en-US", content: "Create email attachment" })
17+
@DisplayMessage({ code: "en-US", content: "Create email attachment ${Filename}" })
18+
@Documentation({
19+
code: "en-US",
20+
content:
21+
"Builds an email attachment object for use with the sendEmailWithAttachments function.\nUse encoding \"base64\" for binary files (e.g. PDFs, images) and \"utf-8\" for plain text.",
22+
})
23+
@Description({
24+
code: "en-US",
25+
content: "Creates an email attachment object.",
26+
})
27+
@Parameter({
28+
runtimeName: "Filename",
29+
name: [{ code: "en-US", content: "File name" }],
30+
description: [{ code: "en-US", content: "The file name shown to the recipient, e.g. \"invoice.pdf\"." }],
31+
})
32+
@Parameter({
33+
runtimeName: "Content",
34+
name: [{ code: "en-US", content: "Content" }],
35+
description: [{ code: "en-US", content: "The attachment content as a string. Interpreted using the encoding." }],
36+
})
37+
@Parameter({
38+
runtimeName: "ContentType",
39+
name: [{ code: "en-US", content: "Content type" }],
40+
description: [{ code: "en-US", content: "The MIME type, e.g. \"application/pdf\". Derived from the file name when omitted." }],
41+
optional: true,
42+
})
43+
@Parameter({
44+
runtimeName: "Encoding",
45+
name: [{ code: "en-US", content: "Encoding" }],
46+
description: [{ code: "en-US", content: "How to decode content, e.g. \"base64\" or \"utf-8\". Defaults to utf-8." }],
47+
optional: true,
48+
})
49+
export class CreateAttachmentFunction {
50+
run(
51+
_context: unknown,
52+
Filename: string,
53+
Content: string,
54+
ContentType?: string,
55+
Encoding?: string
56+
): SmtpAttachment {
57+
return {
58+
filename: Filename,
59+
content: Content,
60+
contentType: ContentType,
61+
encoding: Encoding,
62+
};
63+
}
64+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import "reflect-metadata";
2+
import { describe, expect, it } from "vitest";
3+
import { SendEmailRequestDataSchema } from "../src/helpers.js";
4+
import { SmtpSendResultSchema } from "../src/data_types/smtpSendResult.js";
5+
import { SmtpAttachmentSchema } from "../src/data_types/smtpAttachment.js";
6+
import { CreateAttachmentFunction } from "../src/functions/utils/createAttachmentFunction.js";
7+
8+
describe("SendEmailRequestDataSchema", () => {
9+
it("requires To, Subject and Text", () => {
10+
expect(() => SendEmailRequestDataSchema.parse({ Subject: "hi", Text: "body" })).toThrow();
11+
expect(() => SendEmailRequestDataSchema.parse({ To: "a@example.com", Text: "body" })).toThrow();
12+
expect(() => SendEmailRequestDataSchema.parse({ To: "a@example.com", Subject: "hi" })).toThrow();
13+
});
14+
15+
it("accepts optional Html, From, Cc, Bcc, ReplyTo and Attachments", () => {
16+
const parsed = SendEmailRequestDataSchema.parse({
17+
To: "a@example.com, b@example.com",
18+
Subject: "hi",
19+
Text: "body",
20+
Html: "<p>body</p>",
21+
From: "no-reply@example.com",
22+
Cc: "c@example.com",
23+
Bcc: "d@example.com",
24+
ReplyTo: "reply@example.com",
25+
Attachments: [{ filename: "note.txt", content: "hello" }],
26+
});
27+
expect(parsed.To).toEqual("a@example.com, b@example.com");
28+
expect(parsed.Attachments).toHaveLength(1);
29+
});
30+
});
31+
32+
describe("SmtpSendResultSchema", () => {
33+
it("parses a representative send result", () => {
34+
const result = {
35+
messageId: "<example-message-id@example.com>",
36+
accepted: ["a@example.com"],
37+
rejected: [],
38+
response: "250 2.0.0 OK",
39+
envelope: { from: "no-reply@example.com", to: ["a@example.com"] },
40+
};
41+
const parsed = SmtpSendResultSchema.parse(result);
42+
expect(parsed.messageId).toEqual(result.messageId);
43+
expect(parsed.accepted).toEqual(["a@example.com"]);
44+
});
45+
});
46+
47+
describe("CreateAttachmentFunction", () => {
48+
it("builds a valid SMTP_ATTACHMENT object", () => {
49+
const attachment = new CreateAttachmentFunction().run(
50+
undefined,
51+
"invoice.pdf",
52+
"JVBERi0=",
53+
"application/pdf",
54+
"base64"
55+
);
56+
expect(() => SmtpAttachmentSchema.parse(attachment)).not.toThrow();
57+
expect(attachment.filename).toEqual("invoice.pdf");
58+
expect(attachment.encoding).toEqual("base64");
59+
});
60+
});

actions/smtp-action/tsconfig.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"module": "ESNext",
5+
"moduleResolution": "Bundler",
6+
"declaration": true,
7+
"outDir": "dist",
8+
"strict": true,
9+
"esModuleInterop": true,
10+
"skipLibCheck": true,
11+
"allowImportingTsExtensions": true,
12+
"noEmit": true,
13+
"baseUrl": ".",
14+
"experimentalDecorators": true,
15+
"emitDecoratorMetadata": true,
16+
"types": ["node", "vite/client"]
17+
},
18+
"include": [
19+
"src/**/*",
20+
"test/**/*",
21+
"vite.config.ts"
22+
]
23+
}

actions/smtp-action/vite.config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { defineConfig } from 'vite';
2+
import { resolve } from 'path';
3+
4+
export default defineConfig({
5+
build: {
6+
target: "node18",
7+
ssr: resolve(__dirname, 'src/index.ts'),
8+
rollupOptions: {
9+
external: (id) =>
10+
[
11+
'fs',
12+
'path',
13+
'typescript',
14+
'@code0-tech/hercules'
15+
].includes(id) || id.startsWith('node:')
16+
}
17+
}
18+
});

0 commit comments

Comments
 (0)