From b292051a1349ecd381480243b89aec804e712e43 Mon Sep 17 00:00:00 2001 From: uboness Date: Mon, 23 Feb 2015 04:53:53 +0100 Subject: [PATCH] [email] fixed bugs and added unit tests Original commit: elastic/x-pack-elasticsearch@3b5406d4c8255b0bf98467f41a1b0e237eef5803 --- pom.xml | 7 + .../elasticsearch/alerts/AlertsPlugin.java | 4 +- .../alerts/actions/email/EmailAction.java | 98 +++-- .../alerts/actions/email/service/Account.java | 95 +++-- .../actions/email/service/Accounts.java | 35 +- .../actions/email/service/Attachment.java | 109 ++---- .../actions/email/service/Authentication.java | 30 +- .../alerts/actions/email/service/Email.java | 100 +++-- .../actions/email/service/EmailService.java | 6 - .../alerts/actions/email/service/Inline.java | 159 +++++--- .../email/service/InternalEmailService.java | 48 ++- .../alerts/actions/email/service/Profile.java | 89 ++++- .../email/service/support/BodyPartSource.java | 21 +- .../support/template/ScriptTemplate.java | 12 +- .../alerts/AbstractAlertingTests.java | 17 +- .../alerts/actions/email/EmailActionTest.java | 116 ------ .../actions/email/EmailActionTests.java | 365 ++++++++++++++++++ .../actions/email/service/AccountTests.java | 269 +++++++++++++ .../actions/email/service/AccountsTests.java | 116 ++++++ .../service/InternalEmailServiceTests.java | 60 +++ .../service/ManualPublicSmtpServersTests.java | 120 ++++++ .../email/service/support/EmailServer.java | 110 ++++++ .../alerts/actions/email/service/logo.jpg | Bin 0 -> 37305 bytes 23 files changed, 1578 insertions(+), 408 deletions(-) delete mode 100644 src/test/java/org/elasticsearch/alerts/actions/email/EmailActionTest.java create mode 100644 src/test/java/org/elasticsearch/alerts/actions/email/EmailActionTests.java create mode 100644 src/test/java/org/elasticsearch/alerts/actions/email/service/AccountTests.java create mode 100644 src/test/java/org/elasticsearch/alerts/actions/email/service/AccountsTests.java create mode 100644 src/test/java/org/elasticsearch/alerts/actions/email/service/InternalEmailServiceTests.java create mode 100644 src/test/java/org/elasticsearch/alerts/actions/email/service/ManualPublicSmtpServersTests.java create mode 100644 src/test/java/org/elasticsearch/alerts/actions/email/service/support/EmailServer.java create mode 100644 src/test/resources/org/elasticsearch/alerts/actions/email/service/logo.jpg diff --git a/pom.xml b/pom.xml index f6257b5bc1c..4d324b77cc6 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,13 @@ test + + com.google.apis + google-api-services-gmail + v1-rev23-1.19.1 + test + + org.codehaus.groovy groovy-all diff --git a/src/main/java/org/elasticsearch/alerts/AlertsPlugin.java b/src/main/java/org/elasticsearch/alerts/AlertsPlugin.java index 33a64edc338..6770a5c46f4 100644 --- a/src/main/java/org/elasticsearch/alerts/AlertsPlugin.java +++ b/src/main/java/org/elasticsearch/alerts/AlertsPlugin.java @@ -5,6 +5,7 @@ */ package org.elasticsearch.alerts; +import org.elasticsearch.alerts.actions.email.service.InternalEmailService; import org.elasticsearch.alerts.support.init.InitializingService; import org.elasticsearch.common.collect.ImmutableList; import org.elasticsearch.common.component.LifecycleComponent; @@ -47,7 +48,8 @@ public class AlertsPlugin extends AbstractPlugin { // the initialization service must be first in the list // as other services may depend on one of the initialized // constructs - InitializingService.class); + InitializingService.class, + InternalEmailService.class); } @Override diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/EmailAction.java b/src/main/java/org/elasticsearch/alerts/actions/email/EmailAction.java index b4794f59d33..2aa704a3036 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/EmailAction.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/EmailAction.java @@ -22,6 +22,7 @@ import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentParser; import java.io.IOException; +import java.util.Objects; /** */ @@ -29,23 +30,23 @@ public class EmailAction extends Action { public static final String TYPE = "email"; - private final Email.Builder email; - private final Authentication auth; - private final Profile profile; - private final String account; - private final Template subject; - private final Template textBody; - private final Template htmlBody; - private final boolean attachPayload; + final Email emailPrototype; + final Authentication auth; + final Profile profile; + final String account; + final Template subject; + final Template textBody; + final Template htmlBody; + final boolean attachPayload; - private final EmailService emailService; + final EmailService emailService; - public EmailAction(ESLogger logger, EmailService emailService, Email.Builder email, Authentication auth, Profile profile, + public EmailAction(ESLogger logger, EmailService emailService, Email emailPrototype, Authentication auth, Profile profile, String account, Template subject, Template textBody, Template htmlBody, boolean attachPayload) { super(logger); this.emailService = emailService; - this.email = email; + this.emailPrototype = emailPrototype; this.auth = auth; this.profile = profile; this.account = account; @@ -64,6 +65,10 @@ public class EmailAction extends Action { public Result execute(ExecutionContext ctx, Payload payload) throws IOException { ImmutableMap model = templateModel(ctx, payload); + Email.Builder email = Email.builder() + .id(ctx.id()) + .copyFrom(emailPrototype); + email.id(ctx.id()); email.subject(subject.render(model)); email.textBody(textBody.render(model)); @@ -73,7 +78,7 @@ public class EmailAction extends Action { } if (attachPayload) { - Attachment.Bytes attachment = new Attachment.XContent.Yaml("payload", "payload.yml", "alert execution output", payload); + Attachment.Bytes attachment = new Attachment.XContent.Yaml("payload", "payload.yml", payload); email.attach(attachment); } @@ -86,22 +91,71 @@ public class EmailAction extends Action { } } + @Override + public int hashCode() { + return Objects.hash(emailPrototype, auth, profile, account, subject, textBody, htmlBody, attachPayload); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final EmailAction other = (EmailAction) obj; + return Objects.equals(this.emailPrototype, other.emailPrototype) + && Objects.equals(this.auth, other.auth) + && Objects.equals(this.profile, other.profile) + && Objects.equals(this.account, other.account) + && Objects.equals(this.subject, other.subject) + && Objects.equals(this.textBody, other.textBody) + && Objects.equals(this.htmlBody, other.htmlBody) + && Objects.equals(this.attachPayload, other.attachPayload); + } + @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject(); if (account != null) { - builder.field(EmailAction.Parser.ACCOUNT_FIELD.getPreferredName(), account); + builder.field(Parser.ACCOUNT_FIELD.getPreferredName(), account); } if (profile != null) { - builder.field(EmailAction.Parser.PROFILE_FIELD.getPreferredName(), profile); + builder.field(Parser.PROFILE_FIELD.getPreferredName(), profile); + } + if (auth != null) { + builder.field(Parser.USER_FIELD.getPreferredName(), auth.user()); + builder.field(Parser.PASSWORD_FIELD.getPreferredName(), auth.password()); + } + builder.field(Parser.ATTACH_PAYLOAD_FIELD.getPreferredName(), attachPayload); + if (emailPrototype.from() != null) { + builder.field(Email.FROM_FIELD.getPreferredName(), emailPrototype.from()); + } + if (emailPrototype.to() != null && !emailPrototype.to().isEmpty()) { + builder.field(Email.TO_FIELD.getPreferredName(), (ToXContent) emailPrototype.to()); + } + if (emailPrototype.cc() != null && !emailPrototype.cc().isEmpty()) { + builder.field(Email.CC_FIELD.getPreferredName(), (ToXContent) emailPrototype.cc()); + } + if (emailPrototype.bcc() != null && !emailPrototype.bcc().isEmpty()) { + builder.field(Email.BCC_FIELD.getPreferredName(), (ToXContent) emailPrototype.bcc()); + } + if (emailPrototype.replyTo() != null && !emailPrototype.replyTo().isEmpty()) { + builder.field(Email.REPLY_TO_FIELD.getPreferredName(), (ToXContent) emailPrototype.replyTo()); } - builder.field(Email.TO_FIELD.getPreferredName(), (ToXContent) email.to()); if (subject != null) { builder.field(Email.SUBJECT_FIELD.getPreferredName(), subject); } if (textBody != null) { builder.field(Email.TEXT_BODY_FIELD.getPreferredName(), textBody); } + if (htmlBody != null) { + builder.field(Email.HTML_BODY_FIELD.getPreferredName(), htmlBody); + } + if (emailPrototype.priority() != null) { + builder.field(Email.PRIORITY_FIELD.getPreferredName(), emailPrototype.priority()); + } return builder.endObject(); } @@ -136,7 +190,7 @@ public class EmailAction extends Action { String password = null; String account = null; Profile profile = null; - Email.Builder email = Email.builder(); + Email.Builder email = Email.builder().id("prototype"); Template subject = null; Template textBody = null; Template htmlBody = null; @@ -202,12 +256,9 @@ public class EmailAction extends Action { } } - if (email.to() == null || email.to().isEmpty()) { - throw new ActionSettingsException("could not parse email action. [to] was not found or was empty"); - } + Authentication auth = user != null ? new Authentication(user, password) : null; - Authentication auth = new Authentication(user, password); - return new EmailAction(logger, emailService, email, auth, profile, account, subject, textBody, htmlBody, attachPayload); + return new EmailAction(logger, emailService, email.build(), auth, profile, account, subject, textBody, htmlBody, attachPayload); } @Override @@ -263,7 +314,6 @@ public class EmailAction extends Action { super(type, success); } - public static class Success extends Result { private final EmailService.EmailSent sent; @@ -297,6 +347,10 @@ public class EmailAction extends Action { this.reason = reason; } + public String reason() { + return reason; + } + @Override protected XContentBuilder xContentBody(XContentBuilder builder, Params params) throws IOException { return builder.field("reason", reason); diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/service/Account.java b/src/main/java/org/elasticsearch/alerts/actions/email/service/Account.java index 20745ddaee8..b1c8296ebf2 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/service/Account.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/service/Account.java @@ -43,8 +43,12 @@ public class Account { // applying the defaults on missing emails fields email = config.defaults.apply(email); + if (email.to == null) { + throw new EmailException("email must have [to] recipient"); + } + Transport transport = session.getTransport(SMTP_PROTOCOL); - String user = auth != null ? auth.username() : null; + String user = auth != null ? auth.user() : null; if (user == null) { user = config.smtp.user; if (user == null) { @@ -85,10 +89,10 @@ public class Account { static final String SMTP_SETTINGS_PREFIX = "mail.smtp."; - private final String name; - private final Profile profile; - private final Smtp smtp; - private final EmailDefaults defaults; + final String name; + final Profile profile; + final Smtp smtp; + final EmailDefaults defaults; public Config(String name, Settings settings) { this.name = name; @@ -106,16 +110,16 @@ public class Account { static class Smtp { - private final String host; - private final int port; - private final String user; - private final String password; - private final Properties properties; + final String host; + final int port; + final String user; + final String password; + 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))); + host = settings.get("host", settings.get("localaddress", settings.get("local_address"))); + port = settings.getAsInt("port", settings.getAsInt("localport", settings.getAsInt("local_port", 25))); + user = settings.get("user", settings.get("from", null)); password = settings.get("password", null); properties = loadSmtpProperties(settings); } @@ -165,7 +169,7 @@ public class Account { * 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 { + static class EmailDefaults { final Email.Address from; final Email.AddressList replyTo; @@ -186,16 +190,59 @@ public class Account { } Email apply(Email email) { - return Email.builder() - .from(from) - .replyTo(replyTo) - .priority(priority) - .to(to) - .cc(cc) - .bcc(bcc) - .subject(subject) - .copyFrom(email) - .build(); + Email.Builder builder = Email.builder().copyFrom(email); + if (email.from == null) { + builder.from(from); + } + if (email.replyTo == null) { + builder.replyTo(replyTo); + } + if (email.priority == null) { + builder.priority(priority); + } + if (email.to == null) { + builder.to(to); + } + if (email.cc == null) { + builder.cc(cc); + } + if (email.bcc == null) { + builder.bcc(bcc); + } + if (email.subject == null) { + builder.subject(subject); + } + return builder.build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + EmailDefaults that = (EmailDefaults) o; + + if (bcc != null ? !bcc.equals(that.bcc) : that.bcc != null) return false; + if (cc != null ? !cc.equals(that.cc) : that.cc != null) return false; + if (from != null ? !from.equals(that.from) : that.from != null) return false; + if (priority != that.priority) return false; + if (replyTo != null ? !replyTo.equals(that.replyTo) : that.replyTo != null) return false; + if (subject != null ? !subject.equals(that.subject) : that.subject != null) return false; + if (to != null ? !to.equals(that.to) : that.to != null) return false; + + return true; + } + + @Override + public int hashCode() { + int result = from != null ? from.hashCode() : 0; + result = 31 * result + (replyTo != null ? replyTo.hashCode() : 0); + result = 31 * result + (priority != null ? priority.hashCode() : 0); + result = 31 * result + (to != null ? to.hashCode() : 0); + result = 31 * result + (cc != null ? cc.hashCode() : 0); + result = 31 * result + (bcc != null ? bcc.hashCode() : 0); + result = 31 * result + (subject != null ? subject.hashCode() : 0); + return result; } } } diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/service/Accounts.java b/src/main/java/org/elasticsearch/alerts/actions/email/service/Accounts.java index c9a15893d28..9f70adff620 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/service/Accounts.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/service/Accounts.java @@ -8,7 +8,6 @@ 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; @@ -21,31 +20,27 @@ public class Accounts { private final Map accounts; public Accounts(Settings settings, ESLogger logger) { - settings = settings.getAsSettings("account"); - Map accounts = new HashMap<>(); - for (String name : settings.names()) { - Account.Config config = new Account.Config(name, settings.getAsSettings(name)); + Settings accountsSettings = settings.getAsSettings("account"); + accounts = new HashMap<>(); + for (String name : accountsSettings.names()) { + Account.Config config = new Account.Config(name, accountsSettings.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()); + String defaultAccountName = settings.get("default_account"); + if (defaultAccountName == null) { + if (accounts.isEmpty()) { + this.defaultAccountName = null; } else { - this.defaultAccountName = defaultAccountName; + Account account = accounts.values().iterator().next(); + logger.info("default account set to [{}]", account.name()); + this.defaultAccountName = account.name(); } + } else if (!accounts.containsKey(defaultAccountName)) { + throw new EmailSettingsException("could not fine default account [" + defaultAccountName + "]"); + } else { + this.defaultAccountName = defaultAccountName; } } diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/service/Attachment.java b/src/main/java/org/elasticsearch/alerts/actions/email/service/Attachment.java index 76c7a81fdab..a8ebdeb109c 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/service/Attachment.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/service/Attachment.java @@ -6,7 +6,6 @@ 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; @@ -26,16 +25,8 @@ 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); + protected Attachment(String id, String name, String contentType) { + super(id, name, contentType); } @Override @@ -43,12 +34,26 @@ public abstract class Attachment extends BodyPartSource { 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; } + public abstract String type(); + + /** + * 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("content_type", contentType) + .endObject(); + } + protected abstract void writeTo(MimeBodyPart part) throws MessagingException; public static class File extends Attachment { @@ -57,7 +62,6 @@ public abstract class Attachment extends BodyPartSource { private final Path path; private final DataSource dataSource; - private final String contentType; public File(String id, Path path) { this(id, path.getFileName().toString(), path); @@ -68,21 +72,13 @@ public abstract class Attachment extends BodyPartSource { } public File(String id, String name, Path path) { - this(id, name, name, path); + this(id, name, path, fileTypeMap.getContentType(path.toFile())); } + 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); + super(id, name, contentType); this.path = path; this.dataSource = new FileDataSource(path.toFile()); - this.contentType = contentType; } public Path path() { @@ -93,31 +89,9 @@ public abstract class Attachment extends BodyPartSource { 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(); + part.setDataHandler(new DataHandler(dataSource)); } } @@ -126,20 +100,18 @@ public abstract class Attachment extends BodyPartSource { 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, byte[] bytes) { + this(id, name, bytes, fileTypeMap.getContentType(name)); } - public Bytes(String id, String name, String description, byte[] bytes, String contentType) { - super(id, name, description); + public Bytes(String id, String name, byte[] bytes, String contentType) { + super(id, name, contentType); this.bytes = bytes; - this.contentType = contentType; } public String type() { @@ -150,27 +122,12 @@ public abstract class Attachment extends BodyPartSource { 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 { @@ -180,11 +137,7 @@ public abstract class Attachment extends BodyPartSource { } 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)); + super(id, name, bytes(name, content, type), mimeType(type)); } static String mimeType(XContentType type) { @@ -202,7 +155,7 @@ public abstract class Attachment extends BodyPartSource { try { XContentBuilder builder = XContentBuilder.builder(type.xContent()); content.toXContent(builder, ToXContent.EMPTY_PARAMS); - return builder.bytes().array(); + return builder.bytes().toBytes(); } catch (IOException ioe) { throw new EmailException("could not create an xcontent attachment [" + name + "]", ioe); } @@ -218,10 +171,6 @@ public abstract class Attachment extends BodyPartSource { 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"; @@ -238,10 +187,6 @@ public abstract class Attachment extends BodyPartSource { 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"; diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/service/Authentication.java b/src/main/java/org/elasticsearch/alerts/actions/email/service/Authentication.java index f9c483e653a..7541f870cd5 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/service/Authentication.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/service/Authentication.java @@ -10,19 +10,39 @@ package org.elasticsearch.alerts.actions.email.service; */ public class Authentication { - private final String username; + private final String user; private final String password; - public Authentication(String username, String password) { - this.username = username; + public Authentication(String user, String password) { + this.user = user; this.password = password; } - public String username() { - return username; + public String user() { + return user; } public String password() { return password; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Authentication that = (Authentication) o; + + if (password != null ? !password.equals(that.password) : that.password != null) return false; + if (user != null ? !user.equals(that.user) : that.user != null) return false; + + return true; + } + + @Override + public int hashCode() { + int result = user != null ? user.hashCode() : 0; + result = 31 * result + (password != null ? password.hashCode() : 0); + return result; + } } diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/service/Email.java b/src/main/java/org/elasticsearch/alerts/actions/email/service/Email.java index bf450c507ed..c44f89c16b1 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/service/Email.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/service/Email.java @@ -20,16 +20,14 @@ 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; +import java.util.*; /** * */ public class Email implements ToXContent { + public static final ParseField ID_FIELD = new ParseField("id"); 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"); @@ -40,8 +38,6 @@ public class Email implements ToXContent { 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; @@ -65,7 +61,7 @@ public class Email implements ToXContent { this.from = from; this.replyTo = replyTo; this.priority = priority; - this.sentDate = sentDate; + this.sentDate = sentDate != null ? sentDate : new DateTime(); this.to = to; this.cc = cc; this.bcc = bcc; @@ -76,6 +72,10 @@ public class Email implements ToXContent { this.inlines = inlines; } + public String id() { + return id; + } + public Address from() { return from; } @@ -126,20 +126,46 @@ public class Email implements ToXContent { @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(); + builder.startObject(); + builder.field(ID_FIELD.getPreferredName(), id); + builder.field(FROM_FIELD.getPreferredName(), from); + if (replyTo != null) { + builder.field(REPLY_TO_FIELD.getPreferredName(), (ToXContent) replyTo); + } + if (priority != null) { + builder.field(PRIORITY_FIELD.getPreferredName(), priority); + } + builder.field(SENT_DATE_FIELD.getPreferredName(), sentDate); + builder.field(TO_FIELD.getPreferredName(), (ToXContent) to); + if (cc != null) { + builder.field(CC_FIELD.getPreferredName(), (ToXContent) cc); + } + if (bcc != null) { + builder.field(BCC_FIELD.getPreferredName(), (ToXContent) bcc); + } + builder.field(SUBJECT_FIELD.getPreferredName(), subject); + builder.field(TEXT_BODY_FIELD.getPreferredName(), textBody); + if (htmlBody != null) { + builder.field(HTML_BODY_FIELD.getPreferredName(), htmlBody); + } + return builder.endObject(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Email email = (Email) o; + + if (!id.equals(email.id)) return false; + + return true; + } + + @Override + public int hashCode() { + return id.hashCode(); } public static Builder builder() { @@ -154,7 +180,9 @@ public class Email implements ToXContent { if (token == XContentParser.Token.FIELD_NAME) { currentFieldName = parser.currentName(); } else if ((token.isValue() || token == XContentParser.Token.START_OBJECT || token == XContentParser.Token.START_ARRAY) && currentFieldName != null) { - if (FROM_FIELD.match(currentFieldName)) { + if (ID_FIELD.match(currentFieldName)) { + email.id(parser.text()); + } else if (FROM_FIELD.match(currentFieldName)) { email.from(Address.parse(currentFieldName, token, parser)); } else if (REPLY_TO_FIELD.match(currentFieldName)) { email.replyTo(AddressList.parse(currentFieldName, token, parser)); @@ -174,10 +202,6 @@ public class Email implements ToXContent { email.textBody(parser.text()); } else if (HTML_BODY_FIELD.match(currentFieldName)) { email.htmlBody(parser.text()); - } else if (ATTACHMENTS_FIELD.match(currentFieldName)) { - //@TODO handle this - } else if (INLINES_FIELD.match(currentFieldName)) { - //@TODO handle this } else { throw new EmailException("could not parse email. unrecognized field [" + currentFieldName + "]"); } @@ -293,7 +317,6 @@ public class Email implements ToXContent { 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()); } @@ -427,6 +450,8 @@ public class Email implements ToXContent { public static class AddressList implements Iterable
, ToXContent { + public static final AddressList EMPTY = new AddressList(Collections.
emptyList()); + private final List
addresses; public AddressList(List
addresses) { @@ -446,6 +471,10 @@ public class Email implements ToXContent { return addresses.toArray(new Address[addresses.size()]); } + public int size() { + return addresses.size(); + } + @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startArray(); @@ -499,6 +528,23 @@ public class Email implements ToXContent { 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"); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + AddressList addresses1 = (AddressList) o; + + if (!addresses.equals(addresses1.addresses)) return false; + + return true; + } + + @Override + public int hashCode() { + return addresses.hashCode(); + } } } diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/service/EmailService.java b/src/main/java/org/elasticsearch/alerts/actions/email/service/EmailService.java index 7cee9e3993d..0dbef98ce92 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/service/EmailService.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/service/EmailService.java @@ -5,17 +5,11 @@ */ 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); diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/service/Inline.java b/src/main/java/org/elasticsearch/alerts/actions/email/service/Inline.java index 13084a95170..8a925f20634 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/service/Inline.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/service/Inline.java @@ -6,6 +6,9 @@ package org.elasticsearch.alerts.actions.email.service; import org.elasticsearch.alerts.actions.email.service.support.BodyPartSource; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.inject.Provider; import org.elasticsearch.common.xcontent.XContentBuilder; import javax.activation.DataHandler; @@ -14,7 +17,10 @@ import javax.activation.FileDataSource; import javax.mail.MessagingException; import javax.mail.Part; import javax.mail.internet.MimeBodyPart; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.nio.file.Path; /** @@ -22,26 +28,35 @@ import java.nio.file.Path; */ public abstract class Inline extends BodyPartSource { - public Inline(String id) { - super(id); + protected Inline(String id, String name, String contentType) { + super(id, name, contentType); } - public Inline(String id, String name) { - super(id, name); - } - - public Inline(String id, String name, String description) { - super(id, name, description); - } + public abstract String type(); @Override public final MimeBodyPart bodyPart() throws MessagingException { MimeBodyPart part = new MimeBodyPart(); part.setDisposition(Part.INLINE); + part.setContentID(id); + part.setFileName(name); writeTo(part); return part; } + /** + * 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("content_type", contentType) + .endObject(); + } + protected abstract void writeTo(MimeBodyPart part) throws MessagingException; public static class File extends Inline { @@ -50,32 +65,19 @@ public abstract class Inline extends BodyPartSource { 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); + this(id, name, path, fileTypeMap.getContentType(path.toFile())); } + 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); + super(id, name, contentType); this.path = path; this.dataSource = new FileDataSource(path.toFile()); - this.contentType = contentType; } public Path path() { @@ -86,30 +88,99 @@ public abstract class Inline extends BodyPartSource { 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); + part.setDataHandler(new DataHandler(dataSource, contentType)); + } + } + + public static class Stream extends Inline { + + static final String TYPE = "stream"; + + private final Provider source; + + public Stream(String id, String name, Provider source) { + this(id, name, fileTypeMap.getContentType(name), source); + } + + public Stream(String id, String name, String contentType, Provider source) { + super(id, name, contentType); + this.source = source; } - /** - * 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 String type() { + return TYPE; + } + + @Override + protected void writeTo(MimeBodyPart part) throws MessagingException { + DataSource ds = new StreamDataSource(name, contentType, source); + DataHandler dh = new DataHandler(ds); + part.setDataHandler(dh); + } + + static class StreamDataSource implements DataSource { + + private final String name; + private final String contentType; + private final Provider source; + + public StreamDataSource(String name, String contentType, Provider source) { + this.name = name; + this.contentType = contentType; + this.source = source; + } + + @Override + public InputStream getInputStream() throws IOException { + return source.get(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public String getName() { + return name; + } + } + } + + public static class Bytes extends Stream { + + public Bytes(String id, String name, String contentType, byte[] bytes) { + super(id, name, contentType, new BytesStreamProvider(bytes)); + } + + public Bytes(String id, String name, String contentType, BytesReference bytes) { + super(id, name, contentType, new BytesStreamProvider(bytes)); + } + + static class BytesStreamProvider implements Provider { + + private final BytesReference bytes; + + public BytesStreamProvider(byte[] bytes) { + this(new BytesArray(bytes)); + } + + public BytesStreamProvider(BytesReference bytes) { + this.bytes = bytes; + } + + @Override + public InputStream get() { + return new ByteArrayInputStream(bytes.array(), bytes.arrayOffset(), bytes.length()); + } } } } diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/service/InternalEmailService.java b/src/main/java/org/elasticsearch/alerts/actions/email/service/InternalEmailService.java index e53eec8398a..1f28ecac190 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/service/InternalEmailService.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/service/InternalEmailService.java @@ -5,25 +5,23 @@ */ package org.elasticsearch.alerts.actions.email.service; -import org.elasticsearch.cluster.ClusterState; -import org.elasticsearch.common.component.AbstractComponent; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.common.component.AbstractLifecycleComponent; import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.logging.ESLogger; 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 { +public class InternalEmailService extends AbstractLifecycleComponent implements EmailService { private volatile Accounts accounts; - private final AtomicBoolean started = new AtomicBoolean(false); - @Inject public InternalEmailService(Settings settings, NodeSettingsService nodeSettingsService) { super(settings); @@ -35,6 +33,19 @@ public class InternalEmailService extends AbstractComponent implements EmailServ }); } + @Override + protected void doStart() throws ElasticsearchException { + reset(settings); + } + + @Override + protected void doStop() throws ElasticsearchException { + } + + @Override + protected void doClose() throws ElasticsearchException { + } + @Override public EmailSent send(Email email, Authentication auth, Profile profile) { return send(email, auth, profile, (String) null); @@ -59,29 +70,16 @@ public class InternalEmailService extends AbstractComponent implements EmailServ 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; - } + void reset(Settings nodeSettings) { Settings settings = ImmutableSettings.builder() .put(componentSettings) .put(nodeSettings.getComponentSettings(InternalEmailService.class)) .build(); - accounts = new Accounts(settings, logger); + accounts = createAccounts(settings, logger); + } + + protected Accounts createAccounts(Settings settings, ESLogger logger) { + return new Accounts(settings, logger); } } diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/service/Profile.java b/src/main/java/org/elasticsearch/alerts/actions/email/service/Profile.java index 8fdf4ceb22e..547708a10df 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/service/Profile.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/service/Profile.java @@ -16,7 +16,6 @@ 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; /** @@ -26,11 +25,51 @@ import java.util.Locale; public enum Profile implements ToXContent { STANDARD() { + + @Override + public String textBody(MimeMessage msg) throws IOException, MessagingException { + MimeMultipart mixed = (MimeMultipart) msg.getContent(); + MimeMultipart related = null; + for (int i = 0; i < mixed.getCount(); i++) { + MimeBodyPart part = (MimeBodyPart) mixed.getBodyPart(i); + + if (part.getContentType().startsWith("multipart/related")) { + related = (MimeMultipart) part.getContent(); + break; + } + } + if (related == null) { + throw new EmailException("could not extract body text from mime message"); + } + + MimeMultipart alternative = null; + for (int i = 0; i < related.getCount(); i++) { + MimeBodyPart part = (MimeBodyPart) related.getBodyPart(i); + if (part.getContentType().startsWith("multipart/alternative")) { + alternative = (MimeMultipart) part.getContent(); + break; + } + } + if (alternative == null) { + throw new EmailException("could not extract body text from mime message"); + } + + for (int i = 0; i < alternative.getCount(); i++) { + MimeBodyPart part = (MimeBodyPart) alternative.getBodyPart(i); + if (part.getContentType().startsWith("text/plain")) { + return (String) part.getContent(); + } + } + + throw new EmailException("could not extract body text from mime message"); + } + @Override public MimeMessage toMimeMessage(Email email, Session session) throws MessagingException { MimeMessage message = createCommon(email, session); MimeMultipart mixed = new MimeMultipart("mixed"); + message.setContent(mixed); MimeMultipart related = new MimeMultipart("related"); mixed.addBodyPart(wrap(related, null)); @@ -39,12 +78,16 @@ public enum Profile implements ToXContent { related.addBodyPart(wrap(alternative, "text/alternative")); MimeBodyPart text = new MimeBodyPart(); - text.setText(email.textBody, Charsets.UTF_8.name()); + if (email.textBody != null) { + text.setText(email.textBody, Charsets.UTF_8.name()); + } else { + text.setText("", Charsets.UTF_8.name()); + } alternative.addBodyPart(text); if (email.htmlBody != null) { MimeBodyPart html = new MimeBodyPart(); - text.setText(email.textBody, Charsets.UTF_8.name(), "html"); + html.setText(email.htmlBody, Charsets.UTF_8.name(), "html"); alternative.addBodyPart(html); } @@ -65,18 +108,36 @@ public enum Profile implements ToXContent { }, OUTLOOK() { + + @Override + public String textBody(MimeMessage msg) throws IOException, MessagingException { + return STANDARD.textBody(msg); + } + @Override public MimeMessage toMimeMessage(Email email, Session session) throws MessagingException { return STANDARD.toMimeMessage(email, session); } }, GMAIL() { + + @Override + public String textBody(MimeMessage msg) throws IOException, MessagingException { + return STANDARD.textBody(msg); + } + @Override public MimeMessage toMimeMessage(Email email, Session session) throws MessagingException { return STANDARD.toMimeMessage(email, session); } }, MAC() { + + @Override + public String textBody(MimeMessage msg) throws IOException, MessagingException { + return STANDARD.textBody(msg); + } + @Override public MimeMessage toMimeMessage(Email email, Session session) throws MessagingException { return STANDARD.toMimeMessage(email, session); @@ -87,6 +148,8 @@ public enum Profile implements ToXContent { public abstract MimeMessage toMimeMessage(Email email, Session session) throws MessagingException ; + public abstract String textBody(MimeMessage msg) throws IOException, MessagingException; + public static Profile resolve(String name) { Profile profile = resolve(name, null); if (profile == null) { @@ -104,6 +167,7 @@ public enum Profile implements ToXContent { case "standard": return STANDARD; case "outlook": return OUTLOOK; case "gmail": return GMAIL; + case "mac": return MAC; default: return defaultProfile; } @@ -126,14 +190,19 @@ public enum Profile implements ToXContent { if (email.priority != null) { email.priority.applyTo(message); } - Date sentDate = email.sentDate != null ? email.sentDate.toDate() : new Date(); - message.setSentDate(sentDate); - + message.setSentDate(email.sentDate.toDate()); 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()); + if (email.cc != null) { + message.setRecipients(Message.RecipientType.CC, email.cc.toArray()); + } + if (email.bcc != null) { + message.setRecipients(Message.RecipientType.BCC, email.bcc.toArray()); + } + if (email.subject != null) { + message.setSubject(email.subject, Charsets.UTF_8.name()); + } else { + message.setSubject("", Charsets.UTF_8.name()); + } return message; } diff --git a/src/main/java/org/elasticsearch/alerts/actions/email/service/support/BodyPartSource.java b/src/main/java/org/elasticsearch/alerts/actions/email/service/support/BodyPartSource.java index 3cfa1548bcb..f5339d49258 100644 --- a/src/main/java/org/elasticsearch/alerts/actions/email/service/support/BodyPartSource.java +++ b/src/main/java/org/elasticsearch/alerts/actions/email/service/support/BodyPartSource.java @@ -7,6 +7,7 @@ package org.elasticsearch.alerts.actions.email.service.support; import org.elasticsearch.common.xcontent.ToXContent; +import javax.activation.FileTypeMap; import javax.mail.MessagingException; import javax.mail.internet.MimeBodyPart; @@ -15,22 +16,20 @@ import javax.mail.internet.MimeBodyPart; */ public abstract class BodyPartSource implements ToXContent { + protected static FileTypeMap fileTypeMap = FileTypeMap.getDefaultFileTypeMap(); + protected final String id; protected final String name; - protected final String description; + protected final String contentType; - public BodyPartSource(String id) { - this(id, id); + public BodyPartSource(String id, String contentType) { + this(id, id, contentType); } - public BodyPartSource(String id, String name) { - this(id, name, name); - } - - public BodyPartSource(String id, String name, String description) { + public BodyPartSource(String id, String name, String contentType) { this.id = id; this.name = name; - this.description = description; + this.contentType = contentType; } public String id() { @@ -41,8 +40,8 @@ public abstract class BodyPartSource implements ToXContent { return name; } - public String description() { - return description; + public String contentType() { + return contentType; } public abstract MimeBodyPart bodyPart() throws MessagingException; diff --git a/src/main/java/org/elasticsearch/alerts/support/template/ScriptTemplate.java b/src/main/java/org/elasticsearch/alerts/support/template/ScriptTemplate.java index 029bdc1a81f..9d59fc6824a 100644 --- a/src/main/java/org/elasticsearch/alerts/support/template/ScriptTemplate.java +++ b/src/main/java/org/elasticsearch/alerts/support/template/ScriptTemplate.java @@ -50,6 +50,10 @@ public class ScriptTemplate implements ToXContent, Template { this(service, text, DEFAULT_LANG, ScriptService.ScriptType.INLINE, Collections.emptyMap()); } + public ScriptTemplate(ScriptServiceProxy service, String text, String lang, ScriptService.ScriptType type) { + this(service, text, lang, type, Collections.emptyMap()); + } + public ScriptTemplate(ScriptServiceProxy service, String text, String lang, ScriptService.ScriptType type, Map params) { this.service = service; this.text = text; @@ -135,8 +139,12 @@ public class ScriptTemplate implements ToXContent, Template { @Override public ScriptTemplate parse(XContentParser parser) throws IOException { - assert parser.currentToken() == XContentParser.Token.START_OBJECT : "Expected START_OBJECT, but was " + parser.currentToken(); - + if (parser.currentToken() == XContentParser.Token.VALUE_STRING) { + return new ScriptTemplate(scriptService, parser.text()); + } + if (parser.currentToken() != XContentParser.Token.START_OBJECT) { + throw new ParseException("expected either a string or an object, but found [" + parser.currentToken() + "] instead"); + } String text = null; ScriptService.ScriptType type = ScriptService.ScriptType.INLINE; String lang = DEFAULT_LANG; diff --git a/src/test/java/org/elasticsearch/alerts/AbstractAlertingTests.java b/src/test/java/org/elasticsearch/alerts/AbstractAlertingTests.java index 6315fed66ac..47f1f2d49a6 100644 --- a/src/test/java/org/elasticsearch/alerts/AbstractAlertingTests.java +++ b/src/test/java/org/elasticsearch/alerts/AbstractAlertingTests.java @@ -55,7 +55,7 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.util.*; -import static org.elasticsearch.common.xcontent.XContentFactory.*; +import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; import static org.elasticsearch.index.query.QueryBuilders.*; import static org.elasticsearch.search.builder.SearchSourceBuilder.searchSource; import static org.hamcrest.Matchers.*; @@ -174,12 +174,12 @@ public abstract class AbstractAlertingTests extends ElasticsearchIntegrationTest Email.AddressList to = new Email.AddressList(emailAddressList); - Email.Builder emailBuilder = Email.builder(); + Email.Builder emailBuilder = Email.builder().id("prototype"); emailBuilder.from(from); emailBuilder.to(to); - EmailAction emailAction = new EmailAction(logger, noopEmailService(), emailBuilder, + EmailAction emailAction = new EmailAction(logger, noopEmailService(), emailBuilder.build(), new Authentication("testname", "testpassword"), Profile.STANDARD, "testaccount", body, body, null, true); actions.add(emailAction); @@ -471,19 +471,10 @@ public abstract class AbstractAlertingTests extends ElasticsearchIntegrationTest } private static class NoopEmailService implements EmailService { - @Override - public void start(ClusterState state) { - - } - - @Override - public void stop() { - - } @Override public EmailSent send(Email email, Authentication auth, Profile profile) { - return new EmailSent(auth.username(), email); + return new EmailSent(auth.user(), email); } @Override diff --git a/src/test/java/org/elasticsearch/alerts/actions/email/EmailActionTest.java b/src/test/java/org/elasticsearch/alerts/actions/email/EmailActionTest.java deleted file mode 100644 index 38d29972222..00000000000 --- a/src/test/java/org/elasticsearch/alerts/actions/email/EmailActionTest.java +++ /dev/null @@ -1,116 +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.alerts.Alert; -import org.elasticsearch.alerts.ExecutionContext; -import org.elasticsearch.alerts.Payload; -import org.elasticsearch.alerts.actions.email.service.Authentication; -import org.elasticsearch.alerts.actions.email.service.Email; -import org.elasticsearch.alerts.actions.email.service.EmailService; -import org.elasticsearch.alerts.actions.email.service.Profile; -import org.elasticsearch.alerts.scheduler.schedule.CronSchedule; -import org.elasticsearch.alerts.support.init.proxy.ScriptServiceProxy; -import org.elasticsearch.alerts.support.template.ScriptTemplate; -import org.elasticsearch.cluster.ClusterState; -import org.elasticsearch.common.joda.time.DateTime; -import org.elasticsearch.common.settings.ImmutableSettings; -import org.elasticsearch.common.settings.Settings; -import org.elasticsearch.common.unit.TimeValue; -import org.elasticsearch.env.Environment; -import org.elasticsearch.script.ScriptEngineService; -import org.elasticsearch.script.ScriptService; -import org.elasticsearch.script.mustache.MustacheScriptEngineService; -import org.elasticsearch.test.ElasticsearchTestCase; -import org.elasticsearch.threadpool.ThreadPool; -import org.elasticsearch.watcher.ResourceWatcherService; - -import javax.mail.MessagingException; -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -/** -*/ -public class EmailActionTest extends ElasticsearchTestCase { - - public void testEmailTemplateRender() throws IOException, MessagingException { - - Settings settings = ImmutableSettings.settingsBuilder().build(); - MustacheScriptEngineService mustacheScriptEngineService = new MustacheScriptEngineService(settings); - ThreadPool threadPool = new ThreadPool(ThreadPool.Names.SAME); - Set engineServiceSet = new HashSet<>(); - engineServiceSet.add(mustacheScriptEngineService); - - ScriptServiceProxy scriptService = ScriptServiceProxy.of(new ScriptService(settings, new Environment(), engineServiceSet, new ResourceWatcherService(settings, threadPool))); - - ScriptTemplate template = new ScriptTemplate(scriptService, "{{alert_name}} executed with {{response.hits.total}} hits"); - - EmailService emailService = new EmailServiceMock(); - - Email.Address from = new Email.Address("from@test.com"); - List emailAddressList = new ArrayList<>(); - emailAddressList.add(new Email.Address("to@test.com")); - Email.AddressList to = new Email.AddressList(emailAddressList); - - - Email.Builder emailBuilder = Email.builder(); - emailBuilder.from(from); - emailBuilder.to(to); - - EmailAction emailAction = new EmailAction(logger, emailService, emailBuilder, - new Authentication("testname", "testpassword"), Profile.STANDARD, "testaccount", template, template, null, true); - - //This is ok since the execution of the action only relies on the alert name - Alert alert = new Alert( - "test-serialization", - new CronSchedule("0/5 * * * * ? *"), - null, - null, - new TimeValue(0), - null, - null, - new Alert.Status() - ); - ExecutionContext ctx = new ExecutionContext("test-serialization#1", alert, new DateTime(), new DateTime()); - EmailAction.Result result = emailAction.execute(ctx, new Payload.Simple()); - - threadPool.shutdownNow(); - - assertTrue(result.success()); - - EmailAction.Result.Success success = (EmailAction.Result.Success) result; - assertEquals(success.account(), "testaccount"); - assertArrayEquals(success.email().to().toArray(), to.toArray() ); - assertEquals(success.email().from(), from); - //@TODO add more here - } - - static class EmailServiceMock implements EmailService { - @Override - public void start(ClusterState state) { - - } - - @Override - public void stop() { - - } - - @Override - public EmailSent send(Email email, Authentication auth, Profile profile) { - return new EmailSent(auth.username(), email); - } - - @Override - public EmailSent send(Email email, Authentication auth, Profile profile, String accountName) { - return new EmailSent(accountName, email); - } - } - -} diff --git a/src/test/java/org/elasticsearch/alerts/actions/email/EmailActionTests.java b/src/test/java/org/elasticsearch/alerts/actions/email/EmailActionTests.java new file mode 100644 index 00000000000..90f2f49f51e --- /dev/null +++ b/src/test/java/org/elasticsearch/alerts/actions/email/EmailActionTests.java @@ -0,0 +1,365 @@ +/* + * 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 com.carrotsearch.randomizedtesting.annotations.Repeat; +import org.elasticsearch.alerts.Alert; +import org.elasticsearch.alerts.ExecutionContext; +import org.elasticsearch.alerts.Payload; +import org.elasticsearch.alerts.actions.ActionSettingsException; +import org.elasticsearch.alerts.actions.email.service.*; +import org.elasticsearch.alerts.support.init.proxy.ScriptServiceProxy; +import org.elasticsearch.alerts.support.template.ScriptTemplate; +import org.elasticsearch.alerts.support.template.Template; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.collect.ImmutableMap; +import org.elasticsearch.common.joda.time.DateTime; +import org.elasticsearch.common.settings.ImmutableSettings; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.test.ElasticsearchTestCase; +import org.junit.Test; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * + */ +public class EmailActionTests extends ElasticsearchTestCase { + + @Test @Repeat(iterations = 20) + public void testExecute() throws Exception { + final String account = "account1"; + EmailService service = new EmailService() { + @Override + public EmailSent send(Email email, Authentication auth, Profile profile) { + return new EmailSent(account, email); + } + + @Override + public EmailSent send(Email email, Authentication auth, Profile profile, String accountName) { + return new EmailSent(account, email); + } + }; + Email email = Email.builder().id("prototype").build(); + Authentication auth = new Authentication("user", "passwd"); + Profile profile = randomFrom(Profile.values()); + + Template subject = mock(Template.class); + Template textBody = mock(Template.class); + Template htmlBody = randomBoolean() ? null : mock(Template.class); + boolean attachPayload = randomBoolean(); + EmailAction action = new EmailAction(logger, service, email, auth, profile, account, subject, textBody, htmlBody, attachPayload); + + final Map data = new HashMap<>(); + Payload payload = new Payload() { + @Override + public Map data() { + return data; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.map(data); + } + }; + + String ctxId = randomAsciiOfLength(5); + ExecutionContext ctx = mock(ExecutionContext.class); + when(ctx.id()).thenReturn(ctxId); + Alert alert = mock(Alert.class); + when(alert.name()).thenReturn("alert1"); + when(ctx.alert()).thenReturn(alert); + + Map expectedModel = ImmutableMap.builder() + .put("alert_name", "alert1") + .put("payload", data) + .build(); + + when(subject.render(expectedModel)).thenReturn("_subject"); + when(textBody.render(expectedModel)).thenReturn("_text_body"); + if (htmlBody != null) { + when (htmlBody.render(expectedModel)).thenReturn("_html_body"); + } + + EmailAction.Result result = action.execute(ctx, payload); + + assertThat(result, notNullValue()); + assertThat(result, instanceOf(EmailAction.Result.Success.class)); + assertThat(((EmailAction.Result.Success) result).account(), equalTo(account)); + Email actualEmail = ((EmailAction.Result.Success) result).email(); + assertThat(actualEmail.id(), is(ctxId)); + assertThat(actualEmail, notNullValue()); + assertThat(actualEmail.subject(), is("_subject")); + assertThat(actualEmail.textBody(), is("_text_body")); + if (htmlBody != null) { + assertThat(actualEmail.htmlBody(), is("_html_body")); + } + if (attachPayload) { + assertThat(actualEmail.attachments(), hasKey("payload")); + } + } + + @Test @Repeat(iterations = 20) + public void testParser() throws Exception { + ScriptServiceProxy scriptService = mock(ScriptServiceProxy.class); + EmailService emailService = mock(EmailService.class); + Profile profile = randomFrom(Profile.values()); + Email.Priority priority = randomFrom(Email.Priority.values()); + Email.Address[] to = rarely() ? null : Email.AddressList.parse(randomBoolean() ? "to@domain" : "to1@domain,to2@domain").toArray(); + Email.Address[] cc = rarely() ? null : Email.AddressList.parse(randomBoolean() ? "cc@domain" : "cc1@domain,cc2@domain").toArray(); + Email.Address[] bcc = rarely() ? null : Email.AddressList.parse(randomBoolean() ? "bcc@domain" : "bcc1@domain,bcc2@domain").toArray(); + Email.Address[] replyTo = rarely() ? null : Email.AddressList.parse(randomBoolean() ? "reply@domain" : "reply1@domain,reply2@domain").toArray(); + ScriptTemplate subject = randomBoolean() ? new ScriptTemplate(scriptService, "_subject") : null; + ScriptTemplate textBody = randomBoolean() ? new ScriptTemplate(scriptService, "_text_body") : null; + ScriptTemplate htmlBody = randomBoolean() ? new ScriptTemplate(scriptService, "_text_html") : null; + boolean attachPayload = randomBoolean(); + XContentBuilder builder = jsonBuilder().startObject() + .field("account", "_account") + .field("profile", profile.name()) + .field("user", "_user") + .field("password", "_passwd") + .field("attach_payload", attachPayload) + .field("from", "from@domain") + .field("priority", priority.name()); + if (to != null) { + if (to.length == 1) { + builder.field("to", to[0]); + } else { + builder.array("to", (Object[]) to); + } + } + if (cc != null) { + if (cc.length == 1) { + builder.field("cc", cc[0]); + } else { + builder.array("cc", (Object[]) cc); + } + } + if (bcc != null) { + if (bcc.length == 1) { + builder.field("bcc", bcc[0]); + } else { + builder.array("bcc", (Object[]) bcc); + } + } + if (replyTo != null) { + if (replyTo.length == 1) { + builder.field("reply_to", replyTo[0]); + } else { + builder.array("reply_to", (Object[]) replyTo); + } + } + if (subject != null) { + if (randomBoolean()) { + builder.field("subject", subject.text()); + } else { + builder.field("subject", subject); + } + } + if (textBody != null) { + if (randomBoolean()) { + builder.field("text_body", textBody.text()); + } else { + builder.field("text_body", textBody); + } + } + if (htmlBody != null) { + if (randomBoolean()) { + builder.field("html_body", htmlBody.text()); + } else { + builder.field("html_body", htmlBody); + } + } + BytesReference bytes = builder.bytes(); + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + EmailAction action = new EmailAction.Parser(ImmutableSettings.EMPTY, emailService, + new ScriptTemplate.Parser(ImmutableSettings.EMPTY, scriptService)).parse(parser); + + assertThat(action, notNullValue()); + assertThat(action.account, is("_account")); + assertThat(action.attachPayload, is(attachPayload)); + assertThat(action.auth, notNullValue()); + assertThat(action.auth.user(), is("_user")); + assertThat(action.auth.password(), is("_passwd")); + assertThat(action.subject, is((Template) subject)); + assertThat(action.textBody, is((Template) textBody)); + assertThat(action.htmlBody, is((Template) htmlBody)); + assertThat(action.emailPrototype.priority(), is(priority)); + if (to != null) { + assertThat(action.emailPrototype.to().toArray(), arrayContainingInAnyOrder(to)); + } else { + assertThat(action.emailPrototype.to(), nullValue()); + } + if (cc != null) { + assertThat(action.emailPrototype.cc().toArray(), arrayContainingInAnyOrder(cc)); + } else { + assertThat(action.emailPrototype.cc(), nullValue()); + } + if (bcc != null) { + assertThat(action.emailPrototype.bcc().toArray(), arrayContainingInAnyOrder(bcc)); + } else { + assertThat(action.emailPrototype.bcc(), nullValue()); + } + if (replyTo != null) { + assertThat(action.emailPrototype.replyTo().toArray(), arrayContainingInAnyOrder(replyTo)); + } else { + assertThat(action.emailPrototype.replyTo(), nullValue()); + } + } + + @Test @Repeat(iterations = 20) + public void testParser_SelfGenerated() throws Exception { + EmailService service = mock(EmailService.class); + Email.Builder emailPrototypeBuilder = Email.builder().id("prototype"); + if (randomBoolean()) { + emailPrototypeBuilder.from(new Email.Address("from@domain")); + } + if (randomBoolean()) { + emailPrototypeBuilder.to(Email.AddressList.parse(randomBoolean() ? "to@domain" : "to1@domain,to2@damain")); + } + if (randomBoolean()) { + emailPrototypeBuilder.cc(Email.AddressList.parse(randomBoolean() ? "cc@domain" : "cc1@domain,cc2@damain")); + } + if (randomBoolean()) { + emailPrototypeBuilder.bcc(Email.AddressList.parse(randomBoolean() ? "bcc@domain" : "bcc1@domain,bcc2@damain")); + } + if (randomBoolean()) { + emailPrototypeBuilder.replyTo(Email.AddressList.parse(randomBoolean() ? "reply@domain" : "reply1@domain,reply2@damain")); + } + Email email = emailPrototypeBuilder.build(); + Authentication auth = randomBoolean() ? null : new Authentication("_user", "_passwd"); + Profile profile = randomFrom(Profile.values()); + String account = randomAsciiOfLength(6); + Template subject = new TemplateMock("_subject"); + Template textBody = new TemplateMock("_text_body"); + Template htmlBody = randomBoolean() ? null : new TemplateMock("_html_body"); + boolean attachPayload = randomBoolean(); + + EmailAction action = new EmailAction(logger, service, email, auth, profile, account, subject, textBody, htmlBody, attachPayload); + + XContentBuilder builder = jsonBuilder(); + action.toXContent(builder, Attachment.XContent.EMPTY_PARAMS); + BytesReference bytes = builder.bytes(); + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + EmailAction parsed = new EmailAction.Parser(ImmutableSettings.EMPTY, service, new TemplateMock.Parser()).parse(parser); + assertThat(parsed, equalTo(action)); + + } + + @Test(expected = ActionSettingsException.class) @Repeat(iterations = 100) + public void testParser_Invalid() throws Exception { + ScriptServiceProxy scriptService = mock(ScriptServiceProxy.class); + EmailService emailService = mock(EmailService.class); + XContentBuilder builder = jsonBuilder().startObject() + .field("unknown_field", "value"); + BytesReference bytes = builder.bytes(); + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + new EmailAction.Parser(ImmutableSettings.EMPTY, emailService, + new ScriptTemplate.Parser(ImmutableSettings.EMPTY, scriptService)).parse(parser); + } + + @Test @Repeat(iterations = 20) + public void testParser_Result() throws Exception { + boolean success = randomBoolean(); + Email email = Email.builder().id("_id") + .from(new Email.Address("from@domain")) + .to(Email.AddressList.parse("to@domain")) + .sentDate(new DateTime()) + .subject("_subject") + .textBody("_text_body") + .build(); + XContentBuilder builder = jsonBuilder().startObject() + .field("success", success); + if (success) { + builder.field("email", email); + builder.field("account", "_account"); + } else { + builder.field("reason", "_reason"); + } + builder.endObject(); + BytesReference bytes = builder.bytes(); + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + EmailAction.Result result = new EmailAction.Parser(ImmutableSettings.EMPTY, mock(EmailService.class), new TemplateMock.Parser()) + .parseResult(parser); + assertThat(result.success(), is(success)); + if (success) { + assertThat(result, instanceOf(EmailAction.Result.Success.class)); + assertThat(((EmailAction.Result.Success) result).email(), equalTo(email)); + assertThat(((EmailAction.Result.Success) result).account(), is("_account")); + } else { + assertThat(result, instanceOf(EmailAction.Result.Failure.class)); + assertThat(((EmailAction.Result.Failure) result).reason(), is("_reason")); + } + } + + @Test(expected = EmailException.class) + public void testParser_Result_Invalid() throws Exception { + XContentBuilder builder = jsonBuilder().startObject() + .field("unknown_field", "value") + .endObject(); + BytesReference bytes = builder.bytes(); + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + new EmailAction.Parser(ImmutableSettings.EMPTY, mock(EmailService.class), new TemplateMock.Parser()) + .parseResult(parser); + } + + static class TemplateMock implements Template { + + private final String name; + + public TemplateMock(String name) { + this.name = name; + } + + @Override + public String render(Map model) { + return ""; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.value(name); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TemplateMock that = (TemplateMock) o; + + if (!name.equals(that.name)) return false; + + return true; + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + public static class Parser implements Template.Parser { + + @Override + public TemplateMock parse(XContentParser parser) throws IOException, ParseException { + return new TemplateMock(parser.text()); + } + } + } +} diff --git a/src/test/java/org/elasticsearch/alerts/actions/email/service/AccountTests.java b/src/test/java/org/elasticsearch/alerts/actions/email/service/AccountTests.java new file mode 100644 index 00000000000..15fa087e4b2 --- /dev/null +++ b/src/test/java/org/elasticsearch/alerts/actions/email/service/AccountTests.java @@ -0,0 +1,269 @@ +/* + * 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.EmailServer; +import org.elasticsearch.common.settings.ImmutableSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.test.ElasticsearchTestCase; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import javax.mail.Address; +import javax.mail.Message; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeMessage; +import java.util.Properties; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.Matchers.*; + +/** + * + */ +public class AccountTests extends ElasticsearchTestCase { + + static final String USERNAME = "_user"; + static final String PASSWORD = "_passwd"; + + private EmailServer server; + + @Before + public void init() throws Exception { + server = new EmailServer("localhost", 2500, USERNAME, PASSWORD); + server.start(); + } + + @After + public void cleanup() throws Exception { + server.stop(); + } + + @Test + public void testConfig() throws Exception { + + ImmutableSettings.Builder builder = ImmutableSettings.builder(); + + Profile profile = rarely() ? Profile.STANDARD : randomFrom(Profile.values()); + if (profile != Profile.STANDARD) { + builder.put("profile", profile.name()); + } + + Account.Config.EmailDefaults emailDefaults; + if (randomBoolean()) { + ImmutableSettings.Builder sb = ImmutableSettings.builder(); + if (randomBoolean()) { + sb.put(Email.FROM_FIELD.getPreferredName(), "from@domain"); + } + if (randomBoolean()) { + sb.put(Email.REPLY_TO_FIELD.getPreferredName(), "replyto@domain"); + } + if (randomBoolean()) { + sb.put(Email.PRIORITY_FIELD.getPreferredName(), randomFrom(Email.Priority.values())); + } + if (randomBoolean()) { + sb.put(Email.TO_FIELD.getPreferredName(), "to@domain"); + } + if (randomBoolean()) { + sb.put(Email.CC_FIELD.getPreferredName(), "cc@domain"); + } + if (randomBoolean()) { + sb.put(Email.BCC_FIELD.getPreferredName(), "bcc@domain"); + } + if (randomBoolean()) { + sb.put(Email.SUBJECT_FIELD.getPreferredName(), "_subject"); + } + Settings settings = sb.build(); + emailDefaults = new Account.Config.EmailDefaults(settings); + for (String name : settings.names()) { + builder.put("email_defaults." + name, settings.get(name)); + } + } else { + emailDefaults = new Account.Config.EmailDefaults(ImmutableSettings.EMPTY); + } + + Properties smtpProps = new Properties(); + ImmutableSettings.Builder smtpBuilder = ImmutableSettings.builder(); + String host = "somehost"; + String setting = randomFrom("host", "localaddress", "local_address"); + smtpBuilder.put(setting, host); + if (setting.equals("local_address")) { + // we need to remove the `_`... we only added support for `_` for readability + // the actual properties (java mail properties) don't contain underscores + setting = "localaddress"; + } + smtpProps.put("mail.smtp." + setting, host); + String user = null; + if (randomBoolean()) { + user = randomAsciiOfLength(5); + setting = randomFrom("user", "from"); + smtpBuilder.put(setting, user); + smtpProps.put("mail.smtp." + setting, user); + } + int port = 25; + if (randomBoolean()) { + port = randomIntBetween(2000, 2500); + setting = randomFrom("port", "localport", "local_port"); + smtpBuilder.put(setting, port); + if (setting.equals("local_port")) { + setting = "localport"; + } + smtpProps.setProperty("mail.smtp." + setting, String.valueOf(port)); + } + String password = null; + if (randomBoolean()) { + password = randomAsciiOfLength(8); + smtpBuilder.put("password", password); + smtpProps.put("mail.smtp.password", password); + } + for (int i = 0; i < 5; i++) { + String name = randomAsciiOfLength(5); + String value = randomAsciiOfLength(6); + smtpProps.put("mail.smtp." + name, value); + smtpBuilder.put(name, value); + } + + Settings smtpSettings = smtpBuilder.build(); + for (String name : smtpSettings.names()) { + builder.put("smtp." + name, smtpSettings.get(name)); + } + + Settings settings = builder.build(); + + Account.Config config = new Account.Config("_name", settings); + + if (profile != null) { + assertThat(config.profile, is(profile)); + } + assertThat(config.defaults, equalTo(emailDefaults)); + assertThat(config.smtp, notNullValue()); + assertThat(config.smtp.port, is(port)); + assertThat(config.smtp.host, is(host)); + assertThat(config.smtp.user, is(user)); + assertThat(config.smtp.password, is(password)); + assertThat(config.smtp.properties, equalTo(smtpProps)); + } + + @Test + public void testSend() throws Exception { + Account account = new Account(new Account.Config("default", ImmutableSettings.builder() + .put("smtp.host", "localhost") + .put("smtp.port", 2500) + .put("smtp.user", USERNAME) + .put("smtp.password", PASSWORD) + .build()), logger); + + Email email = Email.builder() + .id("_id") + .from(new Email.Address("from@domain.com")) + .to(Email.AddressList.parse("To")) + .subject("_subject") + .textBody("_text_body") + .build(); + + final CountDownLatch latch = new CountDownLatch(1); + EmailServer.Listener.Handle handle = server.addListener(new EmailServer.Listener() { + @Override + public void on(MimeMessage message) throws Exception { + assertThat(message.getFrom().length, is(1)); + assertThat((InternetAddress) message.getFrom()[0], equalTo(new InternetAddress("from@domain.com"))); + assertThat(message.getRecipients(Message.RecipientType.TO).length, is(1)); + assertThat((InternetAddress) message.getRecipients(Message.RecipientType.TO)[0], equalTo(new InternetAddress("to@domain.com", "To"))); + assertThat(message.getSubject(), equalTo("_subject")); + assertThat(Profile.STANDARD.textBody(message), equalTo("_text_body")); + latch.countDown(); + } + }); + + account.send(email, null, Profile.STANDARD); + + if (!latch.await(5, TimeUnit.SECONDS)) { + fail("waiting for email too long"); + } + + handle.remove(); + } + + @Test + public void testSend_CC_BCC() throws Exception { + Account account = new Account(new Account.Config("default", ImmutableSettings.builder() + .put("smtp.host", "localhost") + .put("smtp.port", 2500) + .put("smtp.user", USERNAME) + .put("smtp.password", PASSWORD) + .build()), logger); + + Email email = Email.builder() + .id("_id") + .from(new Email.Address("from@domain.com")) + .to(Email.AddressList.parse("TO")) + .cc(Email.AddressList.parse("CC1,cc2@domain.com")) + .bcc(Email.AddressList.parse("BCC1,bcc2@domain.com")) + .replyTo(Email.AddressList.parse("noreply@domain.com")) + .build(); + + final CountDownLatch latch = new CountDownLatch(5); + EmailServer.Listener.Handle handle = server.addListener(new EmailServer.Listener() { + @Override + public void on(MimeMessage message) throws Exception { + assertThat(message.getFrom().length, is(1)); + assertThat((InternetAddress) message.getFrom()[0], equalTo(new InternetAddress("from@domain.com"))); + assertThat(message.getRecipients(Message.RecipientType.TO).length, is(1)); + assertThat((InternetAddress) message.getRecipients(Message.RecipientType.TO)[0], equalTo(new InternetAddress("to@domain.com", "TO"))); + assertThat(message.getRecipients(Message.RecipientType.CC).length, is(2)); + assertThat(message.getRecipients(Message.RecipientType.CC), hasItemInArray((Address) new InternetAddress("cc1@domain.com", "CC1"))); + assertThat(message.getRecipients(Message.RecipientType.CC), hasItemInArray((Address) new InternetAddress("cc2@domain.com"))); + assertThat(message.getReplyTo(), arrayWithSize(1)); + assertThat(message.getReplyTo(), hasItemInArray((Address) new InternetAddress("noreply@domain.com"))); + // bcc should not be there... (it's bcc after all) + latch.countDown(); + } + }); + + account.send(email, null, Profile.STANDARD); + + if (!latch.await(5, TimeUnit.SECONDS)) { + fail("waiting for email too long"); + } + + handle.remove(); + } + + @Test + public void testSend_Authentication() throws Exception { + Account account = new Account(new Account.Config("default", ImmutableSettings.builder() + .put("smtp.host", "localhost") + .put("smtp.port", 2500) + .build()), logger); + + Email email = Email.builder() + .id("_id") + .from(new Email.Address("from@domain.com")) + .to(Email.AddressList.parse("To")) + .subject("_subject") + .textBody("_text_body") + .build(); + + final CountDownLatch latch = new CountDownLatch(1); + EmailServer.Listener.Handle handle = server.addListener(new EmailServer.Listener() { + @Override + public void on(MimeMessage message) throws Exception { + latch.countDown(); + } + }); + + account.send(email, new Authentication(USERNAME, PASSWORD), Profile.STANDARD); + + if (!latch.await(5, TimeUnit.SECONDS)) { + fail("waiting for email too long"); + } + + handle.remove(); + } + +} diff --git a/src/test/java/org/elasticsearch/alerts/actions/email/service/AccountsTests.java b/src/test/java/org/elasticsearch/alerts/actions/email/service/AccountsTests.java new file mode 100644 index 00000000000..d13a90ba258 --- /dev/null +++ b/src/test/java/org/elasticsearch/alerts/actions/email/service/AccountsTests.java @@ -0,0 +1,116 @@ +/* + * 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.settings.ImmutableSettings; +import org.elasticsearch.test.ElasticsearchTestCase; +import org.junit.Test; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.isOneOf; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.core.IsNull.nullValue; + +/** + * + */ +public class AccountsTests extends ElasticsearchTestCase { + + @Test + public void testSingleAccount() throws Exception { + ImmutableSettings.Builder builder = ImmutableSettings.builder() + .put("default_account", "account1"); + addAccountSettings("account1", builder); + + Accounts accounts = new Accounts(builder.build(), logger); + Account account = accounts.account("account1"); + assertThat(account, notNullValue()); + assertThat(account.name(), equalTo("account1")); + account = accounts.account(null); // falling back on the default + assertThat(account, notNullValue()); + assertThat(account.name(), equalTo("account1")); + } + + @Test + public void testSingleAccount_NoExplicitDefault() throws Exception { + ImmutableSettings.Builder builder = ImmutableSettings.builder(); + addAccountSettings("account1", builder); + + Accounts accounts = new Accounts(builder.build(), logger); + Account account = accounts.account("account1"); + assertThat(account, notNullValue()); + assertThat(account.name(), equalTo("account1")); + account = accounts.account(null); // falling back on the default + assertThat(account, notNullValue()); + assertThat(account.name(), equalTo("account1")); + } + + @Test + public void testMultipleAccounts() throws Exception { + ImmutableSettings.Builder builder = ImmutableSettings.builder() + .put("default_account", "account1"); + addAccountSettings("account1", builder); + addAccountSettings("account2", builder); + + Accounts accounts = new Accounts(builder.build(), logger); + Account account = accounts.account("account1"); + assertThat(account, notNullValue()); + assertThat(account.name(), equalTo("account1")); + account = accounts.account("account2"); + assertThat(account, notNullValue()); + assertThat(account.name(), equalTo("account2")); + account = accounts.account(null); // falling back on the default + assertThat(account, notNullValue()); + assertThat(account.name(), equalTo("account1")); + } + + @Test + public void testMultipleAccounts_NoExplicitDefault() throws Exception { + ImmutableSettings.Builder builder = ImmutableSettings.builder() + .put("default_account", "account1"); + addAccountSettings("account1", builder); + addAccountSettings("account2", builder); + + Accounts accounts = new Accounts(builder.build(), logger); + Account account = accounts.account("account1"); + assertThat(account, notNullValue()); + assertThat(account.name(), equalTo("account1")); + account = accounts.account("account2"); + assertThat(account, notNullValue()); + assertThat(account.name(), equalTo("account2")); + account = accounts.account(null); + assertThat(account, notNullValue()); + assertThat(account.name(), isOneOf("account1", "account2")); + } + + @Test(expected = EmailSettingsException.class) + public void testMultipleAccounts_UnknownDefault() throws Exception { + ImmutableSettings.Builder builder = ImmutableSettings.builder() + .put("default_account", "unknown"); + addAccountSettings("account1", builder); + addAccountSettings("account2", builder); + new Accounts(builder.build(), logger); + } + + @Test + public void testNoAccount() throws Exception { + ImmutableSettings.Builder builder = ImmutableSettings.builder(); + Accounts accounts = new Accounts(builder.build(), logger); + Account account = accounts.account(null); + assertThat(account, nullValue()); + } + + @Test(expected = EmailSettingsException.class) + public void testNoAccount_WithDefaultAccount() throws Exception { + ImmutableSettings.Builder builder = ImmutableSettings.builder() + .put("default_account", "unknown"); + new Accounts(builder.build(), logger); + } + + private void addAccountSettings(String name, ImmutableSettings.Builder builder) { + builder.put("account." + name + ".smtp.host", "_host"); + } +} diff --git a/src/test/java/org/elasticsearch/alerts/actions/email/service/InternalEmailServiceTests.java b/src/test/java/org/elasticsearch/alerts/actions/email/service/InternalEmailServiceTests.java new file mode 100644 index 00000000000..f4f07f08d3a --- /dev/null +++ b/src/test/java/org/elasticsearch/alerts/actions/email/service/InternalEmailServiceTests.java @@ -0,0 +1,60 @@ +/* + * 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 org.elasticsearch.node.settings.NodeSettingsService; +import org.elasticsearch.test.ElasticsearchTestCase; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.*; + +/** + * + */ +public class InternalEmailServiceTests extends ElasticsearchTestCase { + + private InternalEmailService service; + private Accounts accounts; + + @Before + public void init() throws Exception { + accounts = mock(Accounts.class); + service = new InternalEmailService(ImmutableSettings.EMPTY, new NodeSettingsService(ImmutableSettings.EMPTY)) { + @Override + protected Accounts createAccounts(Settings settings, ESLogger logger) { + return accounts; + } + }; + service.start(); + } + + @After + public void cleanup() throws Exception { + service.stop(); + } + + @Test + public void testSend() throws Exception { + Account account = mock(Account.class); + when(account.name()).thenReturn("account1"); + when(accounts.account("account1")).thenReturn(account); + Email email = mock(Email.class); + Authentication auth = new Authentication("user", "passwd"); + Profile profile = randomFrom(Profile.values()); + EmailService.EmailSent sent = service.send(email, auth, profile, "account1"); + verify(account).send(email, auth, profile); + assertThat(sent, notNullValue()); + assertThat(sent.email(), sameInstance(email)); + assertThat(sent.account(), is("account1")); + } + +} diff --git a/src/test/java/org/elasticsearch/alerts/actions/email/service/ManualPublicSmtpServersTests.java b/src/test/java/org/elasticsearch/alerts/actions/email/service/ManualPublicSmtpServersTests.java new file mode 100644 index 00000000000..41807d932d5 --- /dev/null +++ b/src/test/java/org/elasticsearch/alerts/actions/email/service/ManualPublicSmtpServersTests.java @@ -0,0 +1,120 @@ +/* + * 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.cli.Terminal; +import org.elasticsearch.common.inject.Provider; +import org.elasticsearch.common.settings.ImmutableSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.node.settings.NodeSettingsService; +import org.junit.Ignore; + +import java.io.IOException; +import java.io.InputStream; + +/** + * + */ +@Ignore +public class ManualPublicSmtpServersTests { + + private static final Terminal terminal = Terminal.DEFAULT; + + public static class Gmail { + + public static void main(String[] args) throws Exception { + test(Profile.GMAIL, ImmutableSettings.builder() + .put("alerts.actions.email.service.account.gmail.smtp.auth", true) + .put("alerts.actions.email.service.account.gmail.smtp.starttls.enable", true) + .put("alerts.actions.email.service.account.gmail.smtp.host", "smtp.gmail.com") + .put("alerts.actions.email.service.account.gmail.smtp.port", 587) + .put("alerts.actions.email.service.account.gmail.smtp.user", terminal.readText("username: ")) + .put("alerts.actions.email.service.account.gmail.smtp.password", new String(terminal.readSecret("password: "))) + .put("alerts.actions.email.service.account.gmail.email_defaults.to", terminal.readText("to: ")) + ); + + } + } + + public static class OutlookDotCom { + + public static void main(String[] args) throws Exception { + test(Profile.STANDARD, ImmutableSettings.builder() + .put("alerts.actions.email.service.account.outlook.smtp.auth", true) + .put("alerts.actions.email.service.account.outlook.smtp.starttls.enable", true) + .put("alerts.actions.email.service.account.outlook.smtp.host", "smtp-mail.outlook.com") + .put("alerts.actions.email.service.account.outlook.smtp.port", 587) + .put("alerts.actions.email.service.account.outlook.smtp.user", "elastic.user@outlook.com") + .put("alerts.actions.email.service.account.outlook.smtp.password", "fantastic42") + .put("alerts.actions.email.service.account.outlook.email_defaults.to", "elastic.user@outlook.com") + .put() + ); + } + } + + public static class YahooMail { + + public static void main(String[] args) throws Exception { + test(Profile.STANDARD, ImmutableSettings.builder() + .put("alerts.actions.email.service.account.yahoo.smtp.starttls.enable", true) + .put("alerts.actions.email.service.account.yahoo.smtp.auth", true) + .put("alerts.actions.email.service.account.yahoo.smtp.host", "smtp.mail.yahoo.com") + .put("alerts.actions.email.service.account.yahoo.smtp.port", 587) + .put("alerts.actions.email.service.account.yahoo.smtp.user", "elastic.user@yahoo.com") + .put("alerts.actions.email.service.account.yahoo.smtp.password", "fantastic42") + // note: from must be set to the same authenticated user account + .put("alerts.actions.email.service.account.yahoo.email_defaults.from", "elastic.user@yahoo.com") + .put("alerts.actions.email.service.account.yahoo.email_defaults.to", "elastic.user@yahoo.com") + ); + } + } + + static void test(Profile profile, Settings.Builder builder) throws Exception { + InternalEmailService service = startEmailService(builder); + try { + + ToXContent content = new ToXContent() { + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject() + .field("key1", "value1") + .field("key2", "value2") + .field("key3", "value3") + .endObject(); + } + }; + + Email email = Email.builder() + .id("_id") + .subject("_subject") + .textBody("_text_body") + .htmlBody("html body

") + .attach(new Attachment.XContent.Yaml("test.yml", content)) + .inline(new Inline.Stream("logo", "logo.jpg", new Provider() { + @Override + public InputStream get() { + return InternalEmailServiceTests.class.getResourceAsStream("logo.jpg"); + } + })) + .build(); + + EmailService.EmailSent sent = service.send(email, null, profile); + + terminal.println("email sent via account [%s]", sent.account()); + } finally { + service.stop(); + } + } + + static InternalEmailService startEmailService(Settings.Builder builder) { + Settings settings = builder.build(); + InternalEmailService service = new InternalEmailService(settings, new NodeSettingsService(settings)); + service.start(); + return service; + } +} diff --git a/src/test/java/org/elasticsearch/alerts/actions/email/service/support/EmailServer.java b/src/test/java/org/elasticsearch/alerts/actions/email/service/support/EmailServer.java new file mode 100644 index 00000000000..36dbcb9fb79 --- /dev/null +++ b/src/test/java/org/elasticsearch/alerts/actions/email/service/support/EmailServer.java @@ -0,0 +1,110 @@ +/* + * 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.subethamail.smtp.TooMuchDataException; +import org.subethamail.smtp.auth.EasyAuthenticationHandlerFactory; +import org.subethamail.smtp.auth.LoginFailedException; +import org.subethamail.smtp.auth.UsernamePasswordValidator; +import org.subethamail.smtp.helper.SimpleMessageListener; +import org.subethamail.smtp.helper.SimpleMessageListenerAdapter; +import org.subethamail.smtp.server.SMTPServer; + +import javax.mail.MessagingException; +import javax.mail.Session; +import javax.mail.internet.MimeMessage; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.fail; + +/** + * An mini email smtp server that can be used for unit testing + * + * + */ +public class EmailServer { + + private final List listeners = new CopyOnWriteArrayList<>(); + + private final SMTPServer server; + + public EmailServer(String host, int port, final String username, final String password) { + server = new SMTPServer(new SimpleMessageListenerAdapter(new SimpleMessageListener() { + @Override + public boolean accept(String from, String recipient) { + return true; + } + + @Override + public void deliver(String from, String recipient, InputStream data) throws TooMuchDataException, IOException { + try { + Session session = Session.getDefaultInstance(new Properties()); + MimeMessage msg = new MimeMessage(session, data); + for (Listener listener : listeners) { + try { + listener.on(msg); + } catch (Exception e) { + fail(e.getMessage()); + e.printStackTrace(); + } + } + } catch (MessagingException me) { + throw new RuntimeException("could not create mime message", me); + } + } + }), new EasyAuthenticationHandlerFactory(new UsernamePasswordValidator() { + @Override + public void login(String user, String passwd) throws LoginFailedException { + assertThat(user, is(username)); + assertThat(passwd, is(password)); + } + })); + server.setHostName(host); + server.setPort(port); + } + + public void start() { + server.start(); + } + + public void stop() { + server.stop(); + listeners.clear(); + } + + public Listener.Handle addListener(Listener listener) { + listeners.add(listener); + return new Listener.Handle(listeners, listener); + } + + public static interface Listener { + + void on(MimeMessage message) throws Exception; + + public static class Handle { + + private final List listeners; + private final Listener listener; + + Handle(List listeners, Listener listener) { + this.listeners = listeners; + this.listener = listener; + } + + public void remove() { + listeners.remove(listener); + } + } + + } + +} diff --git a/src/test/resources/org/elasticsearch/alerts/actions/email/service/logo.jpg b/src/test/resources/org/elasticsearch/alerts/actions/email/service/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f45114b4b991044bc0d5522221c23b666c9597f4 GIT binary patch literal 37305 zcmbSy1yo$m(&ylTAVGt>1P|^Z!8Q26puyeUgS)%CyE_DTcMt9aciBnu-hbb>`|X}R zdxyDa=9`Ko9%^ydDEkh3s`rodFO4Xu$7A!0Rc1 z@{69Ojy^!|2e<_S3V;a>1&e?L01)s201}x1fT;ig5Y_Oy4Dba&{J&q2|MP;b^SLBqU(f`oVnfPjX8f`oyB1`7iPCWD56go1$uyn|z9Lq$VpVJF7G z{PEDQ`h5*#EP7%WIAXaFi23<)~Z7ddTGjCa<|0ySRp zWOnw%QCT`R-qF=8pJgX?ZKr)>YkM%+>OKl8#E`SXe)F-a>fFdt!a@Vj011r%1OEmB z3g)+jU|CV2-jOh&fv2GbpeQ5HNq01Or3zTby^+Uc_fnSyi2rV4{ulZ&t5M07NiKU=FBYPU}DZ0`-5s zL(&Bw>VXJ{*KV)=+d)K7S`nj1)_Po61AqJVsE8D}ooCo9Jc$>+|92z^2kIilt01(g zm*m>N17HQLuUQJx1Wj%)kOKdb6vqjP2t=9XCzYT5mpNeU%E^>0N`s6&rBy%w>475Q zd`Oxe%c^s|hwb^@0;5(!>M>q0n^LnQ$i|gkkxbb#9AG{AXYfBaWr7H|F zzSHhsgE0l_jK+4!hG9gJFD{!p==G0n51zlyBv04jP4|5E)4O@k~HLT9@<&E=nOEo|y4blTi zo|*Hzu{FE0)(+45_JxFnWm4%h-W}Di3Te-|brg|LJd&xY>!2k(3FXya^_YDPq022E z`dnWd?o&qW&7vq4FL7Hz?bdCzz|2C5eo{4s3s=L$xGeKNP)ppIwi(sGeN85_jBQIz_4+qmO9|frp*gsvle?-dK<@UH zheeO>PtJ!;QQIjzNp{lz;PUgQ?j9A#oJ+?X@OE+Z7Lz6?K#3-utW?9;vU_5c!TGYy zGMA6aU@+Rf5W^zG%eDO#;HY_$hIp_?Gi8(d>x80+Zn#{vm~J$Mx-Pz_(dotW6Kn@j z&Z6FqCG||a#8Aw#tBd~egF6C+;QUKN!stO;L3irhmOlRnMVGoctR1S{%ymcpwX3!1 z^PiXZ!^lcS=JZ8$_3SgQ-m zS>jrwpEV<|^o;bbzJSMhs{XeRyaM?7sjqnui`ekFWn<0lpfXD}b4(LN_8Z@~{JOP2 zU7Q$8w<)8d9(x&AWiGYZQdL%Q8CyGU0R4c#5W2dO5BP4AoGLl$GG59TLO!ylTcT?; zSFw8g2r$wT0%2$deP3D5w&YhqBmN)^r8zJq9amMKj|1O7GnQ)iTtqTZuUV1M$9>*L zUq)bQWuId9<4=mwEDkcx_^K*UHfznvj+8lR5mx;J@Z}@tGj4XKDj2?BzxHjo-53%K z}7z%Pb57nj?@H~o}NHX(89*n~7o8p?LSbjctdvW&Y@Z{&`_w+P1 zH^t71C2+4u^Rd~>;F(7Qd!3~Pd?LO`1XRPC;cf@(?4|FPrv?2%AWqguo!#>{G4rsx zj{{#n<=b5rnrps%se19`XC{lJZo}F?qR~yV_;L2+HeY&-&xn?xz9q;#!pzcG>EUQ(Lh}@ z29A$wt?|sw?dZL#+SNs>-+8h+UW4P*%!)KPPBJ8^oD#V!|3F~9!U(HY{yY#L35H}+*3jMI-&n``3m#HnC4OI=FjO6Kc z9;g3b0Ku3_@KqkV;mXv2({)fQU782=K5Fbbj`m*uJHKOElmvBFn!|?JnJ1iAw$kLu zq1FRex)1aoyombiW+VbCHl5;gG8gpJ-kNBc(+@@*B)Rnhjo`j4wB;XhbBCLLaDS`S zJ83Rs-ZEax9$Sv=V`S%(Te=2Lni+1_>b-K(LUjx)`^tg~&&d6hIf@_GP3KSNmzmE+ zjRr897*`fd(Zs>&TG7NCPwB8Qfv>zjR-Z@hb^?pYwrxRyTN73BFfgJc>cE8&bnR7{ zt5Y@`#qA>_$Te!gnnS&`COPH$LBgS{K(X=x9H{LgRH2uAk?y7I&JNm5)mTy)%=f{i z6UaVQuF`W>=T9$+G#)s>!^RB$ivBo#SJQLP1vmBp$kzF&mR}wV0$_J;3@6ENRZhKO z4>P%xOe%inJ$$Rsyl(c-()j=Jfgijjc>~_HK)^u00zy03dN-YVy1u;vMpYJlaK;0mB{ox+_dt zVK4QbF)Ol3{rB{3Ps{p^qh;>nI9oRmQBNFHPnHoc8jrMu8^KSO*G;;O)*NzA%70rQ zY^C2!s$E2qwLRrTas9Aq5<4_K9NuTII>oN&yXJcL7O8C!D#QGX6cT&d>1^Vj?E{(H#$L#YIw5d5m%Y6rbDqi{uU_!E{1 zS`&>S5L{89-oMbVos0U3m80y#jU?;mEx}G#BAQ6$Lw- zdC^=rw4Yj!XrhmgZP985iBK@6avh(HzP+1OO_8u?D}Arj{1=iGeff2Qa2*F{{K#`@ zW>NEAOFeVup`ygS0sFY6PwC$EttBZ$uYeDwkCxYO!PZd}aJo*I4Y8Gr-9qW3>a|wh zLFu&;w;txRQ69W2s5xR9!U_9+n`UM%iX}~FW37Z+L)JylbaOu6%a*1lX&j}qVoy+s zV*t;G)p#00T)0fu5wxJ z((k3hlq+GDA{{3*o}*Z(K)-ngdyye74=r6gb-7&CL*Ra~EpJ}qPnrTXJcVAHLHt%F zvC@IF6m@6LSHLE3#(CqLL=aOtclv^HV}6`&do~Lpe~1M+u}sOwD%Dn-Mh+E1MElt; z_a6_BUe^P4f?l@0SjgwI5mvn(9H|S<4Y*~nYCH&MBBdx6vN_>jn8qWIco77vf=$JZ z&8eA})aHH-6LbePhc(e|KPfC;CoVNFlWg&Na6&GQnl38?={cK+x*`!n-i{q@z*6g^ z^EG|#omxCk6U!yIg*rNgo2{&y$Gj9Ko}Ngrk6lU8*{o>n3uYc#QrSx8P0u-Vbdy=W z)?MK|qYMf;iC1mjd<}mByIC!RQ9ewT6Z|{I>e)77gxmRG-xF?~yy&^jeLN#O&?; z)Mr3Dt2{*~U9E8HRV$n+h0D?HGQE81qO`h7O6m^m!e6eo?;^SAI5_REN#|Bo6$S$h z<+OM}m$UbJm~${J|M05-GlvWfWOC~qbCBsP)3tWLS1OepqdIv2dti!O<6ORy8F!Yh zZtepY|CZBRxkZbWvc$p7k;L)lk=3k~^ayB;@kPYwtgBV20_&Q!6+H zhf6on40`L{?F~56F?|i->Lcy{x|x%!pZ+&5G*Gx@1iOAJ>#{e`hcFt(w{>VMe_)Yf zXq^zIlpXXJN@kEi?cQEd(8djGyvJk$a&Hr_P|EcJ&8$-P>ZPg&oiZ8@`x|vS@TRh9 zK(G%-h-j1XPP-?rMCCGf>qJj27Qo{t(+khSs7*T8AseI8%G1w*DsVMr^zrv$NW99o zeR}Np#!^;nzjdwX8^T2wX(^x#A4VMCKy;!z3f8aJ{vt>C#mzuiJKQZmJ<2q49{v07 zyP1v)rL_d&Z6D3FUbYL)QJa3NO^X~0iF(m}ef&)!*@kM0{GRFCS^PExZio3Cyj8#L{;QB$qV}di3~{z1x}?aa~$) z2jBLM6%QBEZ>0~l8f>>llA~IWL?qz-JAK#hSd8~|P|bRE1EEuEh*kpC;oi0jhC@UJ zS$b`=Sz7tA31l``IWYB&U`dDf*np7wza^^D>#2--h|#yBM9#DcQ()C#bkw_6vd?T@ zIpquS&6OAVv_T%hwim{nh@a?fGxU1vS>J(vX&HYYxLs{$5GT)?lQkTW^UZpTuRUXtqJq`kI zZKHZy)tl{9!^764x5ay`0;zSJl)^?&F41%zHIcMoHQ8O~B}-2!79u5;btON}mz!67 zK=$gloR21@YhT&a=!M@u3;tO;A^){>HlLQ2;D;vj?*`I9ka^FwCVA?~$SEy*CJ*sg z@U6ZA{{E-0AMIX*`5v)GW}`ZMIl|F%5q(Cr86;Mr8SbWk^7H^N8(DVe9Ted#^^h<)=kHX|U&SY^Y(5js zO!+=epjSP=zel~I*PzPlJ4)=wpquIeQJD1;_D?1&!B9jw5}*AVY9+;~Pt<^i!@G;9 zC3rXUG0J-JR>$U%aVHsc3BAlI7;?;ogjIw1Ba;Ql&HT;2M%WC5gT*$YP`ri-6I z+$AMCJ$8gJon5s8tU`mNQqZi?@QuYlTxyMQ>hQ`6+#XUF<-c9$h@ zv$(`ev1LR9?FAAA7*1gkqov<)G`G}QU(PGW=Pl3bXo}N}4vXIdV@gw+bkecC#-LFGc6sAbTIEfA=F$avA};noG7I<_J>mu zLirru5_9;{AQ5^A)8N~%yG%`m&g9lmm40!QSu2SURbCpQGgj;DYqI$w!fGb6?<-P% z3+dS<^=pwiKqRPOVka_Og6i+8`z0;@6`6L|+895BVOPke-D(PZ+D5A_4ub7mny4i! z`)HR2927-~vwQGW>;1YHrq+=MaBtdL^o3}H`-FnUpT({ZMYA{YJJ;rgiP4`YKwPxc zca2E#8mV(__1e!tjTti8E};$>!4;-DvEAp%H+`SROw}hvLd9&tm!RK>x9CR|}Wi)7RgMg(z#Ru-Ejtou=x* zIKA(O%}RowFr_h)iCcl=BEFmfQ2DCO`~zOiQAZBuJ+ebm!ZuLXc;R?5a}L@y+gS zyZ;~^Pc^;&+g!&|G+Y=ItYE@{=092BX6S*}u-RQ+WKoxs3xYC*#7rtQD^HN&Gg3X= zsE!CT#u-YsOK|xzom4WJPb8YL4Xhx(UKq`LYfU^el;ob^(~Z*D7KrW4#&03FWX{ZZ zAHp-x35MDKL)Ih8GG&wcK9j|s#tQlhVyCHL3V+J2aNDEOR7GuJ12sgn0T$h+dTeb` zzn-z69V!1dX$*ONWGhQAreYx^N%dgLk4}^{)|)EC14pq{ew4C%uxIGFo5VV5dUEW1 zI_{r>4ry8}7TI*}=0K8aob}f`b=whWpzSKA#;w%DmEWPfZdQ@g=b9gw&ZPYcFt;p1 zEKR3BHWjT7Y{o-X(ure3a?YjKWR2o6KBQY^v$G6DQkg^1N3{czGUkyQnWR9@8p%pj znwaTwhy=iIr3g7$T;)x$Qekfh`*N~HLxOB|zhIEvw3l@8yw_SQt%`LS)wDW{ zl4wQ??#U~MsoOkU#&^vi?&;Zw_?Vy|PvLRXxFv3P-H%v=QIcg;Dxta*bp)y_XOH3j zCPknIJ+As>R!v|lD-aVF_&M^>C4@s-18);bUr4dK&uuKZb#j(8&W-{pNKzf$w0A%> zHIv#Dg>c*~R2~7#zj33K&4PBM;)^(l7S;+}H_iMlsG$C}Din2P*A3jf0@%o_y5_KW z%b#P@f2MI<0^8UeL66jYo!pj-=!OSkIEC__NT$=u_8HYNE9m*prfB&D*S%5#$KR^^ z*;Fo=*1J`&W>*c5b8n#!I#kzJ3SI$>n`JI0r!`lzaz$)4czJbHa?2gEQK(2uGFD$x zzS^)+*6T3th|d5Yoaw%7)tN<`Nrzf7?gX7<4aP7OBuoZ&MHaxSB4nrg4rZIZa~7sR zFqn>cv;XLLKo2Gw(O?^OF>&4^h^UVP(i-0NlTm=X-~H(hnqjZ%5GyRIf)QZ;v?fha z1)p`SohHEt8z~T(e=s*{npYYA!IMrjP@h_QW=DYg)9Qnyjy+PX#kBf-UWK&A9I7F{ zcEOypDNi4=yb=xZ8EM&hu6#ROH?3iaysNj}5@#LB792HA+&d>}`NDK( zoiEK{?`gVt<(iH8QQs#V4_s%7FQ?b_2qA$D8nX-c zF8U>0=p*FyVaY8bMu~7O_Z9|or|lZ`Ueu4+cj{#^<8kE9L`lwsc#?RCy`-5(GpQU{ zSXZ7@A_wR7j@OySK$+!`T}I4k@ldQMoZUI4g2i4RT{k&f`nT;OVy3V@%z?Vwy_LLG5Wh!c|1m3l8Fz7np~&zNDv+Bk#nUC2vTGHqH#E|WOoTKi#?+Ws zzr?THGpYHKLxG(uM-kZQ%%?(6eu=8c&9_WLnkD;yKI7^Vvr=XlsS%nmM_OuU=1N1F zD_4)DdevPsn_}wl3OG<~hj9n4GLtsB!`JOgR|uH9pqo^4VT=5hun%KluO&!4sApcu5HQqSXiVWb(4*$LRAHS=`R2D=#&kda16^>@#FJWh+ zRZ`Re0)I^9i)Yc`BpXNdVwiylGwvi&2NStq0O2W1B< zcio|Q>w(CG7zP7Z6@Qx&J%4FJX(G4Pu;<$)=^&n+QVMQda53BONHVuPp1oF$7U>xu zp-PG{3tNfGp@>nALAxi&N8wGzsu^hEWscioQ~eZ##jN%bx~pB;gyc<%R>YO?cLB9e z1%AGwoX`T`MgUckPEceG&`O&2h&Uc|>4)(SX+1S*h>pqA(D`13rl<)qe1hO-pWYl= zC8bHR-f;LlS+6g*40Yrn*mQblg2L~V8VKaNl)ZD#vuDzTQ4ty_Ai7{98<)B$adg(R zA?46nqeo8{%8Fi1-t(h@6G2U>W?X1OTI}@?KZAwz&Gj*&do@|lQFBP`x*z24{erZ8 zUb6n~t|4Lh!3R_M7R^S11Fr8<==#)rMYr5EwdeKw-iSwi3fg7dR*B;3K8;6Ft zpWHJ~qswpf4;U%`eK~duhS!Rf&&ri2YA-Yc}ez>^Fon&iDLive+#7Vn3tYH(C}( znB?__@Wr<>4S6suhWvG3#Kd;tkezE5`O@ttUi3mb8(H&tnRh1N>ZD6DuKQ!Fbc9pH z-|Ct<$UW)OHD~H#t7UN;nGcuyRps+`w=DP-2)xwhd)=4JBAHZu2<@-a*j)g{0(tDS znB|b;t%5mQJ4*mvwv0!^x}bRSW7*xE+8R6)KG?NfDlv|{WJ_D*U*907I?AZCI+Cdr zG0kH|bYCR)*w=9SOh!g_}~??piWwCVxPUG~^!c zNUFDUkpN5P(e3r&6(ARIjqe_^ajO@i?si1GDV3}d4W&I7x+b?Gf=JjxHFE?7@6i6dClAg9P&AFc3a_tQH2>DH3-vlnHv@7wk(`Vm93q93C*n zWP9KgcT-aoBTC0tJ|yvFFu%hY)d1!XBwP}D&J(kx zhaRU=?M}e=2|gXT zB4php`$3T(^jtQK0nA1YDg-{NKfg{``@TrOpE^g=FQR2q z)YAWK@ZJ&+KsNWdA)MuiWw%5U)kpaolzB{}>yD23YlZnX$7`;cDWqb)^}(W_MsE^% z*pYpJab!m+;RZyakA<%KWCy^>to z&3*miS5&sPS@5n+=le?y-fQqHty2t*b~@as+H1x$dE-1|-@@JaOuY^zEJ3BmU-t|^ z4Ec9e`^)0GN5(QceY=Q#W4-@IJki}SdTSc#vjVeaAtJ2y9s%wBG+eKH65heA^gM7X zfRSp)TRS(V4yBL{ljh5+ZI7+?VZP!qNe|ecvi-RXwJ(4<$Xw=-eyt ziyv#z7#{G2>_|ORRlS$eU0TDbMfLq-;9Ph1L-{^rhOWGR^bX?-D$&KITz3PH4e_*B z01*TccuegvAIGb0f&XftEgFj5HNMDy1&>r1P0(zELpg5kbR3IEyAMNi zc&aUbjt^Jq6tvUaOsCbK0eh+O^=doYvgJ{rjXJ{C22%OI6;M)dLeR^;&ZYNjM7=Lx zgEJzhlbS@z7H;96wIeYz%IrflOe*E!g(%pqVt#q_D=;3KeQ+Y!X^fETTxKs)SZyZI z)RjBDz$R*))oo1WX^x3s0P$NjX_99f|E!%;kjHTwZmt$wqTE(L9&vg$$9cI zz<`_3>J#$i6hhJv!B0!XzMRR10r%BDM;H2|;D7W?v6sI`t}7>DS&nB&!?G6~D1rR< zVkp&7RGIbDFA)6yBhv4EE7x5L z=*n2#x&9lrchq&#qaTu9LHtDpHVsp^M&U*4HiV&`U?bFdA$!oPqUSK8 zK%kB37->j=xe57c-4t*#Q_*D4iT044SuUt}`tlVSs$ zEyFv&w{Y|zc0rERecJ3Fjn%$}LlvA|>{%n?eCK;G>sARo==FHWbyrGbZeJw7`-WE%HG6w0|1E*93p0J! zz0KXxsk;hT44^x_jLf71# zbRx@sa-?OHd3(#D(|9I;k@$VI$Gy38PYh9lK zepetK>hJsjaQ89r@nQBokin%nsQSz{Inuzuf>(UlP=u_4w`jyW{^WrNdf`jMj$15s z)riG(5NaR*>(Y4n^VIB$gI&Gdz-Pop)@;kvim-b&Nx-fk4AU++7D@wu*k+5VxxB<#I^ilL~Ga2`4ajbTGI19213Gc zhkJG#+bdv+@y`wz>R&tHZ(5ph&MgJ!Ez^j&sRLEXh!GeJ95 z)i&qASxYRnOpmiMw0~B_sqch?cmusDq|>NfSsYHVkbOo1CDX&U+0~9N5~u5SRrvLt za*CQ!2a_B7_xF9^={-Op#-ZJsbhI0_g1>nxQoSwsoi=;;j#ZSqji4A4mHZ>iTbi%C z>BGbt-w1dwrx{mVuaLp$YVXCNslAaqM{=sH@Sj91?z=ybO=TzuKI^ zjxz6tFoZL-tux5&%TJ{GTE^_`XfLEULJ&5YXDI8t1#9x1gx{-Oh-xaW7A4L*kA?f; zAcqSsrP2H_N6-p2%^JrWkhC~g8|#?e%flgd3=lv{QSZ?QVc<&X1j4>k7ucT_`JT49 zD6I8yp|V+9E?qm4CiQOrSh*o@OYmzmO7&6#mN7BD#8vwxiUg&;{1^gaw*}(i9SzvDaU~XjzVJu&(+kR#*E2A0vph%oHO-?Q}T8fBw8piLv zH9Po{IE1Lj;Ac{&WpGqiATj-0Z2@bnFOEZ|{-U*bVYwd#zJy`3eYY$;XPZ71i`ffdIA~u9vc;+2R@f z=65vM0%jxR41};@M&php1}@*sL^KiM@An<-kkK_59Nxm}_dDFzuz~kJcpS>D==v$} zc@f>1X$8ZCe12c*;u=rA8we(VTg?b7j$h=gh&8gn-bcdGTbX|_yUov>u!VcjF?{9l zK?)iZ(vpQ{lX(ayiC>r-(lJDz4r-B5$zBRu*-5yuE3{f*W2D%knJ7nA&Q$!Z^(u@7 zPi9%a9VVDTGJ#x$s)v24|>Q6f9mWVqQS}>--#&19ll2cw2Ts9IbLLv#42It&a!?<@co2%wv!#VRg=!j82rsGsc!7IumZy z^D1)jJ6*eUKU@`6e+rcLE_k=^^tgRHf?fJef~wmf!MxJQa{S|WZ`mX#xR$MRIK5x=6SV3s)9C`2}P%ig!O?iA1 zuqJQ(P#L-)rz>Uf&bNL^!n<|#CW|Sh6KK&v6GV!z$EbFm*=HGxEhY9gaP;O-K$pY2 zbk&12Tg-do;<|+ZW&BSR@&D%G+|np;+6;#(XZu~5%Vjl8XxXXaALWc#h?=qXY`IY^ z4lWqlS4@US2TExhrz$QnQx&7J&818me2jb=DlzqUw}F_e#@<$zF3yBjQr6nvt>#6M zZfF@MdoB2LBGfxA>JITjzjSkZyr2OI&%oHq~ zd$(&YaYVU3xd`e;EUJQ%3Ur|}goY!U*2t2haFxV^JpnO9$Oc%r9HyR^%Yr3)56o0^ z{^?i1G{ehQ+x4&(&Uw{ylEMb#qkB7jy`3Lb8F@6UH;%1-g4h-*6FyT@`5w0vt*BD= zG_>piGixWfL=Q=Kfy=+j&I`e$UrHWz{n&GnrG`9pvC>x{R24tq6Yp%msWW$O15eXS zFZi|9mLwe16eGwonpJHd<_v&zFTL~lzspf@XzONX={Yd@?8@KvUvW4gPwy#0WTJMa zi1QW+BTi4_L(7+cjBK*cTfmg#JF0_91(ap z90AqtEmFGoqyM@ZI>poxwjzlUX_N0q6_AKQ&+;G+?fWo^CsdVEf24?p7udz3qZa_u zVqi0#PuEPyljlFjJHOLca>p!bP?cntQdPPy<_Uts^>Z(z^sKqK5gTr6T`N5&pzk7S z(2Vr+t2->x9v7gkmqrYm!Tadp;EW>NHk!o}92lyDGR#=47_v;W=<1$W#I0bGuKGT( zM2d30?0PRToVU7n&&ASqj2r=Sgk*A%IlXavwj{}?A%B%Zo_0_-c@8LdWsZ9=Me;S> zFspuVS+5p|owr*S7wGWZQ(357H_+MtaQo#lr+dUg3)pc&#Ef6lqI|r zByJ)%ncSN=$muGjSoBU{%ooj={;1itq zUSofwqdTRU?SVs=FPnU%(p_%3)~RedLy`&}Le8NH=ZSv5I`JG9ff$FT5%d9k585U} z0WIu(=CJdm*F_cD9_MPhWPUylrOT%EpFv@v*DNnAU6kM5!g<8H#L-Km2C}AvzU}XK zv+3!NFjpR3`MBs9TBj=hgp`y=Nz0KhHNG2DbZg|_cr@oE+lQT&HjvSpG$u>#PD8 z%A`Y=kjjOR6g0b@H|zUE-(sCe@1u8*{OB9?RF66UZ4s6mkX5M~?Rm=;b7fnTTX3@g z)jKyoW*E=j0j+Ca0Wz-usg@ub^T@EF5xvqFTULdzeuwF@4cbHGm4Cg7F*(3_N2MqWY#C7bWxiB@kP<_a95NG28V1Y!( zgkhjJOGN`pkQqo#6OnUjjK(Y*M2>{5=<4(?*7Zr{BPi5r1qo;o7*jhloXFK5VZyrY z;g!}R>(#6vx-t`@;2ps zT4AB;zxaXgd(?~bBc>ub(N zP76tWwiIOxyOP19{Mg?6iwb%k5&7X`bH^Kfk=w)9RhDZ;tYVH-4ZTd|UGo|kNl z_gzV@e+CZlvHBN>Tak8Bkt}(1gt!#|PWdaqG-1+Sl2BxwcA|nv-q~T2M}L{_u>aFY3lr8( z^HLQwFhRrQn#+`tmN(v5Kbb4mmRrE@#)j)!hnB~6)FXDP6j#Ee4sraE)f&x&Yb&(O}%Gf_@aQNzK!%5n{ z(bUV>e2H7r$7h{qGU6$w4O|K`l@P0Nz`tiEOhd}R&L3Xor*w+YZz3^oKTf87&wt~v za%Y!bXvQ=UUO;?yZWu(rU11gdfAf^Mydq((sx|t9Anb&; zt0i1mqhgL=(x6)pS5*jq>RSePr}A407ID>TMRB#zFhprG(Gg4cSn+swAH0TnlDkhK z>*{sq8Hk9Zm=aMv@$`+%M#38Jl>KaRb0<-ElMs4RrXBo{IfVSFS{9lGg-w2O4)I!`1z*!U)YaaeI~ z8n(G?cZAVRORPeHe}0jP4RTEBYxxK_qySGIl%W`}#px5?3naMXO?fP}v1hH2*SnW9 z`SII_%sCvME-oXf@mrrWSm0+SOio4fPBa}Zh|KN@v4|6_(@)1t^U zi{MTsnGZbuM<4kYR+F6$4vhU0X!cTJ*qTY~sIJ<89ADG^?H-I`UR32W>~OYSs?P@o zTz6^XXf9*?mMBj%FZdp7ME6BcoN951Zef#z4k|-&lGc@2HV9t@q)lD{vHf`YGyUDM zjvF{v_`R<9JeLuZu1E3hEL*yd%mrQQ7AQE=Y>~A(eCbB}u|898Dk>PB6qYxh^IA}U z;N=oKoQrv4v5tZt26n+jPtdH@;hZnMuBp1sjS4d0(|gcf*Zy2HZ&zmJmB8Mbj;-f0 z=dYeH^W2U1ewoMcW`PgSyC-kkC&ugpbadSgFePWVv+yagps6cstu(pG{D(MRjxek9 z`E)tXoAHu2w5lp)?5c%}q_iZ>UDRA(d8Ufc zv?d#6N;tp8P8Dn~ zE`hMX|T2qhBr%PG?pMYt~RFKYYP_?h17abH|noQb`eb#O(syUVQI?I9;} z?W7cK)u|tEX^3?xCMmPpH647%FA{_L2p4KKawmo_)-?=WHHsc4PX4u8WUEdC9!Qw1 zrjwCfpfv-yp=qpcQMa>(Hn(-LNkaL@mp^l2nxl%g45b&$Iq1Dq^`C{DcdC z08vB0i-{*P-N{_u_kJDbtfoLId1NN3bVduRtsrd+wE3h>oq}w!)S{{N*v4hc1{^xY zpuFQfzsXM;D?6O3B<&$)Ww^$NmsoLT5-&BVp&k}=mOuqnS$qFPGPX;z<~6OM3LN#8 z^-mR2kwYQm@;Rd97HniWVRWS^kfq2BCr23+_>zk0Tyu%Gq&Vs1eaO+S%KsVL80&Po zEfgU(xAAdV5e0XF%F~Uiv&9%m#k0qlrd8p5e5u_j56eVbF@3nJtEP&TTQI`9IC}RL z+B3Tz1{wuZ=Mo|0HnikX&HkbyILy`Qh^0&f2ww5EjM|$(?%=mr2GzQ7xw+=-k{S2` z_*8%Es91GFf{;k^oVzsb{8zv^)OWxDG64BI4_uCML*zU`(vjgD(8#yR@%y(YP8x2? zU{iJW4%dPd#VM zJ=r2I(#9XcDt4=06q>+aYSul?@eeujAF_8O_e6v89BQTO7X&VW=@{grAa4^pC93Xp zjkUk&4v%Ot?i1RUrDjeuHCda9D%39%≧wtA6{0i`LXF{#K7IXG9i4W#v8cs^*NS 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

|7eHB&ZYdjNk%M-hoTxyCT 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 OFm)6fL2G~VFaOzsyr#(j literal 0 HcmV?d00001