[#13455] Allow real email providers in dev server, and implement SMTP sender#13575
Conversation
There was a problem hiding this comment.
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) implementingEmailSenderService - Updated
EmailSenderto 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.
| 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()) }); |
There was a problem hiding this comment.
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.
| 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())); | |
| } |
There was a problem hiding this comment.
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.
|
One last thing to note, I ran this file through AI and it brought up (justifiably) that the way |
|
Some changes made:
I will smoke test with these fixes on staging soon. |
1917039 to
093addc
Compare
There was a problem hiding this comment.
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.
| } 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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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(); | ||
| } |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
I don't think it's necessary at the moment. Thanks for looking into this!
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
Summary of issues to be addressed in future PRs:
|
samuelfangjw
left a comment
There was a problem hiding this comment.
Thanks for the great work!
Fixes #13455
Outline of Solution