Skip to content

[#13455] Allow real email providers in dev server, and implement SMTP sender#13575

Merged
samuelfangjw merged 47 commits intoTEAMMATES:masterfrom
iZUMi-kyouka:feature/smtp-email-sender
Mar 26, 2026
Merged

[#13455] Allow real email providers in dev server, and implement SMTP sender#13575
samuelfangjw merged 47 commits intoTEAMMATES:masterfrom
iZUMi-kyouka:feature/smtp-email-sender

Conversation

@iZUMi-kyouka
Copy link
Copy Markdown
Contributor

@iZUMi-kyouka iZUMi-kyouka commented Mar 8, 2026

Fixes #13455

Outline of Solution

  • Allow real email providers in EmailSender.java
  • Add new SmtpService.java using Jakarta Mail with Angus Mail as its implementation
  • Add tests for SmtpService in EmailSenderTest.java
  • Add new configs in Config.java and in build.template.properties

@iZUMi-kyouka iZUMi-kyouka marked this pull request as ready for review March 9, 2026 04:09
@mingyuancode mingyuancode requested a review from Copilot March 12, 2026 05:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds SMTP as a supported email provider (including in dev server) and wires it into configuration and the email-sending selection logic.

Changes:

  • Introduced SmtpService (Jakarta Mail/Angus Mail) implementing EmailSenderService
  • Updated EmailSender to select SMTP based on config (alongside Sendgrid/Mailgun/Mailjet)
  • Added SMTP config keys and test coverage for SMTP message conversion/validation

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/main/java/teammates/logic/external/SmtpService.java Implements SMTP-backed email sending and MIME message construction
src/main/java/teammates/logic/api/EmailSender.java Adds SMTP to the service-selection chain (and allows real providers in dev)
src/main/java/teammates/common/util/Config.java Adds SMTP config keys and isUsingSmtp() gating logic
src/main/resources/build.template.properties Documents new SMTP configuration properties
src/test/java/teammates/logic/api/EmailSenderTest.java Adds tests for SMTP MIME conversion and invalid protocol validation
build.gradle Adds Jakarta Mail API + Angus Mail runtime dependency

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/main/java/teammates/common/util/Config.java Outdated
Comment on lines +122 to +126
message.setRecipient(Message.RecipientType.TO, new InternetAddress(wrapper.getRecipient()));
if (wrapper.getBcc() != null && !wrapper.getBcc().isEmpty()) {
message.setRecipient(Message.RecipientType.BCC, new InternetAddress(wrapper.getBcc()));
}
message.setReplyTo(new InternetAddress[] { new InternetAddress(wrapper.getReplyTo()) });
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new InternetAddress(String) and setRecipient(...) only handle a single address; if wrapper.getRecipient(), wrapper.getBcc(), or wrapper.getReplyTo() contain a comma-separated list (a common representation), this will either fail parsing or silently treat it as one address. Use InternetAddress.parse(...) and message.setRecipients(...) / message.setReplyTo(...) with the parsed array to correctly support multiple recipients.

Suggested change
message.setRecipient(Message.RecipientType.TO, new InternetAddress(wrapper.getRecipient()));
if (wrapper.getBcc() != null && !wrapper.getBcc().isEmpty()) {
message.setRecipient(Message.RecipientType.BCC, new InternetAddress(wrapper.getBcc()));
}
message.setReplyTo(new InternetAddress[] { new InternetAddress(wrapper.getReplyTo()) });
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(wrapper.getRecipient()));
if (wrapper.getBcc() != null && !wrapper.getBcc().isEmpty()) {
message.setRecipients(Message.RecipientType.BCC, InternetAddress.parse(wrapper.getBcc()));
}
if (wrapper.getReplyTo() != null && !wrapper.getReplyTo().isEmpty()) {
message.setReplyTo(InternetAddress.parse(wrapper.getReplyTo()));
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, there seems to be no use case of sending or BCC-ing to multiple email address. I think this should not be necessary as of now.

Comment thread src/test/java/teammates/logic/api/EmailSenderTest.java Outdated
@samuelfangjw samuelfangjw self-requested a review March 20, 2026 12:08
Comment thread src/main/java/teammates/common/util/Config.java Outdated
Comment thread src/main/java/teammates/logic/external/SmtpService.java
Comment thread src/main/java/teammates/logic/external/SmtpService.java Outdated
Comment thread src/main/java/teammates/logic/external/SmtpService.java Outdated
Comment thread src/main/resources/build.template.properties
Comment thread build.gradle Outdated
@samuelfangjw
Copy link
Copy Markdown
Member

One last thing to note, I ran this file through AI and it brought up (justifiably) that the way Transport.send() is used results in new connections per email that could result in significant overhead. However, I don't think the complexity of setting up a connection pool that's thread safe is worth it at them moment, especially since we will primarily be using sendgrid. Just something to keep in mind. We can revisit this again if it becomes a significant constraint.

@iZUMi-kyouka
Copy link
Copy Markdown
Contributor Author

Some changes made:

  • Moved all SmtpService tests into its own test class
  • Created stub to allow mocking of Transport.send() behaviour to tests for the SMTP to HTTP code mapping
  • Add mapping of SMTP to HTTP error code
  • Added app.smtp.auth config to allow toggling of auth
  • Removed the timeout configs

I will smoke test with these fixes on staging soon.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +116 to +128
} catch (SMTPSendFailedException sfe) {
// SMTP 5xx errors indicates a permanent failure, while 4xx indicates a transient failure.
// Since HTTP 5xx errors are retried by default while HTTP 4xx errors are not, map the codes accordingly.

int replyCode = sfe.getReturnCode();
// Permanent SMTP send failure, do not retry
if (replyCode >= 500 && replyCode < 600) {
throw new EmailSendingException(sfe, HttpStatus.SC_BAD_REQUEST);
}

// Transient SMTP send failure, retry may succeed
throw new EmailSendingException(sfe, HttpStatus.SC_BAD_GATEWAY);
} catch (MessagingException me) {
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SMTP reply-code → HTTP status mapping is documented as a way to control Cloud Tasks retry behavior, but the current queue worker (SendEmailWorkerAction) retries on any non-2xx regardless of the underlying EmailSendingStatus code. Either adjust the worker to respect “permanent” vs “transient” failures (e.g., return a 2xx for permanent failures to stop retries), or simplify/remove this mapping/comment to avoid misleading future maintainers.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but the current queue worker (SendEmailWorkerAction) retries on any non-2xx regardless of the underlying EmailSendingStatus code.

Let's fix this in another issue, mapping all errors to http 502 in the SendEmailWorkerAction is another code smell that should be resolved.

Comment on lines 29 to 40
EmailSender() {
if (Config.IS_DEV_SERVER) {
service = new EmptyEmailService();
if (Config.isUsingSendgrid()) {
service = new SendgridService();
} else if (Config.isUsingMailgun()) {
service = new MailgunService();
} else if (Config.isUsingMailjet()) {
service = new MailjetService();
} else if (Config.isUsingSmtp()) {
service = new SmtpService();
} else {
if (Config.isUsingSendgrid()) {
service = new SendgridService();
} else if (Config.isUsingMailgun()) {
service = new MailgunService();
} else if (Config.isUsingMailjet()) {
service = new MailjetService();
} else {
service = new EmptyEmailService();
}
service = new EmptyEmailService();
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EmailSender’s provider selection is now config-driven (including dev server), but there isn’t any test coverage asserting the chosen service matches the configured provider (especially the new SMTP branch). Consider adding a focused unit test for the selection logic (may require a small refactor to make the selection testable without relying on static Config initialization).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

@iZUMi-kyouka iZUMi-kyouka Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want to test the logic in Config.java (such as the isUsingSmtp method that verifies all SMTP config params to return true), might not be currently feasible with the way all fields are declared as static final.

As for other classes that depend on Config.java, un general I feel like Config.java is also quite hard to mock as everything is a public static field. While some of the logic is quite trivial, some of the validation I add for isUsingSmtp might not be so.

What's your opinion on this? (though as a defensive and secure practice, for example requiring explicit values for smtp auth and security protocol, these Smtp configs are also checked at runtime, with the appropriate tests written for them, so maybe not so critical to test in the Config.java level)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's necessary at the moment. Thanks for looking into this!

Comment thread src/main/java/teammates/logic/external/SmtpService.java Outdated
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@iZUMi-kyouka
Copy link
Copy Markdown
Contributor Author

iZUMi-kyouka commented Mar 24, 2026

Summary of issues to be addressed in future PRs:

  • SendEmailWorkerAction returning 502 regardless of the HTTP SC from the email sender
  • No thread-safe connection pool in SmtpService to send emails which might become an overhead when sending a lot of emails
  • parseEmail method in EmailSender is publicly exposed despite only used for testing

Copy link
Copy Markdown
Member

@samuelfangjw samuelfangjw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the great work!

@samuelfangjw samuelfangjw enabled auto-merge (squash) March 26, 2026 14:31
@samuelfangjw samuelfangjw merged commit 5c9db6b into TEAMMATES:master Mar 26, 2026
8 checks passed
@samuelfangjw samuelfangjw added this to the V9.0.0-beta.7 milestone Mar 26, 2026
WeeJean pushed a commit to WeeJean/teammates that referenced this pull request Apr 7, 2026
iZUMi-kyouka added a commit to iZUMi-kyouka/teammates that referenced this pull request Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow real email providers in dev server

4 participants