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}