Untested initial impl

This commit is contained in:
Johannes Frohnmeyer 2022-10-22 21:36:37 +02:00
commit 146483b3c4
Signed by: Johannes
GPG Key ID: E76429612C2929F4
37 changed files with 1028 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@ -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

29
build.gradle.kts Normal file
View File

@ -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")
}

1
settings.gradle.kts Normal file
View File

@ -0,0 +1 @@
rootProject.name = "gitea-helpdesk"

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,7 @@
package io.gitlab.jfronny.gitea.helpdesk;
public class UnexpectedMailException extends Exception {
public UnexpectedMailException(String message) {
super(message);
}
}

View File

@ -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<String, String, Exception> 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();
}
}

View File

@ -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 <TEx extends Throwable> void forEachSubscription(ThrowingConsumer<Subscription, TEx> 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<Subscription> 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();
}
}

View File

@ -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) {
}

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
package io.gitlab.jfronny.gitea.helpdesk.gitea;
public class CreateIssueCommentOption {
public String body;
}

View File

@ -0,0 +1,6 @@
package io.gitlab.jfronny.gitea.helpdesk.gitea;
public class CreateIssueOption {
public String body;
public String title;
}

View File

@ -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<List<GiteaIssueComment>>() {}.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<GiteaIssueComment> 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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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<WrappedMessage> 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();
}
}

View File

@ -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<Attachment> getAttachments() throws MessagingException, IOException {
if (message.isMimeType("multipart/*")) {
Multipart mp = (Multipart) message.getContent();
Set<Attachment> 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", "")
);
}
}

View File

@ -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<String, String> 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("<br>\n");
} else {
out.append(c);
}
}
return out.toString();
}
public String getAddress() {
return web.publicAddress;
}
}

View File

@ -0,0 +1,4 @@
<h1>Successfully added comment</h1>
<p>You can find your new comment <a href="%s">here</a></p>
<p>If you wish to unsubscribe from new comments, you may do so <a href="%s">here</a></p>
<p>Please be aware that you will not be sent these a second time!</p>

View File

@ -0,0 +1,3 @@
<h1>Successfully added comment</h1>
<p>You can find your new comment <a href="%s">here</a></p>
<p>No subscription for new comments was registered since this issue is closed</p>

View File

@ -0,0 +1,4 @@
<h1>Successfully added issue</h1>
<p>You can find your new issue <a href="%s">here</a></p>
<p>If you wish to unsubscribe from new comments, you may do so <a href="%s">here</a></p>
<p>Please be aware that you will not be sent these a second time!</p>

View File

@ -0,0 +1,3 @@
<h1>Could not parse mail</h1>
<p>There was an error while trying to parse your E-Mail. Please read the details below and report this issue</p>
<p>%s</p>

View File

@ -0,0 +1,2 @@
<h1>This issue was closed</h1>
<p>This issue was closed, your subscription was automatically revoked</p>

View File

@ -0,0 +1,2 @@
<h1>This issue was moved or deleted</h1>
<p>This issue was moved or deleted and is no longer accessible</p>

View File

@ -0,0 +1,8 @@
<html>
<head>
<style>%s</style>
</head>
<body>
%s
</body>
</html>

View File

@ -0,0 +1,3 @@
<h1>Unexpected</h1>
<p>It seems the E-Mail you sent was not expected. Please read the details below</p>
<p>%s</p>

View File

@ -0,0 +1,2 @@
<h1>Could not close this issue, please report this issue!</h1>
<p>%s</p>

View File

@ -0,0 +1 @@
<h1>URL lacks id, cannot close</h1>

View File

@ -0,0 +1 @@
<h1>Successfully closed issue!</h1>

View File

@ -0,0 +1,2 @@
<h1>Invalid issue</h1>
<p>Lacking either a proper owner or repo (plusses are not supported in either)</p>

View File

@ -0,0 +1,7 @@
<h1>Create issue</h1>
<p>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.</p>
<p>You will receive a timely response containing a link to the created issue.</p>
<p>Additionally, you will be informed if any comments are posted on the issue and may add your own by replying to them</p>
<p>If you wish to unsubscribe, that option will be present in the original reply you receive, but will not be repeated</p>
<p>Please note that only plain text messages without attachments are currently supported due to API limitations</p>
<h2><a href="%s">Create Issue</a></h2>

View File

@ -0,0 +1,11 @@
<html lang="en">
<head>
<title>Gitea Helpdesk</title>
<link rel="stylesheet" href="theme.css" type="text/css">
</head>
<body>
<div style="text-align: center;">
%s
</div>
</body>
</html>

View File

@ -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 */

View File

@ -0,0 +1,2 @@
<h1>Could not unsubscribe, please report this issue!</h1>
<p>%s</p>

View File

@ -0,0 +1 @@
<h1>URL lacks id, cannot unsubscribe</h1>

View File

@ -0,0 +1 @@
<h1>Successfully removed!</h1>