diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 26bccc5..da6cac4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,6 +25,7 @@ include: - woocommerce-action - shopware-action - twilio-action + - smtp-action test-node: image: node:24.10.0 diff --git a/README.md b/README.md index 4cf5dca..e31798f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ The "central" place for all actions — a monorepo containing integrations provi | Action | Description | |--------|-------------| | [GLS](docs/Actions/GLS/overview.md) | GLS ShipIT integration for creating and managing shipments | +| SMTP | Send emails (with attachments) through any SMTP server via nodemailer | ## ENV diff --git a/actions/smtp-action/.gitignore b/actions/smtp-action/.gitignore new file mode 100644 index 0000000..ddc3c2a --- /dev/null +++ b/actions/smtp-action/.gitignore @@ -0,0 +1,3 @@ +.env +node_modules/ +dist/ diff --git a/actions/smtp-action/Dockerfile b/actions/smtp-action/Dockerfile new file mode 100644 index 0000000..5871953 --- /dev/null +++ b/actions/smtp-action/Dockerfile @@ -0,0 +1,13 @@ +FROM node:24.10.0-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ + +RUN npm install + +COPY . . + +RUN npm run build + +CMD ["npm", "run", "start"] diff --git a/actions/smtp-action/README.md b/actions/smtp-action/README.md new file mode 100644 index 0000000..da7e277 --- /dev/null +++ b/actions/smtp-action/README.md @@ -0,0 +1,61 @@ +# SMTP Action + +Send emails through any SMTP server from a Hercules flow. Built on +[nodemailer](https://nodemailer.com/), the de-facto standard SMTP client for +Node.js. + +This action is function-only (like the GLS and Twilio actions) — it exposes +functions to send mail and build attachments, and does not register any +triggers. + +## Configuration + +Configured via the action's `ConfigurationDefinition`s: + +| Identifier | Type | Required | Description | +|---|---|---|---| +| `host` | TEXT | yes | Hostname of the SMTP server, e.g. `smtp.example.com`. | +| `port` | TEXT | no (default `587`) | SMTP port. Common values: `587` (STARTTLS), `465` (implicit TLS). | +| `secure` | TEXT | no (default `false`) | `"true"` to use implicit TLS (port 465), `"false"` for STARTTLS (port 587). | +| `username` | TEXT | no | Username for SMTP AUTH. Leave empty for unauthenticated relays. | +| `password` | TEXT | no | Password for SMTP AUTH. | +| `from_address` | TEXT | no | Default `From` used when a function call omits it, e.g. `Acme `. | + +Beyond the shared Hercules variables (`HERCULES_AUTH_TOKEN`, +`HERCULES_AQUILA_URL`, `HERCULES_ACTION_ID`, `HERCULES_SDK_VERSION`), no +provider-specific environment variables are required — SMTP credentials are +supplied through the configuration above. + +## Functions + +| Identifier | Signature | Description | +|---|---|---| +| `sendEmail` | `(To, Subject, Text, Html?, From?, Cc?, Bcc?, ReplyTo?): SMTP_SEND_RESULT` | Send a plain-text (and optionally HTML) email. `To`/`Cc`/`Bcc` are comma-separated address lists. | +| `sendEmailWithAttachments` | `(To, Subject, Text, Attachments, Html?, From?, Cc?, Bcc?): SMTP_SEND_RESULT` | Send an email with one or more attachments built via `createAttachment`. | +| `createAttachment` | `(Filename, Content, ContentType?, Encoding?): SMTP_ATTACHMENT` | Build an attachment object. Use `base64` encoding for binary files. | + +## Data types + +- `SMTP_SEND_RESULT` — normalized nodemailer `SentMessageInfo`: `messageId`, + `accepted`, `rejected`, `response`, `envelope`. +- `SMTP_ENVELOPE` — the SMTP envelope (`from`, `to`) used for delivery. +- `SMTP_ATTACHMENT` — `filename`, `content`, optional `contentType` and + `encoding`. + +## Provider setup + +Most providers (Gmail, Microsoft 365, Amazon SES, Postmark, Mailgun, etc.) +expose SMTP credentials. For Gmail/Workspace you typically need an +[App Password](https://support.google.com/accounts/answer/185833) rather than +your account password. Point `host`/`port` at the provider's SMTP endpoint and +set `secure` to match the port (465 → `true`, 587 → `false`). + +## Development + +```bash +cd actions/smtp-action +npm install +npm run typecheck +npm run build +npm run test +``` diff --git a/actions/smtp-action/package.json b/actions/smtp-action/package.json new file mode 100644 index 0000000..a8c0ad7 --- /dev/null +++ b/actions/smtp-action/package.json @@ -0,0 +1,26 @@ +{ + "name": "@code0-tech/smtp-action", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "typecheck": "tsc --noEmit", + "build": "vite build", + "test": "vitest run", + "start": "node dist/index.js" + }, + "dependencies": { + "@code0-tech/hercules": "^1.1.1", + "nodemailer": "^6.9.16" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/nodemailer": "^6.4.16", + "tsx": "^4.0.0", + "typescript": "^5.9.3", + "vite": "^8.0.3", + "vitest": "^4.1.10" + } +} diff --git a/actions/smtp-action/src/data_types/smtpAttachment.ts b/actions/smtp-action/src/data_types/smtpAttachment.ts new file mode 100644 index 0000000..45b2d2a --- /dev/null +++ b/actions/smtp-action/src/data_types/smtpAttachment.ts @@ -0,0 +1,23 @@ +import { DisplayMessage, Identifier, Name, Schema } from "@code0-tech/hercules"; +import { z } from "zod"; + +/** + * SMTP_ATTACHMENT mirrors the shape of a nodemailer attachment object + * (see https://nodemailer.com/message/attachments/). Only the fields that make + * sense to build from a flow are exposed; `content` is always provided as a + * string and interpreted according to `encoding` (e.g. "base64" for binary + * files, "utf-8" for text). + */ +export const SmtpAttachmentSchema = z.object({ + filename: z.string().describe("The file name shown to the recipient, e.g. \"invoice.pdf\"."), + content: z.string().describe("The attachment content as a string. Interpreted using the encoding field."), + contentType: z.string().optional().describe("The MIME type of the attachment, e.g. \"application/pdf\". Derived from the filename when omitted."), + encoding: z.string().optional().describe("How to decode content, e.g. \"base64\" or \"utf-8\". Defaults to utf-8."), +}); +export type SmtpAttachment = z.infer; + +@Identifier("SMTP_ATTACHMENT") +@Name({ code: "en-US", content: "Email attachment" }) +@DisplayMessage({ code: "en-US", content: "Email attachment ${filename}" }) +@Schema(SmtpAttachmentSchema) +export class SmtpAttachmentDataType {} diff --git a/actions/smtp-action/src/data_types/smtpSendResult.ts b/actions/smtp-action/src/data_types/smtpSendResult.ts new file mode 100644 index 0000000..8c07e0d --- /dev/null +++ b/actions/smtp-action/src/data_types/smtpSendResult.ts @@ -0,0 +1,35 @@ +import { DisplayMessage, Identifier, Name, Schema } from "@code0-tech/hercules"; +import { z } from "zod"; + +/** + * SMTP_SEND_RESULT is a normalized view of nodemailer's SentMessageInfo + * (see https://nodemailer.com/usage/#sending-mail). It captures the message id + * assigned by the SMTP server together with the accepted/rejected recipient + * lists and the raw server response so flows can branch on delivery outcome. + */ +export const SmtpEnvelopeSchema = z.object({ + from: z.string().describe("The envelope MAIL FROM address, empty when not set."), + to: z.array(z.string()).describe("The envelope RCPT TO addresses."), +}); +export type SmtpEnvelope = z.infer; + +export const SmtpSendResultSchema = z.object({ + messageId: z.string().describe("The Message-ID assigned to the sent message."), + accepted: z.array(z.string()).describe("Recipient addresses the SMTP server accepted."), + rejected: z.array(z.string()).describe("Recipient addresses the SMTP server rejected."), + response: z.string().describe("The last SMTP response string from the server."), + envelope: SmtpEnvelopeSchema.describe("The SMTP envelope actually used for delivery."), +}); +export type SmtpSendResult = z.infer; + +@Identifier("SMTP_ENVELOPE") +@Name({ code: "en-US", content: "Email envelope" }) +@DisplayMessage({ code: "en-US", content: "Email envelope" }) +@Schema(SmtpEnvelopeSchema) +export class SmtpEnvelopeDataType {} + +@Identifier("SMTP_SEND_RESULT") +@Name({ code: "en-US", content: "Email send result" }) +@DisplayMessage({ code: "en-US", content: "Email send result" }) +@Schema(SmtpSendResultSchema) +export class SmtpSendResultDataType {} diff --git a/actions/smtp-action/src/functions/sendEmailFunction.ts b/actions/smtp-action/src/functions/sendEmailFunction.ts new file mode 100644 index 0000000..f64becd --- /dev/null +++ b/actions/smtp-action/src/functions/sendEmailFunction.ts @@ -0,0 +1,99 @@ +import { + Description, + DisplayIcon, + DisplayMessage, + Documentation, + FunctionContext, + Identifier, + Name, + Parameter, + RuntimeError, + Signature, +} from "@code0-tech/hercules"; +import { sendEmail } from "../helpers.js"; +import { SmtpSendResult } from "../data_types/smtpSendResult.js"; + +@Identifier("sendEmail") +@DisplayIcon("tabler:mail") +@Signature("(To: string, Subject: string, Text: string, Html?: string, From?: string, Cc?: string, Bcc?: string, ReplyTo?: string): SMTP_SEND_RESULT") +@Name({ code: "en-US", content: "Send email" }) +@DisplayMessage({ code: "en-US", content: "Send email to ${To}" }) +@Documentation({ + code: "en-US", + content: + "Sends an email through the configured SMTP server using nodemailer.\nProvide `Html` to send a rich body alongside the plain text, and `From` to override the action's default sender. `To`, `Cc`, and `Bcc` accept comma-separated address lists.", +}) +@Description({ + code: "en-US", + content: "Sends an email through the configured SMTP server.", +}) +@Parameter({ + runtimeName: "To", + name: [{ code: "en-US", content: "To" }], + description: [{ code: "en-US", content: "Comma-separated list of recipient email addresses." }], +}) +@Parameter({ + runtimeName: "Subject", + name: [{ code: "en-US", content: "Subject" }], + description: [{ code: "en-US", content: "The subject line of the email." }], +}) +@Parameter({ + runtimeName: "Text", + name: [{ code: "en-US", content: "Text body" }], + description: [{ code: "en-US", content: "The plain-text body of the email." }], +}) +@Parameter({ + runtimeName: "Html", + name: [{ code: "en-US", content: "HTML body" }], + description: [{ code: "en-US", content: "Optional HTML body. When set it is sent alongside the plain-text body." }], + optional: true, +}) +@Parameter({ + runtimeName: "From", + name: [{ code: "en-US", content: "From" }], + description: [{ code: "en-US", content: "The sender address. Falls back to the configured default sender when omitted." }], + optional: true, +}) +@Parameter({ + runtimeName: "Cc", + name: [{ code: "en-US", content: "CC" }], + description: [{ code: "en-US", content: "Comma-separated list of CC recipients." }], + optional: true, +}) +@Parameter({ + runtimeName: "Bcc", + name: [{ code: "en-US", content: "BCC" }], + description: [{ code: "en-US", content: "Comma-separated list of BCC recipients." }], + optional: true, +}) +@Parameter({ + runtimeName: "ReplyTo", + name: [{ code: "en-US", content: "Reply-To" }], + description: [{ code: "en-US", content: "The Reply-To address for the email." }], + optional: true, +}) +export class SendEmailFunction { + async run( + context: FunctionContext, + To: string, + Subject: string, + Text: string, + Html?: string, + From?: string, + Cc?: string, + Bcc?: string, + ReplyTo?: string + ): Promise { + try { + return await sendEmail({ To, Subject, Text, Html, From, Cc, Bcc, ReplyTo }, context); + } catch (error) { + if (error instanceof RuntimeError) { + throw error; + } + if (error instanceof Error) { + throw new RuntimeError("ERROR_SENDING_EMAIL", error.message); + } + throw new RuntimeError("ERROR_SENDING_EMAIL", "An error occurred while sending the email."); + } + } +} diff --git a/actions/smtp-action/src/functions/sendEmailWithAttachmentsFunction.ts b/actions/smtp-action/src/functions/sendEmailWithAttachmentsFunction.ts new file mode 100644 index 0000000..6fa99b5 --- /dev/null +++ b/actions/smtp-action/src/functions/sendEmailWithAttachmentsFunction.ts @@ -0,0 +1,102 @@ +import { + Description, + DisplayIcon, + DisplayMessage, + Documentation, + FunctionContext, + Identifier, + Name, + Parameter, + RuntimeError, + Signature, +} from "@code0-tech/hercules"; +import { sendEmail } from "../helpers.js"; +import { SmtpSendResult } from "../data_types/smtpSendResult.js"; +import { SmtpAttachment } from "../data_types/smtpAttachment.js"; + +@Identifier("sendEmailWithAttachments") +@DisplayIcon("tabler:mail") +@Signature("(To: string, Subject: string, Text: string, Attachments: SMTP_ATTACHMENT, Html?: string, From?: string, Cc?: string, Bcc?: string): SMTP_SEND_RESULT") +@Name({ code: "en-US", content: "Send email with attachments" }) +@DisplayMessage({ code: "en-US", content: "Send email with attachments to ${To}" }) +@Documentation({ + code: "en-US", + content: + "Sends an email with one or more attachments through the configured SMTP server.\nBuild each attachment with the `createAttachment` function. `To`, `Cc`, and `Bcc` accept comma-separated address lists.", +}) +@Description({ + code: "en-US", + content: "Sends an email with attachments through the configured SMTP server.", +}) +@Parameter({ + runtimeName: "To", + name: [{ code: "en-US", content: "To" }], + description: [{ code: "en-US", content: "Comma-separated list of recipient email addresses." }], +}) +@Parameter({ + runtimeName: "Subject", + name: [{ code: "en-US", content: "Subject" }], + description: [{ code: "en-US", content: "The subject line of the email." }], +}) +@Parameter({ + runtimeName: "Text", + name: [{ code: "en-US", content: "Text body" }], + description: [{ code: "en-US", content: "The plain-text body of the email." }], +}) +@Parameter({ + runtimeName: "Attachments", + name: [{ code: "en-US", content: "Attachments" }], + description: [{ code: "en-US", content: "One or more attachments built with the createAttachment function." }], +}) +@Parameter({ + runtimeName: "Html", + name: [{ code: "en-US", content: "HTML body" }], + description: [{ code: "en-US", content: "Optional HTML body. When set it is sent alongside the plain-text body." }], + optional: true, +}) +@Parameter({ + runtimeName: "From", + name: [{ code: "en-US", content: "From" }], + description: [{ code: "en-US", content: "The sender address. Falls back to the configured default sender when omitted." }], + optional: true, +}) +@Parameter({ + runtimeName: "Cc", + name: [{ code: "en-US", content: "CC" }], + description: [{ code: "en-US", content: "Comma-separated list of CC recipients." }], + optional: true, +}) +@Parameter({ + runtimeName: "Bcc", + name: [{ code: "en-US", content: "BCC" }], + description: [{ code: "en-US", content: "Comma-separated list of BCC recipients." }], + optional: true, +}) +export class SendEmailWithAttachmentsFunction { + async run( + context: FunctionContext, + To: string, + Subject: string, + Text: string, + Attachments: SmtpAttachment[], + Html?: string, + From?: string, + Cc?: string, + Bcc?: string + ): Promise { + try { + return await sendEmail( + { To, Subject, Text, Html, From, Cc, Bcc, Attachments: Attachments ?? [] }, + context + ); + } catch (error) { + if (error instanceof RuntimeError) { + throw error; + } + if (error instanceof Error) { + throw new RuntimeError("ERROR_SENDING_EMAIL", error.message); + } + throw new RuntimeError("ERROR_SENDING_EMAIL", "An error occurred while sending the email."); + } + } +} diff --git a/actions/smtp-action/src/functions/utils/createAttachmentFunction.ts b/actions/smtp-action/src/functions/utils/createAttachmentFunction.ts new file mode 100644 index 0000000..7647d65 --- /dev/null +++ b/actions/smtp-action/src/functions/utils/createAttachmentFunction.ts @@ -0,0 +1,64 @@ +import { + Description, + DisplayIcon, + DisplayMessage, + Documentation, + Identifier, + Name, + Parameter, + Signature, +} from "@code0-tech/hercules"; +import { SmtpAttachment } from "../../data_types/smtpAttachment.js"; + +@Identifier("createAttachment") +@DisplayIcon("tabler:paperclip") +@Signature("(Filename: string, Content: string, ContentType?: string, Encoding?: string): SMTP_ATTACHMENT") +@Name({ code: "en-US", content: "Create email attachment" }) +@DisplayMessage({ code: "en-US", content: "Create email attachment ${Filename}" }) +@Documentation({ + code: "en-US", + content: + "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.", +}) +@Description({ + code: "en-US", + content: "Creates an email attachment object.", +}) +@Parameter({ + runtimeName: "Filename", + name: [{ code: "en-US", content: "File name" }], + description: [{ code: "en-US", content: "The file name shown to the recipient, e.g. \"invoice.pdf\"." }], +}) +@Parameter({ + runtimeName: "Content", + name: [{ code: "en-US", content: "Content" }], + description: [{ code: "en-US", content: "The attachment content as a string. Interpreted using the encoding." }], +}) +@Parameter({ + runtimeName: "ContentType", + name: [{ code: "en-US", content: "Content type" }], + description: [{ code: "en-US", content: "The MIME type, e.g. \"application/pdf\". Derived from the file name when omitted." }], + optional: true, +}) +@Parameter({ + runtimeName: "Encoding", + name: [{ code: "en-US", content: "Encoding" }], + description: [{ code: "en-US", content: "How to decode content, e.g. \"base64\" or \"utf-8\". Defaults to utf-8." }], + optional: true, +}) +export class CreateAttachmentFunction { + run( + _context: unknown, + Filename: string, + Content: string, + ContentType?: string, + Encoding?: string + ): SmtpAttachment { + return { + filename: Filename, + content: Content, + contentType: ContentType, + encoding: Encoding, + }; + } +} diff --git a/actions/smtp-action/src/helpers.ts b/actions/smtp-action/src/helpers.ts new file mode 100644 index 0000000..eb8401b --- /dev/null +++ b/actions/smtp-action/src/helpers.ts @@ -0,0 +1,150 @@ +import { FunctionContext, RuntimeError } from "@code0-tech/hercules"; +import nodemailer, { type Transporter } from "nodemailer"; +import { z } from "zod"; +import { SmtpAttachmentSchema } from "./data_types/smtpAttachment.js"; +import { SmtpSendResult, SmtpSendResultSchema } from "./data_types/smtpSendResult.js"; + +/** + * Request payload accepted by {@link sendEmail}. Recipient fields (To/Cc/Bcc) + * are comma-separated address lists so they can be passed as plain strings from + * a flow. + */ +export const SendEmailRequestDataSchema = z.object({ + To: z.string().describe("Comma-separated list of recipient email addresses."), + Subject: z.string().describe("The subject line of the email."), + Text: z.string().describe("The plain-text body of the email."), + Html: z.string().optional().describe("Optional HTML body. When set it is sent alongside the plain-text body."), + From: z.string().optional().describe("The sender address. Falls back to the configured default sender when omitted."), + Cc: z.string().optional().describe("Comma-separated list of CC recipients."), + Bcc: z.string().optional().describe("Comma-separated list of BCC recipients."), + ReplyTo: z.string().optional().describe("The Reply-To address for the email."), + Attachments: z.array(SmtpAttachmentSchema).optional().describe("Files to attach to the email."), +}); +export type SendEmailRequestData = z.infer; + +/** + * Splits a comma-separated address string into a trimmed, non-empty array. + */ +function toAddressList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +/** + * Builds a nodemailer transport from the action configuration. Mirrors the + * per-call client construction used by the twilio action; the transport itself + * is cheap to create and pools connections internally. + */ +function getTransport(context: FunctionContext): Transporter { + const host = context.matchedConfig.findConfig("host") as string; + if (!host) { + throw new RuntimeError( + "MISSING_SMTP_HOST", + "No SMTP host configured. Set the host config for this action." + ); + } + + const portValue = context.matchedConfig.findConfig("port") as string | undefined; + const port = portValue ? Number(portValue) : 587; + if (Number.isNaN(port)) { + throw new RuntimeError("INVALID_SMTP_PORT", `The configured SMTP port "${portValue}" is not a number.`); + } + + const secure = String(context.matchedConfig.findConfig("secure") ?? "").toLowerCase() === "true"; + const user = context.matchedConfig.findConfig("username") as string | undefined; + const pass = context.matchedConfig.findConfig("password") as string | undefined; + + return nodemailer.createTransport({ + host, + port, + secure, + ...(user || pass ? { auth: { user: user ?? "", pass: pass ?? "" } } : {}), + }); +} + +/** + * Resolves the sender address, preferring an explicit From over the configured + * default. Throws when neither is available. + */ +function resolveFrom(context: FunctionContext, from?: string): string { + const resolved = from || ((context.matchedConfig.findConfig("from_address") as string) ?? ""); + if (!resolved) { + throw new RuntimeError( + "MISSING_SMTP_SENDER", + "No sender provided. Pass a From value or configure a default from_address for the action." + ); + } + return resolved; +} + +/** + * nodemailer types accepted/rejected/envelope addresses as `string | Address`. + * Normalize any of those shapes to a plain email string. + */ +function normalizeAddress(address: unknown): string { + if (typeof address === "string") return address; + if (address && typeof address === "object" && "address" in address) { + return String((address as { address: unknown }).address ?? ""); + } + return String(address ?? ""); +} + +/** + * Sends an email through the configured SMTP server using nodemailer and maps + * the result onto the SMTP_SEND_RESULT data type. + */ +export const sendEmail = async ( + data: SendEmailRequestData, + context: FunctionContext +): Promise => { + const parsed = SendEmailRequestDataSchema.parse(data); + const transport = getTransport(context); + const from = resolveFrom(context, parsed.From); + + try { + const info = await transport.sendMail({ + from, + to: toAddressList(parsed.To), + cc: toAddressList(parsed.Cc), + bcc: toAddressList(parsed.Bcc), + replyTo: parsed.ReplyTo, + subject: parsed.Subject, + text: parsed.Text, + ...(parsed.Html ? { html: parsed.Html } : {}), + ...(parsed.Attachments && parsed.Attachments.length > 0 + ? { + attachments: parsed.Attachments.map((attachment) => ({ + filename: attachment.filename, + content: attachment.content, + contentType: attachment.contentType, + encoding: attachment.encoding, + })), + } + : {}), + }); + + const envelope = (info.envelope ?? {}) as { from?: unknown; to?: unknown }; + + return SmtpSendResultSchema.parse({ + messageId: info.messageId ?? "", + accepted: (info.accepted ?? []).map(normalizeAddress), + rejected: (info.rejected ?? []).map(normalizeAddress), + response: info.response ?? "", + envelope: { + from: normalizeAddress(envelope.from), + to: Array.isArray(envelope.to) ? envelope.to.map(normalizeAddress) : [normalizeAddress(envelope.to)].filter((a) => a.length > 0), + }, + }); + } catch (error: unknown) { + if (error instanceof RuntimeError) { + throw error; + } + if (error instanceof Error) { + throw new RuntimeError("ERROR_SENDING_EMAIL", error.message); + } + throw new RuntimeError("ERROR_SENDING_EMAIL", "An error occurred while sending the email."); + } +}; diff --git a/actions/smtp-action/src/index.ts b/actions/smtp-action/src/index.ts new file mode 100644 index 0000000..191a8eb --- /dev/null +++ b/actions/smtp-action/src/index.ts @@ -0,0 +1,100 @@ +import "reflect-metadata"; +import { Action, CodeZeroEvent } from "@code0-tech/hercules"; + +import { SmtpAttachmentDataType } from "./data_types/smtpAttachment.js"; +import { SmtpEnvelopeDataType, SmtpSendResultDataType } from "./data_types/smtpSendResult.js"; +import { SendEmailFunction } from "./functions/sendEmailFunction.js"; +import { SendEmailWithAttachmentsFunction } from "./functions/sendEmailWithAttachmentsFunction.js"; +import { CreateAttachmentFunction } from "./functions/utils/createAttachmentFunction.js"; + +const action = new Action( + process.env.ACTION_ID ?? "smtp-action", + process.env.VERSION ?? "1.0.0", + process.env.AQUILA_URL ?? "127.0.0.1:8081", + "code0-tech", + "tabler:mail", + "SMTP integration: send transactional and notification emails (with attachments) through any SMTP server via nodemailer.", + [{ code: "en-US", content: "SMTP" }], + [ + { + identifier: "host", + type: "TEXT", + name: [{ code: "en-US", content: "SMTP host" }], + description: [{ code: "en-US", content: "The hostname of the SMTP server, e.g. smtp.example.com." }], + linkedDataTypes: ["TEXT"], + }, + { + identifier: "port", + type: "TEXT", + defaultValue: "587", + name: [{ code: "en-US", content: "SMTP port" }], + description: [{ code: "en-US", content: "The port of the SMTP server. Common values are 587 (STARTTLS) and 465 (implicit TLS)." }], + linkedDataTypes: ["TEXT"], + }, + { + identifier: "secure", + type: "TEXT", + defaultValue: "false", + name: [{ code: "en-US", content: "Use implicit TLS" }], + description: [{ code: "en-US", content: "Set to \"true\" to connect using implicit TLS (typically port 465). Use \"false\" for STARTTLS on port 587." }], + linkedDataTypes: ["TEXT"], + }, + { + identifier: "username", + type: "TEXT", + defaultValue: "", + name: [{ code: "en-US", content: "Username" }], + description: [{ code: "en-US", content: "The username used to authenticate with the SMTP server. Leave empty for unauthenticated servers." }], + linkedDataTypes: ["TEXT"], + }, + { + identifier: "password", + type: "TEXT", + defaultValue: "", + name: [{ code: "en-US", content: "Password" }], + description: [{ code: "en-US", content: "The password used to authenticate with the SMTP server." }], + linkedDataTypes: ["TEXT"], + }, + { + identifier: "from_address", + type: "TEXT", + defaultValue: "", + name: [{ code: "en-US", content: "Default sender" }], + description: [{ code: "en-US", content: "The default From address used when an email does not specify a From value, e.g. \"Acme \"." }], + linkedDataTypes: ["TEXT"], + }, + ] +); + +action.registerDataTypeClass(SmtpAttachmentDataType); +action.registerDataTypeClass(SmtpEnvelopeDataType); +action.registerDataTypeClass(SmtpSendResultDataType); + +action.registerRuntimeFunction(SendEmailFunction); +action.registerRuntimeFunction(SendEmailWithAttachmentsFunction); +action.registerRuntimeFunction(CreateAttachmentFunction); + +action.on(CodeZeroEvent.connected, () => { + console.log("Connected to aquila"); +}); + +action.on(CodeZeroEvent.error, (error: Error) => { + console.error("Stream error:", error.message); + console.log("Attempting to reconnect in 5s..."); + setTimeout(() => { + action.connect(process.env.AUTH_TOKEN ?? "your_auth_token_here").catch((err: Error) => { + action.emit(CodeZeroEvent.error, err); + }); + }, 5000); +}); + +action.connect(process.env.AUTH_TOKEN ?? "your_auth_token_here").catch((err: Error) => { + action.emit(CodeZeroEvent.error, err); +}); + +action.on(CodeZeroEvent.moduleUpdated, (message: any) => { + console.dir(message, { depth: null }); + console.dir(action.configs.values(), { depth: null }); +}); + +export { action }; diff --git a/actions/smtp-action/test/index.test.ts b/actions/smtp-action/test/index.test.ts new file mode 100644 index 0000000..2b46e56 --- /dev/null +++ b/actions/smtp-action/test/index.test.ts @@ -0,0 +1,60 @@ +import "reflect-metadata"; +import { describe, expect, it } from "vitest"; +import { SendEmailRequestDataSchema } from "../src/helpers.js"; +import { SmtpSendResultSchema } from "../src/data_types/smtpSendResult.js"; +import { SmtpAttachmentSchema } from "../src/data_types/smtpAttachment.js"; +import { CreateAttachmentFunction } from "../src/functions/utils/createAttachmentFunction.js"; + +describe("SendEmailRequestDataSchema", () => { + it("requires To, Subject and Text", () => { + expect(() => SendEmailRequestDataSchema.parse({ Subject: "hi", Text: "body" })).toThrow(); + expect(() => SendEmailRequestDataSchema.parse({ To: "a@example.com", Text: "body" })).toThrow(); + expect(() => SendEmailRequestDataSchema.parse({ To: "a@example.com", Subject: "hi" })).toThrow(); + }); + + it("accepts optional Html, From, Cc, Bcc, ReplyTo and Attachments", () => { + const parsed = SendEmailRequestDataSchema.parse({ + To: "a@example.com, b@example.com", + Subject: "hi", + Text: "body", + Html: "

body

", + From: "no-reply@example.com", + Cc: "c@example.com", + Bcc: "d@example.com", + ReplyTo: "reply@example.com", + Attachments: [{ filename: "note.txt", content: "hello" }], + }); + expect(parsed.To).toEqual("a@example.com, b@example.com"); + expect(parsed.Attachments).toHaveLength(1); + }); +}); + +describe("SmtpSendResultSchema", () => { + it("parses a representative send result", () => { + const result = { + messageId: "", + accepted: ["a@example.com"], + rejected: [], + response: "250 2.0.0 OK", + envelope: { from: "no-reply@example.com", to: ["a@example.com"] }, + }; + const parsed = SmtpSendResultSchema.parse(result); + expect(parsed.messageId).toEqual(result.messageId); + expect(parsed.accepted).toEqual(["a@example.com"]); + }); +}); + +describe("CreateAttachmentFunction", () => { + it("builds a valid SMTP_ATTACHMENT object", () => { + const attachment = new CreateAttachmentFunction().run( + undefined, + "invoice.pdf", + "JVBERi0=", + "application/pdf", + "base64" + ); + expect(() => SmtpAttachmentSchema.parse(attachment)).not.toThrow(); + expect(attachment.filename).toEqual("invoice.pdf"); + expect(attachment.encoding).toEqual("base64"); + }); +}); diff --git a/actions/smtp-action/tsconfig.json b/actions/smtp-action/tsconfig.json new file mode 100644 index 0000000..65d780f --- /dev/null +++ b/actions/smtp-action/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "baseUrl": ".", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "types": ["node", "vite/client"] + }, + "include": [ + "src/**/*", + "test/**/*", + "vite.config.ts" + ] +} diff --git a/actions/smtp-action/vite.config.ts b/actions/smtp-action/vite.config.ts new file mode 100644 index 0000000..dc45894 --- /dev/null +++ b/actions/smtp-action/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + build: { + target: "node18", + ssr: resolve(__dirname, 'src/index.ts'), + rollupOptions: { + external: (id) => + [ + 'fs', + 'path', + 'typescript', + '@code0-tech/hercules' + ].includes(id) || id.startsWith('node:') + } + } +});