Skip to content

feat(email): integrate Mailjet for transactional email - #30

Merged
GRACENOBLE merged 2 commits into
mainfrom
20-feat-mailjet-transactional-email
Jun 22, 2026
Merged

GRACENOBLE merged 2 commits into
mainfrom
20-feat-mailjet-transactional-email

Conversation

@GRACENOBLE

@GRACENOBLE GRACENOBLE commented Jun 22, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Adds EmailSender interface in usecase/ and MailjetSender implementation in internal/infrastructure/email/ using the Mailjet Go SDK v4
  • Embedded welcome.html template rendered server-side via html/template; NewSandboxSender constructor for integration testing without real delivery
  • Wired into App struct and NewHandler via MAILJET_API_KEY, MAILJET_SECRET_KEY, FROM_EMAIL, FROM_NAME — sender is nil when keys are absent

Test plan

  • Unit tests: mock EmailSender interface verifies correct args and error propagation (internal/usecase/email_usecase_test.go)
  • HTTP unit tests: httptest.Server covers 401 error path and success response parsing (internal/infrastructure/email/mailjet_test.go)
  • Sandbox integration test: skipped when MAILJET_API_KEY/MAILJET_SECRET_KEY not set; run with real credentials to hit live Mailjet sandbox
  • go vet ./... — clean
  • make test — all non-Docker tests pass
  • pnpm lint && pnpm build — clean
  • ./gradlew lint && ./gradlew test — clean

Docs

  • Created backend/docs/email.md covering interface, template pattern, env setup, and testing approach
  • Updated backend/docs/environment.md with Mailjet + Sentry vars
  • Fixed stale backend/docs/bootstrap.md and backend/docs/routing.md (FCM/queue fields from prior features)

Closes #20

Summary by CodeRabbit

Release Notes

  • New Features

    • Added transactional email sending capability for welcome messages via Mailjet integration.
  • Documentation

    • Updated configuration guide and bootstrap documentation with email setup instructions and required environment variables.
  • Tests

    • Added comprehensive test coverage for email sending functionality.
  • Chores

    • Added Mailjet email service library dependency.

Adds EmailSender interface in usecase/, MailjetSender implementation
in internal/infrastructure/email/ with embedded HTML templates, and
wires the client into App and Handler via MAILJET_API_KEY/SECRET_KEY
env vars. Includes unit tests with mock and httptest-based HTTP tests;
sandbox integration test skips when credentials are absent.

Also brings stale bootstrap.md and routing.md docs up to date with
FCM/queue fields added in prior features.

Closes #20
@github-actions github-actions Bot added area: backend Go REST API type: chore Cleanup or maintenance tasks labels Jun 22, 2026
@coderabbitai

coderabbitai Bot commented Jun 22, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@GRACENOBLE, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 51 minutes and 52 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 44ab73f4-7f70-4c60-8ee2-8c71b2695e03

📥 Commits

Reviewing files that changed from the base of the PR and between 3470548 and 2330170.

📒 Files selected for processing (5)
  • backend/docs/_index.md
  • backend/docs/bootstrap.md
  • backend/docs/environment.md
  • backend/docs/routing.md
  • backend/internal/bootstrap/bootstrap.go
📝 Walkthrough

Walkthrough

Adds Mailjet-based transactional email to the Go backend. Introduces a usecase.EmailSender interface, a MailjetSender implementation with an embedded welcome.html template, conditional bootstrap wiring using four new env vars, injection into the HTTP handler, three test files covering mock, unit, and sandbox scenarios, and matching documentation.

Changes

Mailjet transactional email integration

Layer / File(s) Summary
EmailSender interface and usecase contract
backend/internal/usecase/email.go, backend/internal/usecase/email_usecase_test.go
Defines the EmailSender interface with SendWelcomeEmail; adds a mockEmailSender test double with compile-time interface satisfaction and tests for correct argument forwarding and error propagation.
MailjetSender implementation and welcome template
backend/internal/infrastructure/email/mailjet.go, backend/internal/infrastructure/email/templates/welcome.html, backend/go.mod
Adds MailjetSender struct with production and sandbox constructors (plus baseURL override), implements SendWelcomeEmail via Mailjet v3.1 API, adds renderWelcomeTemplate for embedded HTML rendering, and introduces the inline-styled welcome.html template.
MailjetSender unit and integration tests
backend/internal/infrastructure/email/mailjet_test.go
Adds sandbox integration test (skips without credentials), httptest-based unit test for non-200 error propagation, and httptest-based unit test for successful response path.
Bootstrap config, App wiring, and env vars
backend/internal/bootstrap/bootstrap.go, backend/.env.example
Extends Config with four Mailjet fields populated from env vars; extends App with EmailSender; conditionally constructs MailjetSender in Run when credentials are present; adds placeholder entries to .env.example.
Handler injection and server wiring
backend/internal/transport/handlers/handler.go, backend/internal/server/server.go, backend/internal/transport/handlers/health_handler_test.go
Adds emailSender field to Handler, extends NewHandler signature to accept it, passes queueUI in server.go, and updates health handler tests to match the new constructor arity.
Email, routing, bootstrap, and environment docs
backend/docs/email.md, backend/docs/environment.md, backend/docs/routing.md, backend/docs/bootstrap.md, backend/docs/_index.md, README.md
Adds email.md covering interface, constructors, templates, bootstrap wiring, env vars, extension guide, and testing strategy; updates routing, bootstrap, environment docs and the docs index; fixes a README punctuation character.

Sequence Diagram(s)

sequenceDiagram
  actor Caller
  participant bootstrap.Run
  participant email.NewMailjetSender
  participant MailjetSender
  participant MailjetAPI

  bootstrap.Run->>bootstrap.Run: loadConfig (MAILJET_API_KEY, MAILJET_SECRET_KEY, FROM_EMAIL, FROM_NAME)
  alt credentials present
    bootstrap.Run->>email.NewMailjetSender: apiKey, secretKey, fromEmail, fromName
    email.NewMailjetSender-->>bootstrap.Run: *MailjetSender
    bootstrap.Run->>bootstrap.Run: App.EmailSender = *MailjetSender
  else credentials absent
    bootstrap.Run->>bootstrap.Run: App.EmailSender = nil
  end

  Caller->>MailjetSender: SendWelcomeEmail(ctx, toEmail, toName)
  MailjetSender->>MailjetSender: renderWelcomeTemplate(toName)
  MailjetSender->>MailjetAPI: SendMailV31(messages)
  MailjetAPI-->>MailjetSender: response
  MailjetSender-->>Caller: nil or error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • GRACENOBLE/fullstack-template#13: Both PRs update health_handler_test.go to match an evolving NewHandler constructor signature with additional dependency arguments.

Suggested labels

area: backend, type: chore

🐰 A bunny hopped in with a brand-new mail queue,
Mailjet credentials freshly brewed,
SendWelcomeEmail now renders with flair,
HTML templates float through the air.
No credentials? The sender stays nil —
A cautious rabbit who knows the drill! 📬

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(email): integrate Mailjet for transactional email' clearly and concisely summarizes the main change—integrating Mailjet for transactional email—which aligns directly with the primary objective of the changeset.
Linked Issues check ✅ Passed All acceptance criteria from issue #20 are met: Mailjet API integration with rendered HTML body, SendWelcomeEmail implemented and tested, environment variables added to .env.example, EmailSender interface unit-tested with mocks and integration tested against Mailjet sandbox, and documentation created in backend/docs/email.md.
Out of Scope Changes check ✅ Passed All changes are within scope of the linked issue #20. The PR integrates Mailjet email functionality, updates related documentation (bootstrap, routing, environment), and adds necessary infrastructure and wiring—all aligned with transactional email feature requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 20-feat-mailjet-transactional-email

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/docs/bootstrap.md (1)

45-55: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the Mailjet sender to the run sequence.

The docs jump from Firebase init straight to returning App, but the runtime also conditionally builds emailSender before that return. Readers will miss the Mailjet wiring step.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/docs/bootstrap.md` around lines 45 - 55, The bootstrap run sequence
documentation is missing the Mailjet email sender initialization step that
occurs in the actual implementation. Add a new numbered step (between the
current step 7 about Firebase initialization and step 8 about returning the App)
that documents the conditional initialization of the emailSender via the Mailjet
client, similar to how other optional components like Redis and Firebase are
documented. This step should indicate it is skipped when the appropriate
configuration is not provided, maintaining consistency with the documentation
pattern for other conditional initializations.
🧹 Nitpick comments (2)
backend/internal/infrastructure/email/mailjet_test.go (1)

41-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clarify or remove the outdated comment.

The comment references "withSandbox is unexported" and discusses implementation details that don't match the actual code. The test correctly uses the exported NewSandboxSender constructor. Consider simplifying this comment to just explain what the test does.

📝 Suggested clarification
-	// withSandbox is unexported but we can exercise it by keeping the sender
-	// internal to this package test; instead we call the exported constructor
-	// and rely on a helper that forces sandbox mode (see newSandboxSender).
+	// NewSandboxSender enables sandbox mode, which validates requests without delivery.
 	sender := email.NewSandboxSender(apiKey, secretKey, fromEmail, fromName)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/internal/infrastructure/email/mailjet_test.go` around lines 41 - 43,
The comment block above the test that uses NewSandboxSender contains outdated
references to implementation details about withSandbox being unexported that no
longer match the actual test implementation. Replace this comment with a
simpler, clearer explanation that accurately describes what the test does,
specifically that it uses the exported NewSandboxSender constructor to test the
sandbox mode functionality, removing the confusing references to unexported
functions and internal implementation details.
backend/internal/infrastructure/email/mailjet.go (1)

84-102: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider caching the parsed template to improve performance.

The template is read and parsed on every SendWelcomeEmail call. For better performance, parse the template once during NewMailjetSender construction and store it as a *template.Template field in the struct.

♻️ Proposed refactor to cache the template

Add a template field to the struct:

 type MailjetSender struct {
 	client      *mailjet.Client
 	fromEmail   string
 	fromName    string
 	sandboxMode bool
+	welcomeTmpl *template.Template
 }

Parse the template once in the constructor:

 func NewMailjetSender(apiKey, secretKey, fromEmail, fromName string, baseURL ...string) *MailjetSender {
 	client := mailjet.NewMailjetClient(apiKey, secretKey, baseURL...)
+	tmpl, err := parseWelcomeTemplate()
+	if err != nil {
+		// Handle initialization error - could panic or return error from constructor
+		panic(fmt.Sprintf("email: failed to parse welcome template: %v", err))
+	}
 	return &MailjetSender{
 		client:    client,
 		fromEmail: fromEmail,
 		fromName:  fromName,
+		welcomeTmpl: tmpl,
 	}
 }

Update SendWelcomeEmail to use the cached template:

 func (s *MailjetSender) SendWelcomeEmail(_ context.Context, toEmail, toName string) error {
-	html, err := renderWelcomeTemplate(toName)
+	html, err := s.renderWelcomeTemplate(toName)
 	if err != nil {
 		return fmt.Errorf("email: render welcome template: %w", err)
 	}

Simplify the render function:

-func renderWelcomeTemplate(name string) (string, error) {
-	raw, err := templateFS.ReadFile("templates/welcome.html")
-	if err != nil {
-		return "", fmt.Errorf("read welcome template: %w", err)
-	}
-
-	tmpl, err := template.New("welcome").Parse(string(raw))
-	if err != nil {
-		return "", fmt.Errorf("parse welcome template: %w", err)
-	}
-
+func parseWelcomeTemplate() (*template.Template, error) {
+	raw, err := templateFS.ReadFile("templates/welcome.html")
+	if err != nil {
+		return nil, fmt.Errorf("read welcome template: %w", err)
+	}
+	return template.New("welcome").Parse(string(raw))
+}
+
+func (s *MailjetSender) renderWelcomeTemplate(name string) (string, error) {
 	var buf bytes.Buffer
-	if err := tmpl.Execute(&buf, map[string]string{"Name": name}); err != nil {
+	if err := s.welcomeTmpl.Execute(&buf, map[string]string{"Name": name}); err != nil {
 		return "", fmt.Errorf("execute welcome template: %w", err)
 	}
 	return buf.String(), nil
 }

Apply the same pattern to NewSandboxSender.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/internal/infrastructure/email/mailjet.go` around lines 84 - 102, The
renderWelcomeTemplate function reads and parses the welcome template file on
every call, which is inefficient. Add a welcomeTemplate field (of type
*template.Template) to the Mailjet sender struct and initialize it once in the
NewMailjetSender constructor by reading and parsing the template. Then update
renderWelcomeTemplate to accept the cached template as a parameter and only
execute it without re-reading or re-parsing. Apply the same pattern to
NewSandboxSender to ensure the template is cached there as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/docs/_index.md`:
- Line 22: The Transactional email (Mailjet) row in the documentation index
table is missing the wiring file references that are documented in email.md. Add
the server wiring file and the handler wiring file to the pipe-separated list of
files in the Mailjet index row, ensuring all documented files referenced in
email.md are included alongside the existing use case, sender, template, and
bootstrap file entries.

In `@backend/docs/bootstrap.md`:
- Around line 17-27: The App struct documentation in bootstrap.md is incomplete
and missing the EmailSender field that has been added to the actual App struct.
Add the EmailSender field to the type App struct definition in bootstrap.md
between FCMSender and Config, with appropriate documentation noting whether it
is optional and any corresponding environment variable conditions, following the
same comment pattern used for other optional fields like Firebase and FCMSender.

In `@backend/docs/environment.md`:
- Around line 39-43: The documentation for FROM_EMAIL and FROM_NAME currently
states they are only read when MAILJET_API_KEY is set, but the sender
initialization actually requires both MAILJET_API_KEY and MAILJET_SECRET_KEY to
be present. Update the descriptions for both FROM_EMAIL and FROM_NAME
environment variables to clarify that they are only read when both
MAILJET_API_KEY and MAILJET_SECRET_KEY are provided, rather than just the API
key alone.

In `@backend/docs/routing.md`:
- Around line 20-38: The documentation in routing.md is missing the emailSender
field from the Handler struct and the emailSender parameter from the NewHandler
function signature, even though these have been added to the actual
implementation in handler.go. Add emailSender as a field to the Handler struct
with an appropriate usecase type (following the pattern of other fields like
fcmSender) and add it as the last parameter to the NewHandler function signature
to keep the documentation in sync with the implementation.

In `@backend/internal/bootstrap/bootstrap.go`:
- Around line 147-156: The Mailjet sender initialization in the bootstrap.go
file only validates the presence of MailjetAPIKey and MailjetSecretKey before
creating the emailSender, but does not validate that FromEmail is also
configured. This allows the application to start with a misconfigured sender
that will fail later during email operations. Modify the condition that checks
whether to initialize the Mailjet sender to also verify that cfg.FromEmail is
not empty, ensuring all required configuration fields (MailjetAPIKey,
MailjetSecretKey, and FromEmail) are present. If the configuration is incomplete
or partially configured, return a startup configuration error instead of
silently proceeding with a broken sender.

---

Outside diff comments:
In `@backend/docs/bootstrap.md`:
- Around line 45-55: The bootstrap run sequence documentation is missing the
Mailjet email sender initialization step that occurs in the actual
implementation. Add a new numbered step (between the current step 7 about
Firebase initialization and step 8 about returning the App) that documents the
conditional initialization of the emailSender via the Mailjet client, similar to
how other optional components like Redis and Firebase are documented. This step
should indicate it is skipped when the appropriate configuration is not
provided, maintaining consistency with the documentation pattern for other
conditional initializations.

---

Nitpick comments:
In `@backend/internal/infrastructure/email/mailjet_test.go`:
- Around line 41-43: The comment block above the test that uses NewSandboxSender
contains outdated references to implementation details about withSandbox being
unexported that no longer match the actual test implementation. Replace this
comment with a simpler, clearer explanation that accurately describes what the
test does, specifically that it uses the exported NewSandboxSender constructor
to test the sandbox mode functionality, removing the confusing references to
unexported functions and internal implementation details.

In `@backend/internal/infrastructure/email/mailjet.go`:
- Around line 84-102: The renderWelcomeTemplate function reads and parses the
welcome template file on every call, which is inefficient. Add a welcomeTemplate
field (of type *template.Template) to the Mailjet sender struct and initialize
it once in the NewMailjetSender constructor by reading and parsing the template.
Then update renderWelcomeTemplate to accept the cached template as a parameter
and only execute it without re-reading or re-parsing. Apply the same pattern to
NewSandboxSender to ensure the template is cached there as well.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7058bcd5-22bd-41de-bb76-72ec8c0606fc

📥 Commits

Reviewing files that changed from the base of the PR and between e847680 and 3470548.

⛔ Files ignored due to path filters (1)
  • backend/go.sum is excluded by !**/*.sum
📒 Files selected for processing (17)
  • README.md
  • backend/.env.example
  • backend/docs/_index.md
  • backend/docs/bootstrap.md
  • backend/docs/email.md
  • backend/docs/environment.md
  • backend/docs/routing.md
  • backend/go.mod
  • backend/internal/bootstrap/bootstrap.go
  • backend/internal/infrastructure/email/mailjet.go
  • backend/internal/infrastructure/email/mailjet_test.go
  • backend/internal/infrastructure/email/templates/welcome.html
  • backend/internal/server/server.go
  • backend/internal/transport/handlers/handler.go
  • backend/internal/transport/handlers/health_handler_test.go
  • backend/internal/usecase/email.go
  • backend/internal/usecase/email_usecase_test.go

Comment thread backend/docs/_index.md Outdated
Comment thread backend/docs/bootstrap.md Outdated
Comment thread backend/docs/environment.md Outdated
Comment thread backend/docs/routing.md
Comment thread backend/internal/bootstrap/bootstrap.go
- Validate full Mailjet config at startup: if any of API key, secret key,
  or FROM_EMAIL is partially set, validateConfig now returns an error
  rather than starting with a misconfigured sender
- Add EmailSender to App struct and Run sequence in bootstrap.md
- Add emailSender field and NewHandler param to routing.md
- Clarify FROM_EMAIL/FROM_NAME gate in environment.md (both credentials
  required, not just API key)
- Add server.go and handler.go to _index.md Mailjet sources list
@GRACENOBLE
GRACENOBLE merged commit fe3b1af into main Jun 22, 2026
2 checks passed
@GRACENOBLE
GRACENOBLE deleted the 20-feat-mailjet-transactional-email branch June 22, 2026 23:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: backend Go REST API type: chore Cleanup or maintenance tasks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: integrate Mailjet for transactional email (SMTP)

1 participant