diff --git a/apps/routerconsole/java/src/net/i2p/router/update/ConsoleUpdateManager.java b/apps/routerconsole/java/src/net/i2p/router/update/ConsoleUpdateManager.java
index 1ad16eb111bdc8511b71f79483712df024fec61d..3d2919f396e8cbdfa16eba8164a824c637d63c44 100644
--- a/apps/routerconsole/java/src/net/i2p/router/update/ConsoleUpdateManager.java
+++ b/apps/routerconsole/java/src/net/i2p/router/update/ConsoleUpdateManager.java
@@ -188,6 +188,7 @@ public class ConsoleUpdateManager implements UpdateManager, RouterApp {
         PluginUpdateHandler puh = new PluginUpdateHandler(_context, this);
         register((Checker)puh, PLUGIN, HTTP, 0);
         register((Updater)puh, PLUGIN, HTTP, 0);
+        register((Updater)puh, PLUGIN, FILE, 0);
         // Don't do this until we can prevent it from retrying the same thing again...
         // handled inside P.U.H. for now
         //register((Updater)puh, PLUGIN, FILE, 0);
@@ -523,7 +524,8 @@ public class ConsoleUpdateManager implements UpdateManager, RouterApp {
         UpdateItem item = new UpdateItem(PLUGIN, name);
         VersionAvailable va = _available.get(item);
         if (va == null) {
-            va = new VersionAvailable("", "", HTTP, uris);
+            UpdateMethod method = "file".equals(uri.getScheme()) ? FILE : HTTP;
+            va = new VersionAvailable("", "", method, uris);
             _available.putIfAbsent(item, va);
         }
         if (_log.shouldLog(Log.WARN))
@@ -971,8 +973,8 @@ public class ConsoleUpdateManager implements UpdateManager, RouterApp {
      *  @param t may be null
      */
     public void notifyTaskFailed(UpdateTask task, String reason, Throwable t) {
-        if (_log.shouldLog(Log.WARN))
-            _log.warn("Failed " + task + " for " + task.getType() + ": " + reason, t);
+        if (_log.shouldLog(Log.ERROR))
+            _log.error("Failed " + task + " for " + task.getType() + ": " + reason, t);
         List<RegisteredUpdater> toTry = _downloaders.get(task);
         if (toTry != null) {
             UpdateItem ui = new UpdateItem(task.getType(), task.getID());
@@ -988,8 +990,27 @@ public class ConsoleUpdateManager implements UpdateManager, RouterApp {
         _downloaders.remove(task);
         _activeCheckers.remove(task);
         // any other types that shouldn't display?
-        if (task.getURI() != null && task.getType() != TYPE_DUMMY)
-            finishStatus("<b>" + _("Transfer failed from {0}", linkify(task.getURI().toString())) + "</b>");
+        if (task.getURI() != null && task.getType() != TYPE_DUMMY) {
+            StringBuilder buf = new StringBuilder(256);
+            buf.append("<b>");
+            String uri = task.getURI().toString();
+            if (uri.startsWith("file:") || task.getMethod() == FILE) {
+                uri = DataHelper.stripHTML(task.getURI().getPath());
+                buf.append(_("Install failed from {0}", uri));
+            } else {
+                buf.append(_("Transfer failed from {0}"));
+            }
+            if (reason != null && reason.length() > 0) {
+                buf.append("<br>");
+                buf.append(reason);
+            }
+            if (t != null && t.getMessage() != null && t.getMessage().length() > 0) {
+                buf.append("<br>");
+                buf.append(DataHelper.stripHTML(t.getMessage()));
+            }
+            buf.append("</b>");
+            finishStatus(buf.toString());
+        }
     }
 
     /**
diff --git a/apps/routerconsole/java/src/net/i2p/router/update/PluginUpdateHandler.java b/apps/routerconsole/java/src/net/i2p/router/update/PluginUpdateHandler.java
index f327f739da95915bfd2fcc47be0bf2845d94e22a..b360ecb2f6b6cea856ee7815079a85d516e100bc 100644
--- a/apps/routerconsole/java/src/net/i2p/router/update/PluginUpdateHandler.java
+++ b/apps/routerconsole/java/src/net/i2p/router/update/PluginUpdateHandler.java
@@ -67,7 +67,8 @@ class PluginUpdateHandler implements Checker, Updater {
     public UpdateTask update(UpdateType type, UpdateMethod method, List<URI> updateSources,
                                String appName, String newVersion, long maxTime) {
         if (type != UpdateType.PLUGIN ||
-            method != UpdateMethod.HTTP || updateSources.isEmpty())
+            (method != UpdateMethod.HTTP && method != UpdateMethod.FILE) ||
+            updateSources.isEmpty())
             return null;
         Properties props = PluginStarter.pluginProperties(_context, appName);
         String oldVersion = props.getProperty("version");
diff --git a/apps/routerconsole/java/src/net/i2p/router/update/PluginUpdateRunner.java b/apps/routerconsole/java/src/net/i2p/router/update/PluginUpdateRunner.java
index 65e9f5584af90ab0cc28374b9307d59d54e3937b..9b7143ed52371b2cde6a40acd74e339307b8f976 100644
--- a/apps/routerconsole/java/src/net/i2p/router/update/PluginUpdateRunner.java
+++ b/apps/routerconsole/java/src/net/i2p/router/update/PluginUpdateRunner.java
@@ -82,16 +82,16 @@ class PluginUpdateRunner extends UpdateRunner {
         protected void update() {
 
             _updated = false;
-            if(_xpi2pURL.startsWith("file://")) {
-                updateStatus("<b>" + _("Attempting to install from file {0}", _xpi2pURL) + "</b>");
-                // strip off "file://"
-                String xpi2pfile = _xpi2pURL.substring(7);
-                if(xpi2pfile.length() == 0) {
-                        statusDone("<b>" + _("No file specified {0}", _xpi2pURL) + "</b>");
+            if (_xpi2pURL.startsWith("file:") || _method == UpdateMethod.FILE) {
+                // strip off file:// or just file:
+                String xpi2pfile = _uri.getPath();
+                if(xpi2pfile == null || xpi2pfile.length() == 0) {
+                        statusDone("<b>" + _("Bad URL {0}", _xpi2pURL) + "</b>");
                 } else {
                     // copy the contents of from to _updateFile
                     long alreadyTransferred = (new File(xpi2pfile)).getAbsoluteFile().length();
                     if(FileUtil.copy((new File(xpi2pfile)).getAbsolutePath(), _updateFile, true, false)) {
+                        updateStatus("<b>" + _("Attempting to install from file {0}", _xpi2pURL) + "</b>");
                         transferComplete(alreadyTransferred, alreadyTransferred, 0L, _xpi2pURL, _updateFile, false);
                     } else {
                         statusDone("<b>" + _("Failed to install from file {0}, copy failed.", _xpi2pURL) + "</b>");
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHandler.java b/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHandler.java
index 729a4b14d1c73629cd39e0bcc281b037e0256a3a..d957938fff4ffbb6b7e1e309c9175a5152583034 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHandler.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHandler.java
@@ -1,6 +1,10 @@
 package net.i2p.router.web;
 
+import java.io.BufferedOutputStream;
 import java.io.File;
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.OutputStream;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.HashMap;
@@ -13,11 +17,15 @@ import java.util.Set;
 
 import net.i2p.app.ClientApp;
 import net.i2p.app.ClientAppState;
+import net.i2p.crypto.SU3File;
+import net.i2p.crypto.TrustedUpdate;
+import net.i2p.data.DataHelper;
 import net.i2p.router.client.ClientManagerFacadeImpl;
 import net.i2p.router.startup.ClientAppConfig;
 import net.i2p.router.startup.LoadClientAppsJob;
 import net.i2p.router.update.ConsoleUpdateManager;
 import static net.i2p.update.UpdateType.*;
+import net.i2p.util.SecureFileOutputStream;
 
 import org.eclipse.jetty.server.handler.ContextHandlerCollection;
 
@@ -66,6 +74,15 @@ public class ConfigClientsHandler extends FormHandler {
                 addFormError("Plugins disabled");
             return;
         }
+        if (_action.equals(_("Install Plugin from File"))) {
+            if (pluginsEnabled &&
+                (_context.getBooleanPropertyDefaultTrue(ConfigClientsHelper.PROP_ENABLE_PLUGIN_INSTALL) ||
+                 isAdvanced()))
+                installPluginFromFile();
+            else
+                addFormError("Plugins disabled");
+            return;
+        }
         if (_action.equals(_("Update All Installed Plugins"))) {
             if (pluginsEnabled)
                 updateAllPlugins();
@@ -388,6 +405,73 @@ public class ConfigClientsHandler extends FormHandler {
         installPlugin(null, url);
     }
 
+    /**
+     *  @since 0.9.19
+     */
+    private void installPluginFromFile() {
+        InputStream in = _requestWrapper.getInputStream("pluginFile");
+        // go to some trouble to verify it's an su3 or xpi2p file before
+        // passing it along, so we can display a good error message
+        byte[] su3Magic = DataHelper.getASCII(SU3File.MAGIC);
+        byte[] zipMagic = new byte[] { 0x50, 0x4b, 0x03, 0x04 };
+        byte[] magic = new byte[TrustedUpdate.HEADER_BYTES + zipMagic.length];
+        File tmp =  null;
+        OutputStream out = null;
+        try {
+            // non-null but zero bytes if no file entered, don't know why
+            if (in == null || in.available() <= 0) {
+                addFormError(_("You must enter a file"));
+                return;
+            }
+            DataHelper.read(in, magic);
+            boolean isSU3 = DataHelper.eq(magic, 0, su3Magic, 0, su3Magic.length);
+            if (!isSU3) {
+                if (!DataHelper.eq(magic, TrustedUpdate.HEADER_BYTES, zipMagic, 0, zipMagic.length)) {
+                    String name = _requestWrapper.getFilename("pluginFile");
+                    if (name == null)
+                        name = "File";
+                    throw new IOException(name + " is not an xpi2p or su3 plugin");
+                }
+            }
+            tmp =  new File(_context.getTempDir(), "plugin-" + _context.random().nextInt() + (isSU3 ? ".su3" : ".xpi2p"));
+            out = new BufferedOutputStream(new SecureFileOutputStream(tmp));
+            out.write(magic);
+            byte buf[] = new byte[16*1024];
+            int read = 0;
+            while ( (read = in.read(buf)) != -1)  {
+                out.write(buf, 0, read);
+            }
+            out.close();
+            String url = tmp.toURI().toString();
+            // threaded... TODO inline to get better result to UI?
+            installPlugin(null, url);
+            // above sleeps 1000, give it some more time?
+            // or check for complete?
+            ConsoleUpdateManager mgr = UpdateHandler.updateManager(_context);
+            if (mgr == null)
+                return;
+            for (int i = 0; i < 10; i++) {
+                if (!mgr.isUpdateInProgress(PLUGIN)) {
+                    tmp.delete();
+                    break;
+                }
+                try {
+                   Thread.sleep(1000);
+                } catch (InterruptedException ie) {}
+             }
+             String status = mgr.getStatus();
+             if (status != null && status.length() > 0)
+                 addFormNoticeNoEscape(status);
+        } catch (IOException ioe) {
+            addFormError(_("Install from file failed") + " - " + ioe.getMessage());
+        } finally {
+            // it's really a ByteArrayInputStream but we'll play along...
+            if (in != null)
+                try { in.close(); } catch (IOException ioe) {}
+            if (out != null)  try { out.close(); } catch (IOException ioe) {}
+        }
+    }
+
     private void updatePlugin(String app) {
         Properties props = PluginStarter.pluginProperties(_context, app);
         String url = props.getProperty("updateURL.su3");
@@ -434,10 +518,14 @@ public class ConfigClientsHandler extends FormHandler {
             addFormError(_("Bad URL {0}", url));
             return;
         }
-        if (mgr.installPlugin(app, uri))
-            addFormNotice(_("Downloading plugin from {0}", url));
-        else
+        if (mgr.installPlugin(app, uri)) {
+            if (url.startsWith("file:"))
+                addFormNotice(_("Installing plugin from {0}", uri.getPath()));
+            else
+                addFormNotice(_("Downloading plugin from {0}", url));
+        } else {
             addFormError("Cannot install, check logs");
+        }
         // So that update() will post a status to the summary bar before we reload
         try {
            Thread.sleep(1000);
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/ReseedBundler.java b/apps/routerconsole/java/src/net/i2p/router/web/ReseedBundler.java
index 6e5f391814df86c36551f445d4bf61d8f1fd35d2..949f2f5dfab7f84bba0548cbe2cebaa5e8813d1f 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/ReseedBundler.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/ReseedBundler.java
@@ -130,6 +130,7 @@ class ReseedBundler {
                 entry.setTime(ri.getPublished());
                 zip.putNextEntry(entry);
                 ri.writeBytes(zip);
+                zip.closeEntry();
             }
         } catch (DataFormatException dfe) {
             rv.delete();
diff --git a/apps/routerconsole/jsp/configclients.jsp b/apps/routerconsole/jsp/configclients.jsp
index eed9bf0d20713e396dfe472d03c854a1cab92285..65f67daf506500b2516be7c204be130bfeefc8a6 100644
--- a/apps/routerconsole/jsp/configclients.jsp
+++ b/apps/routerconsole/jsp/configclients.jsp
@@ -115,7 +115,7 @@ input.default { width: 1px; height: 1px; visibility: hidden; }
 <form action="" method="POST">
 <input type="hidden" name="nonce" value="<%=pageNonce%>" >
  <jsp:getProperty name="clientshelper" property="form3" />
-<hr><div class="formaction">
+<div class="formaction">
  <input type="submit" class="cancel" name="foo" value="<%=intl._("Cancel")%>" />
  <input type="submit" name="action" class="accept" value="<%=intl._("Save Plugin Configuration")%>" />
 </div></form></div>
@@ -123,43 +123,44 @@ input.default { width: 1px; height: 1px; visibility: hidden; }
        } // pluginUpdateEnabled
        if (clientshelper.isPluginInstallEnabled()) {
 %>
-<h3><a name="plugin"></a><%=intl._("Plugin Installation")%></h3><p>
+<h3><a name="plugin"></a><%=intl._("Plugin Installation from URL")%></h3><p>
  <%=intl._("Look for available plugins on {0}.", "<a href=\"http://plugins.i2p\">plugins.i2p</a>")%>
  <%=intl._("To install a plugin, enter the download URL:")%>
  </p>
-<%
-       } // pluginInstallEnabled
-       if (clientshelper.isPluginInstallEnabled() || clientshelper.isPluginUpdateEnabled()) {
-%>
 <div class="wideload">
 <form action="configclients" method="POST">
 <input type="hidden" name="nonce" value="<%=pageNonce%>" >
-<%
-           if (clientshelper.isPluginInstallEnabled()) {
-%>
 <p>
  <input type="text" size="60" name="pluginURL" >
  </p><hr><div class="formaction">
  <input type="submit" name="action" class="default" value="<%=intl._("Install Plugin")%>" />
  <input type="submit" class="cancel" name="foo" value="<%=intl._("Cancel")%>" />
  <input type="submit" name="action" class="download" value="<%=intl._("Install Plugin")%>" />
-</div>
-<%
-           } // pluginInstallEnabled
-%>
-</div>
+</div></form></div>
+
+
+<div class="wideload">
+<h3><a name="plugin"></a><%=intl._("Plugin Installation from File")%></h3>
+<form action="configclients" method="POST" enctype="multipart/form-data" accept-charset="UTF-8">
+<input type="hidden" name="nonce" value="<%=pageNonce%>" >
+<p><%=intl._("Install plugin from file.")%>
+<br><%=intl._("Select xpi2p or su3 file")%> :
+<input type="file" name="pluginFile" >
+</p><hr><div class="formaction">
+<input type="submit" name="action" class="download" value="<%=intl._("Install Plugin from File")%>" />
+</div></form></div>
 <%
-           if (clientshelper.isPluginUpdateEnabled()) {
+       } // pluginInstallEnabled
+       if (clientshelper.isPluginUpdateEnabled()) {
 %>
-<hr><div class="formaction">
+<h3><a name="plugin"></a><%=intl._("Update All Plugins")%></h3>
+<div class="formaction">
+<form action="configclients" method="POST">
+<input type="hidden" name="nonce" value="<%=pageNonce%>" >
  <input type="submit" name="action" class="reload" value="<%=intl._("Update All Installed Plugins")%>" />
-</div>
-<%
-           } // pluginUpdateEnabled
-%>
 </form></div>
 <%
-       } // pluginInstallEnabled || pluginUpdateEnabled
+       } // pluginUpdateEnabled
    } // showPlugins
 %>
 </div></div></body></html>
diff --git a/apps/susimail/src/src/i2p/susi/webmail/WebMail.java b/apps/susimail/src/src/i2p/susi/webmail/WebMail.java
index ada36171b35250235ca92624e7026a2bf46e6d7a..4df447af8516764775c5b96dbe260ed74a3f7324 100644
--- a/apps/susimail/src/src/i2p/susi/webmail/WebMail.java
+++ b/apps/susimail/src/src/i2p/susi/webmail/WebMail.java
@@ -1829,6 +1829,7 @@ public class WebMail extends HttpServlet
 					ZipEntry entry = new ZipEntry( name );
 					zip.putNextEntry( entry );
 					zip.write( content.content, content.offset, content.length );
+					zip.closeEntry();
 					zip.finish();
 					shown = true;
 				} catch (IOException e) {