Skip to main content

Mailing

The mailing module links mail addresses to Discord users. It sends a confirmation mail with a code, verifies the code and sends mails over SMTP. Addresses are stored only as a salted hash and a shortened form like jo***@***il.com.

implementation("dev.chojo.aether:mailing:2.0.0")

Configuration​

Mailing holds the mail server settings:

KeyDefaultMeaning
mailSaltnone, requiredSalt for the address hashes. Keep it secret and never change it, or stored hashes stop matching. Creating the service fails without it.
smtp.host, smtp.port, smtp.ssl"", 0, falseThe SMTP server for sending. Port 0 uses the default port of the protocol.
imap.host, imap.port, imap.ssl"", 0, falseThe IMAP server, where a copy of each sent mail is stored. Port 0 as for SMTP.
user""Login and sender address.
password""Password for SMTP and IMAP.
mailer""Value of the X-Mailer header. Blank leaves the header out.
confirmationPath/user/settingsPath of the page that confirms an address, see Templates.
propertiesemptyExtra Jakarta Mail properties, for example mail.smtp.auth: "true".
mailing:
mailSalt: a-long-random-string
smtp:
host: smtp.example.com
port: 465
ssl: true
imap:
host: imap.example.com
port: 993
ssl: true
user: bot@example.com
password: secret
mailer: My Bot
confirmationPath: /account/mail

Like the supporter configuration, these classes keep their values in private fields. Read them with a mapper that can see private fields, for example Ocular.

Templates​

You provide the mail texts as HTML files on the class path, under mails/:

FileSent whenPlaceholders
mails/account_confirmation.htmlA user registers an address.{{ url }} is the confirmation link, {{ mail }} the address.
mails/link_account.htmlSomeone buys on Ko-fi with an unknown address and no Discord account.{{ mail }} is the address, {{ host }} your host.

To keep them somewhere else, create the templates with new MailTemplates("my/mails/", classLoader). The class loader must see the files.

The confirmation link is <host><confirmationPath>?code=<code>&hash=<hash>. Your web app needs a page at that path that reads the two parameters and calls verifyMail.

Your classes​

TypeWhat it is
AMailEntryOne stored address. You implement verify(), regenerateVerificationCode() and updateUser(userId), which save the change.
IUserMailsThe addresses of one user: getMail(hash), addMail(entry), removeMail(hash), mails(), userId().
IUserMailsProviderFinds the IUserMails of a user (byUser) or of an address hash (byHash).
AMailServiceThe service. You implement createMailEntry(...), which creates your entry class.
public class MailService extends AMailService<UserMail> {
public MailService(MailServiceConfig<UserMail> config) {
super(config);
}

@Override
protected UserMail createMailEntry(long user, MailSource source, String mailHash, String mailShort,
boolean verified, Instant verificationRequested, String verificationCode) {
return new UserMail(user, source, mailHash, mailShort, verified, verificationRequested, verificationCode);
}
}

MailService mailService = new MailService(new MailServiceConfig<>(
"https://bot.example.com",
mailing,
new MailTemplates(),
userMailsProvider,
repository::deleteUnverifiedMails));
mailService.start();

MailServiceConfig takes the public host of your web app, the Mailing configuration, the templates, your IUserMailsProvider and a cleanup task. start() runs the cleanup every 60 minutes, starting 30 minutes later. The service does not call start() itself.

Register and verify an address​

Result<UserMail, FailureReason> result = mailService.registerAndPromptVerify(userId, address, MailSource.USER);
if (result.isFailure()) {
reply("That did not work: " + result.failureReason());
}

registerAndPromptVerify checks the address, stores a new entry through your IUserMails and sends the confirmation mail in the background. For an address that is already known but not verified, the service calls updateUser on the existing entry with the new user and sends the mail again. It fails with:

ReasonWhen
INVALID_FORMATThe address is not valid.
ALREADY_REGISTEREDThe address is verified already.

When the user opens the link, call verifyMail(userId, hash, code). It returns null on success, otherwise:

ReasonWhen
UNKNOWN_ADDRESSNo entry has this hash.
WRONG_USERThe entry belongs to another user.
INVALID_CODEThe code does not match.
CODE_EXPIREDThe code is older than one hour.

getRetryAfterSeconds(userId) tells how many seconds are left of a five minute wait since the user's last request. The service does not enforce the wait; check it before calling registerAndPromptVerify.

registerVerifiedMail(userId, address, source) stores an address as verified right away, without a mail. The Ko-fi module uses it for buyers whose Discord account is known.

Mail sources​

MailSource tells where an address came from: DISCORD, USER or KOFI. Add your own with MailSource.of(name) and register it in MailSourceRegistry.INSTANCE.

Sending other mails​

sendMail(new Mail(address, subject, html)) sends any HTML mail. It sends over SMTP and then stores a copy in the folder Sent below the IMAP inbox. Both steps are tried three times; failures are logged. The mail session uses only the properties from your configuration, not the system properties.