MailMergeDbQueue.java

/***************************************************************************
   Copyright 2012 Emily Estes

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
***************************************************************************/
package net.metanotion.email;


import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Iterator;
import java.util.Map;
import javax.sql.DataSource;

import net.metanotion.io.File;
import net.metanotion.io.FileSystem;
import net.metanotion.io.Serializer;
import net.metanotion.sql.SchemaGenerator;
import net.metanotion.sql.SequenceExecutor;
import net.metanotion.util.Dictionary;
import net.metanotion.util.Dispatcher;
import net.metanotion.util.Message;
import net.metanotion.web.HttpMethod;
import net.metanotion.web.HttpValues;
import net.metanotion.web.concrete.BasicRequestObject;

import net.metanotion.simpletemplate.ResourceFactory;
import net.metanotion.simpletemplate.TemplateResources;
import net.metanotion.scripting.ObjectServer;

/** <p>This class uses the simple template language package to provide templated "mail merge"
for transactional email type systems. The generated emails are queued in a database table of
the form:<br />
<code>CREATE TABLE MailQueue (<br />
&nbsp;&nbsp;&nbsp;&nbsp;Sender VARCHAR,<br />
&nbsp;&nbsp;&nbsp;&nbsp;EmailAddress VARCHAR,<br />
&nbsp;&nbsp;&nbsp;&nbsp;Subject VARCHAR,<br />
&nbsp;&nbsp;&nbsp;&nbsp;MessageBody VARCHAR<br />
);<br /></code>
The table may have other fields, but this is the structure this class relies on. The schema
much of the rest of the framework uses is available in <code>src/sql/auth.schema.sql</code></p>

<p>While the templating language is reused, the only scriptable object this class
exposes is an associative array whose "methods" return constant values stored
in the array. So for instance if our data object to "merge" with the email template is:<br />
<code>{ foo: "bar", key: "value" }<br /></code>
In the template we could say:<br />
<code>&lt;? Message.foo ?><br /></code>
or<br />
<code>&lt;? Message.key() ?><br /></code>
or even(since MailMerge maps EVERY object name to itself).<br />
<code>&lt;? foo() ?><br /></code>
or<br />
<code>&lt;? key ?><br /></code></p>
*/
public final class MailMergeDbQueue implements MailMerge {
	private static final SchemaGenerator schemaGenerator = new SchemaGenerator() {
		@Override public Iterator<String> openSchema() { return SequenceExecutor.openSchema(MailMergeDbQueue.class, ";--"); }
	};

	/** This returns a SchemaGenerator instance that returns an iterator of the statements required to build the
	expected schema for the email queue tables.
		@return an instance of SchemaGenerator that will enumerate the SQL DDL required to build the email queue
			database tables.
	*/
	public static SchemaGenerator schemaFactory() { return schemaGenerator; }

	/** A Message instance to be used with the dispatcher for the scripting objects exposed by the MailMerge class. */
	private static final class MailMessage implements Message<Object> {
		/** This class just returns a constant for it's result. And this is it. */
		private final String v;
		/** Create a message that requests the MailMerge class as it's receiver, but instead of using the MailMerge
			class, we just return a constant.
			@param value The constant to return when this message is evaluated.
		*/
		public MailMessage(final String value) { this.v = value;  }
		@Override public Object call(final Object o) { return v; }
		@Override public Class<Object> receiverType() { return Object.class; }
	}

	/** A Dispatcher to handle method calls for the MailMerge class during template evaluation.
		The data object this dispatcher processes is the standard one the template engine generates:
		A pair of a string and an object. The key is String containing the name of the scripting object requested,
		and the value is a Pair as well, The key of that pair is a string containing the name of the method to call,
		and the value is a RequestObject since the template engine is geared toward HTTP Requests.
		However, the MailMerge class uses the {@link net.metanotion.web.concrete.BasicRequestObject} which
		serves up a {@link java.util.Map}&lt;String,Object> for the request "variables", which in this case, is the
		map of fields to values sent with the mail merge request.
	*/
	private static final class MailDispatcher implements Dispatcher<Object, Map.Entry<String,Dictionary<String,Object>>> {
		@Override public Message<Object> dispatch(final Map.Entry<String,Dictionary<String,Object>> data) {
			/** The template interpreter gives the dispatcher a pair of a pair
				<Object, <Method, RequestObject of parameter values>>
				Since the "method name" here is actually the key for the data we're merging with the email and the
				RequestObject is wrapping our data, we just need to get ask the request object for the value associated
				with our "method name".

				The mail merge class doesn't actually care about a reference to the object involved, EVERY object maps to
				data map we're merging.
			*/
			final Dictionary<String,Object> d = data.getValue();
			return new MailMessage(d.get(data.getKey()).toString());
		}
	}
	private static final MailDispatcher mailDispatcher = new MailDispatcher();

	/** The scripting engine needs an object server instance,, and since we only have one object, and it is actually
		passed in via the Request object, we just have to return a dispatcher from this class, and we just return
		something for the get method so the template engine doesn't throw a null pointer exception. */
	private static final class MailObjectServer implements ObjectServer {
		@Override public Dispatcher dispatcher(final String obj) { return mailDispatcher; }
		@Override public Object get(final String obj) { return this; }
	};
	private static final MailObjectServer objectServer = new MailObjectServer();

	/** The database to store the results of the merged emails into. */
	private final DataSource ds;
	/** The resource factory used to load email templates from. */
	private final ResourceFactory templates;

	/** Use an abstract file system for templates and a datasource for queueing emails.
		@param ds DataSource to use for db connections to insert emails in the queue.
		@param fs The file system to retrieve templates from.
	*/
	public MailMergeDbQueue(final DataSource ds, final FileSystem<? extends File> fs) { this(ds, new TemplateResources(fs)); }

	/** Use a ResourceFactory for templates and a datasource for queueing emails.
		@param ds DataSource to use for db connections to insert emails in the queue.
		@param templates The resource factory abstraction to retreive and process templates.
	*/
	public MailMergeDbQueue(final DataSource ds, final ResourceFactory templates) {
		this.ds = ds;
		this.templates = templates;
	}

	/** Take a map of values to merge, and a template name, and load up the template from the ResourceFactory
		and execute it, and return the resulting object as a string. Uses a ByteArrayOutputStream so the
		serialize class can write out the result to a buffer encoded as a UTF-8 string.
		@param values The values to merge with the template.
		@param templateURN the URI of the Resource to request from the ResourceFactory.
		@return The processed template merged with the value map.
		@throws IOException because of the ByteArrayOutputStream buffer instance.
	*/
	private String merge(final Map<String, Object> values, final String templateURN) throws IOException {
		final Object messageObj =
			templates.get(templateURN).skin(objectServer, new BasicRequestObject(HttpMethod.GET, "", values));
		final ByteArrayOutputStream baos = new ByteArrayOutputStream();
		if(messageObj instanceof HttpValues) {
			final Object r2 = ((HttpValues) messageObj).unwrap();
			if(r2 != null) { Serializer.write(r2, baos, StandardCharsets.UTF_8); }
		} else {
			Serializer.write(messageObj, baos, StandardCharsets.UTF_8);
		}
		return new String(baos.toByteArray(), StandardCharsets.UTF_8);
	}

	private static final int QUERY_SENDER = 1;
	private static final int QUERY_ADDRESS = 2;
	private static final int QUERY_SUBJECT = 3;
	private static final int QUERY_MESSAGE = 4;

	@Override public void send(final String sender, final String address, final String subject, final String message) {
		try (final Connection conn = ds.getConnection()) {
			try (final PreparedStatement stmt = conn.prepareStatement(
				"INSERT INTO MailQueue(Sender, EmailAddress, Subject, MessageBody) VALUES (?, ?, ?, ?)")) {
				stmt.setString(QUERY_SENDER, sender);
				stmt.setString(QUERY_ADDRESS, address);
				stmt.setString(QUERY_SUBJECT, subject);
				stmt.setString(QUERY_MESSAGE, message);
				stmt.executeUpdate();
			}
		} catch (final SQLException sqle) {
			throw new RuntimeException(sqle);
		}
	}

	@Override public void sendEmail(	final String sender,
									final String address,
									final Map<String, Object> values,
									final String templateURN) throws IOException {
		final String body = this.merge(values, templateURN);
		final String[] lines = body.split("\\r?\\n", 2);
		if(lines.length > 1) {
			this.send(sender, address, lines[0], lines[1]);
		} else {
			this.send(sender, address, "", lines[0]);
		}
	}

	@Override public void sendEmail(final String sender, final String address, final String subject,
			final Map<String, Object> values, final String templateURN) throws IOException {
		this.send(sender, address, subject, this.merge(values, templateURN));
	}
}