Skip to content

Commit d155c11

Browse files
feat(): introduce Email plugin
1 parent 9f073bc commit d155c11

39 files changed

+3049
-232
lines changed

.devcontainer/Dockerfile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ ENV HOME="/root"
1414
# --------------------------------------
1515
# Git
1616
# --------------------------------------
17-
# Need to add the devcontainer workspace folder as a safe directory to enable git
17+
# Need to add the devcontainer workspace folder as a safe directory to enable git
1818
# version control system to be enabled in the containers file system.
19-
RUN git config --global --add safe.directory "/workspaces/plugin-template"
19+
RUN git config --global --add safe.directory "/workspaces/plugin-email"
2020
# --------------------------------------
2121

2222
# --------------------------------------
@@ -53,11 +53,11 @@ ENV PATH="$PATH:$JAVA_HOME/bin"
5353
# Will load a custom configuration file for Micronaut
5454
ENV MICRONAUT_ENVIRONMENTS=local,override
5555
# Sets the path where you save plugins as Jar and is loaded during the startup process
56-
ENV KESTRA_PLUGINS_PATH="/workspaces/plugin-template/local/plugins"
56+
ENV KESTRA_PLUGINS_PATH="/workspaces/plugin-email/local/plugins"
5757
# --------------------------------------
5858

5959
# --------------------------------------
60-
# SSH
60+
# SSH
6161
# --------------------------------------
6262
RUN mkdir -p ~/.ssh
6363
RUN touch ~/.ssh/config

.devcontainer/devcontainer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
2-
"name": "plugin-template",
2+
"name": "plugin-email",
33
"build": {
44
"context": ".",
55
"dockerfile": "Dockerfile"
66
},
7-
"workspaceFolder": "/workspaces/plugin-template",
7+
"workspaceFolder": "/workspaces/plugin-email",
88
"forwardPorts": [8080],
99
"customizations": {
1010
"vscode": {

build.gradle

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ java {
2929
}
3030

3131
group = "io.kestra.plugin"
32-
description = 'Plugin template for Kestra'
32+
description = 'Plugin Email for Kestra'
3333

3434
tasks.withType(JavaCompile).configureEach {
3535
options.encoding = "UTF-8"
@@ -49,6 +49,10 @@ dependencies {
4949
annotationProcessor group: "io.kestra", name: "processor", version: kestraVersion
5050
compileOnly group: "io.kestra", name: "core", version: kestraVersion
5151
compileOnly group: "io.kestra", name: "script", version: kestraVersion
52+
53+
// libs
54+
api 'org.simplejavamail:simple-java-mail:8.12.6'
55+
api 'org.eclipse.angus:jakarta.mail:2.0.5'
5256
}
5357

5458

@@ -91,11 +95,15 @@ dependencies {
9195
testImplementation group: "io.kestra", name: "repository-memory", version: kestraVersion
9296
testImplementation group: "io.kestra", name: "runner-memory", version: kestraVersion
9397
testImplementation group: "io.kestra", name: "storage-local", version: kestraVersion
98+
testImplementation group: "io.kestra", name: "scheduler", version: kestraVersion
99+
testImplementation group: "io.kestra", name: "worker", version: kestraVersion
94100

95101
// test
96102
testImplementation "org.junit.jupiter:junit-jupiter-engine"
97103
testImplementation "org.hamcrest:hamcrest"
98104
testImplementation "org.hamcrest:hamcrest-library"
105+
106+
testImplementation group: 'com.icegreen', name: 'greenmail-junit5', version: '2.1.7'
99107
}
100108

101109
/**********************************************************************************************************************\
@@ -175,8 +183,8 @@ jar {
175183
manifest {
176184
attributes(
177185
"X-Kestra-Name": project.name,
178-
"X-Kestra-Title": "Template",
179-
"X-Kestra-Group": project.group + ".templates",
186+
"X-Kestra-Title": "Email",
187+
"X-Kestra-Group": project.group + ".email",
180188
"X-Kestra-Description": project.description,
181189
"X-Kestra-Version": project.version
182190
)

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
version=1.1.0-SNAPSHOT
2-
kestraVersion=1.0.0
2+
kestraVersion=1.1.11

settings.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
rootProject.name = 'plugin-template'
1+
rootProject.name = 'plugin-email'
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package io.kestra.plugin.email;
2+
3+
import io.kestra.core.models.property.Property;
4+
import io.kestra.core.models.triggers.AbstractTrigger;
5+
import io.kestra.core.runners.RunContext;
6+
import io.swagger.v3.oas.annotations.media.Schema;
7+
import jakarta.validation.constraints.NotNull;
8+
import lombok.Builder;
9+
import lombok.Getter;
10+
import lombok.NoArgsConstructor;
11+
import lombok.experimental.SuperBuilder;
12+
13+
import java.time.Duration;
14+
15+
16+
@SuperBuilder
17+
@Getter
18+
@NoArgsConstructor
19+
public abstract class AbstractMailTrigger extends AbstractTrigger {
20+
21+
public Duration getInterval(){
22+
return Duration.ofSeconds(60);
23+
}
24+
@Schema(title = "Mail server protocol", description = "The protocol to use for connecting to the mail server")
25+
@Builder.Default
26+
protected final Property<MailService.Protocol> protocol = Property.ofValue(MailService.Protocol.IMAP);
27+
28+
@Schema(title = "Mail server host", description = "The hostname or IP address of the mail server")
29+
@NotNull
30+
protected Property<String> host;
31+
32+
@Schema(title = "Mail server port", description = "The port number of the mail server. Defaults: IMAP=993 (SSL), 143 (non-SSL); POP3=995 (SSL), 110 (non-SSL)")
33+
protected Property<Integer> port;
34+
35+
@Schema(title = "Username", description = "The username for authentication")
36+
@NotNull
37+
protected Property<String> username;
38+
39+
@Schema(title = "Password", description = "The password for authentication")
40+
@NotNull
41+
protected Property<String> password;
42+
43+
@Schema(title = "Mail folder", description = "The mail folder to monitor (IMAP only)")
44+
@Builder.Default
45+
protected final Property<String> folder = Property.ofValue("INBOX");
46+
47+
@Schema(title = "Use SSL", description = "Whether to use SSL/TLS encryption")
48+
@Builder.Default
49+
protected final Property<Boolean> ssl = Property.ofValue(true);
50+
51+
@Schema(title = "Trust all certificates", description = "Whether to trust all SSL certificates (use with caution)")
52+
@Builder.Default
53+
protected final Property<Boolean> trustAllCertificates = Property.ofValue(false);
54+
55+
@Schema(title = "Check interval", description = "How frequently to check for new emails")
56+
@Builder.Default
57+
protected final Property<Duration> interval = Property.ofValue(Duration.ofSeconds(60));
58+
59+
protected MailService.MailConfiguration renderMailConfiguration(RunContext runContext) throws Exception {
60+
String rProtocol = String.valueOf(runContext.render(this.protocol).as(MailService.Protocol.class).orElseThrow());
61+
String rHost = runContext.render(this.host).as(String.class).orElseThrow();
62+
String rUsername = runContext.render(this.username).as(String.class).orElseThrow();
63+
String rPassword = runContext.render(this.password).as(String.class).orElseThrow();
64+
String rFolder = runContext.render(this.folder).as(String.class).orElse("INBOX");
65+
Boolean rSsl = runContext.render(this.ssl).as(Boolean.class).orElse(true);
66+
Boolean rTrustAllCertificates = runContext.render(this.trustAllCertificates).as(Boolean.class).orElse(false);
67+
Duration rInterval = runContext.render(this.interval).as(Duration.class).orElse(getInterval());
68+
69+
Integer rPort = runContext.render(this.port).as(Integer.class)
70+
.orElse(MailService.getDefaultPort(MailService.Protocol.valueOf(rProtocol), rSsl));
71+
72+
return new MailService.MailConfiguration(rProtocol, rHost, rPort, rUsername, rPassword, rFolder, rSsl, rTrustAllCertificates, rInterval);
73+
}
74+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package io.kestra.plugin.email;
2+
3+
import io.kestra.core.models.annotations.Example;
4+
import io.kestra.core.models.annotations.Plugin;
5+
import io.kestra.core.models.property.Property;
6+
import io.kestra.core.models.tasks.VoidOutput;
7+
import io.kestra.core.plugins.notifications.ExecutionInterface;
8+
import io.kestra.core.plugins.notifications.ExecutionService;
9+
import io.kestra.core.runners.RunContext;
10+
import io.swagger.v3.oas.annotations.media.Schema;
11+
import lombok.*;
12+
import lombok.experimental.SuperBuilder;
13+
14+
import java.util.Map;
15+
16+
@SuperBuilder
17+
@ToString
18+
@EqualsAndHashCode
19+
@Getter
20+
@NoArgsConstructor
21+
@Schema(
22+
title = "Send an email with the execution information.",
23+
description = "The message will include a link to the execution page in the UI along with the execution ID, namespace, flow name, the start date, duration, and the final status of the execution. If the task failed, then the task that led to the failure is specified.\n\n" +
24+
"Use this notification task only in a flow that has a [Flow trigger](https://kestra.io/docs/administrator-guide/monitoring#alerting), as shown in this example. Don't use this notification task in `errors` tasks. Instead, for `errors` tasks, use the [MailSend](https://kestra.io/plugins/plugin-notifications/tasks/mail/io.kestra.plugin.email.mailsend) task."
25+
)
26+
@Plugin(
27+
examples = {
28+
@Example(
29+
title = "Send an email notification on a failed flow execution",
30+
full = true,
31+
code = """
32+
id: failure_alert
33+
namespace: company.team
34+
35+
tasks:
36+
- id: send_alert
37+
type: io.kestra.plugin.email.MailExecution
38+
to: hello@kestra.io
39+
from: hello@kestra.io
40+
subject: "The workflow execution {{trigger.executionId}} failed for the flow {{trigger.flowId}} in the namespace {{trigger.namespace}}"
41+
host: mail.privateemail.com
42+
port: 465
43+
username: "{{ secret('EMAIL_USERNAME') }}"
44+
password: "{{ secret('EMAIL_PASSWORD') }}"
45+
executionId: "{{ trigger.executionId }}"
46+
47+
triggers:
48+
- id: failed_prod_workflows
49+
type: io.kestra.plugin.core.trigger.Flow
50+
conditions:
51+
- type: io.kestra.plugin.core.condition.ExecutionStatus
52+
in:
53+
- FAILED
54+
- WARNING
55+
- type: io.kestra.plugin.core.condition.ExecutionNamespace
56+
namespace: prod
57+
prefix: true
58+
"""
59+
)
60+
}
61+
)
62+
public class MailExecution extends MailTemplate implements ExecutionInterface {
63+
@Builder.Default
64+
private final Property<String> executionId = Property.ofExpression("{{ execution.id }}");
65+
private Property<Map<String, Object>> customFields;
66+
private Property<String> customMessage;
67+
68+
@Schema(
69+
hidden = true
70+
)
71+
protected Property<String> htmlTextContent;
72+
73+
@Override
74+
public VoidOutput run(RunContext runContext) throws Exception {
75+
this.templateUri = Property.ofValue("mail-template.hbs.peb");
76+
this.textTemplateUri = Property.ofValue("text-template.hbs.peb");
77+
this.templateRenderMap = Property.ofValue(ExecutionService.executionMap(runContext, this));
78+
79+
return super.run(runContext);
80+
}
81+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package io.kestra.plugin.email;
2+
3+
import io.kestra.core.models.annotations.Example;
4+
import io.kestra.core.models.annotations.Plugin;
5+
import io.kestra.core.models.conditions.ConditionContext;
6+
import io.kestra.core.models.executions.Execution;
7+
import io.kestra.core.models.triggers.PollingTriggerInterface;
8+
import io.kestra.core.models.triggers.TriggerContext;
9+
import io.kestra.core.models.triggers.TriggerOutput;
10+
import io.kestra.core.models.triggers.TriggerService;
11+
import io.kestra.core.runners.RunContext;
12+
import io.swagger.v3.oas.annotations.media.Schema;
13+
import lombok.EqualsAndHashCode;
14+
import lombok.Getter;
15+
import lombok.NoArgsConstructor;
16+
import lombok.ToString;
17+
import lombok.experimental.SuperBuilder;
18+
19+
import java.time.Duration;
20+
import java.time.ZonedDateTime;
21+
import java.util.Comparator;
22+
import java.util.List;
23+
import java.util.Optional;
24+
25+
@SuperBuilder
26+
@ToString
27+
@EqualsAndHashCode
28+
@Getter
29+
@NoArgsConstructor
30+
@Schema(
31+
title = "Trigger on new email messages.",
32+
description = "Monitor a mailbox for new emails via IMAP or POP3 protocols."
33+
)
34+
@Plugin(
35+
examples = {
36+
@Example(
37+
title = "Monitor Gmail inbox for new emails",
38+
full = true,
39+
code = """
40+
id: email_monitor
41+
namespace: company.team
42+
43+
tasks:
44+
- id: process_email
45+
type: io.kestra.core.tasks.log.Log
46+
message: |
47+
New email received:
48+
Subject: {{ trigger.latestEmail.subject }}
49+
From: {{ trigger.latestEmail.from }}
50+
Date: {{ trigger.latestEmail.date }}
51+
52+
triggers:
53+
- id: gmail_inbox_trigger
54+
type: io.kestra.plugin.email.MailReceivedTrigger
55+
protocol: IMAP
56+
host: imap.gmail.com
57+
port: 993
58+
username: "{{ secret('GMAIL_USERNAME') }}"
59+
password: "{{ secret('GMAIL_PASSWORD') }}"
60+
folder: INBOX
61+
interval: PT30S
62+
ssl: true
63+
"""
64+
)
65+
}
66+
)
67+
public class MailReceivedTrigger extends AbstractMailTrigger
68+
implements PollingTriggerInterface, TriggerOutput<MailService.Output> {
69+
70+
@Override
71+
public Optional<Execution> evaluate(ConditionContext conditionContext, TriggerContext context) throws Exception {
72+
RunContext runContext = conditionContext.getRunContext();
73+
MailService.MailConfiguration mailConfig = renderMailConfiguration(runContext);
74+
75+
try {
76+
ZonedDateTime lastCheckTime = getLastCheckTime(context,mailConfig.interval);
77+
List<MailService.EmailData> newEmails = MailService.fetchNewEmails(runContext, mailConfig.protocol,
78+
mailConfig.host, mailConfig.port,
79+
mailConfig.username, mailConfig.password, mailConfig.folder, mailConfig.ssl,
80+
mailConfig.trustAllCertificates, lastCheckTime);
81+
82+
if (newEmails.isEmpty()) {
83+
return Optional.empty();
84+
}
85+
86+
MailService.EmailData latest = newEmails.stream()
87+
.max(Comparator.comparing(MailService.EmailData::getDate))
88+
.orElse(newEmails.getFirst());
89+
90+
MailService.Output output = MailService.Output.builder()
91+
.latestEmail(latest)
92+
.total(newEmails.size())
93+
.emails(newEmails)
94+
.build();
95+
96+
Execution execution = TriggerService.generateExecution(this, conditionContext, context, output);
97+
return Optional.of(execution);
98+
99+
} catch (Exception e) {
100+
runContext.logger().error("Error checking for new emails", e);
101+
throw new RuntimeException("Failed to fetch emails: " + e.getMessage(), e);
102+
}
103+
}
104+
105+
private ZonedDateTime getLastCheckTime(TriggerContext context,Duration interval){
106+
if(context.getNextExecutionDate()==null){
107+
return ZonedDateTime.now().minus(getInterval());
108+
}
109+
return context.getNextExecutionDate().minus(interval);
110+
}
111+
}

0 commit comments

Comments
 (0)