001 package de.deepamehta.plugins.mail;
002
003 import java.io.File;
004 import java.util.Collection;
005 import java.util.HashSet;
006 import java.util.List;
007 import java.util.Map;
008 import java.util.logging.Level;
009 import java.util.logging.Logger;
010
011 import javax.mail.internet.InternetAddress;
012 import javax.ws.rs.GET;
013 import javax.ws.rs.HeaderParam;
014 import javax.ws.rs.POST;
015 import javax.ws.rs.Path;
016 import javax.ws.rs.PathParam;
017 import javax.ws.rs.Produces;
018 import javax.ws.rs.QueryParam;
019 import javax.ws.rs.WebApplicationException;
020 import javax.ws.rs.core.MediaType;
021
022 import org.apache.commons.mail.EmailAttachment;
023 import org.apache.commons.mail.EmailException;
024 import org.apache.commons.mail.HtmlEmail;
025 import org.codehaus.jettison.json.JSONException;
026 import org.jsoup.nodes.Document;
027
028 import de.deepamehta.core.Association;
029 import de.deepamehta.core.CompositeValue;
030 import de.deepamehta.core.RelatedTopic;
031 import de.deepamehta.core.ResultSet;
032 import de.deepamehta.core.Topic;
033 import de.deepamehta.core.model.AssociationModel;
034 import de.deepamehta.core.model.CompositeValueModel;
035 import de.deepamehta.core.model.SimpleValue;
036 import de.deepamehta.core.model.TopicModel;
037 import de.deepamehta.core.model.TopicRoleModel;
038 import de.deepamehta.core.osgi.PluginActivator;
039 import de.deepamehta.core.service.ClientState;
040 import de.deepamehta.core.service.Directives;
041 import de.deepamehta.core.service.PluginService;
042 import de.deepamehta.core.service.annotation.ConsumesService;
043 import de.deepamehta.core.service.event.PostCreateTopicListener;
044 import de.deepamehta.core.storage.spi.DeepaMehtaTransaction;
045 import de.deepamehta.plugins.accesscontrol.model.ACLEntry;
046 import de.deepamehta.plugins.accesscontrol.model.AccessControlList;
047 import de.deepamehta.plugins.accesscontrol.model.Operation;
048 import de.deepamehta.plugins.accesscontrol.model.UserRole;
049 import de.deepamehta.plugins.accesscontrol.service.AccessControlService;
050 import de.deepamehta.plugins.files.ResourceInfo;
051 import de.deepamehta.plugins.files.service.FilesService;
052 import de.deepamehta.plugins.mail.service.MailService;
053
054 @Path("/mail")
055 @Produces(MediaType.APPLICATION_JSON)
056 public class MailPlugin extends PluginActivator implements MailService, PostCreateTopicListener {
057
058 private static Logger log = Logger.getLogger(MailPlugin.class.getName());
059
060 // URI constants
061
062 public static final String AGGREGATION = "dm4.core.aggregation";
063
064 public static final String COMPOSITION = "dm4.core.composition";
065
066 public static final String CHILD = "dm4.core.child";
067
068 public static final String CHILD_TYPE = "dm4.core.child_type";
069
070 public static final String TOPIC_TYPE = "dm4.core.topic_type";
071
072 public static final String PARENT = "dm4.core.parent";
073
074 public static final String PARENT_TYPE = "dm4.core.parent_type";
075
076 public static final String FILE = "dm4.files.file";
077
078 public static final String ATTACHMENTS = "attachments";
079
080 public static final String BODY = "dm4.mail.body";
081
082 public static final String EMAIL_ADDRESS = "dm4.contacts.email_address";
083
084 public static final String DATE = "dm4.mail.date";
085
086 public static final String FROM = "dm4.mail.from";
087
088 public static final String MAIL = "dm4.mail";
089
090 public static final String MESSAGE_ID = "dm4.mail.id";
091
092 public static final String RECIPIENT = "dm4.mail.recipient";
093
094 public static final String RECIPIENT_TYPE = "dm4.mail.recipient.type";
095
096 public static final String SENDER = "dm4.mail.sender";
097
098 public static final String SIGNATURE = "dm4.mail.signature";
099
100 public static final String SUBJECT = "dm4.mail.subject";
101
102 public static final String USER_ACCOUNT = "dm4.accesscontrol.user_account";
103
104 // service references
105
106 private AccessControlService acService;
107
108 private FilesService fileService = null;
109
110 // package internal helpers
111
112 MailConfigurationCache config = null;
113
114 ImageCidEmbedment cidEmbedment = null;
115
116 Autocomplete autocomplete = null;
117
118 boolean isInitialized;
119
120 /**
121 * @see #associateRecipient(long, long, RecipientType, de.deepamehta.core.service.ClientState)
122 */
123 @POST
124 @Path("{mail}/recipient/{address}")
125 public Association associateRecipient(//
126 @PathParam("mail") long mailId,//
127 @PathParam("address") long addressId,//
128 @HeaderParam("Cookie") ClientState cookie) {
129 return associateRecipient(mailId, addressId, config.getDefaultRecipientType(), cookie);
130 }
131
132 @Override
133 public Association associateRecipient(long mailId, long addressId, RecipientType type, ClientState clientState) {
134 log.info("associate " + mailId + " with recipient address " + addressId);
135
136 // create value of the recipient association
137 CompositeValueModel value = new CompositeValueModel()//
138 .putRef(RECIPIENT_TYPE, type.getUri())//
139 .putRef(EMAIL_ADDRESS, addressId);
140
141 // get and update or create a new recipient association
142 RelatedTopic recipient = getContactOfEmail(addressId);
143 Association association = getRecipientAssociation(mailId, addressId, recipient.getId());
144
145 if (association == null) { // create a recipient association
146 return associateRecipient(mailId, recipient, value, clientState);
147 } else { // update address and type references
148 association.setCompositeValue(value, clientState, new Directives());
149 return association;
150 }
151 }
152
153 @Override
154 public void associateValidatedRecipients(long mailId, List<Topic> recipients, ClientState cookie) {
155 for (Topic recipient : recipients) {
156 Topic topic = dms.getTopic(recipient.getId(), true);
157 if (topic.getCompositeValue().has(EMAIL_ADDRESS)) {
158 String personal = recipient.getSimpleValue().toString();
159 for (Topic email : topic.getCompositeValue().getTopics(EMAIL_ADDRESS)) {
160 String address = email.getSimpleValue().toString();
161 try {
162 new InternetAddress(address, personal).validate();
163 } catch (Exception e) {
164 log.log(Level.INFO, "email address '" + address + "' of contact '" + //
165 personal + "'" + " is invalid: " + e.getMessage());
166 continue; // check the next one
167 }
168 // associate validated email address as BCC recipient
169 associateRecipient(mailId, email.getId(), RecipientType.BCC, cookie);
170 }
171 }
172 }
173 }
174
175 /**
176 * @see #associateSender(long, long, de.deepamehta.core.service.ClientState)
177 */
178 @POST
179 @Path("{mail}/sender/{address}")
180 public Association mailSender(//
181 @PathParam("mail") long mailId,//
182 @PathParam("address") long addressId,//
183 @HeaderParam("Cookie") ClientState cookie) {
184 return associateSender(mailId, addressId, cookie);
185 }
186
187 @Override
188 public Association associateSender(long mailId, long addressId, ClientState clientState) {
189 log.info("associate " + mailId + " with sender " + addressId);
190
191 // create value of sender association
192 CompositeValueModel value = new CompositeValueModel().putRef(EMAIL_ADDRESS, addressId);
193
194 // find existing sender association
195 RelatedTopic sender = getContactOfEmail(addressId);
196 RelatedTopic oldSender = getSender(mailId, false);
197
198 if (oldSender == null) { // create the first sender association
199 return associateSender(mailId, sender, value, clientState);
200 } else { // update or delete the old sender
201 Association association = getSenderAssociation(mailId, oldSender.getId());
202 DeepaMehtaTransaction tx = dms.beginTx();
203 try {
204 if (sender.getId() != oldSender.getId()) { // delete the old one
205 dms.deleteAssociation(association.getId());
206 association = associateSender(mailId, sender, value, clientState);
207 } else { // update composite
208 association.setCompositeValue(value, clientState, new Directives());
209 }
210 tx.success();
211 } finally {
212 tx.finish();
213 }
214 return association;
215 }
216 }
217
218 /**
219 * Returns the parent of each search type substring match.
220 *
221 * @param term
222 * String to search.
223 * @param cookie
224 * Actual cookie.
225 * @return Parent model of each result topic.
226 */
227 @GET
228 @Path("/autocomplete/{term}")
229 public List<TopicModel> search(@PathParam("term") String term, //
230 @HeaderParam("Cookie") ClientState cookie) {
231 String query = "*" + term + "*";
232 log.info("autocomplete " + query);
233 return autocomplete.search(query, cookie);
234 }
235
236 /**
237 * Creates a copy of the mail.
238 *
239 * @param mailId
240 * ID of the mail topic to clone.
241 * @param includeRecipients
242 * Copy recipients of the origin?
243 * @param cookie
244 * Actual cookie.
245 * @return Cloned mail topic with associated sender and recipients.
246 */
247 @POST
248 @Path("/{mail}/copy")
249 public Topic copyMail(//
250 @PathParam("mail") long mailId,//
251 @QueryParam("recipients") boolean includeRecipients,//
252 @HeaderParam("Cookie") ClientState cookie) {
253 log.info("copy mail " + mailId);
254 DeepaMehtaTransaction tx = dms.beginTx();
255 try {
256 Topic mail = dms.getTopic(mailId, true);
257 TopicModel model = new TopicModel(MAIL, mail.getCompositeValue().getModel() // copy
258 .put(DATE, "").put(MESSAGE_ID, "")); // nullify date and ID
259 Topic clone = dms.createTopic(model, cookie);
260
261 // copy sender association
262 RelatedTopic sender = getSender(mail, true);
263 associateSender(clone.getId(), sender, sender.getRelatingAssociation()//
264 .getCompositeValue().getModel(), cookie);
265
266 // copy recipient associations
267 if (includeRecipients) {
268 for (RelatedTopic recipient : mail.getRelatedTopics(RECIPIENT,//
269 PARENT, CHILD, null, false, true, 0)) {
270 for (Association association : dms.getAssociations(mail.getId(),//
271 recipient.getId())) {
272 if (association.getTypeUri().equals(RECIPIENT) == false) {
273 continue; // sender or something else found
274 }
275 CompositeValue value = dms.getAssociation(association.getId(), true).getCompositeValue();
276 associateRecipient(clone.getId(), recipient, value.getModel(), cookie);
277 }
278 }
279 }
280 tx.success();
281 return clone;
282 } finally {
283 tx.finish();
284 }
285 }
286
287 /**
288 * Creates a new mail to all email addresses of the contact topic.
289 *
290 * @param recipientId
291 * ID of a recipient contact topic.
292 * @param cookie
293 * Actual cookie.
294 * @return Mail topic with associated recipient.
295 */
296 @POST
297 @Path("/write/{recipient}")
298 public Topic writeTo(@PathParam("recipient") long recipientId,//
299 @HeaderParam("Cookie") ClientState cookie) {
300 log.info("write a mail to recipient " + recipientId);
301 DeepaMehtaTransaction tx = dms.beginTx();
302 try {
303 Topic mail = dms.createTopic(new TopicModel(MAIL), cookie);
304 Topic recipient = dms.getTopic(recipientId, true);
305 if (recipient.getCompositeValue().has(EMAIL_ADDRESS)) {
306 for (Topic address : recipient.getCompositeValue().getTopics(EMAIL_ADDRESS)) {
307 associateRecipient(mail.getId(), address.getId(),//
308 config.getDefaultRecipientType(), cookie);
309 }
310 }
311 tx.success();
312 return mail;
313 } finally {
314 tx.finish();
315 }
316 }
317
318 /**
319 * @return Default recipient type.
320 */
321 @GET
322 @Path("/recipient/default")
323 public String getDefaultRecipientType() {
324 return config.getDefaultRecipientType().getUri();
325 }
326
327 /**
328 * @return Recipient types.
329 */
330 @GET
331 @Path("/recipient/types")
332 public ResultSet<RelatedTopic> getRecipientTypes() {
333 return config.getRecipientTypes();
334 }
335
336 /**
337 * @see #getSearchParentTypes
338 */
339 @GET
340 @Path("/search/parents")
341 public ResultSet<Topic> listSearchParentTypes() {
342 Collection<Topic> parents = getSearchParentTypes();
343 return new ResultSet<Topic>(parents.size(), new HashSet<Topic>(parents));
344 }
345
346 @Override
347 public Collection<Topic> getSearchParentTypes() {
348 return config.getSearchParentTypes();
349 }
350
351 /**
352 * Load the configuration.
353 *
354 * Useful after type and configuration changes with the web-client.
355 */
356 @GET
357 @Path("/config/load")
358 public Topic loadConfiguration() {
359 log.info("load mail configuration");
360 config = new MailConfigurationCache(dms);
361 autocomplete = new Autocomplete(dms, config);
362 return config.getTopic();
363 }
364
365 /**
366 * Sets the default sender and signature of a mail topic after creation.
367 */
368 @Override
369 public void postCreateTopic(Topic topic, ClientState clientState, Directives directives) {
370 if (topic.getTypeUri().equals(MAIL)) {
371 if (topic.getCompositeValue().has(FROM) == false) { // new mail
372 associateDefaultSender(topic, clientState);
373 } else { // copied mail
374 Topic from = topic.getCompositeValue().getTopic(FROM);
375 if (from.getSimpleValue().booleanValue() == false) { // sender?
376 associateDefaultSender(topic, clientState);
377 }
378 }
379 }
380 }
381
382 /**
383 * @see #send(Mail)
384 */
385 @POST
386 @Path("/{mail}/send")
387 public StatusReport send(//
388 @PathParam("mail") long mailId) {
389 log.info("send mail " + mailId);
390 return send(new Mail(mailId, dms));
391 }
392
393 @Override
394 public StatusReport send(Mail mail) {
395 StatusReport statusReport = new StatusReport(mail.getTopic());
396
397 HtmlEmail email = new HtmlEmail();
398 email.setDebug(true); // => System.out.println(SMTP communication);
399 email.setHostName(config.getSmtpHost());
400
401 try {
402 InternetAddress sender = mail.getSender();
403 email.setFrom(sender.getAddress(), sender.getPersonal());
404 } catch (Exception e) {
405 reportException(statusReport, Level.INFO, MailError.SENDER, e);
406 }
407
408 try {
409 String subject = mail.getSubject();
410 if (subject.isEmpty()) { // caught immediately
411 throw new IllegalArgumentException("Subject of mail is empty");
412 }
413 email.setSubject(subject);
414 } catch (Exception e) {
415 reportException(statusReport, Level.INFO, MailError.CONTENT, e);
416 }
417
418 try {
419 Document body = cidEmbedment.embedImages(email, mail.getBody());
420 String text = body.text();
421 if (text.isEmpty()) { // caught immediately
422 throw new IllegalArgumentException("Text body of mail is empty");
423 }
424 email.setTextMsg(text);
425 email.setHtmlMsg(body.html());
426 } catch (Exception e) {
427 reportException(statusReport, Level.INFO, MailError.CONTENT, e);
428 }
429
430 for (Long fileId : mail.getAttachmentIds()) {
431 try {
432 String path = fileService.getFile(fileId).getAbsolutePath();
433 EmailAttachment attachment = new EmailAttachment();
434 attachment.setPath(path);
435 log.fine("attach " + path);
436 email.attach(attachment);
437 } catch (Exception e) {
438 reportException(statusReport, Level.INFO, MailError.ATTACHMENTS, e);
439 }
440 }
441
442 RecipientsByType recipients = new RecipientsByType();
443 try {
444 recipients = mail.getRecipients();
445 try {
446 mapRecipients(email, recipients);
447 } catch (Exception e) {
448 reportException(statusReport, Level.SEVERE, MailError.RECIPIENT_TYPE, e);
449 }
450 } catch (InvalidRecipients e) {
451 for (String recipient : e.getRecipients()) {
452 log.log(Level.INFO, MailError.RECIPIENTS.getMessage() + ": " + recipient);
453 statusReport.addError(MailError.RECIPIENTS, recipient);
454 }
455 }
456
457 if (statusReport.hasErrors()) {
458 statusReport.setMessage("Mail can NOT be sent");
459 } else { // send, update message ID and return status with attached mail
460 try {
461 String messageId = email.send();
462 statusReport.setMessage("Mail was SUCCESSFULLY sent to " + //
463 recipients.getCount() + " mail addresses");
464 mail.setMessageId(messageId);
465 } catch (EmailException e) {
466 statusReport.setMessage("Sending mail FAILED");
467 reportException(statusReport, Level.SEVERE, MailError.SEND, e);
468 } catch (Exception e) { // error after send
469 reportException(statusReport, Level.SEVERE, MailError.UPDATE, e);
470 }
471 }
472 return statusReport;
473 }
474
475 /**
476 * Initialize.
477 */
478 @Override
479 public void init() {
480 isInitialized = true;
481 configureIfReady();
482 }
483
484 /**
485 * Reference file service and create the attachment directory if not exists.
486 */
487 @Override
488 @ConsumesService({ "de.deepamehta.plugins.accesscontrol.service.AccessControlService",
489 "de.deepamehta.plugins.files.service.FilesService" })
490 public void serviceArrived(PluginService service) {
491 if (service instanceof AccessControlService) {
492 acService = (AccessControlService) service;
493 } else if (service instanceof FilesService) {
494 fileService = (FilesService) service;
495 cidEmbedment = new ImageCidEmbedment(fileService);
496 }
497 configureIfReady();
498 }
499
500 private void configureIfReady() {
501 if (isInitialized && acService != null && fileService != null) {
502 createAttachmentDirectory();
503 checkACLsOfMigration();
504 loadConfiguration();
505 }
506 }
507
508 @Override
509 public void serviceGone(PluginService service) {
510 if (service == acService) {
511 acService = null;
512 } else if (service == fileService) {
513 fileService = null;
514 cidEmbedment = null;
515 }
516 }
517
518 private void checkACLsOfMigration() {
519 Topic config = dms.getTopic("uri", new SimpleValue("dm4.mail.config"), false);
520 if (acService.getCreator(config) == null) {
521 log.info("initial ACL update of configuration");
522 Topic admin = acService.getUsername("admin");
523 String adminName = admin.getSimpleValue().toString();
524 acService.setCreator(config, adminName);
525 acService.setOwner(config, adminName);
526 acService.setACL(config, new AccessControlList(new ACLEntry(Operation.WRITE, UserRole.OWNER)));
527 }
528 }
529
530 private void createAttachmentDirectory() {
531 // TODO move the initialization to migration "0"
532 try {
533 ResourceInfo resourceInfo = fileService.getResourceInfo(ATTACHMENTS);
534 String kind = resourceInfo.toJSON().getString("kind");
535 if (kind.equals("directory") == false) {
536 String repoPath = System.getProperty("dm4.filerepo.path");
537 String message = "attachment storage directory " + repoPath + File.separator + ATTACHMENTS
538 + " can not be used";
539 throw new IllegalStateException(message);
540 }
541 } catch (WebApplicationException e) { // !exists
542 // catch fileService info request error => create directory
543 if (e.getResponse().getStatus() != 404) {
544 throw e;
545 } else {
546 log.info("create attachment directory");
547 fileService.createFolder(ATTACHMENTS, "/");
548 }
549 } catch (JSONException e) {
550 throw new RuntimeException(e);
551 }
552 }
553
554 /**
555 * @param mail
556 * @param clientState
557 *
558 * @see MailPlugin#postCreateTopic(Topic, ClientState, Directives)
559 */
560 private void associateDefaultSender(Topic mail, ClientState clientState) {
561 Topic creator = null;
562 RelatedTopic sender = null;
563
564 Topic creatorName = acService.getUsername(acService.getCreator(mail));
565 if (creatorName != null) {
566 creator = creatorName.getRelatedTopic(null, CHILD, PARENT, USER_ACCOUNT, false, false);
567 }
568
569 // get user account specific sender
570 if (creator != null) {
571 sender = getSender(creator, true);
572 }
573
574 // get the configured default sender instead
575 if (sender == null) {
576 sender = config.getDefaultSender();
577 }
578
579 if (sender != null) {
580 DeepaMehtaTransaction tx = dms.beginTx();
581 try {
582 CompositeValueModel value = sender.getRelatingAssociation()//
583 .getCompositeValue().getModel();
584 associateSender(mail.getId(), sender, value, clientState);
585 long addressId = value.getTopic(EMAIL_ADDRESS).getId();
586 RelatedTopic signature = getContactSignature(sender, addressId);
587 if (signature != null) {
588 mail.getCompositeValue().getModel().add(SIGNATURE, signature.getModel());
589 }
590 tx.success();
591 } finally {
592 tx.finish();
593 }
594 }
595 }
596
597 private Association associateRecipient(long topicId, Topic recipient, CompositeValueModel value,
598 ClientState clientState) {
599 return dms.createAssociation(new AssociationModel(RECIPIENT,//
600 new TopicRoleModel(recipient.getId(), CHILD),//
601 new TopicRoleModel(topicId, PARENT), value), clientState);
602 }
603
604 private Association associateSender(long topicId, Topic sender, CompositeValueModel value, ClientState clientState) {
605 return dms.createAssociation(new AssociationModel(SENDER,//
606 new TopicRoleModel(sender.getId(), CHILD),//
607 new TopicRoleModel(topicId, PARENT), value), clientState);
608 }
609
610 private RelatedTopic getContactOfEmail(long addressId) {
611 return dms.getTopic(addressId, false).getRelatedTopic(COMPOSITION, CHILD, PARENT, null, false, false);
612 }
613
614 private RelatedTopic getContactSignature(Topic topic, long addressId) {
615 for (RelatedTopic signature : topic.getRelatedTopics(SENDER, CHILD, PARENT, SIGNATURE, true, true, 0)) {
616 CompositeValue value = signature.getRelatingAssociation().getCompositeValue();
617 if (addressId == value.getTopic(EMAIL_ADDRESS).getId()) {
618 return signature;
619 }
620 }
621 return null;
622 }
623
624 private Association getRecipientAssociation(long topicId, long addressId, long recipientId) {
625 for (Association recipient : dms.getAssociations(topicId, recipientId)) {
626 Association association = dms.getAssociation(recipient.getId(), true);
627 Topic address = association.getCompositeValue().getTopic(EMAIL_ADDRESS);
628 if (association.getTypeUri().equals(RECIPIENT) && address.getId() == addressId) {
629 return association;
630 }
631 }
632 return null;
633 }
634
635 private RelatedTopic getSender(long topicId, boolean fetchRelatingComposite) {
636 return getSender(dms.getTopic(topicId, false), fetchRelatingComposite);
637 }
638
639 private RelatedTopic getSender(Topic topic, boolean fetchRelatingComposite) {
640 return topic.getRelatedTopic(SENDER, PARENT, CHILD, null, false, fetchRelatingComposite);
641 }
642
643 private Association getSenderAssociation(long topicId, long senderId) {
644 return dms.getAssociation(SENDER, topicId, senderId, PARENT, CHILD, true);
645 }
646
647 private void mapRecipients(HtmlEmail email, Map<RecipientType, List<InternetAddress>> recipients)
648 throws EmailException {
649 for (RecipientType type : recipients.keySet()) {
650 switch (type) {
651 case BCC:
652 email.setBcc(recipients.get(type));
653 break;
654 case CC:
655 email.setCc(recipients.get(type));
656 break;
657 case TO:
658 email.setTo(recipients.get(type));
659 break;
660 default:
661 throw new IllegalArgumentException(type.toString());
662 }
663 }
664 }
665
666 private void reportException(StatusReport report, Level level, MailError error, Exception e) {
667 String message = e.getMessage();
668 Throwable cause = e.getCause();
669 if (cause != null) {
670 message += ": " + cause.getMessage();
671 }
672 String logMessage = error.getMessage() + ": " + message;
673 if (level == Level.WARNING || level == Level.SEVERE) {
674 log.log(level, logMessage, e); // log the exception trace
675 } else {
676 log.log(level, logMessage); // log only the message
677 }
678 report.addError(error, message);
679 }
680 }