From 146483b3c4328e6c4fd18919c748ca6812471620 Mon Sep 17 00:00:00 2001 From: JFronny Date: Sat, 22 Oct 2022 21:36:37 +0200 Subject: [PATCH] Untested initial impl --- .gitignore | 38 +++++ build.gradle.kts | 29 ++++ settings.gradle.kts | 1 + .../gitlab/jfronny/gitea/helpdesk/Config.java | 100 +++++++++++++ .../gitlab/jfronny/gitea/helpdesk/Main.java | 59 ++++++++ .../helpdesk/UnexpectedMailException.java | 7 + .../jfronny/gitea/helpdesk/UpdateTask.java | 135 ++++++++++++++++++ .../gitea/helpdesk/db/DBInterface.java | 126 ++++++++++++++++ .../gitea/helpdesk/db/Subscription.java | 4 + .../helpdesk/db/UncheckedSQLException.java | 15 ++ .../gitea/CreateIssueCommentOption.java | 5 + .../helpdesk/gitea/CreateIssueOption.java | 6 + .../gitea/helpdesk/gitea/GiteaInterface.java | 61 ++++++++ .../gitea/helpdesk/gitea/GiteaIssue.java | 16 +++ .../helpdesk/gitea/GiteaIssueComment.java | 8 ++ .../gitea/helpdesk/mail/Attachment.java | 12 ++ .../gitea/helpdesk/mail/MailInterface.java | 112 +++++++++++++++ .../gitea/helpdesk/mail/WrappedMessage.java | 108 ++++++++++++++ .../gitea/helpdesk/web/WebInterface.java | 127 ++++++++++++++++ src/main/resources/mail/comment.html | 4 + src/main/resources/mail/comment_closed.html | 3 + src/main/resources/mail/create.html | 4 + src/main/resources/mail/error.html | 3 + src/main/resources/mail/issue_closed.html | 2 + src/main/resources/mail/issue_deleted.html | 2 + src/main/resources/mail/template.html | 8 ++ src/main/resources/mail/unexpected.html | 3 + src/main/resources/web/close/failure.html | 2 + src/main/resources/web/close/missing_id.html | 1 + src/main/resources/web/close/success.html | 1 + .../resources/web/issue/missing_repo.html | 2 + src/main/resources/web/issue/success.html | 7 + src/main/resources/web/template.html | 11 ++ src/main/resources/web/theme.css | 2 + .../resources/web/unsubscribe/failure.html | 2 + .../resources/web/unsubscribe/missing_id.html | 1 + .../resources/web/unsubscribe/success.html | 1 + 37 files changed, 1028 insertions(+) create mode 100644 .gitignore create mode 100644 build.gradle.kts create mode 100644 settings.gradle.kts create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/Config.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/Main.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/UnexpectedMailException.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/UpdateTask.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/DBInterface.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/Subscription.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/UncheckedSQLException.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/CreateIssueCommentOption.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/CreateIssueOption.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaInterface.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaIssue.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaIssueComment.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/Attachment.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/MailInterface.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/WrappedMessage.java create mode 100644 src/main/java/io/gitlab/jfronny/gitea/helpdesk/web/WebInterface.java create mode 100644 src/main/resources/mail/comment.html create mode 100644 src/main/resources/mail/comment_closed.html create mode 100644 src/main/resources/mail/create.html create mode 100644 src/main/resources/mail/error.html create mode 100644 src/main/resources/mail/issue_closed.html create mode 100644 src/main/resources/mail/issue_deleted.html create mode 100644 src/main/resources/mail/template.html create mode 100644 src/main/resources/mail/unexpected.html create mode 100644 src/main/resources/web/close/failure.html create mode 100644 src/main/resources/web/close/missing_id.html create mode 100644 src/main/resources/web/close/success.html create mode 100644 src/main/resources/web/issue/missing_repo.html create mode 100644 src/main/resources/web/issue/success.html create mode 100644 src/main/resources/web/template.html create mode 100644 src/main/resources/web/theme.css create mode 100644 src/main/resources/web/unsubscribe/failure.html create mode 100644 src/main/resources/web/unsubscribe/missing_id.html create mode 100644 src/main/resources/web/unsubscribe/success.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c12baaf --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +config.json5 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..3d197d6 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + java + application + id("com.github.johnrengelman.shadow") version "7.1.2" +} + +application { + mainClass.set("io.gitlab.jfronny.gitea.helpdesk.Main") +} + +group = "io.gitlab.jfronny" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() + maven("https://gitlab.com/api/v4/projects/35745143/packages/maven") +} + +dependencies { + implementation("com.sun.mail:jakarta.mail:2.0.1") + implementation("org.postgresql:postgresql:42.5.0") + implementation("org.apache.commons:commons-dbcp2:2.9.0") + implementation("io.gitlab.jfronny:commons:2022.10.22+18-33-39") + implementation("io.gitlab.jfronny:commons-gson:2022.10.22+18-33-39") + implementation("com.kohlschutter.junixsocket:junixsocket-core:2.6.0") + implementation("org.jsoup:jsoup:1.15.3") + implementation("net.freeutils:jlhttp:2.6") + compileOnly("org.jetbrains:annotations:23.0.0") +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..379c9a9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "gitea-helpdesk" diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/Config.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/Config.java new file mode 100644 index 0000000..75e2d53 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/Config.java @@ -0,0 +1,100 @@ +package io.gitlab.jfronny.gitea.helpdesk; + +public class Config { + public Gitea gitea; + public EMail email; + public Database database; + public Web web; + + public static class Gitea { + public String host; + public String token; + + public void validate() throws IllegalConfigException { + if (host == null) throw new IllegalConfigException("Missing host in gitea"); + if (host.endsWith("/")) host = host.substring(0, host.length() - 1); + if (token == null) throw new IllegalConfigException("Missing token in gitea"); + } + } + + public static class EMail { + public String address; + public SMTP smtp; + public POP3 pop3; + public Integer waitTime; + + public static class SMTP { + public String host; + public String password; + public Boolean ssl; + + public void validate() throws IllegalConfigException { + if (host == null) throw new IllegalConfigException("Missing host in email/smtp"); + if (!Main.HOST_PATTERN.matcher(host).matches()) throw new IllegalConfigException("Illegal host in email/smtp"); + if (password == null) throw new IllegalConfigException("Missing password in email/smtp"); + if (ssl == null) throw new IllegalConfigException("Missing ssl in email/smtp"); + } + } + + public static class POP3 { + public String host; + public String password; + public Boolean ssl; + + public void validate() throws IllegalConfigException { + if (host == null) throw new IllegalConfigException("Missing host in email/pop3"); + if (!Main.HOST_PATTERN.matcher(host).matches()) throw new IllegalConfigException("Illegal host in email/pop3"); + if (password == null) throw new IllegalConfigException("Missing password in email/pop3"); + if (ssl == null) throw new IllegalConfigException("Missing ssl in email/pop3"); + } + } + + public void validate() throws IllegalConfigException { + if (address == null) throw new IllegalConfigException("Missing address in email"); + if (!Main.MAIL_PATTERN.matcher(address).matches()) throw new IllegalConfigException("Illegal mail address"); + if (smtp == null) throw new IllegalConfigException("Missing smtp in email"); + smtp.validate(); + if (pop3 == null) throw new IllegalConfigException("Missing pop3 in email"); + pop3.validate(); + if (waitTime == null) throw new IllegalConfigException("Missing waitTime in email"); + if (waitTime < 0) throw new IllegalConfigException("Illegal wait time"); + } + } + + public static class Database { + public String connectionString; + + public void validate() throws IllegalConfigException { + if (connectionString == null) throw new IllegalConfigException("Missing connectionString in database"); + } + } + + public static class Web { + public Integer port; + public String publicAddress; + + public void validate() throws IllegalConfigException { + if (port == null) throw new IllegalConfigException("Missing port in web"); + if (port <= 0) throw new IllegalConfigException("Illegal port"); + if (publicAddress == null) throw new IllegalConfigException("Missing publicAddress in web"); + if (publicAddress.endsWith("/")) publicAddress = publicAddress.substring(0, publicAddress.length() - 1); + } + } + + public void validate() throws IllegalConfigException { + if (gitea == null) throw new IllegalConfigException("Lacking gitea config"); + gitea.validate(); + if (email == null) throw new IllegalConfigException("Lacking gitea config"); + email.validate(); + if (database == null) throw new IllegalConfigException("Lacking gitea config"); + database.validate(); + if (web == null) throw new IllegalConfigException("Lacking web in config"); + web.validate(); + } + + public static class IllegalConfigException extends Exception { + public IllegalConfigException(String message) { + super(message); + } + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/Main.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/Main.java new file mode 100644 index 0000000..c0f28ce --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/Main.java @@ -0,0 +1,59 @@ +package io.gitlab.jfronny.gitea.helpdesk; + +import io.gitlab.jfronny.commons.HttpUtils; +import io.gitlab.jfronny.commons.log.Logger; +import io.gitlab.jfronny.commons.serialize.Serializer; +import io.gitlab.jfronny.commons.serialize.gson.api.v1.GsonHolders; +import io.gitlab.jfronny.gitea.helpdesk.db.DBInterface; +import io.gitlab.jfronny.gitea.helpdesk.gitea.GiteaInterface; +import io.gitlab.jfronny.gitea.helpdesk.mail.MailInterface; +import io.gitlab.jfronny.gitea.helpdesk.web.WebInterface; +import jakarta.mail.MessagingException; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +// https://git.meli.delivery/meli/issue-bot +// https://www.javatpoint.com/example-of-receiving-attachment-with-email-using-java-mail-api +public class Main { + public static final Logger LOG = Logger.forName("Gitea-Helpdesk"); + public static final Pattern MAIL_PATTERN = Pattern.compile("(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)])\n"); + public static final Pattern HOST_PATTERN = Pattern.compile("(?:[0-9a-zA-Z-._~]+|[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})(?::[0-9]{1,5})?"); + public static final Pattern ALPHANUMERIC_PATTERN = Pattern.compile("[a-zA-Z0-9]+"); + + public static void main(String[] args) throws IOException, SQLException, MessagingException, Config.IllegalConfigException, InterruptedException { + GsonHolders.registerSerializer(); + HttpUtils.setUserAgent("Gitea-Helpdesk/1.0"); + LOG.info("Running Gitea-Helpdesk"); + Config config; + try (Reader r = Files.newBufferedReader(Path.of("config.json5"))) { + config = Serializer.getInstance().deserialize(r, Config.class); + } + config.validate(); + GiteaInterface gitea = new GiteaInterface(config.gitea); + try (DBInterface db = new DBInterface(config.database); + MailInterface mail = new MailInterface(config.email); + WebInterface web = new WebInterface(config.web, config.email.address, db, gitea)) { + UpdateTask updateTask = new UpdateTask(db, mail, gitea, web); + while (true) { + updateTask.run(); + Thread.sleep(config.email.waitTime * 1000); + } + } + } + + public static String getResource(String path) { + try (InputStream is = Main.class.getResourceAsStream(path); + InputStreamReader isr = new InputStreamReader(Objects.requireNonNull(is)); + BufferedReader br = new BufferedReader(isr)) { + return br.lines().collect(Collectors.joining("\n")); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/UnexpectedMailException.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/UnexpectedMailException.java new file mode 100644 index 0000000..b3366f4 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/UnexpectedMailException.java @@ -0,0 +1,7 @@ +package io.gitlab.jfronny.gitea.helpdesk; + +public class UnexpectedMailException extends Exception { + public UnexpectedMailException(String message) { + super(message); + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/UpdateTask.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/UpdateTask.java new file mode 100644 index 0000000..5ddbb78 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/UpdateTask.java @@ -0,0 +1,135 @@ +package io.gitlab.jfronny.gitea.helpdesk; + +import io.gitlab.jfronny.commons.StringFormatter; +import io.gitlab.jfronny.commons.throwable.ThrowingBiConsumer; +import io.gitlab.jfronny.gitea.helpdesk.db.DBInterface; +import io.gitlab.jfronny.gitea.helpdesk.db.Subscription; +import io.gitlab.jfronny.gitea.helpdesk.gitea.*; +import io.gitlab.jfronny.gitea.helpdesk.mail.MailInterface; +import io.gitlab.jfronny.gitea.helpdesk.mail.WrappedMessage; +import io.gitlab.jfronny.gitea.helpdesk.web.WebInterface; +import jakarta.mail.MessagingException; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.sql.SQLException; + +public record UpdateTask(DBInterface db, MailInterface mail, GiteaInterface gitea, WebInterface web) implements Runnable { + private static final String TEMPLATE = Main.getResource("mail/template.html").formatted(WebInterface.THEME, "%s"); + private static final String MAIL_ERROR = mail("error.html"); + private static final String MAIL_UNEXPECTED = mail("unexpected.html"); + private static final String MAIL_CREATE = mail("create.html"); + private static final String MAIL_COMMENT = mail("comment.html"); + private static final String MAIL_COMMENT_CLOSED = mail("comment_closed.html"); + private static final String MAIL_ISSUE_DELETED = mail("issue_deleted.html"); + private static final String MAIL_ISSUE_CLOSED = mail("issue_closed.html"); + + private static String mail(String path) { + return TEMPLATE.formatted(Main.getResource("mail/" + path)); + } + + @Override + public void run() { + try { + updateSubscriptions(); + processEmails(); + } catch (Exception e) { + Main.LOG.error("Could not run update task", e); + } + } + + private void updateSubscriptions() throws Exception { + String[] addressParts = mail.getAddress().split("@"); + db.forEachSubscription(subscription -> { + ThrowingBiConsumer reply = (content, subject) -> { + String[] previousMessages = subscription.referenceChain().split(" "); + String previousMessageId = previousMessages[previousMessages.length - 1]; + //TODO test + mail.reply(addressParts[0] + "+reply+" + subscription.id(), subscription.email(), content, subject, previousMessageId, subscription.referenceChain(), null); + }; + GiteaIssue issue; + try { + issue = gitea.getIssue(subscription.repoOwner(), subscription.repo(), subscription.issue()); + } catch (FileNotFoundException fe) { + reply.accept(MAIL_ISSUE_DELETED, "Issue deleted"); + db.removeSubscription(subscription.id()); + return; + } + if (issue.state.equals("closed")) { //TODO test + reply.accept(MAIL_ISSUE_CLOSED, "Issue closed"); + db.removeSubscription(subscription.id()); + } + for (GiteaIssueComment comment : gitea.getComments(subscription.repoOwner(), subscription.repo(), subscription.issue())) { + if (comment.id > subscription.issueComment()) { + reply.accept(comment.body, issue.title); + db.updateSubscriptionIssueComment(subscription.id(), comment.id); + } + } + }); + } + + private void processEmails() throws SQLException, MessagingException, IOException { + String[] addressParts = mail.getAddress().split("@"); + for (WrappedMessage message : mail.getInbox()) { + try { + String[] args = message.getRecipientSubAddress().split("\\+"); + switch (args[0]) { + case "create" -> { + if (args.length != 3) throw new UnexpectedMailException("Create classifier only allows two parameters"); + String owner = args[1]; + String repo = args[2]; + checkArgs(owner, repo); + GiteaIssue issue = gitea.createIssue(owner, repo, message.getSubject(), formatBody(message)); + String id = db.addSubscription(message.getSender(), owner, repo, issue.id, 0, message.getSender()); + String unsubscribeUrl = web.getAddress() + "/unsubscribe?id=" + id; //TODO test + message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id, MAIL_CREATE.formatted(issue.url, unsubscribeUrl)); + } + case "reply" -> { + if (args.length != 2) throw new UnexpectedMailException("Reply classifier only allows one parameter"); + Subscription sub = db.getSubscription(args[1]).orElseThrow(() -> new UnexpectedMailException("Reply classifier does not represent an active issue")); + GiteaIssueComment commentId = gitea.addComment(sub.repoOwner(), sub.repo(), sub.issue(), formatBody(message)); + db.updateSubscriptionIssueComment(sub.id(), commentId.id); + db.updateSubscriptionReferenceChain(sub.id(), sub.referenceChain() + " " + message.getSender()); + } + case "comment" -> { + if (args.length == 4) throw new UnexpectedMailException("Comment classifier requires four parameters"); + String owner = args[1]; + String repo = args[2]; + long issueId = Long.parseLong(args[3]); + checkArgs(owner, repo); + GiteaIssue issue; + try { + issue = gitea.getIssue(owner, repo, issueId); + } catch (FileNotFoundException fe) { + throw new UnexpectedMailException("This issue does not exist"); + } + gitea.addComment(owner, repo, issueId, formatBody(message)); + if (issue.state.equals("closed")) { + message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id, MAIL_COMMENT_CLOSED.formatted(issue.url)); + } else { + String id = db.addSubscription(message.getSender(), owner, repo, issue.id, 0, message.getSender()); + String unsubscribeUrl = web.getAddress() + "/unsubscribe?id=" + id; //TODO test + message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id, MAIL_COMMENT.formatted(issue.url, unsubscribeUrl)); + } + } + default -> throw new UnexpectedMailException("Did not expect classifier " + args[0]); + } + } catch (UnexpectedMailException | NumberFormatException t) { + message.reply(mail.getAddress(), MAIL_UNEXPECTED.formatted(WebInterface.escapeHTML(StringFormatter.toString(t)))); + } catch (Throwable t) { + Main.LOG.error("Could not parse mail", t); + message.reply(mail.getAddress(), MAIL_ERROR.formatted(WebInterface.escapeHTML(StringFormatter.toString(t)))); + } + } + } + + private void checkArgs(String owner, String repo) throws UnexpectedMailException { + //TODO document this limitation + if (!Main.ALPHANUMERIC_PATTERN.matcher(owner).matches()) throw new UnexpectedMailException("Unsupported owner string"); + if (!Main.ALPHANUMERIC_PATTERN.matcher(repo).matches()) throw new UnexpectedMailException("Unsupported repo string"); + } + + private String formatBody(WrappedMessage message) throws UnexpectedMailException, MessagingException, IOException { + return "Submitted by " + message.getSenderName() + ":\n\n" + message.getText(); + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/DBInterface.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/DBInterface.java new file mode 100644 index 0000000..9457fda --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/DBInterface.java @@ -0,0 +1,126 @@ +package io.gitlab.jfronny.gitea.helpdesk.db; + +import io.gitlab.jfronny.commons.throwable.ThrowingConsumer; +import io.gitlab.jfronny.gitea.helpdesk.Config; +import org.apache.commons.dbcp2.BasicDataSource; + +import java.sql.*; +import java.util.Optional; + +public class DBInterface implements AutoCloseable { + private final BasicDataSource ds = new BasicDataSource(); + + public DBInterface(Config.Database config) throws SQLException { + ds.setUrl("jdbc:" + config.connectionString); + ds.setMinIdle(1); + ds.setMaxIdle(10); + ds.setMaxOpenPreparedStatements(100); + try (Statement st = ds.getConnection().createStatement()) { + st.execute(""" + CREATE OR REPLACE FUNCTION generate_uid(size INT) RETURNS TEXT AS $$ + DECLARE + characters TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + bytes BYTEA := gen_random_bytes(size); + l INT := length(characters); + i INT := 0; + output TEXT := ''; + BEGIN + WHILE i < size LOOP + output := output || substr(characters, get_byte(bytes, i) % l + 1, 1); + i := i + 1; + END LOOP; + RETURN output; + END; + $$ LANGUAGE plpgsql VOLATILE;"""); + st.execute(""" + create table if not exists subscriptions( + id text primary key default generate_uid(20), + email text, + repo_owner text, + repo text, + issue bigint, + issue_comment bigint, + reference_chain text + )"""); + } + } + + public void forEachSubscription(ThrowingConsumer action) throws SQLException, TEx { + try (Statement st = ds.getConnection().createStatement(); + ResultSet rs = st.executeQuery("select * from subscriptions")) { + while (rs.next()) { + action.accept(get(rs)); + } + } + } + + public String addSubscription(String email, String repoOwner, String repo, long issue, long issueComment, String referenceChain) throws SQLException { + try (PreparedStatement st = ds.getConnection().prepareStatement(""" + insert into subscriptions (email, repo_owner, repo, issue, issue_comment, reference_chain) + values (?, ?, ?, ?, ?, ?) + returning id""")) { + st.setString(1, email); + st.setString(2, repoOwner); + st.setString(3, repo); + st.setLong(4, issue); + st.setLong(5, issueComment); + st.setString(6, referenceChain); + try (ResultSet rs = st.executeQuery()) { + rs.next(); + return rs.getString("id"); + } + } + } + + public Optional getSubscription(String id) throws SQLException { + try (PreparedStatement st = ds.getConnection() + .prepareStatement("select id, email, repo_owner, repo, issue, issue_comment, reference_chain from subscriptions where id = ?")) { + st.setString(1, id); + try (ResultSet rs = st.executeQuery()) { + if (rs.next()) { + return Optional.of(get(rs)); + } else return Optional.empty(); + } + } + } + + public void removeSubscription(String id) throws SQLException { + try (PreparedStatement st = ds.getConnection().prepareStatement("delete from subscriptions where id = ?")) { + st.setString(1, id); + st.executeUpdate(); + } + } + + public void updateSubscriptionIssueComment(String id, long issueComment) throws SQLException { + try (PreparedStatement st = ds.getConnection().prepareStatement("update subscriptions set issue_comment = ? where id = ?")) { + st.setLong(1, issueComment); + st.setString(2, id); + st.executeUpdate(); + } + } + + public void updateSubscriptionReferenceChain(String id, String referenceChain) throws SQLException { + try (PreparedStatement st = ds.getConnection().prepareStatement("update subscriptions set reference_chain = ? where id = ?")) { + st.setString(1, referenceChain); + st.setString(2, id); + st.executeUpdate(); + } + } + + private Subscription get(ResultSet rs) throws SQLException { + return new Subscription( + rs.getString("id"), + rs.getString("email"), + rs.getString("repo_owner"), + rs.getString("repo"), + rs.getLong("issue"), + rs.getLong("issue_comment"), + rs.getString("reference_chain") + ); + } + + @Override + public void close() throws SQLException { + ds.close(); + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/Subscription.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/Subscription.java new file mode 100644 index 0000000..45e94c4 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/Subscription.java @@ -0,0 +1,4 @@ +package io.gitlab.jfronny.gitea.helpdesk.db; + +public record Subscription(String id, String email, String repoOwner, String repo, long issue, long issueComment, String referenceChain) { +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/UncheckedSQLException.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/UncheckedSQLException.java new file mode 100644 index 0000000..32bcfba --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/db/UncheckedSQLException.java @@ -0,0 +1,15 @@ +package io.gitlab.jfronny.gitea.helpdesk.db; + +import java.sql.SQLException; + +public class UncheckedSQLException extends RuntimeException { + private final SQLException exception; + + public UncheckedSQLException(SQLException exception) { + this.exception = exception; + } + + public SQLException getException() { + return exception; + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/CreateIssueCommentOption.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/CreateIssueCommentOption.java new file mode 100644 index 0000000..3494305 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/CreateIssueCommentOption.java @@ -0,0 +1,5 @@ +package io.gitlab.jfronny.gitea.helpdesk.gitea; + +public class CreateIssueCommentOption { + public String body; +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/CreateIssueOption.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/CreateIssueOption.java new file mode 100644 index 0000000..c48b0cc --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/CreateIssueOption.java @@ -0,0 +1,6 @@ +package io.gitlab.jfronny.gitea.helpdesk.gitea; + +public class CreateIssueOption { + public String body; + public String title; +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaInterface.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaInterface.java new file mode 100644 index 0000000..a5e9103 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaInterface.java @@ -0,0 +1,61 @@ +package io.gitlab.jfronny.gitea.helpdesk.gitea; + +import io.gitlab.jfronny.commons.HttpUtils; +import io.gitlab.jfronny.gitea.helpdesk.Config; +import io.gitlab.jfronny.gson.reflect.TypeToken; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.net.URISyntaxException; +import java.util.List; + +public class GiteaInterface { + private static final Type issueListType = new TypeToken>() {}.getType(); + private final Config.Gitea gitea; + + public GiteaInterface(Config.Gitea gitea) { + this.gitea = gitea; + } + + public GiteaIssue getIssue(String owner, String repo, long issue) throws URISyntaxException, IOException { + return HttpUtils.get(gitea.host + "/api/v1/repos/" + owner + "/" + repo + "/issues/" + issue) + .header("Authorization", "token " + gitea.token) + .sendSerialized(GiteaIssue.class); + } + + public void closeIssue(String owner, String repo, long issue) throws URISyntaxException, IOException { + HttpUtils.patch(gitea.host + "/api/v1/repos/" + owner + "/" + repo + "/issues/" + issue) + .bodyJson("{\"state\":\"closed\"}") + .header("Authorization", "token " + gitea.token) + .send(); + } + + public GiteaIssue createIssue(String owner, String repo, String title, String body) throws URISyntaxException, IOException { + CreateIssueOption opt = new CreateIssueOption(); + opt.title = title; + opt.body = body; + return HttpUtils.post(gitea.host + "/api/v1/repos/" + owner + "/" + repo + "/issues") + .bodySerialized(opt) + .header("Authorization", "token " + gitea.token) + .sendSerialized(GiteaIssue.class); + } + + public GiteaIssueComment addComment(String owner, String repo, long issue, String body) throws URISyntaxException, IOException { + CreateIssueCommentOption opt = new CreateIssueCommentOption(); + opt.body = body; + return HttpUtils.post(gitea.host + "/api/v1/repos/" + owner + "/" + repo + "/issues/" + issue + "/comments") + .bodySerialized(opt) + .header("Authorization", "token " + gitea.token) + .sendSerialized(GiteaIssueComment.class); + } + + public List getComments(String owner, String repo, long issue) throws URISyntaxException, IOException { + return HttpUtils.get(gitea.host + "/api/v1/repos/" + owner + "/" + repo + "/issues/" + issue + "/comments") + .header("Authorization", "token " + gitea.token) + .sendSerialized(issueListType); + } + + public String getAddress() { + return gitea.host; + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaIssue.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaIssue.java new file mode 100644 index 0000000..35916a1 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaIssue.java @@ -0,0 +1,16 @@ +package io.gitlab.jfronny.gitea.helpdesk.gitea; + +public class GiteaIssue { + public Long id; + public String url; + public String title; + public String body; + public String state; + + public static class Repository { + public String full_name; + public Long id; + public String name; + public String owner; + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaIssueComment.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaIssueComment.java new file mode 100644 index 0000000..dc28ec0 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/gitea/GiteaIssueComment.java @@ -0,0 +1,8 @@ +package io.gitlab.jfronny.gitea.helpdesk.gitea; + +public class GiteaIssueComment { + public String body; + public Long id; + public String issue_url; + public String pull_request_url; +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/Attachment.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/Attachment.java new file mode 100644 index 0000000..ddc01f6 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/Attachment.java @@ -0,0 +1,12 @@ +package io.gitlab.jfronny.gitea.helpdesk.mail; + +import jakarta.mail.MessagingException; + +import java.io.IOException; +import java.io.InputStream; + +public record Attachment(String fileName, InputStreamGenerator content) { + public interface InputStreamGenerator { + InputStream getInputStream() throws IOException, MessagingException; + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/MailInterface.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/MailInterface.java new file mode 100644 index 0000000..151ff92 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/MailInterface.java @@ -0,0 +1,112 @@ +package io.gitlab.jfronny.gitea.helpdesk.mail; + +import com.sun.mail.pop3.POP3Store; +import io.gitlab.jfronny.gitea.helpdesk.Config; +import jakarta.mail.*; +import jakarta.mail.internet.*; +import org.jsoup.Jsoup; + +import java.util.*; +import java.util.stream.Collectors; + +public class MailInterface implements AutoCloseable { + private final Config.EMail config; + private final Session pop3; + private final Session smtp; + private POP3Store lastPop3; + + public MailInterface(Config.EMail config) { + this.config = config; + { + Properties properties = new Properties(); + String[] splitHost = config.pop3.host.split(":"); + if (splitHost.length == 1) { + properties.put("mail.pop3.host", config.pop3.host); + } else { + properties.put("mail.pop3.host", splitHost[0]); + properties.put("mail.pop3.port", Integer.parseInt(splitHost[1])); + } + if (config.pop3.ssl) properties.put("mail.pop3.ssl.enable", true); + this.pop3 = Session.getInstance(properties); + } + { + Properties properties = new Properties(); + String[] splitHost = config.smtp.host.split(":"); + if (splitHost.length == 1) { + properties.put("mail.smtp.host", config.smtp.host); + } else { + properties.put("mail.smtp.host", splitHost[0]); + properties.put("mail.smtp.port", Integer.parseInt(splitHost[1])); + } + if (config.smtp.ssl) properties.put("mail.smtp.ssl.enable", true); + properties.put("mail.smtp.auth", true); + this.smtp = Session.getInstance(properties); + } + } + + private POP3Store getStore() throws MessagingException { + boolean reopen = lastPop3 == null; + if (lastPop3 != null && !lastPop3.isConnected()) { + lastPop3.close(); + reopen = true; + } + if (reopen) { + lastPop3 = (POP3Store) pop3.getStore("pop3"); + lastPop3.connect(config.address, config.pop3.password); + } + return lastPop3; + } + + public Set getInbox() throws MessagingException { + try (Folder inbox = getStore().getFolder("INBOX")) { + inbox.open(Folder.READ_ONLY); + return Arrays.stream(inbox.getMessages()) + .map(s -> new WrappedMessage((MimeMessage) s, config.address, this)) + .collect(Collectors.toSet()); + } + } + + public void reply(String from, String to, String content, String subject, String previousMessageId, String previousReferences, String previousInReplyTo) throws MessagingException { + MimeMessage reply = new MimeMessage(smtp); + reply.setFrom(from); + reply.addRecipients(Message.RecipientType.TO, to); + reply.setSubject(subject.startsWith("Re: ") ? subject : "Re: " + subject); + + final MimeBodyPart textPart = new MimeBodyPart(); + textPart.setContent(Jsoup.parse(content).text(), "text/plain"); + final MimeBodyPart htmlPart = new MimeBodyPart(); + htmlPart.setContent(content, "text/html"); + final MimeMultipart mp = new MimeMultipart("alternative"); + mp.addBodyPart(textPart); + mp.addBodyPart(htmlPart); + reply.setContent(mp); + + if (previousMessageId != null) reply.setHeader("In-Reply-To", previousMessageId); + + if (previousReferences == null) previousReferences = previousInReplyTo; + if (previousMessageId != null) { + if (previousReferences != null) previousReferences = MimeUtility.unfold(previousReferences) + " " + previousMessageId; + else previousReferences = previousMessageId; + } + if (previousReferences != null) reply.setHeader("References", MimeUtility.fold(12, previousReferences)); + send(reply); + } + + public void send(Message message) throws MessagingException { + Address[] recipients = message.getAllRecipients(); + if (recipients.length != 1) throw new MessagingException("Unexpected number of recipients: " + recipients.length); + try (Transport transport = smtp.getTransport(recipients[0])) { + transport.connect(config.address, config.smtp.password); + transport.sendMessage(message, recipients); + } + } + + public String getAddress() { + return config.address; + } + + @Override + public void close() throws MessagingException { + if (lastPop3 != null) lastPop3.close(); + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/WrappedMessage.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/WrappedMessage.java new file mode 100644 index 0000000..64517d6 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/mail/WrappedMessage.java @@ -0,0 +1,108 @@ +package io.gitlab.jfronny.gitea.helpdesk.mail; + +import io.gitlab.jfronny.gitea.helpdesk.UnexpectedMailException; +import jakarta.mail.*; +import jakarta.mail.internet.*; + +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.Set; + +public class WrappedMessage { + public final MimeMessage message; + private final String[] selfParts; + private final MailInterface mail; + + public WrappedMessage(MimeMessage message, String selfAddress, MailInterface mail) { + this.message = message; + this.selfParts = selfAddress.split("@"); + this.mail = mail; + } + + public String getRecipientSubAddress() throws MessagingException, UnexpectedMailException { + for (Address recipient : message.getAllRecipients()) { + String[] parts = recipient.toString().split("@"); + if (parts.length != 2) throw new UnexpectedMailException("Invalid recipient"); + if (!parts[1].equals(selfParts[1])) continue; + if (!parts[0].startsWith(selfParts[0] + "+")) continue; + return parts[0].substring(selfParts[0].length() + 1); + } + throw new UnexpectedMailException("Lacking proper recipient"); + } + + public String getText() throws MessagingException, IOException { + return getText(message); + } + + static String getText(Part p) throws MessagingException, IOException { + if (p.isMimeType("text/*")) { + String s = (String)p.getContent(); + // p.isMimeType("text/html") ? Jsoup.parse(s).text() : s + //TODO ensure this works + return s; + } + if (p.isMimeType("multipart/alternative")) { + // prefer html text over plain text + Multipart mp = (Multipart)p.getContent(); + String text = null; + for (int i = 0; i < mp.getCount(); i++) { + Part bp = mp.getBodyPart(i); + if (bp.isMimeType("text/plain")) { + if (text == null) text = getText(bp); + } else if (bp.isMimeType("text/html")) { + String s = getText(bp); + if (s != null) return s; + } else return getText(bp); + } + return text; + } else if (p.isMimeType("multipart/*")) { + Multipart mp = (Multipart)p.getContent(); + for (int i = 0; i < mp.getCount(); i++) { + String s = getText(mp.getBodyPart(i)); + if (s != null) return s; + } + } + return null; + } + + public Set getAttachments() throws MessagingException, IOException { + if (message.isMimeType("multipart/*")) { + Multipart mp = (Multipart) message.getContent(); + Set attachments = new LinkedHashSet<>(); + for (int i = 0; i < mp.getCount(); i++) { + MimeBodyPart part = (MimeBodyPart) mp.getBodyPart(i); + if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) { + attachments.add(new Attachment(part.getFileName(), part::getInputStream)); + } + } + return Set.copyOf(attachments); + } else return Set.of(); + } + + public String getSubject() throws MessagingException { + return message.getSubject(); + } + + public String getSender() throws MessagingException { + return message.getFrom()[0].toString(); + } + + public String getSenderName() throws MessagingException { + if (message.getFrom()[0] instanceof InternetAddress addr) { + return addr.getPersonal(); + } + return "Sender Suppressed"; + } + + public void reply(String from, String content) throws MessagingException { + mail.reply( + from, + getSender(), + content, + getSubject(), + message.getHeader("Message-Id", null), + message.getHeader("References", " "), + message.getHeader("In-Reply-To", "") + ); + } +} diff --git a/src/main/java/io/gitlab/jfronny/gitea/helpdesk/web/WebInterface.java b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/web/WebInterface.java new file mode 100644 index 0000000..bcff9c1 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/gitea/helpdesk/web/WebInterface.java @@ -0,0 +1,127 @@ +package io.gitlab.jfronny.gitea.helpdesk.web; + +import io.gitlab.jfronny.commons.StringFormatter; +import io.gitlab.jfronny.gitea.helpdesk.Config; +import io.gitlab.jfronny.gitea.helpdesk.Main; +import io.gitlab.jfronny.gitea.helpdesk.db.DBInterface; +import io.gitlab.jfronny.gitea.helpdesk.gitea.GiteaInterface; +import net.freeutils.httpserver.HTTPServer; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +public class WebInterface implements AutoCloseable { + public static final String THEME = Main.getResource("web/theme.css"); + private static final String TEMPLATE = Main.getResource("web/template.html"); + private static final String ISSUE_MISSING_REPO = page("issue/missing_repo.html"); + private static final String ISSUE_SUCCESS = page("issue/success.html"); + private static final String UNSUBSCRIBE_MISSING_ID = page("unsubscribe/missing_id.html"); + private static final String UNSUBSCRIBE_FAILURE = page("unsubscribe/failure.html"); + private static final String UNSUBSCRIBE_SUCCESS = page("unsubscribe/success.html"); + private static final String CLOSE_MISSING_ID = page("close/missing_id.html"); + private static final String CLOSE_FAILURE = page("close/failure.html"); + private static final String CLOSE_SUCCESS = page("close/success.html"); + private static String page(String path) { + return TEMPLATE.formatted(Main.getResource("web/" + path)); + } + + private final HTTPServer server; + private final Config.Web web; + + public WebInterface(Config.Web web, String address, DBInterface db, GiteaInterface gitea) throws IOException { + server = new HTTPServer(web.port); + this.web = web; + HTTPServer.VirtualHost host = server.getVirtualHost(null); + host.addContext("/unsubscribe", (req, resp) -> { + String id = req.getParams().get("id"); + if (id == null) { + resp.send(404, UNSUBSCRIBE_MISSING_ID); + } else { + try { + db.removeSubscription(id); + resp.send(202, UNSUBSCRIBE_SUCCESS); + } catch (Throwable e) { + resp.send(500, UNSUBSCRIBE_FAILURE.formatted(escapeHTML(StringFormatter.toString(e)))); + } + } + return 0; + }); + host.addContext("/close", ((req, resp) -> { + String id = req.getParams().get("id"); + if (id == null) { + resp.send(404, CLOSE_MISSING_ID); + } else { + try { + db.getSubscription(id).ifPresentOrElse(sub -> { + try { + gitea.closeIssue(sub.repoOwner(), sub.repo(), sub.issue()); + resp.send(202, CLOSE_SUCCESS); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + }, () -> { + try { + resp.send(404, CLOSE_MISSING_ID); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (Throwable e) { + resp.send(500, CLOSE_FAILURE.formatted(escapeHTML(StringFormatter.toString(e)))); + } + } + return 0; + })); + String[] addr = address.split("@"); + host.addContext("/issue", (req, resp) -> { + Map params = req.getParams(); + String owner = params.get("owner"); + String repo = params.get("repo"); + if (owner == null || repo == null || owner.contains("+") || repo.contains("+")) { + resp.send(404, ISSUE_MISSING_REPO); + } else { + String mail = "mailto:create+" + addr[0] + '+' + owner + '+' + repo; + resp.send(200, ISSUE_SUCCESS.formatted(escapeHTML(mail))); + } + return 0; + }); + host.addContext("/theme.css", (req, resp) -> { + byte[] content = THEME.getBytes(StandardCharsets.UTF_8); + resp.sendHeaders(200, content.length, -1, "W/\"" + Integer.toHexString(THEME.hashCode()) + "\"", "text/css; charset=utf-8", null); + resp.getBody().write(content); + return 0; + }); + server.start(); + } + + @Override + public void close() { + server.stop(); + } + + public static String escapeHTML(String s) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c > 127 || c == '"' || c == '\'' || c == '<' || c == '>' || c == '&') { + out.append("&#"); + out.append((int) c); + out.append(';'); + } else if (c == '\n') { + out.append("
\n"); + } else { + out.append(c); + } + } + return out.toString(); + } + + public String getAddress() { + return web.publicAddress; + } +} diff --git a/src/main/resources/mail/comment.html b/src/main/resources/mail/comment.html new file mode 100644 index 0000000..29a9fc2 --- /dev/null +++ b/src/main/resources/mail/comment.html @@ -0,0 +1,4 @@ +

Successfully added comment

+

You can find your new comment here

+

If you wish to unsubscribe from new comments, you may do so here

+

Please be aware that you will not be sent these a second time!

\ No newline at end of file diff --git a/src/main/resources/mail/comment_closed.html b/src/main/resources/mail/comment_closed.html new file mode 100644 index 0000000..65c26a0 --- /dev/null +++ b/src/main/resources/mail/comment_closed.html @@ -0,0 +1,3 @@ +

Successfully added comment

+

You can find your new comment here

+

No subscription for new comments was registered since this issue is closed

\ No newline at end of file diff --git a/src/main/resources/mail/create.html b/src/main/resources/mail/create.html new file mode 100644 index 0000000..65f152c --- /dev/null +++ b/src/main/resources/mail/create.html @@ -0,0 +1,4 @@ +

Successfully added issue

+

You can find your new issue here

+

If you wish to unsubscribe from new comments, you may do so here

+

Please be aware that you will not be sent these a second time!

\ No newline at end of file diff --git a/src/main/resources/mail/error.html b/src/main/resources/mail/error.html new file mode 100644 index 0000000..4f98db6 --- /dev/null +++ b/src/main/resources/mail/error.html @@ -0,0 +1,3 @@ +

Could not parse mail

+

There was an error while trying to parse your E-Mail. Please read the details below and report this issue

+

%s

\ No newline at end of file diff --git a/src/main/resources/mail/issue_closed.html b/src/main/resources/mail/issue_closed.html new file mode 100644 index 0000000..c937df0 --- /dev/null +++ b/src/main/resources/mail/issue_closed.html @@ -0,0 +1,2 @@ +

This issue was closed

+

This issue was closed, your subscription was automatically revoked

\ No newline at end of file diff --git a/src/main/resources/mail/issue_deleted.html b/src/main/resources/mail/issue_deleted.html new file mode 100644 index 0000000..5af0123 --- /dev/null +++ b/src/main/resources/mail/issue_deleted.html @@ -0,0 +1,2 @@ +

This issue was moved or deleted

+

This issue was moved or deleted and is no longer accessible

\ No newline at end of file diff --git a/src/main/resources/mail/template.html b/src/main/resources/mail/template.html new file mode 100644 index 0000000..2e59a11 --- /dev/null +++ b/src/main/resources/mail/template.html @@ -0,0 +1,8 @@ + + + + + +%s + + \ No newline at end of file diff --git a/src/main/resources/mail/unexpected.html b/src/main/resources/mail/unexpected.html new file mode 100644 index 0000000..3a33008 --- /dev/null +++ b/src/main/resources/mail/unexpected.html @@ -0,0 +1,3 @@ +

Unexpected

+

It seems the E-Mail you sent was not expected. Please read the details below

+

%s

\ No newline at end of file diff --git a/src/main/resources/web/close/failure.html b/src/main/resources/web/close/failure.html new file mode 100644 index 0000000..87518ce --- /dev/null +++ b/src/main/resources/web/close/failure.html @@ -0,0 +1,2 @@ +

Could not close this issue, please report this issue!

+

%s

\ No newline at end of file diff --git a/src/main/resources/web/close/missing_id.html b/src/main/resources/web/close/missing_id.html new file mode 100644 index 0000000..0bda91b --- /dev/null +++ b/src/main/resources/web/close/missing_id.html @@ -0,0 +1 @@ +

URL lacks id, cannot close

\ No newline at end of file diff --git a/src/main/resources/web/close/success.html b/src/main/resources/web/close/success.html new file mode 100644 index 0000000..63f10dc --- /dev/null +++ b/src/main/resources/web/close/success.html @@ -0,0 +1 @@ +

Successfully closed issue!

\ No newline at end of file diff --git a/src/main/resources/web/issue/missing_repo.html b/src/main/resources/web/issue/missing_repo.html new file mode 100644 index 0000000..4104e60 --- /dev/null +++ b/src/main/resources/web/issue/missing_repo.html @@ -0,0 +1,2 @@ +

Invalid issue

+

Lacking either a proper owner or repo (plusses are not supported in either)

\ No newline at end of file diff --git a/src/main/resources/web/issue/success.html b/src/main/resources/web/issue/success.html new file mode 100644 index 0000000..2f4cfe7 --- /dev/null +++ b/src/main/resources/web/issue/success.html @@ -0,0 +1,7 @@ +

Create issue

+

By clicking on the following link, you agree that the name you use to send your E-Mail and its content will be displayed in the issue created by mailing.

+

You will receive a timely response containing a link to the created issue.

+

Additionally, you will be informed if any comments are posted on the issue and may add your own by replying to them

+

If you wish to unsubscribe, that option will be present in the original reply you receive, but will not be repeated

+

Please note that only plain text messages without attachments are currently supported due to API limitations

+

Create Issue

\ No newline at end of file diff --git a/src/main/resources/web/template.html b/src/main/resources/web/template.html new file mode 100644 index 0000000..2cfcf6c --- /dev/null +++ b/src/main/resources/web/template.html @@ -0,0 +1,11 @@ + + + Gitea Helpdesk + + + +
+%s +
+ + \ No newline at end of file diff --git a/src/main/resources/web/theme.css b/src/main/resources/web/theme.css new file mode 100644 index 0000000..f934620 --- /dev/null +++ b/src/main/resources/web/theme.css @@ -0,0 +1,2 @@ +@import url("https://fonts.googleapis.com/css?family=Nunito");html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,textarea,input,select,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{font-family:"Nunito",sans-serif;color:#000}body,html{padding:0;margin:0;overflow-x:hidden;background-color:#fff}nav{font-family:"Nunito",sans-serif;background-color:#212121;color:#fff;display:-webkit-box;display:-ms-flexbox;display:flex}nav header{padding:8px;display:inline}nav header a{text-decoration:none;color:#fff}nav header a:hover{color:#00c853}nav header+input:checked+div{display:block}nav div{display:none;margin-left:auto}nav div ul{list-style:none;display:-webkit-box;display:-ms-flexbox;display:flex;margin:0 10px}nav div ul li a{display:inline-block;padding:8px;color:#fff;text-decoration:none}nav div ul li:hover{background-color:#00c853}nav div ul li ul{right:0;position:relative;background:pink}@media (max-width: 630px){nav{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}nav header{text-align:center}nav div{margin:auto}nav div ul{padding:0}nav div ul li{border-top-left-radius:5px;border-top-right-radius:5px}nav div ul li[active],nav div ul li.active{border:1px solid #00c853;border-bottom:none}blockquote{border-left:4px solid #00c853;padding:8px 5px;margin:0}blockquote p{font-size:0.4rem}}@media (min-width: 630px){nav div ul li.active,nav div ul li[active]{border:1px solid #00c853;border-bottom:none;border-top:none}nav header label{display:none}}section[container]{max-width:38em;margin:auto;padding:5px}h1{font-size:2.35em}h2{font-size:2em}h3{font-size:1.75em}h4{font-size:1.5em}h5{font-size:1.25em}h6{font-size:1em}a{color:#00c853}a:hover{color:#212121}mark{background-color:#00c853}code{font-family:monospace;background-color:#bdbdbd;padding-left:5px;padding-right:5px}blockquote{border-left:4px solid #00c853;padding:8px 10px;width:100%}blockquote p{font-style:italic;font-size:1.1rem}blockquote footer::before{content:"\2014 \00A0"}blockquote footer cite{font-style:italic;color:#bdbdbd}pre{background:#eee;overflow-x:auto;text-align:left;padding:5px}pre code{display:block;padding:0 10px;background:transparent}table{display:table;padding:5px;border-collapse:collapse}table thead,table tbody{text-align:left}table tr th,table tr td{padding:5px 10px;border-bottom:1px solid #00c853}div[overflow]{overflow-x:auto;max-width:100vw}div[overflow] ::-webkit-scrollbar{height:0}img{max-width:100%;border-radius:5px}form div{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;margin:10px 5px}form div p{margin:0px}form input,form select{font-size:1rem;padding:5px;border:1px solid #bdbdbd;color:#212121}form input:active,form input:focus,form select:active,form select:focus{outline-color:#00c853}form input[type="submit"]{padding:10px;background-color:#00c853;color:#000;border-radius:5px;border:none;cursor:pointer}form input[type="submit"]:active,form input[type="submit"]:focus{outline:none}form input[type="submit"]:active{background-color:#212121;color:#00c853}form input[type="submit"]:disabled{background:#bdbdbd;cursor:not-allowed}form input[type="submit"][secondary]{background-color:#212121;color:#00c853}form input[type="submit"][secondary]:active{background-color:#00c853;color:initial}textarea{color:#212121;width:-webkit-fill-available;font-size:1rem;padding:5px}textarea:active,textarea:focus{outline-color:#00c853}button{padding:10px;background-color:#00c853;color:#000;border-radius:5px;border:none;cursor:pointer}button:active,button:focus{outline:none}button:active{background-color:#212121;color:#00c853}button:disabled{background:#bdbdbd;cursor:not-allowed}button[secondary]{background-color:#212121;color:#00c853}button[secondary]:active{background-color:#00c853;color:initial}body>footer{background-color:#212121;position:relative;bottom:0;width:100%;padding:5px;color:#fff} +/*# sourceMappingURL=brightlight-green.css.map */ \ No newline at end of file diff --git a/src/main/resources/web/unsubscribe/failure.html b/src/main/resources/web/unsubscribe/failure.html new file mode 100644 index 0000000..ea5e0cf --- /dev/null +++ b/src/main/resources/web/unsubscribe/failure.html @@ -0,0 +1,2 @@ +

Could not unsubscribe, please report this issue!

+

%s

\ No newline at end of file diff --git a/src/main/resources/web/unsubscribe/missing_id.html b/src/main/resources/web/unsubscribe/missing_id.html new file mode 100644 index 0000000..6366423 --- /dev/null +++ b/src/main/resources/web/unsubscribe/missing_id.html @@ -0,0 +1 @@ +

URL lacks id, cannot unsubscribe

\ No newline at end of file diff --git a/src/main/resources/web/unsubscribe/success.html b/src/main/resources/web/unsubscribe/success.html new file mode 100644 index 0000000..aae749c --- /dev/null +++ b/src/main/resources/web/unsubscribe/success.html @@ -0,0 +1 @@ +

Successfully removed!

\ No newline at end of file