Adapt to changes in Argeo TP
authorMathieu Baudier <mbaudier@argeo.org>
Mon, 4 Dec 2023 15:56:55 +0000 (16:56 +0100)
committerMathieu Baudier <mbaudier@argeo.org>
Mon, 4 Dec 2023 15:56:55 +0000 (16:56 +0100)
13 files changed:
Makefile
org.argeo.slc.cms/src/org/argeo/cms/integration/CmsExceptionsChain.java [new file with mode: 0644]
org.argeo.slc.cms/src/org/argeo/cms/integration/CmsLoginServlet.java [new file with mode: 0644]
org.argeo.slc.cms/src/org/argeo/cms/integration/CmsLogoutServlet.java [new file with mode: 0644]
org.argeo.slc.cms/src/org/argeo/cms/integration/CmsPrivateServletContext.java [new file with mode: 0644]
org.argeo.slc.cms/src/org/argeo/cms/integration/CmsSessionDescriptor.java [new file with mode: 0644]
org.argeo.slc.cms/src/org/argeo/cms/integration/CmsTokenServlet.java [new file with mode: 0644]
org.argeo.slc.cms/src/org/argeo/cms/integration/TestEndpoint.java [new file with mode: 0644]
org.argeo.slc.cms/src/org/argeo/cms/integration/TokenDescriptor.java [new file with mode: 0644]
org.argeo.slc.cms/src/org/argeo/cms/integration/package-info.java [new file with mode: 0644]
org.argeo.slc.cms/src/org/argeo/cms/mail/EmailMigration.java [new file with mode: 0644]
org.argeo.slc.cms/src/org/argeo/cms/mail/EmailUtils.java [new file with mode: 0644]
org.argeo.slc.runtime/build.properties

index 37eed20b3abec32d9c0eebd8d752e92bfcdb411d..65c241c943fbf54954311d3456b10984a4a2279d 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -28,7 +28,7 @@ crypto/fips/org.argeo.tp.crypto \
 log/syslogger/org.argeo.tp \
 org.argeo.tp \
 org.argeo.tp.httpd \
-org.argeo.tp.utils \
+org.argeo.tp.sys \
 osgi/api/org.argeo.tp.osgi \
 osgi/equinox/org.argeo.tp.eclipse \
 swt/rap/org.argeo.tp.swt \
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsExceptionsChain.java b/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsExceptionsChain.java
new file mode 100644 (file)
index 0000000..6727229
--- /dev/null
@@ -0,0 +1,80 @@
+package org.argeo.cms.integration;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import javax.servlet.http.HttpServletResponse;
+
+import org.argeo.api.cms.CmsLog;
+import org.argeo.cms.util.ExceptionsChain;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/** Serialisable wrapper of a {@link Throwable}. */
+public class CmsExceptionsChain extends ExceptionsChain {
+       public final static CmsLog log = CmsLog.getLog(CmsExceptionsChain.class);
+
+       public CmsExceptionsChain() {
+               super();
+       }
+
+       public CmsExceptionsChain(Throwable exception) {
+               super(exception);
+               if (log.isDebugEnabled())
+                       log.error("Exception chain", exception);
+       }
+
+       public String toJsonString(ObjectMapper objectMapper) {
+               try {
+                       return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(this);
+               } catch (JsonProcessingException e) {
+                       throw new IllegalStateException("Cannot write system exceptions " + toString(), e);
+               }
+       }
+
+       public void writeAsJson(ObjectMapper objectMapper, Writer writer) {
+               try {
+                       JsonGenerator jg = objectMapper.writerWithDefaultPrettyPrinter().getFactory().createGenerator(writer);
+                       jg.writeObject(this);
+               } catch (IOException e) {
+                       throw new IllegalStateException("Cannot write system exceptions " + toString(), e);
+               }
+       }
+
+       public void writeAsJson(ObjectMapper objectMapper, HttpServletResponse resp) {
+               try {
+                       resp.setContentType("application/json");
+                       resp.setStatus(500);
+                       writeAsJson(objectMapper, resp.getWriter());
+               } catch (IOException e) {
+                       throw new IllegalStateException("Cannot write system exceptions " + toString(), e);
+               }
+       }
+
+//     public static void main(String[] args) throws Exception {
+//             try {
+//                     try {
+//                             try {
+//                                     testDeeper();
+//                             } catch (Exception e) {
+//                                     throw new Exception("Less deep exception", e);
+//                             }
+//                     } catch (Exception e) {
+//                             throw new RuntimeException("Top exception", e);
+//                     }
+//             } catch (Exception e) {
+//                     CmsExceptionsChain systemErrors = new CmsExceptionsChain(e);
+//                     ObjectMapper objectMapper = new ObjectMapper();
+//                     System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(systemErrors));
+//                     System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(e));
+//                     e.printStackTrace();
+//             }
+//     }
+//
+//     static void testDeeper() throws Exception {
+//             throw new IllegalStateException("Deep exception");
+//     }
+
+}
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsLoginServlet.java b/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsLoginServlet.java
new file mode 100644 (file)
index 0000000..29a3137
--- /dev/null
@@ -0,0 +1,112 @@
+package org.argeo.cms.integration;
+
+import java.io.IOException;
+import java.util.Locale;
+import java.util.Set;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.NameCallback;
+import javax.security.auth.callback.PasswordCallback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.argeo.api.cms.CmsAuth;
+import org.argeo.api.cms.CmsSessionId;
+import org.argeo.cms.auth.RemoteAuthCallback;
+import org.argeo.cms.auth.RemoteAuthCallbackHandler;
+import org.argeo.cms.servlet.ServletHttpRequest;
+import org.argeo.cms.servlet.ServletHttpResponse;
+import org.osgi.service.useradmin.Authorization;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/** Externally authenticate an http session. */
+public class CmsLoginServlet extends HttpServlet {
+       public final static String PARAM_USERNAME = "username";
+       public final static String PARAM_PASSWORD = "password";
+
+       private static final long serialVersionUID = 2478080654328751539L;
+       private ObjectMapper objectMapper = new ObjectMapper();
+
+       @Override
+       protected void doGet(HttpServletRequest request, HttpServletResponse response)
+                       throws ServletException, IOException {
+               doPost(request, response);
+       }
+
+       @Override
+       protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+               LoginContext lc = null;
+               String username = req.getParameter(PARAM_USERNAME);
+               String password = req.getParameter(PARAM_PASSWORD);
+               ServletHttpRequest request = new ServletHttpRequest(req);
+               ServletHttpResponse response = new ServletHttpResponse(resp);
+               try {
+                       lc = new LoginContext(CmsAuth.LOGIN_CONTEXT_USER, new RemoteAuthCallbackHandler(request, response) {
+                               public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
+                                       for (Callback callback : callbacks) {
+                                               if (callback instanceof NameCallback && username != null)
+                                                       ((NameCallback) callback).setName(username);
+                                               else if (callback instanceof PasswordCallback && password != null)
+                                                       ((PasswordCallback) callback).setPassword(password.toCharArray());
+                                               else if (callback instanceof RemoteAuthCallback) {
+                                                       ((RemoteAuthCallback) callback).setRequest(request);
+                                                       ((RemoteAuthCallback) callback).setResponse(response);
+                                               }
+                                       }
+                               }
+                       });
+                       lc.login();
+
+                       Subject subject = lc.getSubject();
+                       CmsSessionId cmsSessionId = extractFrom(subject.getPrivateCredentials(CmsSessionId.class));
+                       if (cmsSessionId == null) {
+                               resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+                               return;
+                       }
+                       Authorization authorization = extractFrom(subject.getPrivateCredentials(Authorization.class));
+                       Locale locale = extractFrom(subject.getPublicCredentials(Locale.class));
+
+                       CmsSessionDescriptor cmsSessionDescriptor = new CmsSessionDescriptor(authorization.getName(),
+                                       cmsSessionId.getUuid().toString(), authorization.getRoles(), authorization.toString(),
+                                       locale != null ? locale.toString() : null);
+
+                       resp.setContentType("application/json");
+                       JsonGenerator jg = objectMapper.getFactory().createGenerator(resp.getWriter());
+                       jg.writeObject(cmsSessionDescriptor);
+
+                       String redirectTo = redirectTo(req);
+                       if (redirectTo != null)
+                               resp.sendRedirect(redirectTo);
+               } catch (LoginException e) {
+                       resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+                       return;
+               }
+       }
+
+       protected <T> T extractFrom(Set<T> creds) {
+               if (creds.size() > 0)
+                       return creds.iterator().next();
+               else
+                       return null;
+       }
+
+       /**
+        * To be overridden in order to return a richer {@link CmsSessionDescriptor} to
+        * be serialized.
+        */
+       protected CmsSessionDescriptor enrichJson(CmsSessionDescriptor cmsSessionDescriptor) {
+               return cmsSessionDescriptor;
+       }
+
+       protected String redirectTo(HttpServletRequest request) {
+               return null;
+       }
+}
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsLogoutServlet.java b/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsLogoutServlet.java
new file mode 100644 (file)
index 0000000..d18637d
--- /dev/null
@@ -0,0 +1,79 @@
+package org.argeo.cms.integration;
+
+import java.io.IOException;
+import java.util.Set;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.argeo.api.cms.CmsAuth;
+import org.argeo.api.cms.CmsSessionId;
+import org.argeo.cms.CurrentUser;
+import org.argeo.cms.auth.RemoteAuthCallback;
+import org.argeo.cms.auth.RemoteAuthCallbackHandler;
+import org.argeo.cms.servlet.ServletHttpRequest;
+import org.argeo.cms.servlet.ServletHttpResponse;
+
+/** Externally authenticate an http session. */
+public class CmsLogoutServlet extends HttpServlet {
+       private static final long serialVersionUID = 2478080654328751539L;
+
+       @Override
+       protected void doGet(HttpServletRequest request, HttpServletResponse response)
+                       throws ServletException, IOException {
+               doPost(request, response);
+       }
+
+       @Override
+       protected void doPost(HttpServletRequest request, HttpServletResponse response)
+                       throws ServletException, IOException {
+               ServletHttpRequest httpRequest = new ServletHttpRequest(request);
+               ServletHttpResponse httpResponse = new ServletHttpResponse(response);
+               LoginContext lc = null;
+               try {
+                       lc = new LoginContext(CmsAuth.LOGIN_CONTEXT_USER,
+                                       new RemoteAuthCallbackHandler(httpRequest, httpResponse) {
+                                               public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
+                                                       for (Callback callback : callbacks) {
+                                                               if (callback instanceof RemoteAuthCallback) {
+                                                                       ((RemoteAuthCallback) callback).setRequest(httpRequest);
+                                                                       ((RemoteAuthCallback) callback).setResponse(httpResponse);
+                                                               }
+                                                       }
+                                               }
+                                       });
+                       lc.login();
+
+                       Subject subject = lc.getSubject();
+                       CmsSessionId cmsSessionId = extractFrom(subject.getPrivateCredentials(CmsSessionId.class));
+                       if (cmsSessionId != null) {// logged in
+                               CurrentUser.logoutCmsSession(subject);
+                       }
+
+               } catch (LoginException e) {
+                       // ignore
+               }
+
+               String redirectTo = redirectTo(request);
+               if (redirectTo != null)
+                       response.sendRedirect(redirectTo);
+       }
+
+       protected <T> T extractFrom(Set<T> creds) {
+               if (creds.size() > 0)
+                       return creds.iterator().next();
+               else
+                       return null;
+       }
+
+       protected String redirectTo(HttpServletRequest request) {
+               return null;
+       }
+}
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsPrivateServletContext.java b/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsPrivateServletContext.java
new file mode 100644 (file)
index 0000000..09f17ae
--- /dev/null
@@ -0,0 +1,80 @@
+package org.argeo.cms.integration;
+
+import java.io.IOException;
+import java.security.AccessControlContext;
+import java.util.Map;
+
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.argeo.api.cms.CmsAuth;
+import org.argeo.cms.auth.RemoteAuthCallbackHandler;
+import org.argeo.cms.auth.RemoteAuthUtils;
+import org.argeo.cms.servlet.ServletHttpRequest;
+import org.argeo.cms.servlet.ServletHttpResponse;
+import org.osgi.service.http.context.ServletContextHelper;
+
+/** Manages security access to servlets. */
+public class CmsPrivateServletContext extends ServletContextHelper {
+       public final static String LOGIN_PAGE = "argeo.cms.integration.loginPage";
+       public final static String LOGIN_SERVLET = "argeo.cms.integration.loginServlet";
+       private String loginPage;
+       private String loginServlet;
+
+       public void init(Map<String, String> properties) {
+               loginPage = properties.get(LOGIN_PAGE);
+               loginServlet = properties.get(LOGIN_SERVLET);
+       }
+
+       /**
+        * Add the {@link AccessControlContext} as a request attribute, or redirect to
+        * the login page.
+        */
+       @Override
+       public boolean handleSecurity(final HttpServletRequest req, HttpServletResponse resp) throws IOException {
+               LoginContext lc = null;
+               ServletHttpRequest request = new ServletHttpRequest(req);
+               ServletHttpResponse response = new ServletHttpResponse(resp);
+
+               String pathInfo = req.getPathInfo();
+               String servletPath = req.getServletPath();
+               if ((pathInfo != null && (servletPath + pathInfo).equals(loginPage)) || servletPath.contentEquals(loginServlet))
+                       return true;
+               try {
+                       lc = CmsAuth.USER.newLoginContext(new RemoteAuthCallbackHandler(request, response));
+                       lc.login();
+               } catch (LoginException e) {
+                       lc = processUnauthorized(req, resp);
+                       if (lc == null)
+                               return false;
+               }
+//             Subject.doAs(lc.getSubject(), new PrivilegedAction<Void>() {
+//
+//                     @Override
+//                     public Void run() {
+//                             // TODO also set login context in order to log out ?
+//                             RemoteAuthUtils.configureRequestSecurity(request);
+//                             return null;
+//                     }
+//
+//             });
+
+               return true;
+       }
+
+//     @Override
+//     public void finishSecurity(HttpServletRequest req, HttpServletResponse resp) {
+//             RemoteAuthUtils.clearRequestSecurity(new ServletHttpRequest(req));
+//     }
+
+       protected LoginContext processUnauthorized(HttpServletRequest request, HttpServletResponse response) {
+               try {
+                       response.sendRedirect(loginPage);
+               } catch (IOException e) {
+                       throw new RuntimeException("Cannot redirect to login page", e);
+               }
+               return null;
+       }
+}
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsSessionDescriptor.java b/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsSessionDescriptor.java
new file mode 100644 (file)
index 0000000..30de616
--- /dev/null
@@ -0,0 +1,96 @@
+package org.argeo.cms.integration;
+
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.argeo.api.cms.CmsSession;
+import org.osgi.service.useradmin.Authorization;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/** A serializable descriptor of an internal {@link CmsSession}. */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class CmsSessionDescriptor implements Serializable, Authorization {
+       private static final long serialVersionUID = 8592162323372641462L;
+
+       private String name;
+       private String cmsSessionId;
+       private String displayName;
+       private String locale;
+       private Set<String> roles;
+
+       public CmsSessionDescriptor() {
+       }
+
+       public CmsSessionDescriptor(String name, String cmsSessionId, String[] roles, String displayName, String locale) {
+               this.name = name;
+               this.displayName = displayName;
+               this.cmsSessionId = cmsSessionId;
+               this.locale = locale;
+               this.roles = Collections.unmodifiableSortedSet(new TreeSet<>(Arrays.asList(roles)));
+       }
+
+       public String getName() {
+               return name;
+       }
+
+       public void setName(String name) {
+               this.name = name;
+       }
+
+       public String getDisplayName() {
+               return displayName;
+       }
+
+       public void setDisplayName(String displayName) {
+               this.displayName = displayName;
+       }
+
+       public String getCmsSessionId() {
+               return cmsSessionId;
+       }
+
+       public void setCmsSessionId(String cmsSessionId) {
+               this.cmsSessionId = cmsSessionId;
+       }
+
+       public Boolean isAnonymous() {
+               return name == null;
+       }
+
+       public String getLocale() {
+               return locale;
+       }
+
+       public void setLocale(String locale) {
+               this.locale = locale;
+       }
+
+       @Override
+       public boolean hasRole(String name) {
+               return roles.contains(name);
+       }
+
+       @Override
+       public String[] getRoles() {
+               return roles.toArray(new String[roles.size()]);
+       }
+
+       public void setRoles(String[] roles) {
+               this.roles = Collections.unmodifiableSortedSet(new TreeSet<>(Arrays.asList(roles)));
+       }
+
+       @Override
+       public int hashCode() {
+               return cmsSessionId != null ? cmsSessionId.hashCode() : super.hashCode();
+       }
+
+       @Override
+       public String toString() {
+               return displayName != null ? displayName : name != null ? name : super.toString();
+       }
+
+}
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsTokenServlet.java b/org.argeo.slc.cms/src/org/argeo/cms/integration/CmsTokenServlet.java
new file mode 100644 (file)
index 0000000..c355ecd
--- /dev/null
@@ -0,0 +1,117 @@
+package org.argeo.cms.integration;
+
+import java.io.IOException;
+import java.time.ZonedDateTime;
+import java.util.Set;
+import java.util.UUID;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.argeo.api.acr.ldap.NamingUtils;
+import org.argeo.api.cms.CmsAuth;
+import org.argeo.api.cms.directory.CmsUserManager;
+import org.argeo.cms.auth.RemoteAuthCallback;
+import org.argeo.cms.auth.RemoteAuthCallbackHandler;
+import org.argeo.cms.servlet.ServletHttpRequest;
+import org.argeo.cms.servlet.ServletHttpResponse;
+import org.osgi.service.useradmin.Authorization;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/** Provides access to tokens. */
+public class CmsTokenServlet extends HttpServlet {
+       private static final long serialVersionUID = 302918711430864140L;
+
+       public final static String PARAM_EXPIRY_DATE = "expiryDate";
+       public final static String PARAM_TOKEN = "token";
+
+       private final static int DEFAULT_HOURS = 24;
+
+       private CmsUserManager userManager;
+       private ObjectMapper objectMapper = new ObjectMapper();
+
+       @Override
+       protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+               ServletHttpRequest request = new ServletHttpRequest(req);
+               ServletHttpResponse response = new ServletHttpResponse(resp);
+               LoginContext lc = null;
+               try {
+                       lc = new LoginContext(CmsAuth.LOGIN_CONTEXT_USER, new RemoteAuthCallbackHandler(request, response) {
+                               public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
+                                       for (Callback callback : callbacks) {
+                                               if (callback instanceof RemoteAuthCallback) {
+                                                       ((RemoteAuthCallback) callback).setRequest(request);
+                                                       ((RemoteAuthCallback) callback).setResponse(response);
+                                               }
+                                       }
+                               }
+                       });
+                       lc.login();
+               } catch (LoginException e) {
+                       // ignore
+               }
+
+               try {
+                       Subject subject = lc.getSubject();
+                       Authorization authorization = extractFrom(subject.getPrivateCredentials(Authorization.class));
+                       String token = UUID.randomUUID().toString();
+                       String expiryDateStr = req.getParameter(PARAM_EXPIRY_DATE);
+                       ZonedDateTime expiryDate;
+                       if (expiryDateStr != null) {
+                               expiryDate = NamingUtils.ldapDateToZonedDateTime(expiryDateStr);
+                       } else {
+                               expiryDate = ZonedDateTime.now().plusHours(DEFAULT_HOURS);
+                               expiryDateStr = NamingUtils.instantToLdapDate(expiryDate);
+                       }
+                       userManager.addAuthToken(authorization.getName(), token, expiryDate);
+
+                       TokenDescriptor tokenDescriptor = new TokenDescriptor();
+                       tokenDescriptor.setUsername(authorization.getName());
+                       tokenDescriptor.setToken(token);
+                       tokenDescriptor.setExpiryDate(expiryDateStr);
+//                     tokenDescriptor.setRoles(Collections.unmodifiableSortedSet(new TreeSet<>(Arrays.asList(roles))));
+
+                       resp.setContentType("application/json");
+                       JsonGenerator jg = objectMapper.getFactory().createGenerator(resp.getWriter());
+                       jg.writeObject(tokenDescriptor);
+               } catch (Exception e) {
+                       new CmsExceptionsChain(e).writeAsJson(objectMapper, resp);
+               }
+       }
+
+       @Override
+       protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+               // temporarily wrap POST for ease of testing
+               doPost(req, resp);
+       }
+
+       @Override
+       protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+               try {
+                       String token = req.getParameter(PARAM_TOKEN);
+                       userManager.expireAuthToken(token);
+               } catch (Exception e) {
+                       new CmsExceptionsChain(e).writeAsJson(objectMapper, resp);
+               }
+       }
+
+       protected <T> T extractFrom(Set<T> creds) {
+               if (creds.size() > 0)
+                       return creds.iterator().next();
+               else
+                       return null;
+       }
+
+       public void setUserManager(CmsUserManager userManager) {
+               this.userManager = userManager;
+       }
+}
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/integration/TestEndpoint.java b/org.argeo.slc.cms/src/org/argeo/cms/integration/TestEndpoint.java
new file mode 100644 (file)
index 0000000..a09d83e
--- /dev/null
@@ -0,0 +1,184 @@
+package org.argeo.cms.integration;
+
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Map;
+
+import javax.security.auth.Subject;
+import javax.websocket.CloseReason;
+import javax.websocket.EndpointConfig;
+import javax.websocket.OnClose;
+import javax.websocket.OnError;
+import javax.websocket.OnMessage;
+import javax.websocket.OnOpen;
+import javax.websocket.RemoteEndpoint;
+import javax.websocket.Session;
+import javax.websocket.server.PathParam;
+import javax.websocket.server.ServerEndpoint;
+
+import org.argeo.api.acr.ldap.NamingUtils;
+import org.argeo.api.cms.CmsLog;
+import org.argeo.cms.websocket.server.CmsWebSocketConfigurator;
+import org.argeo.cms.websocket.server.WebSocketView;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventConstants;
+import org.osgi.service.event.EventHandler;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/** Provides WebSocket access. */
+@ServerEndpoint(value = "/cms/status/test/{topic}", configurator = CmsWebSocketConfigurator.class)
+public class TestEndpoint implements EventHandler {
+       private final static CmsLog log = CmsLog.getLog(TestEndpoint.class);
+
+       final static String TOPICS_BASE = "/test";
+       final static String INPUT = "input";
+       final static String TOPIC = "topic";
+       final static String VIEW_UID = "viewUid";
+       final static String COMPUTATION_UID = "computationUid";
+       final static String MESSAGES = "messages";
+       final static String ERRORS = "errors";
+
+       final static String EXCEPTION = "exception";
+       final static String MESSAGE = "message";
+
+       private BundleContext bc = FrameworkUtil.getBundle(TestEndpoint.class).getBundleContext();
+
+       private String wsSessionId;
+       private RemoteEndpoint.Basic remote;
+       private ServiceRegistration<EventHandler> eventHandlerSr;
+
+       // json
+       private ObjectMapper objectMapper = new ObjectMapper();
+
+       private WebSocketView view;
+
+       @OnOpen
+       public void onOpen(Session session, EndpointConfig endpointConfig) {
+               Map<String, List<String>> parameters = NamingUtils.queryToMap(session.getRequestURI());
+               String path = NamingUtils.getQueryValue(parameters, "path");
+               log.debug("WS Path: " + path);
+
+               wsSessionId = session.getId();
+
+               // 24h timeout
+               session.setMaxIdleTimeout(1000 * 60 * 60 * 24);
+
+               Map<String, Object> userProperties = session.getUserProperties();
+               Subject subject = null;
+//             AccessControlContext accessControlContext = (AccessControlContext) userProperties
+//                             .get(ServletContextHelper.REMOTE_USER);
+//             Subject subject = Subject.getSubject(accessControlContext);
+//             // Deal with authentication failure
+//             if (subject == null) {
+//                     try {
+//                             CloseReason.CloseCode closeCode = new CloseReason.CloseCode() {
+//
+//                                     @Override
+//                                     public int getCode() {
+//                                             return 4001;
+//                                     }
+//                             };
+//                             session.close(new CloseReason(closeCode, "Unauthorized"));
+//                             if (log.isTraceEnabled())
+//                                     log.trace("Unauthorized web socket " + wsSessionId + ". Closing with code " + closeCode.getCode()
+//                                                     + ".");
+//                             return;
+//                     } catch (IOException e) {
+//                             // silent
+//                     }
+//                     return;// ignore
+//             }
+
+               if (log.isDebugEnabled())
+                       log.debug("WS#" + wsSessionId + " open for: " + subject);
+               remote = session.getBasicRemote();
+               view = new WebSocketView(subject);
+
+               // OSGi events
+               String[] topics = new String[] { TOPICS_BASE + "/*" };
+               Hashtable<String, Object> ht = new Hashtable<>();
+               ht.put(EventConstants.EVENT_TOPIC, topics);
+               ht.put(EventConstants.EVENT_FILTER, "(" + VIEW_UID + "=" + view.getUid() + ")");
+               eventHandlerSr = bc.registerService(EventHandler.class, this, ht);
+
+               if (log.isDebugEnabled())
+                       log.debug("New view " + view.getUid() + " opened, via web socket.");
+       }
+
+       @OnMessage
+       public void onWebSocketText(@PathParam("topic") String topic, Session session, String message)
+                       throws JsonMappingException, JsonProcessingException {
+               try {
+                       if (log.isTraceEnabled())
+                               log.trace("WS#" + view.getUid() + " received:\n" + message + "\n");
+//                     JsonNode jsonNode = objectMapper.readTree(message);
+//                     String topic = jsonNode.get(TOPIC).textValue();
+
+                       final String computationUid = null;
+//                     if (MY_TOPIC.equals(topic)) {
+//                             view.checkRole(SPECIFIC_ROLE);
+//                             computationUid= process();
+//                     }
+                       remote.sendText("ACK " + topic);
+               } catch (Exception e) {
+                       log.error("Error when receiving web socket message", e);
+                       sendSystemErrorMessage(e);
+               }
+       }
+
+       @OnClose
+       public void onWebSocketClose(CloseReason reason) {
+               if (eventHandlerSr != null)
+                       eventHandlerSr.unregister();
+               if (view != null && log.isDebugEnabled())
+                       log.debug("WS#" + view.getUid() + " closed: " + reason);
+       }
+
+       @OnError
+       public void onWebSocketError(Throwable cause) {
+               if (view != null) {
+                       log.error("WS#" + view.getUid() + " ERROR", cause);
+               } else {
+                       if (log.isTraceEnabled())
+                               log.error("Error in web socket session " + wsSessionId, cause);
+               }
+       }
+
+       @Override
+       public void handleEvent(Event event) {
+               try {
+                       Object uid = event.getProperty(COMPUTATION_UID);
+                       Exception exception = (Exception) event.getProperty(EXCEPTION);
+                       if (exception != null) {
+                               CmsExceptionsChain systemErrors = new CmsExceptionsChain(exception);
+                               String sent = systemErrors.toJsonString(objectMapper);
+                               remote.sendText(sent);
+                               return;
+                       }
+                       String topic = event.getTopic();
+                       if (log.isTraceEnabled())
+                               log.trace("WS#" + view.getUid() + " " + topic + ": notify event " + topic + "#" + uid + ", " + event);
+               } catch (Exception e) {
+                       log.error("Error when handling event for WebSocket", e);
+                       sendSystemErrorMessage(e);
+               }
+
+       }
+
+       /** Sends an error message in JSON format. */
+       protected void sendSystemErrorMessage(Exception e) {
+               CmsExceptionsChain systemErrors = new CmsExceptionsChain(e);
+               try {
+                       if (remote != null)
+                               remote.sendText(systemErrors.toJsonString(objectMapper));
+               } catch (Exception e1) {
+                       log.error("Cannot send WebSocket system error messages " + systemErrors, e1);
+               }
+       }
+}
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/integration/TokenDescriptor.java b/org.argeo.slc.cms/src/org/argeo/cms/integration/TokenDescriptor.java
new file mode 100644 (file)
index 0000000..1541b4f
--- /dev/null
@@ -0,0 +1,49 @@
+package org.argeo.cms.integration;
+
+import java.io.Serializable;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/** A serializable descriptor of a token. */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class TokenDescriptor implements Serializable {
+       private static final long serialVersionUID = -6607393871416803324L;
+
+       private String token;
+       private String username;
+       private String expiryDate;
+//     private Set<String> roles;
+
+       public String getToken() {
+               return token;
+       }
+
+       public void setToken(String token) {
+               this.token = token;
+       }
+
+       public String getUsername() {
+               return username;
+       }
+
+       public void setUsername(String username) {
+               this.username = username;
+       }
+
+//     public Set<String> getRoles() {
+//             return roles;
+//     }
+//
+//     public void setRoles(Set<String> roles) {
+//             this.roles = roles;
+//     }
+
+       public String getExpiryDate() {
+               return expiryDate;
+       }
+
+       public void setExpiryDate(String expiryDate) {
+               this.expiryDate = expiryDate;
+       }
+
+}
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/integration/package-info.java b/org.argeo.slc.cms/src/org/argeo/cms/integration/package-info.java
new file mode 100644 (file)
index 0000000..1405737
--- /dev/null
@@ -0,0 +1,2 @@
+/** Argeo CMS integration (JSON, web services). */
+package org.argeo.cms.integration;
\ No newline at end of file
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/mail/EmailMigration.java b/org.argeo.slc.cms/src/org/argeo/cms/mail/EmailMigration.java
new file mode 100644 (file)
index 0000000..30a74f3
--- /dev/null
@@ -0,0 +1,524 @@
+package org.argeo.cms.mail;
+
+import static java.lang.System.Logger.Level.DEBUG;
+import static java.lang.System.Logger.Level.ERROR;
+import static org.argeo.cms.mail.EmailUtils.describe;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.System.Logger;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Enumeration;
+import java.util.Properties;
+
+import javax.mail.FetchProfile;
+import javax.mail.Folder;
+import javax.mail.Message;
+import javax.mail.MessagingException;
+import javax.mail.Multipart;
+import javax.mail.Session;
+import javax.mail.Store;
+import javax.mail.URLName;
+import javax.mail.internet.InternetHeaders;
+import javax.mail.internet.MimeBodyPart;
+import javax.mail.internet.MimeMessage;
+import javax.mail.search.HeaderTerm;
+import javax.mail.util.SharedFileInputStream;
+
+import com.sun.mail.imap.IMAPFolder;
+import com.sun.mail.mbox.MboxFolder;
+import com.sun.mail.mbox.MboxMessage;
+
+/** Migrates emails from one storage to the another one. */
+public class EmailMigration {
+       private final static Logger logger = System.getLogger(EmailMigration.class.getName());
+
+//     private String targetBaseDir;
+
+       private String sourceServer;
+       private String sourceUsername;
+       private String sourcePassword;
+
+       private String targetServer;
+       private String targetUsername;
+       private String targetPassword;
+
+       private boolean targetSupportDualTypeFolders = true;
+
+       public void process() throws MessagingException, IOException {
+//             Path baseDir = Paths.get(targetBaseDir).resolve(sourceUsername).resolve("mbox");
+
+               Store sourceStore = null;
+               try {
+                       Properties sourceProperties = System.getProperties();
+                       sourceProperties.setProperty("mail.store.protocol", "imaps");
+
+                       Session sourceSession = Session.getInstance(sourceProperties, null);
+                       // session.setDebug(true);
+                       sourceStore = sourceSession.getStore("imaps");
+                       sourceStore.connect(sourceServer, sourceUsername, sourcePassword);
+
+                       Folder defaultFolder = sourceStore.getDefaultFolder();
+//                     migrateFolders(baseDir, defaultFolder);
+
+                       // Always start with Inbox
+//                     Folder inboxFolder = sourceStore.getFolder(EmailUtils.INBOX);
+//                     migrateFolder(baseDir, inboxFolder);
+
+                       Properties targetProperties = System.getProperties();
+                       targetProperties.setProperty("mail.imap.starttls.enable", "true");
+                       targetProperties.setProperty("mail.imap.auth", "true");
+
+                       Session targetSession = Session.getInstance(targetProperties, null);
+                       // session.setDebug(true);
+                       Store targetStore = targetSession.getStore("imap");
+                       targetStore.connect(targetServer, targetUsername, targetPassword);
+
+//                     Folder targetFolder = targetStore.getFolder(EmailUtils.INBOX);
+//                     logger.log(DEBUG, "Source message count " + inboxFolder.getMessageCount());
+//                     logger.log(DEBUG, "Target message count " + targetFolder.getMessageCount());
+
+                       migrateFolders(defaultFolder, targetStore);
+               } finally {
+                       if (sourceStore != null)
+                               sourceStore.close();
+
+               }
+       }
+
+       protected void migrateFolders(Folder sourceParentFolder, Store targetStore) throws MessagingException, IOException {
+               folders: for (Folder sourceFolder : sourceParentFolder.list()) {
+                       String sourceFolderName = sourceFolder.getName();
+
+                       String sourceFolderFullName = sourceFolder.getFullName();
+                       char sourceFolderSeparator = sourceParentFolder.getSeparator();
+                       char targetFolderSeparator = targetStore.getDefaultFolder().getSeparator();
+                       String targetFolderFullName = sourceFolderFullName.replace(sourceFolderSeparator, targetFolderSeparator);
+
+                       // GMail specific
+                       if (sourceFolderFullName.equals("[Gmail]")) {
+                               migrateFolders(sourceFolder, targetStore);
+                               continue folders;
+                       }
+                       if (sourceFolderFullName.startsWith("[Gmail]")) {
+                               String subFolderName = null;
+                               // Make it configurable
+                               switch (sourceFolderName) {
+                               case "All Mail":
+                               case "Important":
+                               case "Spam":
+                                       continue folders;
+                               case "Sent Mail":
+                                       subFolderName = "Sent";
+                               default:
+                                       // does nothing
+                               }
+                               targetFolderFullName = subFolderName == null ? sourceFolder.getName() : subFolderName;
+                       }
+
+                       // nature of the source folder
+                       int messageCount = (sourceFolder.getType() & Folder.HOLDS_MESSAGES) != 0 ? sourceFolder.getMessageCount()
+                                       : 0;
+                       boolean hasSubFolders = (sourceFolder.getType() & Folder.HOLDS_FOLDERS) != 0
+                                       ? sourceFolder.list().length != 0
+                                       : false;
+
+                       Folder targetFolder;
+                       if (targetSupportDualTypeFolders) {
+                               targetFolder = targetStore.getFolder(targetFolderFullName);
+                               if (!targetFolder.exists()) {
+                                       targetFolder.create(Folder.HOLDS_FOLDERS | Folder.HOLDS_MESSAGES);
+                                       logger.log(DEBUG, "Created HOLDS_FOLDERS | HOLDS_MESSAGES folder " + targetFolder.getFullName());
+                               }
+
+                       } else {
+                               if (hasSubFolders) {// has sub-folders
+                                       if (messageCount == 0) {
+                                               targetFolder = targetStore.getFolder(targetFolderFullName);
+                                               if (!targetFolder.exists()) {
+                                                       targetFolder.create(Folder.HOLDS_FOLDERS);
+                                                       logger.log(DEBUG, "Created HOLDS_FOLDERS folder " + targetFolder.getFullName());
+                                               }
+                                       } else {// also has messages
+                                               Folder parentFolder = targetStore.getFolder(targetFolderFullName);
+                                               if (!parentFolder.exists()) {
+                                                       parentFolder.create(Folder.HOLDS_FOLDERS);
+                                                       logger.log(DEBUG, "Created HOLDS_FOLDERS folder " + parentFolder.getFullName());
+                                               }
+                                               String miscFullName = targetFolderFullName + targetFolderSeparator + "_Misc";
+                                               targetFolder = targetStore.getFolder(miscFullName);
+                                               if (!targetFolder.exists()) {
+                                                       targetFolder.create(Folder.HOLDS_MESSAGES);
+                                                       logger.log(DEBUG, "Created HOLDS_MESSAGES folder " + targetFolder.getFullName());
+                                               }
+                                       }
+                               } else {// no sub-folders
+                                       if (messageCount == 0) { // empty
+                                               logger.log(DEBUG, "Skip empty folder " + targetFolderFullName);
+                                               continue folders;
+                                       }
+                                       targetFolder = targetStore.getFolder(targetFolderFullName);
+                                       if (!targetFolder.exists()) {
+                                               targetFolder.create(Folder.HOLDS_MESSAGES);
+                                               logger.log(DEBUG, "Created HOLDS_MESSAGES folder " + targetFolder.getFullName());
+                                       }
+                               }
+                       }
+
+                       if (messageCount != 0) {
+
+                               targetFolder.open(Folder.READ_WRITE);
+                               try {
+                                       long begin = System.currentTimeMillis();
+                                       sourceFolder.open(Folder.READ_ONLY);
+                                       migrateFolder(sourceFolder, targetFolder);
+                                       long duration = System.currentTimeMillis() - begin;
+                                       logger.log(DEBUG, targetFolderFullName + " - Migration of " + messageCount + " messages took "
+                                                       + (duration / 1000) + " s (" + (duration / messageCount) + " ms per message)");
+                               } finally {
+                                       sourceFolder.close();
+                                       targetFolder.close();
+                               }
+                       }
+
+                       // recursive
+                       if (hasSubFolders) {
+                               migrateFolders(sourceFolder, targetStore);
+                       }
+               }
+       }
+
+       protected void migrateFoldersToFs(Path baseDir, Folder sourceFolder) throws MessagingException, IOException {
+               folders: for (Folder folder : sourceFolder.list()) {
+                       String folderName = folder.getName();
+
+                       if ((folder.getType() & Folder.HOLDS_MESSAGES) != 0) {
+                               // Make it configurable
+                               switch (folderName) {
+                               case "All Mail":
+                               case "Important":
+                                       continue folders;
+                               default:
+                                       // doe nothing
+                               }
+                               migrateFolderToFs(baseDir, folder);
+                       }
+                       if ((folder.getType() & Folder.HOLDS_FOLDERS) != 0) {
+                               migrateFoldersToFs(baseDir.resolve(folder.getName()), folder);
+                       }
+               }
+       }
+
+       protected void migrateFolderToFs(Path baseDir, Folder sourceFolder) throws MessagingException, IOException {
+
+               String folderName = sourceFolder.getName();
+               sourceFolder.open(Folder.READ_ONLY);
+
+               Folder targetFolder = null;
+               try {
+                       int messageCount = sourceFolder.getMessageCount();
+                       logger.log(DEBUG, folderName + " - Message count : " + messageCount);
+                       if (messageCount == 0)
+                               return;
+//                     logger.log(DEBUG, folderName + " - Unread Messages : " + sourceFolder.getUnreadMessageCount());
+
+                       boolean saveAsFiles = false;
+
+                       if (saveAsFiles) {
+                               Message messages[] = sourceFolder.getMessages();
+
+                               for (int i = 0; i < messages.length; ++i) {
+//                                     logger.log(DEBUG, "MESSAGE #" + (i + 1) + ":");
+                                       Message msg = messages[i];
+//                                     String from = "unknown";
+//                                     if (msg.getReplyTo().length >= 1) {
+//                                             from = msg.getReplyTo()[0].toString();
+//                                     } else if (msg.getFrom().length >= 1) {
+//                                             from = msg.getFrom()[0].toString();
+//                                     }
+                                       String subject = msg.getSubject();
+                                       Instant sentDate = msg.getSentDate().toInstant();
+//                                     logger.log(DEBUG, "Saving ... " + subject + " from " + from + " (" + sentDate + ")");
+                                       String fileName = sentDate + "  " + subject;
+                                       Path file = baseDir.resolve(fileName);
+                                       savePartsAsFiles(msg.getContent(), file);
+                               }
+                       } else {
+                               long begin = System.currentTimeMillis();
+                               targetFolder = openMboxTargetFolder(sourceFolder, baseDir);
+                               migrateFolder(sourceFolder, targetFolder);
+                               long duration = System.currentTimeMillis() - begin;
+                               logger.log(DEBUG, folderName + " - Migration of " + messageCount + " messages took " + (duration / 1000)
+                                               + " s (" + (duration / messageCount) + " ms per message)");
+                       }
+               } finally {
+                       sourceFolder.close();
+                       if (targetFolder != null)
+                               targetFolder.close();
+               }
+       }
+
+       protected Folder migrateFolder(Folder sourceFolder, Folder targetFolder) throws MessagingException, IOException {
+               String folderName = targetFolder.getName();
+
+               int lastSourceNumber;
+               int currentTargetMessageCount = targetFolder.getMessageCount();
+               if (currentTargetMessageCount != 0) {
+                       MimeMessage lastTargetMessage = (MimeMessage) targetFolder.getMessage(currentTargetMessageCount);
+                       logger.log(DEBUG, folderName + " - Last target message " + describe(lastTargetMessage));
+                       Date lastTargetSent = lastTargetMessage.getReceivedDate();
+                       Message[] lastSourceMessage = sourceFolder
+                                       .search(new HeaderTerm(EmailUtils.MESSAGE_ID, lastTargetMessage.getMessageID()));
+                       if (lastSourceMessage.length == 0)
+                               throw new IllegalStateException("No message found with message ID " + lastTargetMessage.getMessageID());
+                       if (lastSourceMessage.length != 1) {
+                               for (Message msg : lastSourceMessage) {
+                                       logger.log(ERROR, "Message " + describe(msg));
+
+                               }
+                               throw new IllegalStateException(
+                                               lastSourceMessage.length + " messages found with received date " + lastTargetSent.toInstant());
+                       }
+                       lastSourceNumber = lastSourceMessage[0].getMessageNumber();
+               } else {
+                       lastSourceNumber = 0;
+               }
+               logger.log(DEBUG, folderName + " - Last source message number " + lastSourceNumber);
+
+               int countToRetrieve = sourceFolder.getMessageCount() - lastSourceNumber;
+
+               FetchProfile fetchProfile = new FetchProfile();
+               fetchProfile.add(FetchProfile.Item.FLAGS);
+               fetchProfile.add(FetchProfile.Item.ENVELOPE);
+               fetchProfile.add(FetchProfile.Item.CONTENT_INFO);
+               fetchProfile.add(FetchProfile.Item.SIZE);
+               if (sourceFolder instanceof IMAPFolder) {
+                       // IMAPFolder sourceImapFolder = (IMAPFolder) sourceFolder;
+                       fetchProfile.add(IMAPFolder.FetchProfileItem.HEADERS);
+                       fetchProfile.add(IMAPFolder.FetchProfileItem.MESSAGE);
+               }
+
+               int batchSize = 100;
+               int batchCount = countToRetrieve / batchSize;
+               if (countToRetrieve % batchSize != 0)
+                       batchCount = batchCount + 1;
+               // int batchCount = 2; // for testing
+               for (int i = 0; i < batchCount; i++) {
+                       long begin = System.currentTimeMillis();
+
+                       int start = lastSourceNumber + i * batchSize + 1;
+                       int end = lastSourceNumber + (i + 1) * batchSize;
+                       if (end >= (lastSourceNumber + countToRetrieve + 1))
+                               end = lastSourceNumber + countToRetrieve;
+                       Message[] sourceMessages = sourceFolder.getMessages(start, end);
+                       sourceFolder.fetch(sourceMessages, fetchProfile);
+                       // targetFolder.appendMessages(sourceMessages);
+                       // sourceFolder.copyMessages(sourceMessages,targetFolder);
+
+                       copyMessages(sourceMessages, targetFolder);
+//                     copyMessagesToMbox(sourceMessages, targetFolder);
+
+                       String describeLast = describe(sourceMessages[sourceMessages.length - 1]);
+
+//             if (i % 10 == 9) {
+                       // free memory from fetched messages
+                       sourceFolder.close();
+                       targetFolder.close();
+
+                       sourceFolder.open(Folder.READ_ONLY);
+                       targetFolder.open(Folder.READ_WRITE);
+//                     logger.log(DEBUG, "Open/close folder in order to free memory");
+//             }
+
+                       long duration = System.currentTimeMillis() - begin;
+                       logger.log(DEBUG, folderName + " - batch " + i + " took " + (duration / 1000) + " s, "
+                                       + (duration / (end - start + 1)) + " ms per message. Last message " + describeLast);
+               }
+
+               return targetFolder;
+       }
+
+       protected Folder openMboxTargetFolder(Folder sourceFolder, Path baseDir) throws MessagingException, IOException {
+               String folderName = sourceFolder.getName();
+               if (sourceFolder.getName().equals(EmailUtils.INBOX_UPPER_CASE))
+                       folderName = EmailUtils.INBOX;// Inbox
+
+               Path targetDir = baseDir;// .resolve("mbox");
+               Files.createDirectories(targetDir);
+               Path targetPath;
+               if (((sourceFolder.getType() & Folder.HOLDS_FOLDERS) != 0) && sourceFolder.list().length != 0) {
+                       Path dir = targetDir.resolve(folderName);
+                       Files.createDirectories(dir);
+                       targetPath = dir.resolve("_Misc");
+               } else {
+                       targetPath = targetDir.resolve(folderName);
+               }
+               if (!Files.exists(targetPath))
+                       Files.createFile(targetPath);
+               URLName targetUrlName = new URLName("mbox:" + targetPath.toString());
+               Properties targetProperties = new Properties();
+               // targetProperties.setProperty("mail.mime.address.strict", "false");
+               Session targetSession = Session.getDefaultInstance(targetProperties);
+               Folder targetFolder = targetSession.getFolder(targetUrlName);
+               targetFolder.open(Folder.READ_WRITE);
+
+               return targetFolder;
+       }
+
+       protected void copyMessages(Message[] sourceMessages, Folder targetFolder) throws MessagingException {
+               targetFolder.appendMessages(sourceMessages);
+       }
+
+       protected void copyMessagesToMbox(Message[] sourceMessages, Folder targetFolder)
+                       throws MessagingException, IOException {
+               Message[] targetMessages = new Message[sourceMessages.length];
+               for (int j = 0; j < sourceMessages.length; j++) {
+                       MimeMessage sourceMm = (MimeMessage) sourceMessages[j];
+                       InternetHeaders ih = new InternetHeaders();
+                       for (Enumeration<String> e = sourceMm.getAllHeaderLines(); e.hasMoreElements();) {
+                               ih.addHeaderLine(e.nextElement());
+                       }
+                       Path tmpFileSource = Files.createTempFile("argeo-mbox-source", ".txt");
+                       Path tmpFileTarget = Files.createTempFile("argeo-mbox-target", ".txt");
+                       Files.copy(sourceMm.getRawInputStream(), tmpFileSource, StandardCopyOption.REPLACE_EXISTING);
+
+                       // we use ISO_8859_1 because it is more robust than US_ASCII with regard to
+                       // missing characters
+                       try (BufferedReader reader = Files.newBufferedReader(tmpFileSource, StandardCharsets.ISO_8859_1);
+                                       BufferedWriter writer = Files.newBufferedWriter(tmpFileTarget, StandardCharsets.ISO_8859_1);) {
+                               int lineNumber = 0;
+                               String line = null;
+                               try {
+                                       while ((line = reader.readLine()) != null) {
+                                               lineNumber++;
+                                               if (line.startsWith("From ")) {
+                                                       writer.write(">" + line);
+                                                       logger.log(DEBUG,
+                                                                       "Fix line " + lineNumber + " in " + EmailUtils.describe(sourceMm) + ": " + line);
+                                               } else {
+                                                       writer.write(line);
+                                               }
+                                               writer.newLine();
+                                       }
+                               } catch (IOException e) {
+                                       logger.log(ERROR, "Error around line " + lineNumber + " of " + tmpFileSource);
+                                       throw e;
+                               }
+                       }
+
+                       MboxMessage mboxMessage = new MboxMessage((MboxFolder) targetFolder, ih,
+                                       new SharedFileInputStream(tmpFileTarget.toFile()), sourceMm.getMessageNumber(),
+                                       EmailUtils.getUnixFrom(sourceMm), true);
+                       targetMessages[j] = mboxMessage;
+
+                       // clean up
+                       Files.delete(tmpFileSource);
+                       Files.delete(tmpFileTarget);
+               }
+               targetFolder.appendMessages(targetMessages);
+
+       }
+
+       /** Save body parts and attachments as plain files. */
+       protected void savePartsAsFiles(Object content, Path fileBase) throws IOException, MessagingException {
+               OutputStream out = null;
+               InputStream in = null;
+               try {
+                       if (content instanceof Multipart) {
+                               Multipart multi = ((Multipart) content);
+                               int parts = multi.getCount();
+                               for (int j = 0; j < parts; ++j) {
+                                       MimeBodyPart part = (MimeBodyPart) multi.getBodyPart(j);
+                                       if (part.getContent() instanceof Multipart) {
+                                               // part-within-a-part, do some recursion...
+                                               savePartsAsFiles(part.getContent(), fileBase);
+                                       } else {
+                                               String extension = "";
+                                               if (part.isMimeType("text/html")) {
+                                                       extension = "html";
+                                               } else {
+                                                       if (part.isMimeType("text/plain")) {
+                                                               extension = "txt";
+                                                       } else {
+                                                               // Try to get the name of the attachment
+                                                               extension = part.getDataHandler().getName();
+                                                       }
+                                               }
+                                               String filename = fileBase + "." + extension;
+                                               System.out.println("... " + filename);
+                                               out = new FileOutputStream(new File(filename));
+                                               in = part.getInputStream();
+                                               int k;
+                                               while ((k = in.read()) != -1) {
+                                                       out.write(k);
+                                               }
+                                       }
+                               }
+                       }
+               } finally {
+                       if (in != null) {
+                               in.close();
+                       }
+                       if (out != null) {
+                               out.flush();
+                               out.close();
+                       }
+               }
+       }
+
+       public void setSourceServer(String sourceServer) {
+               this.sourceServer = sourceServer;
+       }
+
+       public void setSourceUsername(String sourceUsername) {
+               this.sourceUsername = sourceUsername;
+       }
+
+       public void setSourcePassword(String sourcePassword) {
+               this.sourcePassword = sourcePassword;
+       }
+
+       public void setTargetServer(String targetServer) {
+               this.targetServer = targetServer;
+       }
+
+       public void setTargetUsername(String targetUsername) {
+               this.targetUsername = targetUsername;
+       }
+
+       public void setTargetPassword(String targetPassword) {
+               this.targetPassword = targetPassword;
+       }
+
+       public static void main(String args[]) throws Exception {
+               if (args.length < 6)
+                       throw new IllegalArgumentException(
+                                       "usage: <source IMAP server> <source username> <source password> <target IMAP server> <target username> <target password>");
+               String sourceServer = args[0];
+               String sourceUsername = args[1];
+               String sourcePassword = args[2];
+               String targetServer = args[3];
+               String targetUsername = args[4];
+               String targetPassword = args[5];
+
+               EmailMigration emailMigration = new EmailMigration();
+               emailMigration.setSourceServer(sourceServer);
+               emailMigration.setSourceUsername(sourceUsername);
+               emailMigration.setSourcePassword(sourcePassword);
+               emailMigration.setTargetServer(targetServer);
+               emailMigration.setTargetUsername(targetUsername);
+               emailMigration.setTargetPassword(targetPassword);
+
+               emailMigration.process();
+       }
+}
diff --git a/org.argeo.slc.cms/src/org/argeo/cms/mail/EmailUtils.java b/org.argeo.slc.cms/src/org/argeo/cms/mail/EmailUtils.java
new file mode 100644 (file)
index 0000000..09b7310
--- /dev/null
@@ -0,0 +1,118 @@
+package org.argeo.cms.mail;
+
+import java.util.Date;
+
+import javax.mail.Address;
+import javax.mail.Flags;
+import javax.mail.Message;
+import javax.mail.MessagingException;
+import javax.mail.internet.InternetAddress;
+import javax.mail.internet.MimeMessage;
+
+/** Utilities around emails. */
+public class EmailUtils {
+       public final static String INBOX = "Inbox";
+       public final static String INBOX_UPPER_CASE = "INBOX";
+       public final static String MESSAGE_ID = "Message-ID";
+
+       public static String getMessageId(Message msg) {
+               try {
+                       return msg instanceof MimeMessage ? ((MimeMessage) msg).getMessageID() : "<N/A>";
+               } catch (MessagingException e) {
+                       throw new IllegalStateException("Cannot extract message id from " + msg, e);
+               }
+       }
+
+       public static String describe(Message msg) {
+               try {
+                       return "Message " + msg.getMessageNumber() + " " + msg.getSentDate().toInstant() + " " + getMessageId(msg);
+               } catch (MessagingException e) {
+                       throw new IllegalStateException("Cannot describe " + msg, e);
+               }
+       }
+
+       static void setHeadersFromFlags(MimeMessage msg, Flags flags) {
+               try {
+                       StringBuilder status = new StringBuilder();
+                       if (flags.contains(Flags.Flag.SEEN))
+                               status.append('R');
+                       if (!flags.contains(Flags.Flag.RECENT))
+                               status.append('O');
+                       if (status.length() > 0)
+                               msg.setHeader("Status", status.toString());
+                       else
+                               msg.removeHeader("Status");
+
+                       boolean sims = false;
+                       String s = msg.getHeader("X-Status", null);
+                       // is it a SIMS 2.0 format X-Status header?
+                       sims = s != null && s.length() == 4 && s.indexOf('$') >= 0;
+                       //status.setLength(0);
+                       if (flags.contains(Flags.Flag.DELETED))
+                               status.append('D');
+                       else if (sims)
+                               status.append('$');
+                       if (flags.contains(Flags.Flag.FLAGGED))
+                               status.append('F');
+                       else if (sims)
+                               status.append('$');
+                       if (flags.contains(Flags.Flag.ANSWERED))
+                               status.append('A');
+                       else if (sims)
+                               status.append('$');
+                       if (flags.contains(Flags.Flag.DRAFT))
+                               status.append('T');
+                       else if (sims)
+                               status.append('$');
+                       if (status.length() > 0)
+                               msg.setHeader("X-Status", status.toString());
+                       else
+                               msg.removeHeader("X-Status");
+
+                       String[] userFlags = flags.getUserFlags();
+                       if (userFlags.length > 0) {
+                               status.setLength(0);
+                               for (int i = 0; i < userFlags.length; i++)
+                                       status.append(userFlags[i]).append(' ');
+                               status.setLength(status.length() - 1); // smash trailing space
+                               msg.setHeader("X-Keywords", status.toString());
+                       }
+                       if (flags.contains(Flags.Flag.DELETED)) {
+                               s = msg.getHeader("X-Dt-Delete-Time", null);
+                               if (s == null)
+                                       // XXX - should be time
+                                       msg.setHeader("X-Dt-Delete-Time", "1");
+                       }
+               } catch (MessagingException e) {
+                       // ignore it
+               }
+       }
+
+    protected static String getUnixFrom(MimeMessage msg) {
+       Address[] afrom;
+       String from;
+       Date ddate;
+       String date;
+       try {
+           if ((afrom = msg.getFrom()) == null ||
+                   !(afrom[0] instanceof InternetAddress) ||
+                   (from = ((InternetAddress)afrom[0]).getAddress()) == null)
+               from = "UNKNOWN";
+           if ((ddate = msg.getReceivedDate()) == null ||
+                   (ddate = msg.getSentDate()) == null)
+               ddate = new Date();
+       } catch (MessagingException e) {
+           from = "UNKNOWN";
+           ddate = new Date();
+       }
+       date = ddate.toString();
+       // date is of the form "Sat Aug 12 02:30:00 PDT 1995"
+       // need to strip out the timezone
+       return "From " + from + " " +
+               date.substring(0, 20) + date.substring(24);
+    }
+
+       /** Singleton. */
+       private EmailUtils() {
+       }
+}
index 7786bd6ec5dd97d2dd5a76b2fc32f750dcdb9e85..5d082eaf6b26b7e5312bb461f335d96028189808 100644 (file)
@@ -2,8 +2,4 @@ source.. = src/
 output.. = bin/
 bin.includes = META-INF/,\
                .
-additional.bundles = org.w3c.dom.svg,\
-                     org.w3c.dom.smil,\
-                     org.w3c.css.sac,\
-                     org.apache.xmlgraphics,\
-                     org.argeo.init
+additional.bundles = org.argeo.init