Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ include:
- woocommerce-action
- shopware-action
- twilio-action
- smtp-action

test-node:
image: node:24.10.0
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions actions/smtp-action/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.env
node_modules/
dist/
13 changes: 13 additions & 0 deletions actions/smtp-action/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
61 changes: 61 additions & 0 deletions actions/smtp-action/README.md
Original file line number Diff line number Diff line change
@@ -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 <no-reply@example.com>`. |

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
```
26 changes: 26 additions & 0 deletions actions/smtp-action/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
23 changes: 23 additions & 0 deletions actions/smtp-action/src/data_types/smtpAttachment.ts
Original file line number Diff line number Diff line change
@@ -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<typeof SmtpAttachmentSchema>;

@Identifier("SMTP_ATTACHMENT")
@Name({ code: "en-US", content: "Email attachment" })
@DisplayMessage({ code: "en-US", content: "Email attachment ${filename}" })
@Schema(SmtpAttachmentSchema)
export class SmtpAttachmentDataType {}
36 changes: 36 additions & 0 deletions actions/smtp-action/src/data_types/smtpEmailTriggerPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { DisplayMessage, Identifier, Name, Schema } from "@code0-tech/hercules";
import { z } from "zod";

export const SmtpEmailReceivedPayloadSchema = z.object({
from: z.string().describe("The sender address of the received email."),
to: z.array(z.string()).describe("The recipient addresses of the received email."),
subject: z.string().optional().describe("The subject line of the received email."),
text: z.string().optional().describe("The plain-text body of the received email."),
html: z.string().optional().describe("The HTML body of the received email."),
messageId: z.string().optional().describe("The Message-ID of the received email."),
date: z.string().optional().describe("The delivery date of the received email."),
});
export type SmtpEmailReceivedPayload = z.infer<typeof SmtpEmailReceivedPayloadSchema>;

export const SmtpEmailSentPayloadSchema = z.object({
from: z.string().optional().describe("The sender address of the sent email."),
to: z.array(z.string()).describe("The recipient addresses of the sent email."),
subject: z.string().optional().describe("The subject line of the sent email."),
text: z.string().optional().describe("The plain-text body of the sent email."),
html: z.string().optional().describe("The HTML body of the sent email."),
messageId: z.string().optional().describe("The Message-ID of the sent email."),
status: z.string().optional().describe("The delivery status of the sent email."),
});
export type SmtpEmailSentPayload = z.infer<typeof SmtpEmailSentPayloadSchema>;

@Identifier("SMTP_EMAIL_RECEIVED_PAYLOAD")
@Name({ code: "en-US", content: "SMTP email received payload" })
@DisplayMessage({ code: "en-US", content: "SMTP email received payload" })
@Schema(SmtpEmailReceivedPayloadSchema)
export class SmtpEmailReceivedPayloadDataType {}

@Identifier("SMTP_EMAIL_SENT_PAYLOAD")
@Name({ code: "en-US", content: "SMTP email sent payload" })
@DisplayMessage({ code: "en-US", content: "SMTP email sent payload" })
@Schema(SmtpEmailSentPayloadSchema)
export class SmtpEmailSentPayloadDataType {}
35 changes: 35 additions & 0 deletions actions/smtp-action/src/data_types/smtpSendResult.ts
Original file line number Diff line number Diff line change
@@ -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<typeof SmtpEnvelopeSchema>;

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<typeof SmtpSendResultSchema>;

@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 {}
12 changes: 12 additions & 0 deletions actions/smtp-action/src/events/smtpEmailReceivedTrigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Description, DisplayIcon, DisplayMessage, EventSetting, Identifier, Name, Rest, Signature } from "@code0-tech/hercules";

@Identifier("SmtpEmailReceivedTrigger")
@DisplayIcon("tabler:mail")
@Name({ code: "en-US", content: "Email received" })
@Description({ code: "en-US", content: "Triggered when an email is received by the configured SMTP inbox." })
@DisplayMessage({ code: "en-US", content: "Email received from ${from}" })
@Signature("<A extends REST_AUTH_TYPE>(httpSchema: HTTP_SCHEMA, httpURL: HTTP_URL, httpMethod: HTTP_METHOD, httpAuth: A, httpAuthValue: REST_AUTH_VALUE<A>, input_schema?: SMTP_EMAIL_RECEIVED_PAYLOAD): REST_ADAPTER_INPUT<SMTP_EMAIL_RECEIVED_PAYLOAD>")
@EventSetting({ identifier: "input_schema", hidden: true })
@EventSetting({ identifier: "httpMethod", hidden: true, defaultValue: "POST" })
@EventSetting({ identifier: "httpSchema", hidden: true, defaultValue: "application/json" })
export class SmtpEmailReceivedTrigger extends Rest {}
12 changes: 12 additions & 0 deletions actions/smtp-action/src/events/smtpEmailSentTrigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Description, DisplayIcon, DisplayMessage, EventSetting, Identifier, Name, Rest, Signature } from "@code0-tech/hercules";

@Identifier("SmtpEmailSentTrigger")
@DisplayIcon("tabler:mail")
@Name({ code: "en-US", content: "Email sent" })
@Description({ code: "en-US", content: "Triggered when an email is successfully sent through the configured SMTP server." })
@DisplayMessage({ code: "en-US", content: "Email sent to ${to}" })
@Signature("<A extends REST_AUTH_TYPE>(httpSchema: HTTP_SCHEMA, httpURL: HTTP_URL, httpMethod: HTTP_METHOD, httpAuth: A, httpAuthValue: REST_AUTH_VALUE<A>, input_schema?: SMTP_EMAIL_SENT_PAYLOAD): REST_ADAPTER_INPUT<SMTP_EMAIL_SENT_PAYLOAD>")
@EventSetting({ identifier: "input_schema", hidden: true })
@EventSetting({ identifier: "httpMethod", hidden: true, defaultValue: "POST" })
@EventSetting({ identifier: "httpSchema", hidden: true, defaultValue: "application/json" })
export class SmtpEmailSentTrigger extends Rest {}
99 changes: 99 additions & 0 deletions actions/smtp-action/src/functions/sendEmailFunction.ts
Original file line number Diff line number Diff line change
@@ -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("(Recipients: string, Subject: string, Text: string, Html?: string, From?: string, CarbonCopy?: string, Bcc?: string, ReplyTo?: string): SMTP_SEND_RESULT")
@Name({ code: "en-US", content: "Send email" })
@DisplayMessage({ code: "en-US", content: "Send email to ${Recipients}" })
@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. `Recipients`, `CarbonCopy`, and `Bcc` accept comma-separated address lists.",
})
@Description({
code: "en-US",
content: "Sends an email through the configured SMTP server.",
})
@Parameter({
runtimeName: "Recipients",
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: "CarbonCopy",
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,
Recipients: string,
Subject: string,
Text: string,
Html?: string,
From?: string,
CarbonCopy?: string,
Bcc?: string,
ReplyTo?: string
): Promise<SmtpSendResult> {
try {
return await sendEmail({ To:Recipients, Subject, Text, Html, From, Cc: CarbonCopy, 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.");
}
}
}
Loading