Enhanced email action

- Introducing the notion of email account (i.e. smtp account). It is now possible to configure multiple email accounts (in node settings) via which the emails will be sent. The email alert action can be configured with an account name, to indicate which account should be used, if no account is configured, the email will be sent with the _default account_. The default account can also be configured using the `default_account` node setting.
- `InternalEmailService` maintains the email sessions and responsible for sending emails.
- the account settings are dynamic (configurable at runtime)
- `Email` class was introduces to abstract away the email structure (`javax.mail`'s `Message` is not the most intuitive construct to deal with. `Email` enables setting both `text` and `html` content and also support normal and inlined attachments.
- "profiles" were added to support different email message formats. Unfortunately the different email systems don't fully comply to the standards and each has their own way of structuring the mime message (especially when it comes to attachments). The `Profile` enum abstracts this by letting the user define what email system structure it wants to support. we define 4 profiles - `GMAIL`, `MAC`, `OUTLOOK` and `STANDARD`. `STANDARD` is the official way of structuring the mime message according to the different RFC standard (it also serves as the default profile).
- The `EmailAction` only task is to create an `Email` based on the action settings and the payload, and send it via the `EmailService`.

Original commit: elastic/x-pack-elasticsearch@2b893c8127
This commit is contained in:
uboness 2015-02-10 01:06:48 +01:00
parent ec42ec8fdc
commit 70b5d36098
19 changed files with 1635 additions and 322 deletions

View File

@ -36,6 +36,13 @@
<!-- Test dependencies -->
<dependency>
<groupId>org.subethamail</groupId>
<artifactId>subethasmtp</artifactId>
<version>3.1.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>

View File

@ -6,6 +6,7 @@
package org.elasticsearch.alerts;
import org.elasticsearch.alerts.actions.Action;
import org.elasticsearch.alerts.history.FiredAlert;
import org.elasticsearch.alerts.throttle.Throttler;
import org.elasticsearch.alerts.transform.Transform;
import org.elasticsearch.alerts.trigger.Trigger;
@ -19,6 +20,7 @@ import java.util.Map;
*/
public class AlertContext {
private final String runId;
private final Alert alert;
private final DateTime fireTime;
private final DateTime scheduledTime;
@ -30,11 +32,16 @@ public class AlertContext {
private Map<String, Action.Result> actionsResults = new HashMap<>();
public AlertContext(Alert alert, DateTime fireTime, DateTime scheduledTime) {
this.runId = FiredAlert.firedAlertId(alert, scheduledTime);
this.alert = alert;
this.fireTime = fireTime;
this.scheduledTime = scheduledTime;
}
public String runId() {
return runId;
}
public Alert alert() {
return alert;
}

View File

@ -275,7 +275,7 @@ public class AlertsService extends AbstractComponent {
if (state.compareAndSet(State.STOPPED, State.STARTING)) {
ClusterState clusterState = initialState;
// Try to load alert store before the action manager, b/c action depends on alert store
// Try to load alert store before the action service, b/c action depends on alert store
while (true) {
if (alertsStore.start(clusterState)) {
break;

View File

@ -6,7 +6,8 @@
package org.elasticsearch.alerts.actions;
import org.elasticsearch.alerts.actions.email.EmailAction;
import org.elasticsearch.alerts.actions.email.EmailSettingsService;
import org.elasticsearch.alerts.actions.email.service.EmailService;
import org.elasticsearch.alerts.actions.email.service.InternalEmailService;
import org.elasticsearch.alerts.actions.index.IndexAction;
import org.elasticsearch.alerts.actions.webhook.HttpClient;
import org.elasticsearch.alerts.actions.webhook.WebhookAction;
@ -39,7 +40,6 @@ public class ActionModule extends AbstractModule {
bind(IndexAction.Parser.class).asEagerSingleton();
parsersBinder.addBinding(IndexAction.TYPE).to(IndexAction.Parser.class);
for (Map.Entry<String, Class<? extends Action.Parser>> entry : parsers.entrySet()) {
bind(entry.getValue()).asEagerSingleton();
parsersBinder.addBinding(entry.getKey()).to(entry.getValue());
@ -47,7 +47,7 @@ public class ActionModule extends AbstractModule {
bind(ActionRegistry.class).asEagerSingleton();
bind(HttpClient.class).asEagerSingleton();
bind(EmailSettingsService.class).asEagerSingleton();
bind(EmailService.class).to(InternalEmailService.class).asEagerSingleton();
}

View File

@ -8,24 +8,19 @@ package org.elasticsearch.alerts.actions.email;
import org.elasticsearch.alerts.AlertContext;
import org.elasticsearch.alerts.Payload;
import org.elasticsearch.alerts.actions.Action;
import org.elasticsearch.alerts.actions.ActionException;
import org.elasticsearch.alerts.actions.email.service.*;
import org.elasticsearch.alerts.support.StringTemplateUtils;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.component.AbstractComponent;
import org.elasticsearch.common.inject.Inject;
import org.elasticsearch.common.inject.internal.Nullable;
import org.elasticsearch.common.logging.ESLogger;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.script.ScriptService;
import javax.mail.*;
import javax.mail.internet.AddressException;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.io.IOException;
import java.util.*;
import java.util.HashMap;
import java.util.Map;
/**
*/
@ -33,37 +28,33 @@ public class EmailAction extends Action<EmailAction.Result> {
public static final String TYPE = "email";
private final List<InternetAddress> emailAddresses;
//Optional, can be null, will use defaults from emailSettings (EmailServiceConfig)
private final String fromAddress;
private final StringTemplateUtils.Template subjectTemplate;
private final StringTemplateUtils.Template messageTemplate;
private static final StringTemplateUtils.Template DEFAULT_SUBJECT_TEMPLATE = new StringTemplateUtils.Template(
"Elasticsearch Alert {{alert_name}} triggered", null, "mustache", ScriptService.ScriptType.INLINE);
private static final StringTemplateUtils.Template DEFAULT_MESSAGE_TEMPLATE = new StringTemplateUtils.Template(
"{{alert_name}} triggered with {{response.hits.total}} results", null, "mustache", ScriptService.ScriptType.INLINE);
private final Email.Builder email;
private final Authentication auth;
private final Profile profile;
private final String account;
private final StringTemplateUtils.Template subject;
private final StringTemplateUtils.Template textBody;
private final StringTemplateUtils.Template htmlBody;
private final boolean attachPayload;
private final EmailService emailService;
private final StringTemplateUtils templateUtils;
private final EmailSettingsService emailSettingsService;
protected EmailAction(ESLogger logger, EmailSettingsService emailSettingsService,
StringTemplateUtils templateUtils, @Nullable StringTemplateUtils.Template subjectTemplate,
@Nullable StringTemplateUtils.Template messageTemplate, @Nullable String fromAddress,
List<InternetAddress> emailAddresses) {
protected EmailAction(ESLogger logger, EmailService emailService, StringTemplateUtils templateUtils,
Email.Builder email, Authentication auth, Profile profile, String account,
StringTemplateUtils.Template subject, StringTemplateUtils.Template textBody,
StringTemplateUtils.Template htmlBody, boolean attachPayload) {
super(logger);
this.emailService = emailService;
this.templateUtils = templateUtils;
this.emailSettingsService = emailSettingsService;
this.emailAddresses = new ArrayList<>();
this.emailAddresses.addAll(emailAddresses);
this.subjectTemplate = subjectTemplate;
this.messageTemplate = messageTemplate;
this.fromAddress = fromAddress;
this.email = email;
this.auth = auth;
this.profile = profile;
this.account = account;
this.subject = subject;
this.textBody = textBody;
this.htmlBody = htmlBody;
this.attachPayload = attachPayload;
}
@Override
@ -73,122 +64,72 @@ public class EmailAction extends Action<EmailAction.Result> {
@Override
public Result execute(AlertContext ctx, Payload payload) throws IOException {
email.id(ctx.runId());
final EmailSettingsService.EmailServiceConfig emailSettings = emailSettingsService.emailServiceConfig();
Map<String, Object> alertParams = new HashMap<>();
alertParams.put(Action.ALERT_NAME_VARIABLE_NAME, ctx.alert().name());
alertParams.put(RESPONSE_VARIABLE_NAME, payload.data());
Properties props = new Properties();
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.host", emailSettings.host());
props.put("mail.smtp.port", emailSettings.port());
final Session session;
String text = templateUtils.executeTemplate(subject, alertParams);
email.subject(text);
if (emailSettings.password() != null) {
final String username;
if (emailSettings.username() != null) {
username = emailSettings.username();
} else {
username = emailSettings.defaultFromAddress();
}
text = templateUtils.executeTemplate(textBody, alertParams);
email.textBody(text);
if (username == null) {
return new Result.Failure("unable to send email for alert [" +
ctx.alert().name() + "]. username or the default [from] address is not set");
}
session = Session.getInstance(props,
new javax.mail.Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(username, emailSettings.password());
}
});
} else {
session = Session.getDefaultInstance(props);
if (htmlBody != null) {
text = templateUtils.executeTemplate(htmlBody, alertParams);
email.htmlBody(text);
}
String subject = null;
String body = null;
if (attachPayload) {
Attachment.Bytes attachment = new Attachment.XContent.Yaml("payload", "payload.yml", "alert execution output", payload);
email.attach(attachment);
}
try {
Message email = new MimeMessage(session);
String fromAddressToUse = emailSettings.defaultFromAddress();
if (fromAddress != null) {
fromAddressToUse = fromAddress;
}
email.setFrom(new InternetAddress(fromAddressToUse));
email.setRecipients(Message.RecipientType.TO, emailAddresses.toArray(new Address[1]));
Map<String, Object> alertParams = new HashMap<>();
alertParams.put(Action.ALERT_NAME_VARIABLE_NAME, ctx.alert().name());
alertParams.put(RESPONSE_VARIABLE_NAME, payload.data());
subject = templateUtils.executeTemplate(
subjectTemplate != null ? subjectTemplate : DEFAULT_SUBJECT_TEMPLATE,
alertParams);
email.setSubject(subject);
body = templateUtils.executeTemplate(
messageTemplate != null ? messageTemplate : DEFAULT_MESSAGE_TEMPLATE,
alertParams);
email.setText(body);
Transport.send(email);
return new Result.Success(fromAddressToUse, emailAddresses, subject, body);
} catch (MessagingException me) {
logger.error("failed to send mail for alert [{}]", me, ctx.alert().name());
return new Result.Failure(me.getMessage());
EmailService.EmailSent sent = emailService.send(email.build(), auth, profile, account);
return new Result.Success(sent);
} catch (EmailException ee) {
logger.error("could not send email for alert [{}]", ee, ctx.alert().name());
return new Result.Failure("could not send email for alert [" + ctx.alert().name() + "]. error: " + ee.getMessage());
}
}
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
builder.startObject();
builder.field(Parser.ADDRESSES_FIELD.getPreferredName());
builder.startArray();
for (Address emailAddress : emailAddresses){
builder.value(emailAddress.toString());
if (account != null) {
builder.field(Parser.ACCOUNT_FIELD.getPreferredName(), account);
}
builder.endArray();
if (subjectTemplate != null) {
StringTemplateUtils.writeTemplate(Parser.SUBJECT_TEMPLATE_FIELD.getPreferredName(), subjectTemplate, builder, params);
if (profile != null) {
builder.field(Parser.PROFILE_FIELD.getPreferredName(), profile);
}
if (messageTemplate != null) {
StringTemplateUtils.writeTemplate(Parser.MESSAGE_TEMPLATE_FIELD.getPreferredName(), messageTemplate, builder, params);
builder.array(Email.TO_FIELD.getPreferredName(), email.to());
if (subject != null) {
StringTemplateUtils.writeTemplate(Email.SUBJECT_FIELD.getPreferredName(), subject, builder, params);
}
if (fromAddress != null) {
builder.field(Parser.FROM_FIELD.getPreferredName(), fromAddress);
if (textBody != null) {
StringTemplateUtils.writeTemplate(Email.TEXT_BODY_FIELD.getPreferredName(), textBody, builder, params);
}
builder.endObject();
return builder;
return builder.endObject();
}
public static class Parser extends AbstractComponent implements Action.Parser<EmailAction> {
public static final ParseField FROM_FIELD = new ParseField("from");
public static final ParseField ADDRESSES_FIELD = new ParseField("addresses");
public static final ParseField MESSAGE_TEMPLATE_FIELD = new ParseField("message_template");
public static final ParseField SUBJECT_TEMPLATE_FIELD = new ParseField("subject_template");
public static final ParseField ACCOUNT_FIELD = new ParseField("account");
public static final ParseField PROFILE_FIELD = new ParseField("profile");
public static final ParseField USER_FIELD = new ParseField("user");
public static final ParseField PASSWD_FIELD = new ParseField("password");
public static final ParseField ATTACH_PAYLOAD_FIELD = new ParseField("attach_payload");
private final StringTemplateUtils templateUtils;
private final EmailSettingsService emailSettingsService;
private final EmailService emailService;
@Inject
public Parser(Settings settings, EmailSettingsService emailSettingsService, StringTemplateUtils templateUtils) {
public Parser(Settings settings, EmailService emailService, StringTemplateUtils templateUtils) {
super(settings);
this.emailService = emailService;
this.templateUtils = templateUtils;
this.emailSettingsService = emailSettingsService;
}
@Override
@ -198,49 +139,68 @@ public class EmailAction extends Action<EmailAction.Result> {
@Override
public EmailAction parse(XContentParser parser) throws IOException {
StringTemplateUtils.Template subjectTemplate = null;
StringTemplateUtils.Template messageTemplate = null;
String fromAddress = null;
List<InternetAddress> addresses = new ArrayList<>();
String user = null;
String password = null;
String account = null;
Profile profile = null;
Email.Builder email = Email.builder();
StringTemplateUtils.Template subject = null;
StringTemplateUtils.Template textBody = null;
StringTemplateUtils.Template htmlBody = null;
boolean attachPayload = false;
String currentFieldName = null;
XContentParser.Token token;
while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
if (token == XContentParser.Token.FIELD_NAME) {
currentFieldName = parser.currentName();
} else if (token.isValue()) {
if (SUBJECT_TEMPLATE_FIELD.match(currentFieldName)) {
subjectTemplate = StringTemplateUtils.readTemplate(parser);
} else if (MESSAGE_TEMPLATE_FIELD.match(currentFieldName)) {
messageTemplate = StringTemplateUtils.readTemplate(parser);
} else if (FROM_FIELD.match(currentFieldName)) {
fromAddress = parser.text();
} else if (Email.FROM_FIELD.match(currentFieldName)) {
email.from(Email.Address.parse(currentFieldName, token, parser));
} else if (Email.REPLY_TO_FIELD.match(currentFieldName)) {
email.replyTo(Email.AddressList.parse(currentFieldName, token, parser));
} else if (Email.TO_FIELD.match(currentFieldName)) {
email.to(Email.AddressList.parse(currentFieldName, token, parser));
} else if (Email.CC_FIELD.match(currentFieldName)) {
email.cc(Email.AddressList.parse(currentFieldName, token, parser));
} else if (Email.BCC_FIELD.match(currentFieldName)) {
email.bcc(Email.AddressList.parse(currentFieldName, token, parser));
} else if (token == XContentParser.Token.VALUE_STRING) {
if (Email.PRIORITY_FIELD.match(currentFieldName)) {
email.priority(Email.Priority.resolve(parser.text()));
} else if (Email.SUBJECT_FIELD.match(currentFieldName)) {
subject = StringTemplateUtils.readTemplate(parser);
} else if (Email.TEXT_BODY_FIELD.match(currentFieldName)) {
textBody = StringTemplateUtils.readTemplate(parser);
} else if (Email.HTML_BODY_FIELD.match(currentFieldName)) {
htmlBody = StringTemplateUtils.readTemplate(parser);
} else if (ACCOUNT_FIELD.match(currentFieldName)) {
account = parser.text();
} else if (USER_FIELD.match(currentFieldName)) {
user = parser.text();
} else if (PASSWD_FIELD.match(currentFieldName)) {
password = parser.text();
} else if (PROFILE_FIELD.match(currentFieldName)) {
profile = Profile.resolve(parser.text());
} else {
throw new ActionException("could not parse email action. unexpected field [" + currentFieldName + "]");
throw new EmailException("could not parse email action. unrecognized string field [" + currentFieldName + "]");
}
} else if (token == XContentParser.Token.START_ARRAY) {
if (ADDRESSES_FIELD.match(currentFieldName)) {
while (parser.nextToken() != XContentParser.Token.END_ARRAY) {
try {
addresses.add(InternetAddress.parse(parser.text())[0]);
} catch (AddressException ae) {
throw new ActionException("could not parse email action. unable to parse [" + parser.text() + "] as an email address", ae);
}
}
} else if (token == XContentParser.Token.VALUE_BOOLEAN) {
if (ATTACH_PAYLOAD_FIELD.match(currentFieldName)) {
attachPayload = parser.booleanValue();
} else {
throw new ActionException("could not parse email action. unexpected field [" + currentFieldName + "]");
throw new EmailException("could not parse email action. unrecognized boolean field [" + currentFieldName + "]");
}
} else {
throw new ActionException("could not parse email action. unexpected token [" + token + "]");
throw new EmailException("could not parse email action. unexpected token [" + token + "]");
}
}
if (addresses.isEmpty()) {
throw new ActionException("could not parse email action. [addresses] was not found or was empty");
if (email.to() == null || email.to().isEmpty()) {
throw new EmailException("could not parse email action. [to] was not found or was empty");
}
return new EmailAction(logger, emailSettingsService, templateUtils, subjectTemplate, messageTemplate, fromAddress, addresses);
Authentication auth = new Authentication(user, password);
return new EmailAction(logger, emailService, templateUtils, email, auth, profile, account, subject, textBody, htmlBody, attachPayload);
}
}
@ -252,44 +212,26 @@ public class EmailAction extends Action<EmailAction.Result> {
public static class Success extends Result {
private final String from;
private final List<InternetAddress> recipients;
private final String subject;
private final String body;
private final EmailService.EmailSent sent;
private Success(String from, List<InternetAddress> recipients, String subject, String body) {
private Success(EmailService.EmailSent sent) {
super(TYPE, true);
this.from = from;
this.recipients = recipients;
this.subject = subject;
this.body = body;
this.sent = sent;
}
@Override
public XContentBuilder xContentBody(XContentBuilder builder, Params params) throws IOException {
builder.field("fromAddress", from);
builder.field("subject", subject);
builder.array("to", recipients);
builder.field("body", body);
return builder;
return builder.field("account", sent.account())
.field("email", sent.email());
}
public String from() {
return from;
public String account() {
return sent.account();
}
public String subject() {
return subject;
public Email email() {
return sent.email();
}
public String body() {
return body;
}
public List<InternetAddress> recipients() {
return recipients;
}
}
public static class Failure extends Result {

View File

@ -1,141 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email;
import org.elasticsearch.cluster.settings.DynamicSettings;
import org.elasticsearch.cluster.settings.Validator;
import org.elasticsearch.common.component.AbstractComponent;
import org.elasticsearch.common.inject.Inject;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.node.settings.NodeSettingsService;
/**
*/
public class EmailSettingsService extends AbstractComponent implements NodeSettingsService.Listener {
static final String PORT_SETTING = "alerts.action.email.server.port";
static final String SERVER_SETTING = "alerts.action.email.server.name";
static final String FROM_SETTING = "alerts.action.email.from.address";
static final String USERNAME_SETTING = "alerts.action.email.from.username";
static final String PASSWORD_SETTING = "alerts.action.email.from.password";
private static final String DEFAULT_SERVER = "smtp.gmail.com";
private static final int DEFAULT_PORT = 578;
private volatile EmailServiceConfig emailServiceConfig = new EmailServiceConfig(DEFAULT_SERVER, DEFAULT_PORT, null, null, null);
@Inject
public EmailSettingsService(Settings settings, DynamicSettings dynamicSettings, NodeSettingsService nodeSettingsService) {
super(settings);
//TODO Add validators for hosts and email addresses
dynamicSettings.addDynamicSetting(PORT_SETTING, Validator.POSITIVE_INTEGER);
dynamicSettings.addDynamicSetting(SERVER_SETTING);
dynamicSettings.addDynamicSetting(FROM_SETTING);
dynamicSettings.addDynamicSetting(USERNAME_SETTING);
dynamicSettings.addDynamicSetting(PASSWORD_SETTING);
nodeSettingsService.addListener(this);
updateSettings(settings);
}
public EmailServiceConfig emailServiceConfig() {
return emailServiceConfig;
}
// This is useful to change all settings at the same time. Otherwise we may change the username then email gets send
// and then change the password and then the email sending fails.
//
// Also this reduces the number of volatile writes
static class EmailServiceConfig {
private String host;
private int port;
private String username;
private String password;
private String defaultFromAddress;
public String host() {
return host;
}
public int port() {
return port;
}
public String username() {
return username;
}
public String password() {
return password;
}
public String defaultFromAddress() {
return defaultFromAddress;
}
private EmailServiceConfig(String host, int port, String userName, String password, String defaultFromAddress) {
this.host = host;
this.port = port;
this.username = userName;
this.password = password;
this.defaultFromAddress = defaultFromAddress;
}
}
@Override
public void onRefreshSettings(Settings settings) {
updateSettings(settings);
}
private void updateSettings(Settings settings) {
boolean changed = false;
String host = emailServiceConfig.host;
String newHost = settings.get(SERVER_SETTING);
if (newHost != null && !newHost.equals(host)) {
logger.info("host changed from [{}] to [{}]", host, newHost);
host = newHost;
changed = true;
}
int port = emailServiceConfig.port;
int newPort = settings.getAsInt(PORT_SETTING, -1);
if (newPort != -1) {
logger.info("port changed from [{}] to [{}]", port, newPort);
port = newPort;
changed = true;
}
String fromAddress = emailServiceConfig.defaultFromAddress;
String newFromAddress = settings.get(FROM_SETTING);
if (newFromAddress != null && !newFromAddress.equals(fromAddress)) {
logger.info("from changed from [{}] to [{}]", fromAddress, newFromAddress);
fromAddress = newFromAddress;
changed = true;
}
String userName = emailServiceConfig.username;
String newUserName = settings.get(USERNAME_SETTING);
if (newUserName != null && !newUserName.equals(userName)) {
logger.info("username changed from [{}] to [{}]", userName, newUserName);
userName = newFromAddress;
changed = true;
}
String password = emailServiceConfig.password;
String newPassword = settings.get(PASSWORD_SETTING);
if (newPassword != null && !newPassword.equals(password)) {
logger.info("password changed");
password = newPassword;
changed = true;
}
if (changed) {
logger.info("one or more settings have changed, updating the email service config");
emailServiceConfig = new EmailServiceConfig(host, port, fromAddress, userName, password);
}
}
}

View File

@ -0,0 +1,202 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
import org.elasticsearch.common.logging.ESLogger;
import org.elasticsearch.common.settings.ImmutableSettings;
import org.elasticsearch.common.settings.Settings;
import javax.mail.MessagingException;
import javax.mail.Session;
import javax.mail.Transport;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.util.Map;
import java.util.Properties;
/**
*
*/
public class Account {
static final String SMTP_PROTOCOL = "smtp";
private final ESLogger logger;
private final Config config;
private final Session session;
Account(Config config, ESLogger logger) {
this.config = config;
this.logger = logger;
session = config.createSession();
}
public String name() {
return config.name;
}
public void send(Email email, Authentication auth, Profile profile) throws MessagingException {
// applying the defaults on missing emails fields
email = config.defaults.apply(email);
Transport transport = session.getTransport(SMTP_PROTOCOL);
String user = auth != null ? auth.username() : null;
if (user == null) {
user = config.smtp.user;
if (user == null) {
user = InternetAddress.getLocalAddress(session).getAddress();
}
}
String password = auth != null ? auth.password() : null;
if (password == null) {
password = config.smtp.password;
}
if (profile == null) {
profile = config.profile;
}
transport.connect(config.smtp.host, config.smtp.port, user, password);
try {
MimeMessage message = profile.toMimeMessage(email, session);
String mid = message.getMessageID();
message.saveChanges();
if (mid != null) {
// saveChanges may rewrite/remove the message id, so
// we need to add it back
message.setHeader(Profile.MESSAGE_ID_HEADER, mid);
}
transport.sendMessage(message, message.getAllRecipients());
} finally {
if (transport != null) {
try {
transport.close();
} catch (MessagingException me) {
logger.error("failed to close email transport for account [" + config.name + "]");
}
}
}
}
static class Config {
static final String SMTP_SETTINGS_PREFIX = "mail.smtp.";
private final String name;
private final Profile profile;
private final Smtp smtp;
private final EmailDefaults defaults;
public Config(String name, Settings settings) {
this.name = name;
profile = Profile.resolve(settings.get("profile"), Profile.STANDARD);
defaults = new EmailDefaults(settings.getAsSettings("email_defaults"));
smtp = new Smtp(settings.getAsSettings(SMTP_PROTOCOL));
if (smtp.host == null) {
throw new EmailSettingsException("missing required email account setting for account [" + name + "]. 'smtp.host' must be configured");
}
}
public Session createSession() {
return Session.getInstance(smtp.properties);
}
static class Smtp {
private final String host;
private final int port;
private final String user;
private final String password;
private final Properties properties;
public Smtp(Settings settings) {
host = settings.get("host");
port = settings.getAsInt("port", settings.getAsInt("localport", 25));
user = settings.get("user", settings.get("from", settings.get("local_address", null)));
password = settings.get("password", null);
properties = loadSmtpProperties(settings);
}
/**
* loads the standard Java Mail properties as settings from the given account settings.
* The standard settings are not that readable, therefore we enabled the user to configure
* those in a readable way... this method first loads the smtp settings (which corresponds to
* all Java Mail {@code mail.smtp.*} settings), and then replaces the readable keys to the official
* "unreadable" keys. We'll then use these settings when crea
*/
static Properties loadSmtpProperties(Settings settings) {
ImmutableSettings.Builder builder = ImmutableSettings.builder().put(settings);
replace(builder, "connection_timeout", "connectiontimeout");
replace(builder, "write_timeout", "writetimeout");
replace(builder, "local_address", "localaddress");
replace(builder, "local_port", "localport");
replace(builder, "allow_8bitmime", "allow8bitmime");
replace(builder, "send_partial", "sendpartial");
replace(builder, "sasl.authorization_id", "sasl.authorizationid");
replace(builder, "sasl.use_canonical_hostname", "sasl.usecanonicalhostname");
replace(builder, "wait_on_quit", "quitwait");
replace(builder, "report_success", "reportsuccess");
replace(builder, "mail_extension", "mailextension");
replace(builder, "use_rset", "userset");
settings = builder.build();
Properties props = new Properties();
for (Map.Entry<String, String> entry : settings.getAsMap().entrySet()) {
props.setProperty(SMTP_SETTINGS_PREFIX + entry.getKey(), entry.getValue());
}
return props;
}
static void replace(ImmutableSettings.Builder settings, String currentKey, String newKey) {
String value = settings.remove(currentKey);
if (value != null) {
settings.put(newKey, value);
}
}
}
/**
* holds email fields that can be configured on the account. These fields
* will hold the default values for missing fields in email messages. Having
* the ability to create these default can substantially reduced the configuration
* needed on each alert (e.g. if all the emails are always sent to the same recipients
* one could set those here and leave them out on the alert definition).
*/
class EmailDefaults {
final Email.Address from;
final Email.AddressList replyTo;
final Email.Priority priority;
final Email.AddressList to;
final Email.AddressList cc;
final Email.AddressList bcc;
final String subject;
public EmailDefaults(Settings settings) {
from = Email.Address.parse(settings, Email.FROM_FIELD.getPreferredName());
replyTo = Email.AddressList.parse(settings, Email.REPLY_TO_FIELD.getPreferredName());
priority = Email.Priority.parse(settings, Email.PRIORITY_FIELD.getPreferredName());
to = Email.AddressList.parse(settings, Email.TO_FIELD.getPreferredName());
cc = Email.AddressList.parse(settings, Email.CC_FIELD.getPreferredName());
bcc = Email.AddressList.parse(settings, Email.BCC_FIELD.getPreferredName());
subject = settings.get(Email.SUBJECT_FIELD.getPreferredName());
}
Email apply(Email email) {
return Email.builder()
.from(from)
.replyTo(replyTo)
.priority(priority)
.to(to)
.cc(cc)
.bcc(bcc)
.subject(subject)
.copyFrom(email)
.build();
}
}
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
import org.elasticsearch.common.logging.ESLogger;
import org.elasticsearch.common.settings.Settings;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
*
*/
public class Accounts {
private final String defaultAccountName;
private final Map<String, Account> accounts;
public Accounts(Settings settings, ESLogger logger) {
settings = settings.getAsSettings("account");
Map<String, Account> accounts = new HashMap<>();
for (String name : settings.names()) {
Account.Config config = new Account.Config(name, settings.getAsSettings(name));
Account account = new Account(config, logger);
accounts.put(name, account);
}
if (accounts.isEmpty()) {
this.accounts = Collections.emptyMap();
this.defaultAccountName = null;
} else {
this.accounts = accounts;
String defaultAccountName = settings.get("default_account");
if (defaultAccountName == null) {
Account account = accounts.values().iterator().next();
logger.error("default account set to [{}]", account.name());
this.defaultAccountName = account.name();
} else if (!accounts.containsKey(defaultAccountName)) {
Account account = accounts.values().iterator().next();
this.defaultAccountName = account.name();
logger.error("could not find configured default account [{}]. falling back on account [{}]", defaultAccountName, account.name());
} else {
this.defaultAccountName = defaultAccountName;
}
}
}
/**
* Returns the account associated with the given name. If there is not such account, {@code null} is returned.
* If the given name is {@code null}, the default account will be returned.
*
* @param name The name of the requested account
* @return The account associated with the given name.
*/
public Account account(String name) {
if (name == null) {
name = defaultAccountName;
}
return accounts.get(name);
}
}

View File

@ -0,0 +1,251 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
import org.elasticsearch.alerts.actions.email.service.support.BodyPartSource;
import org.elasticsearch.common.base.Charsets;
import org.elasticsearch.common.xcontent.ToXContent;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentType;
import javax.activation.DataHandler;
import javax.activation.DataSource;
import javax.activation.FileDataSource;
import javax.mail.MessagingException;
import javax.mail.Part;
import javax.mail.internet.MimeBodyPart;
import javax.mail.util.ByteArrayDataSource;
import java.io.IOException;
import java.nio.file.Path;
/**
*
*/
public abstract class Attachment extends BodyPartSource {
public Attachment(String id) {
super(id);
}
public Attachment(String id, String name) {
super(id, name);
}
public Attachment(String id, String name, String description) {
super(id, name, description);
}
@Override
public final MimeBodyPart bodyPart() throws MessagingException {
MimeBodyPart part = new MimeBodyPart();
part.setContentID(id);
part.setFileName(name);
part.setDescription(description, Charsets.UTF_8.name());
part.setDisposition(Part.ATTACHMENT);
writeTo(part);
return part;
}
protected abstract void writeTo(MimeBodyPart part) throws MessagingException;
public static class File extends Attachment {
static final String TYPE = "file";
private final Path path;
private final DataSource dataSource;
private final String contentType;
public File(String id, Path path) {
this(id, path.getFileName().toString(), path);
}
public File(String id, Path path, String contentType) {
this(id, path.getFileName().toString(), path, contentType);
}
public File(String id, String name, Path path) {
this(id, name, name, path);
}
public File(String id, String name, Path path, String contentType) {
this(id, name, name, path, contentType);
}
public File(String id, String name, String description, Path path) {
this(id, name, description, path, null);
}
public File(String id, String name, String description, Path path, String contentType) {
super(id, name, description);
this.path = path;
this.dataSource = new FileDataSource(path.toFile());
this.contentType = contentType;
}
public Path path() {
return path;
}
public String type() {
return TYPE;
}
String contentType() {
return contentType != null ? contentType : dataSource.getContentType();
}
@Override
public void writeTo(MimeBodyPart part) throws MessagingException {
DataSource dataSource = new FileDataSource(path.toFile());
DataHandler handler = contentType != null ?
new DataHandler(dataSource, contentType) :
new DataHandler(dataSource);
part.setDataHandler(handler);
}
/**
* intentionally not emitting path as it may come as an information leak
*/
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
return builder.startObject()
.field("type", type())
.field("id", id)
.field("name", name)
.field("description", description)
.field("content_type", contentType())
.endObject();
}
}
public static class Bytes extends Attachment {
static final String TYPE = "bytes";
private final byte[] bytes;
private final String contentType;
public Bytes(String id, byte[] bytes, String contentType) {
this(id, id, bytes, contentType);
}
public Bytes(String id, String name, byte[] bytes, String contentType) {
this(id, name, name, bytes, contentType);
}
public Bytes(String id, String name, String description, byte[] bytes, String contentType) {
super(id, name, description);
this.bytes = bytes;
this.contentType = contentType;
}
public String type() {
return TYPE;
}
public byte[] bytes() {
return bytes;
}
public String contentType() {
return contentType;
}
@Override
public void writeTo(MimeBodyPart part) throws MessagingException {
DataSource dataSource = new ByteArrayDataSource(bytes, contentType);
DataHandler handler = new DataHandler(dataSource);
part.setDataHandler(handler);
}
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
return builder.startObject()
.field("type", type())
.field("id", id)
.field("name", name)
.field("description", description)
.field("content_type", contentType)
.endObject();
}
}
public static class XContent extends Bytes {
protected XContent(String id, ToXContent content, XContentType type) {
this(id, id, content, type);
}
protected XContent(String id, String name, ToXContent content, XContentType type) {
this(id, name, name, content, type);
}
protected XContent(String id, String name, String description, ToXContent content, XContentType type) {
super(id, name, description, bytes(name, content, type), mimeType(type));
}
static String mimeType(XContentType type) {
switch (type) {
case JSON: return "application/json";
case YAML: return "application/yaml";
case SMILE: return "application/smile";
case CBOR: return "application/cbor";
default:
throw new EmailException("unsupported xcontent attachment type [" + type.name() + "]");
}
}
static byte[] bytes(String name, ToXContent content, XContentType type) {
try {
XContentBuilder builder = XContentBuilder.builder(type.xContent());
content.toXContent(builder, ToXContent.EMPTY_PARAMS);
return builder.bytes().array();
} catch (IOException ioe) {
throw new EmailException("could not create an xcontent attachment [" + name + "]", ioe);
}
}
public static class Yaml extends XContent {
public Yaml(String id, ToXContent content) {
super(id, content, XContentType.YAML);
}
public Yaml(String id, String name, ToXContent content) {
super(id, name, content, XContentType.YAML);
}
public Yaml(String id, String name, String description, ToXContent content) {
super(id, name, description, content, XContentType.YAML);
}
@Override
public String type() {
return "yaml";
}
}
public static class Json extends XContent {
public Json(String id, ToXContent content) {
super(id, content, XContentType.JSON);
}
public Json(String id, String name, ToXContent content) {
super(id, name, content, XContentType.JSON);
}
public Json(String id, String name, String description, ToXContent content) {
super(id, name, description, content, XContentType.JSON);
}
@Override
public String type() {
return "json";
}
}
}
}

View File

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
/**
*
*/
public class Authentication {
private final String username;
private final String password;
public Authentication(String username, String password) {
this.username = username;
this.password = password;
}
public String username() {
return username;
}
public String password() {
return password;
}
}

View File

@ -0,0 +1,460 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.base.Charsets;
import org.elasticsearch.common.collect.ImmutableMap;
import org.elasticsearch.common.joda.time.DateTime;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.ToXContent;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentParser;
import javax.mail.MessagingException;
import javax.mail.internet.AddressException;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
/**
*
*/
public class Email implements ToXContent {
public static final ParseField FROM_FIELD = new ParseField("from");
public static final ParseField REPLY_TO_FIELD = new ParseField("reply_to");
public static final ParseField PRIORITY_FIELD = new ParseField("priority");
public static final ParseField SENT_DATE_FIELD = new ParseField("sent_date");
public static final ParseField TO_FIELD = new ParseField("to");
public static final ParseField CC_FIELD = new ParseField("cc");
public static final ParseField BCC_FIELD = new ParseField("bcc");
public static final ParseField SUBJECT_FIELD = new ParseField("subject");
public static final ParseField TEXT_BODY_FIELD = new ParseField("text_body");
public static final ParseField HTML_BODY_FIELD = new ParseField("html_body");
public static final ParseField ATTACHMENTS_FIELD = new ParseField("attachments");
public static final ParseField INLINES_FIELD = new ParseField("inlines");
final String id;
final Address from;
final AddressList replyTo;
final Priority priority;
final DateTime sentDate;
final AddressList to;
final AddressList cc;
final AddressList bcc;
final String subject;
final String textBody;
final String htmlBody;
final ImmutableMap<String, Attachment> attachments;
final ImmutableMap<String, Inline> inlines;
public Email(String id, Address from, AddressList replyTo, Priority priority, DateTime sentDate,
AddressList to, AddressList cc, AddressList bcc, String subject, String textBody, String htmlBody,
ImmutableMap<String, Attachment> attachments, ImmutableMap<String, Inline> inlines) {
this.id = id;
this.from = from;
this.replyTo = replyTo;
this.priority = priority;
this.sentDate = sentDate;
this.to = to;
this.cc = cc;
this.bcc = bcc;
this.subject = subject;
this.textBody = textBody;
this.htmlBody = htmlBody;
this.attachments = attachments;
this.inlines = inlines;
}
public Address from() {
return from;
}
public AddressList replyTo() {
return replyTo;
}
public Priority priority() {
return priority;
}
public DateTime sentDate() {
return sentDate;
}
public AddressList to() {
return to;
}
public AddressList cc() {
return cc;
}
public AddressList bcc() {
return bcc;
}
public String subject() {
return subject;
}
public String textBody() {
return textBody;
}
public String htmlBody() {
return htmlBody;
}
public ImmutableMap<String, Attachment> attachments() {
return attachments;
}
public ImmutableMap<String, Inline> inlines() {
return inlines;
}
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
return builder.startObject()
.field(FROM_FIELD.getPreferredName(), from)
.field(REPLY_TO_FIELD.getPreferredName(), (ToXContent) replyTo)
.field(PRIORITY_FIELD.getPreferredName(), priority)
.field(SENT_DATE_FIELD.getPreferredName(), sentDate)
.field(TO_FIELD.getPreferredName(), (ToXContent) to)
.field(CC_FIELD.getPreferredName(), (ToXContent) cc)
.field(BCC_FIELD.getPreferredName(), (ToXContent) bcc)
.field(SUBJECT_FIELD.getPreferredName(), subject)
.field(TEXT_BODY_FIELD.getPreferredName(), textBody)
.field(HTML_BODY_FIELD.getPreferredName(), htmlBody)
.field(ATTACHMENTS_FIELD.getPreferredName(), attachments)
.field(INLINES_FIELD.getPreferredName(), inlines)
.endObject();
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String id;
private Address from;
private AddressList replyTo;
private Priority priority;
private DateTime sentDate;
private AddressList to;
private AddressList cc;
private AddressList bcc;
private String subject;
private String textBody;
private String htmlBody;
private ImmutableMap.Builder<String, Attachment> attachments = ImmutableMap.builder();
private ImmutableMap.Builder<String, Inline> inlines = ImmutableMap.builder();
private Builder() {
}
public Builder copyFrom(Email email) {
id = email.id;
from = email.from;
replyTo = email.replyTo;
priority = email.priority;
sentDate = email.sentDate;
to = email.to;
cc = email.cc;
bcc = email.bcc;
subject = email.subject;
textBody = email.textBody;
htmlBody = email.htmlBody;
attachments.putAll(email.attachments);
inlines.putAll(email.inlines);
return this;
}
public Builder id(String id) {
this.id = id;
return this;
}
public Builder from(Address from) {
this.from = from;
return this;
}
public Builder replyTo(AddressList replyTo) {
this.replyTo = replyTo;
return this;
}
public Builder priority(Priority priority) {
this.priority = priority;
return this;
}
public Builder sentDate(DateTime sentDate) {
this.sentDate = sentDate;
return this;
}
public Builder to(AddressList to) {
this.to = to;
return this;
}
public AddressList to() {
return to;
}
public Builder cc(AddressList cc) {
this.cc = cc;
return this;
}
public Builder bcc(AddressList bcc) {
this.bcc = bcc;
return this;
}
public Builder subject(String subject) {
this.subject = subject;
return this;
}
public Builder textBody(String text) {
this.textBody = text;
return this;
}
public Builder htmlBody(String html) {
this.htmlBody = html;
return this;
}
public Builder attach(Attachment attachment) {
attachments.put(attachment.id(), attachment);
return this;
}
public Builder inline(Inline inline) {
inlines.put(inline.id(), inline);
return this;
}
public Email build() {
assert id != null : "email id should not be null (should be set to the alert id";
assert to != null && !to.isEmpty() : "email must have a [to] recipient";
return new Email(id, from, replyTo, priority, sentDate, to, cc, bcc, subject, textBody, htmlBody, attachments.build(), inlines.build());
}
}
public static enum Priority implements ToXContent {
HIGHEST(1),
HIGH(2),
NORMAL(3),
LOW(4),
LOWEST(5);
static final String HEADER = "X-Priority";
private final int value;
private Priority(int value) {
this.value = value;
}
public void applyTo(MimeMessage message) throws MessagingException {
message.setHeader(HEADER, String.valueOf(value));
}
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
return builder.value(name().toLowerCase(Locale.ROOT));
}
public static Priority resolve(String name) {
Priority priority = resolve(name, null);
if (priority == null) {
throw new EmailSettingsException("unknown email priority [" + name + "]");
}
return priority;
}
public static Priority resolve(String name, Priority defaultPriority) {
if (name == null) {
return defaultPriority;
}
switch (name.toLowerCase()) {
case "highest": return HIGHEST;
case "high": return HIGH;
case "normal": return NORMAL;
case "low": return LOW;
case "lowest": return LOWEST;
default:
return defaultPriority;
}
}
public static Priority parse(Settings settings, String name) {
String value = settings.get(name);
if (value == null) {
return null;
}
return resolve(value);
}
}
public static class Address extends javax.mail.internet.InternetAddress implements ToXContent {
public static final ParseField ADDRESS_NAME_FIELD = new ParseField("name");
public static final ParseField ADDRESS_EMAIL_FIELD = new ParseField("email");
public Address(String address) throws AddressException {
super(address);
}
public Address(String address, String personal) throws UnsupportedEncodingException {
super(address, personal, Charsets.UTF_8.name());
}
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
return builder.value(toUnicodeString());
}
public static Address parse(String field, XContentParser.Token token, XContentParser parser) throws IOException {
if (token == XContentParser.Token.VALUE_STRING) {
String text = parser.text();
try {
return new Email.Address(parser.text());
} catch (AddressException ae) {
throw new EmailException("could not parse [" + text + "] in field [" + field + "] as address. address must be RFC822 encoded", ae);
}
}
if (token == XContentParser.Token.START_OBJECT) {
String email = null;
String name = null;
String currentFieldName = null;
while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
if (token == XContentParser.Token.FIELD_NAME) {
currentFieldName = parser.currentName();
} else if (token == XContentParser.Token.VALUE_STRING) {
if (ADDRESS_EMAIL_FIELD.match(currentFieldName)) {
email = parser.text();
} else if (ADDRESS_NAME_FIELD.match(currentFieldName)) {
name = parser.text();
} else {
throw new EmailException("could not parse [" + field + "] object as address. unknown address field [" + currentFieldName + "]");
}
}
}
if (email == null) {
throw new EmailException("could not parse [" + field + "] as address. address object must define an [email] field");
}
try {
return name != null ? new Email.Address(email, name) : new Email.Address(email);
} catch (AddressException ae) {
throw new EmailException("could not parse [" + field + "] as address", ae);
}
}
throw new EmailException("could not parse [" + field + "] as address. address must either be a string (RFC822 encoded) or an object specifying the address [name] and [email]");
}
public static Address parse(Settings settings, String name) {
String value = settings.get(name);
try {
return value != null ? new Address(value) : null;
} catch (AddressException ae) {
throw new EmailSettingsException("could not parse [" + value + "] as a RFC822 email address", ae);
}
}
}
public static class AddressList implements Iterable<Address>, ToXContent {
private final List<Address> addresses;
public AddressList(List<Address> addresses) {
this.addresses = addresses;
}
public boolean isEmpty() {
return addresses.isEmpty();
}
@Override
public Iterator<Address> iterator() {
return addresses.iterator();
}
public Address[] toArray() {
return addresses.toArray(new Address[addresses.size()]);
}
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
return builder.value(addresses);
}
public static AddressList parse(String text) throws AddressException {
InternetAddress[] addresses = InternetAddress.parse(text);
List<Address> list = new ArrayList<>(addresses.length);
for (InternetAddress address : addresses) {
list.add(new Address(address.toUnicodeString()));
}
return new AddressList(list);
}
public static AddressList parse(Settings settings, String name) {
String[] addresses = settings.getAsArray(name);
if (addresses == null || addresses.length == 0) {
return null;
}
try {
List<Address> list = new ArrayList<>(addresses.length);
for (String address : addresses) {
list.add(new Address(address));
}
return new AddressList(list);
} catch (AddressException ae) {
throw new EmailSettingsException("could not parse [" + settings.get(name) + "] as a list of RFC822 email address", ae);
}
}
public static Email.AddressList parse(String field, XContentParser.Token token, XContentParser parser) throws IOException {
if (token == XContentParser.Token.VALUE_STRING) {
String text = parser.text();
try {
return parse(parser.text());
} catch (AddressException ae) {
throw new EmailException("could not parse field [" + field + "] with value [" + text + "] as address list. address(es) must be RFC822 encoded", ae);
}
}
if (token == XContentParser.Token.START_ARRAY) {
List<Email.Address> addresses = new ArrayList<>();
while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) {
addresses.add(Address.parse(field, token, parser));
}
return new Email.AddressList(addresses);
}
throw new EmailException("could not parse [" + field + "] as address list. field must either be a string " +
"(comma-separated list of RFC822 encoded addresses) or an array of objects representing addresses");
}
}
}

View File

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
import org.elasticsearch.alerts.actions.ActionException;
/**
*
*/
public class EmailException extends ActionException {
public EmailException(String msg) {
super(msg);
}
public EmailException(String msg, Throwable cause) {
super(msg, cause);
}
}

View File

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
import org.elasticsearch.cluster.ClusterState;
/**
*
*/
public interface EmailService {
void start(ClusterState state);
void stop();
EmailSent send(Email email, Authentication auth, Profile profile);
EmailSent send(Email email, Authentication auth, Profile profile, String accountName);
static class EmailSent {
private final String account;
private final Email email;
public EmailSent(String account, Email email) {
this.account = account;
this.email = email;
}
public String account() {
return account;
}
public Email email() {
return email;
}
}
}

View File

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
/**
*
*/
public class EmailSettingsException extends EmailException {
public EmailSettingsException(String msg) {
super(msg);
}
public EmailSettingsException(String msg, Throwable cause) {
super(msg, cause);
}
}

View File

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
import org.elasticsearch.alerts.actions.email.service.support.BodyPartSource;
import org.elasticsearch.common.xcontent.XContentBuilder;
import javax.activation.DataHandler;
import javax.activation.DataSource;
import javax.activation.FileDataSource;
import javax.mail.MessagingException;
import javax.mail.Part;
import javax.mail.internet.MimeBodyPart;
import java.io.IOException;
import java.nio.file.Path;
/**
*
*/
public abstract class Inline extends BodyPartSource {
public Inline(String id) {
super(id);
}
public Inline(String id, String name) {
super(id, name);
}
public Inline(String id, String name, String description) {
super(id, name, description);
}
@Override
public final MimeBodyPart bodyPart() throws MessagingException {
MimeBodyPart part = new MimeBodyPart();
part.setDisposition(Part.INLINE);
writeTo(part);
return part;
}
protected abstract void writeTo(MimeBodyPart part) throws MessagingException;
public static class File extends Inline {
static final String TYPE = "file";
private final Path path;
private DataSource dataSource;
private final String contentType;
public File(String id, Path path) {
this(id, path.getFileName().toString(), path);
}
public File(String id, Path path, String contentType) {
this(id, path.getFileName().toString(), path, contentType);
}
public File(String id, String name, Path path) {
this(id, name, name, path);
}
public File(String id, String name, Path path, String contentType) {
this(id, name, name, path, contentType);
}
public File(String id, String name, String description, Path path) {
this(id, name, description, path, null);
}
public File(String id, String name, String description, Path path, String contentType) {
super(id, name, description);
this.path = path;
this.dataSource = new FileDataSource(path.toFile());
this.contentType = contentType;
}
public Path path() {
return path;
}
public String type() {
return TYPE;
}
String contentType() {
return contentType != null ? contentType : dataSource.getContentType();
}
@Override
public void writeTo(MimeBodyPart part) throws MessagingException {
DataHandler handler = contentType != null ?
new DataHandler(dataSource, contentType) :
new DataHandler(dataSource);
part.setDataHandler(handler);
}
/**
* intentionally not emitting path as it may come as an information leak
*/
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
return builder.startObject()
.field("type", type())
.field("id", id)
.field("name", name)
.field("description", description)
.field("content_type", contentType())
.endObject();
}
}
}

View File

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
import org.elasticsearch.cluster.ClusterState;
import org.elasticsearch.common.component.AbstractComponent;
import org.elasticsearch.common.inject.Inject;
import org.elasticsearch.common.settings.ImmutableSettings;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.node.settings.NodeSettingsService;
import javax.mail.MessagingException;
import java.util.concurrent.atomic.AtomicBoolean;
/**
*
*/
public class InternalEmailService extends AbstractComponent implements EmailService {
private volatile Accounts accounts;
private final AtomicBoolean started = new AtomicBoolean(false);
@Inject
public InternalEmailService(Settings settings, NodeSettingsService nodeSettingsService) {
super(settings);
nodeSettingsService.addListener(new NodeSettingsService.Listener() {
@Override
public void onRefreshSettings(Settings settings) {
reset(settings);
}
});
}
@Override
public EmailSent send(Email email, Authentication auth, Profile profile) {
return send(email, auth, profile, (String) null);
}
@Override
public EmailSent send(Email email, Authentication auth, Profile profile, String accountName) {
Account account = accounts.account(accountName);
if (account == null) {
throw new EmailException("failed to send email [" + email + "] via account [" + accountName + "]. account does not exist");
}
return send(email, auth, profile, account);
}
EmailSent send(Email email, Authentication auth, Profile profile, Account account) {
assert account != null;
try {
account.send(email, auth, profile);
} catch (MessagingException me) {
throw new EmailException("failed to send email [" + email + "] via account [" + account.name() + "]", me);
}
return new EmailSent(account.name(), email);
}
@Override
public synchronized void start(ClusterState state) {
if (started.get()) {
return;
}
reset(state.metaData().settings());
started.set(true);
}
@Override
public synchronized void stop() {
started.set(false);
}
synchronized void reset(Settings nodeSettings) {
if (!started.get()) {
return;
}
Settings settings = ImmutableSettings.builder()
.put(componentSettings)
.put(nodeSettings.getComponentSettings(InternalEmailService.class))
.build();
accounts = new Accounts(settings, logger);
}
}

View File

@ -0,0 +1,151 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service;
import org.elasticsearch.common.base.Charsets;
import org.elasticsearch.common.xcontent.ToXContent;
import org.elasticsearch.common.xcontent.XContentBuilder;
import javax.mail.Message;
import javax.mail.MessagingException;
import javax.mail.Session;
import javax.mail.internet.MimeBodyPart;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMultipart;
import java.io.IOException;
import java.util.Date;
import java.util.Locale;
/**
* A profile of an email client, can be seen as a strategy to emulate a real world email client
* (different clients potentially support different mime message structures)
*/
public enum Profile implements ToXContent {
STANDARD() {
@Override
public MimeMessage toMimeMessage(Email email, Session session) throws MessagingException {
MimeMessage message = createCommon(email, session);
MimeMultipart mixed = new MimeMultipart("mixed");
MimeMultipart related = new MimeMultipart("related");
mixed.addBodyPart(wrap(related, null));
MimeMultipart alternative = new MimeMultipart("alternative");
related.addBodyPart(wrap(alternative, "text/alternative"));
MimeBodyPart text = new MimeBodyPart();
text.setText(email.textBody, Charsets.UTF_8.name());
alternative.addBodyPart(text);
if (email.htmlBody != null) {
MimeBodyPart html = new MimeBodyPart();
text.setText(email.textBody, Charsets.UTF_8.name(), "html");
alternative.addBodyPart(html);
}
if (!email.inlines.isEmpty()) {
for (Inline inline : email.inlines.values()) {
related.addBodyPart(inline.bodyPart());
}
}
if (!email.attachments.isEmpty()) {
for (Attachment attachment : email.attachments.values()) {
mixed.addBodyPart(attachment.bodyPart());
}
}
return message;
}
},
OUTLOOK() {
@Override
public MimeMessage toMimeMessage(Email email, Session session) throws MessagingException {
return STANDARD.toMimeMessage(email, session);
}
},
GMAIL() {
@Override
public MimeMessage toMimeMessage(Email email, Session session) throws MessagingException {
return STANDARD.toMimeMessage(email, session);
}
},
MAC() {
@Override
public MimeMessage toMimeMessage(Email email, Session session) throws MessagingException {
return STANDARD.toMimeMessage(email, session);
}
};
static final String MESSAGE_ID_HEADER = "Message-ID";
public abstract MimeMessage toMimeMessage(Email email, Session session) throws MessagingException ;
public static Profile resolve(String name) {
Profile profile = resolve(name, null);
if (profile == null) {
throw new EmailSettingsException("unsupported email profile [" + name + "]");
}
return profile;
}
public static Profile resolve(String name, Profile defaultProfile) {
if (name == null) {
return defaultProfile;
}
switch (name.toLowerCase(Locale.ROOT)) {
case "std":
case "standard": return STANDARD;
case "outlook": return OUTLOOK;
case "gmail": return GMAIL;
default:
return defaultProfile;
}
}
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
return builder.value(name().toLowerCase());
}
static MimeMessage createCommon(Email email, Session session) throws MessagingException {
MimeMessage message = new MimeMessage(session);
message.setHeader(MESSAGE_ID_HEADER, email.id);
if (email.from != null) {
message.setFrom(email.from);
}
if (email.replyTo != null) {
message.setReplyTo(email.replyTo.toArray());
}
if (email.priority != null) {
email.priority.applyTo(message);
}
Date sentDate = email.sentDate != null ? email.sentDate.toDate() : new Date();
message.setSentDate(sentDate);
message.setRecipients(Message.RecipientType.TO, email.to.toArray());
message.setRecipients(Message.RecipientType.CC, email.cc.toArray());
message.setRecipients(Message.RecipientType.BCC, email.bcc.toArray());
message.setSubject(email.subject, Charsets.UTF_8.name());
return message;
}
static MimeBodyPart wrap(MimeMultipart multipart, String contentType) throws MessagingException {
MimeBodyPart part = new MimeBodyPart();
if (contentType == null) {
part.setContent(multipart);
} else {
part.setContent(multipart, contentType);
}
return part;
}
}

View File

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.alerts.actions.email.service.support;
import org.elasticsearch.common.xcontent.ToXContent;
import javax.mail.MessagingException;
import javax.mail.internet.MimeBodyPart;
/**
*
*/
public abstract class BodyPartSource implements ToXContent {
protected final String id;
protected final String name;
protected final String description;
public BodyPartSource(String id) {
this(id, id);
}
public BodyPartSource(String id, String name) {
this(id, name, name);
}
public BodyPartSource(String id, String name, String description) {
this.id = id;
this.name = name;
this.description = description;
}
public String id() {
return id;
}
public String name() {
return name;
}
public String description() {
return description;
}
public abstract MimeBodyPart bodyPart() throws MessagingException;
}

View File

@ -57,7 +57,7 @@ public class FiredAlert implements ToXContent {
}
public FiredAlert(Alert alert, DateTime scheduledTime, DateTime fireTime, State state) {
this.id = alert.name() + "#" + scheduledTime.toDateTimeISO();
this.id = firedAlertId(alert, scheduledTime);
this.name = alert.name();
this.fireTime = fireTime;
this.scheduledTime = scheduledTime;
@ -91,6 +91,10 @@ public class FiredAlert implements ToXContent {
}
}
public static String firedAlertId(Alert alert, DateTime dateTime) {
return alert.name() + "#" + dateTime.toDateTimeISO();
}
public DateTime scheduledTime() {
return scheduledTime;
}