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 }