001/* 002 * Stallion Core: A Modern Web Framework 003 * 004 * Copyright (C) 2015 - 2016 Stallion Software LLC. 005 * 006 * This program is free software: you can redistribute it and/or modify it under the terms of the 007 * GNU General Public License as published by the Free Software Foundation, either version 2 of 008 * the License, or (at your option) any later version. This program is distributed in the hope that 009 * it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 010 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public 011 * License for more details. You should have received a copy of the GNU General Public License 012 * along with this program. If not, see <http://www.gnu.org/licenses/gpl-2.0.html>. 013 * 014 * 015 * 016 */ 017 018package io.stallion.email; 019 020import io.stallion.Context; 021import io.stallion.exceptions.ConfigException; 022import io.stallion.exceptions.UsageException; 023import io.stallion.plugins.javascript.Sandbox; 024import io.stallion.services.LocalMemoryCache; 025import io.stallion.services.Log; 026import io.stallion.settings.Settings; 027import io.stallion.templating.TemplateRenderer; 028import io.stallion.utils.DateUtils; 029import io.stallion.utils.SimpleTemplate; 030import io.stallion.utils.GeneralUtils; 031 032import java.net.URL; 033import java.time.ZonedDateTime; 034import java.time.format.DateTimeFormatter; 035import java.util.Map; 036 037import static io.stallion.utils.Literals.*; 038import static io.stallion.Context.*; 039 040/** 041 * This class a helper class for defining a way to send emails to a 042 * Contactable (usually a User or a Contact). You create a subclass, 043 * override getTemplate() and getSubject() to define the template and 044 * subject to use, override any additional handlers, and then you can send 045 * an email via: 046 * 047 * new MyEmailer(user, myContextVars).sendEmail() 048 * 049 * ContactableEmailer does a lot of extra work for you, such as adding 050 * in extra variables, excluding optedout users, preventing duplicates, 051 * making it easy to use templated subject lines, etc, etc. 052 * 053 * @param <T> 054 */ 055public abstract class ContactableEmailer<T extends Contactable> { 056 057 protected T user; 058 protected String template; 059 protected URL templateUrl; 060 protected String weekStamp; 061 protected String hourStamp; 062 protected String dayStamp; 063 protected Sandbox sandbox; 064 protected String minuteStamp; 065 066 067 068 private Map<String, Object> context = map(); 069 070 public ContactableEmailer(T user) { 071 this.user = user; 072 } 073 074 public ContactableEmailer(T user, Map<String, Object> context) { 075 this.context.putAll(context); 076 this.user = user; 077 } 078 079 { 080 ZonedDateTime now = DateUtils.utcNow(); 081 weekStamp = now.format(DateTimeFormatter.ofPattern("YYYY-w")); 082 dayStamp = now.format(DateTimeFormatter.ofPattern("YYYY-MM-dd")); 083 minuteStamp = now.format(DateTimeFormatter.ofPattern("YYYY-MM-dd HHmm")); 084 hourStamp = now.format(DateTimeFormatter.ofPattern("YYYY-MM-dd HH")); 085 context.put("weekStamp", weekStamp); 086 context.put("dayStamp", dayStamp); 087 context.put("minuteStamp", minuteStamp); 088 context.put("hourStamp", hourStamp); 089 context.put("qaPrefix", ""); 090 context.put("envPrefix", ""); 091 context.put("env", settings().getEnv()); 092 if (!"prod".equals(settings().getEnv())) { 093 context.put("qaPrefix", "qa"); 094 context.put("envPrefix", settings().getEnv()); 095 } 096 } 097 098 099 100 public boolean sendEmail() { 101 if (user == null) { 102 throw new UsageException("Tried to send email, but user is null!"); 103 } 104 105 if (empty(user.getEmail())) { 106 throw new UsageException("Tried to send email, but email address was empty! id=" + user.getId()); 107 } 108 prepareContext(); 109 onPrepareContext(); 110 111 if (checkDefaultOptOut()) { 112 Log.info("User {0} id:{1} has opted out of emails via checkDefaultOptOut()", user.getEmail(), user.getId()); 113 return false; 114 } 115 if (checkOptOut()) { 116 Log.info("User {0} id:{1} has opted out of emails via checkOptOut().", user.getEmail(), user.getId()); 117 return false; 118 } 119 if (hasSeenKey()) { 120 Log.warn("You already have sent an email recently with the unique key {0}", transformMaybe(getUniqueKey())); 121 return false; 122 } 123 markSeenKey(); 124 125 String templatePath = null; 126 127 templatePath = getTemplate(); 128 129 130 String html; 131 if (getSandbox() != null) { 132 html = TemplateRenderer.instance().renderSandboxedTemplate(getSandbox(), templatePath, context); 133 } else { 134 html = TemplateRenderer.instance().renderTemplate(templatePath, context); 135 } 136 EmailSender emailer = EmailSender.newSender(); 137 emailer 138 .setFrom(transformMaybe(getFromAddress())) 139 .setHtml(html) 140 .setReplyTo(transformMaybe(getReplyTo())) 141 .setSubject(transformMaybe(getSubject())) 142 .setShouldLog(shouldLog()) 143 .setCustomKey(transformMaybe(getUniqueKey())) 144 .setTo(user.getEmail()); 145 onPreSend(); 146 return emailer.send(); 147 } 148 149 public boolean shouldLog() { 150 return true; 151 } 152 153 /** 154 * Apply default heuristics for opting out the user from email. 155 * 156 * @return 157 */ 158 public boolean checkDefaultOptOut() { 159 if (user.isOptedOut() && !isTransactional()) { 160 return true; 161 } 162 if (user.isTotallyOptedOut()) { 163 return true; 164 } 165 if (user.isDisabled()) { 166 return true; 167 } 168 if (user.getDeleted()) { 169 return true; 170 } 171 return false; 172 } 173 174 /** 175 * Return true if this is an email that opt-out should not apply to. 176 * For example, return true for: password reset emails, security alerts, transaction reciepts. 177 * 178 * return false for all other emails that need an unsubscribe link, per CAN-SPAM laws. 179 * 180 * @return 181 */ 182 public abstract boolean isTransactional(); 183 184 /** 185 * Override this to check for additional conditions where the user might be 186 * opted out. 187 * 188 * @return 189 */ 190 public boolean checkOptOut() { 191 return false; 192 } 193 194 /** 195 * If not null, will render the email template with the sandbox, thus limiting 196 * access in the template context to site-wide data. 197 * @return 198 */ 199 public Sandbox getSandbox() { 200 return sandbox; 201 } 202 203 public <E extends ContactableEmailer> E setSandbox(Sandbox box) { 204 this.sandbox = box; 205 return (E)this; 206 } 207 208 /** 209 * Override this to do any additional actions just before the email is finally sent out. 210 * This may be useful for logging the message to a database. 211 */ 212 public void onPreSend() { 213 214 } 215 216 private String transformMaybe(String s) { 217 if (s.contains("{") && s.contains("}")) { 218 return new SimpleTemplate(s, context).render(); 219 } else { 220 return s; 221 } 222 } 223 224 protected void prepareContext() { 225 context.put("user", user); 226 context.put("contact", user); 227 context.put("subjectSlug", GeneralUtils.slugify(getSubject())); 228 context.put("baseUrl", Context.getSettings().getSiteUrl()); 229 context.put("emailType", getEmailType()); 230 context.put("emailer", this); 231 } 232 233 /** 234 * Override this to prepare additional context variables. 235 */ 236 protected void onPrepareContext() { 237 238 } 239 240 protected void updateContext(Map<String, ? extends Object> context) { 241 this.context.putAll(context); 242 } 243 244 /** 245 * Add additional data to the template context 246 * @param key 247 * @param val 248 */ 249 public <Y extends ContactableEmailer> Y put(String key, Object val) { 250 this.context.put(key, val); 251 return (Y)this; 252 } 253 /** 254 * The type of the email, used for logging, defaults to using the 255 * Java class name, can be overrident 256 * @return 257 */ 258 public String getEmailType() { 259 return this.getClass().getCanonicalName(); 260 } 261 262 /** 263 * Checks to see if the unique key for this email has been seen. 264 * 265 * @return 266 */ 267 protected boolean hasSeenKey() { 268 if (!empty(getUniqueKey())) { 269 Object seen = LocalMemoryCache.get("contactEmailerKeys", transformMaybe(getUniqueKey())); 270 if (seen instanceof Boolean && true == (boolean)seen) { 271 return true; 272 } 273 } 274 return false; 275 } 276 277 /** 278 * Mark the unique key as being seen. 279 */ 280 protected void markSeenKey() { 281 if (!empty(getUniqueKey())) { 282 LocalMemoryCache.set("contactEmailerKeys", transformMaybe(getUniqueKey()), true, 100000); 283 } 284 } 285 286 /** 287 * The path to the jinja template. If the template is in a resource file, 288 * should be "pluginName:/my-template.jinja" where my-template.jinja is in the 289 * templates folder in your resources directory in your java project. If it is a 290 * built-in stallion template, use "stallion:/my-template.jinja" 291 * 292 * @return 293 */ 294 public abstract String getTemplate(); 295 296 /** 297 * Your email subject. This is interpreted as a SimpleTemplate -- so if you do 298 * "Hello, {{ contact.firstName }}" or "Hello, {{ myVar }}" then it will look-up 299 * the variable from the context and interpolate it. 300 * 301 * @return 302 */ 303 public abstract String getSubject(); 304 305 /** 306 * Who is sending the email. Defaults to email.defaultFromAddress in Stallion settings, 307 * or the first admin email if that does not exist, or the email user and host, if that 308 * doesn't exist. 309 * 310 * @return 311 */ 312 public String getFromAddress() { 313 if (emptyInstance(Context.getSettings().getEmail())) { 314 throw new ConfigException("Email settings are null, and no override for the from email address was set."); 315 } 316 if (!emptyInstance(Context.getSettings().getEmail().getDefaultFromAddress())) { 317 return Context.getSettings().getEmail().getDefaultFromAddress(); 318 } 319 if (Settings.instance().getEmail().getAdminEmails().size() > 0) { 320 return Settings.instance().getEmail().getAdminEmails().get(0); 321 } 322 323 String host = Context.getSettings().getEmail().getHost(); 324 String email = Context.getSettings().getEmail().getUsername(); 325 if (!email.contains("@")) { 326 return email + "@" + host; 327 } else { 328 return email; 329 } 330 } 331 332 /** 333 * Override this to set a custom-reply to address. 334 * @return 335 */ 336 public String getReplyTo() { 337 return ""; 338 } 339 340 /** 341 * Override this to set a custom list of CC addresses 342 * @return 343 */ 344 public String getCc() { 345 return ""; 346 } 347 348 /** 349 * This is used to prevent accidentally sending duplicate emails, by default the 350 * uniqueKey is built from the subject, the contact id, the current week, and the email type. 351 * 352 * @return 353 */ 354 public String getUniqueKey() { 355 return truncate(GeneralUtils.slugify(getSubject()), 150) + "-" + user.getEmail() + "-" + weekStamp + getEmailType(); 356 } 357 358 359 protected Map<String, Object> getContext() { 360 return context; 361 } 362}