In this article, we’re going to walk through how to build a scalable and reliable transactional email service on AWS, built entirely serverless with EventBridge, SQS, EventBridge Scheduler, Lambda, Amazon SES, and MJML for beautifully responsive templates.
This is a common requirement for online learning platforms, e-commerce stores, SaaS products, or any application that needs to send highly customised, on-brand emails in response to things happening in the system, a sign-up, a purchase, a completed course, or a review.
“The goal is simple: when something meaningful happens in the business, the right person receives a beautiful, personalised email, sometimes instantly, sometimes a few hours later, without duplicates, without hammering a known-bad address, and without any of it blocking the rest of the system.”
The Problem We’re Solving
Email looks easy until you actually build it for production. “Just call SES” works for a prototype, but a real email service has to answer some harder questions:
- What triggers an email? You don’t want business logic littered with
sendEmail()calls. Emails should be a reaction to domain events, not something bolted into every use case. - How do you handle volume? A course goes live, and suddenly you need to notify thousands of subscribers. You can’t send those synchronously inside a request.
- What about delayed emails? Some emails are best sent immediately (a welcome email), others a few hours or days later (a nudge, a follow-up, a “you haven’t finished your course” reminder).
- How do you keep templates maintainable and on-brand? Hand-writing HTML email is misery. Email clients are a compatibility minefield.
- How do you protect your sender reputation? Send to enough dead addresses, and your bounce rate climbs, your reputation drops, and eventually your real emails land in spam, or SES pauses your account entirely.
- How do you avoid sending the same email twice? Event-driven systems retry. At least once delivery means duplicates are a question of when, not if.
We need a system that:
- Reacts to domain events rather than being called directly.
- Decouples sending from the originating request so nothing blocks.
- Supports both immediate and delayed delivery with the same pipeline.
- Renders highly customised, responsive templates that look right everywhere.
- Is idempotent so retries never produce duplicate emails.
- Protects sender reputation by suppressing known-bad addresses.
- Records everything for auditing and future “email history” features.
Let’s dive into how we achieve this with an event-driven pipeline.
Our Example
This is the real email service that powers Study From Experts, our online learning platform where students sign up, enrol in courses taught by expert instructors, complete modules, leave reviews, and earn certificates. Every one of those moments is a chance to send a well-timed, personalised email. We’re sharing it here in the spirit of building in public (as usual!).
We already publish domain events to a central EventBridge bus whenever something happens: UserSignedUp, CourseCompleted, CourseReviewed, CertificateGenerated, and so on. The email service is just one of many consumers of that bus. It doesn’t care who produced the event or why; it only cares whether the event should result in an email.
💡 Note: All code examples are for discussion only and can be further productionised. Any account IDs, ARNs, IP addresses, and email addresses shown are placeholders.
Architecture Overview 🏗️
The email service is a two-stage pipeline. The first stage decides whether and when to send. The second stage actually renders and delivers. A separate, independent flow handles bounces coming back from SES.
┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ EventBridge │────▶│ EventBridge Rule │────▶│ SQS │
│ (domain events) │ │ (email-triggering │ │ (event intake queue)│
│ UserSignedUp etc│ │ events only) │ │ │
└──────────────────┘ └──────────────────────┘ └───────────┬──────────┘
│
▼
┌────────────────────────────┐
│ Lambda: process-email- │
│ queue │
│ - maps event → email type │
│ - immediate OR delayed? │
└───────┬───────────┬────────┘
immediate (delay=0)│ │ delayed (delay>0)
▼ ▼
┌────────────────────┐ ┌───────────────────────┐
│ SQS Email Queue │ │ EventBridge Scheduler│
│ (direct send) │ │ (one-time at(...)) │
└─────────┬──────────┘ └──────────┬────────────┘
│ │
│ (schedule fires) │
│◀───────────────────────┘
▼
┌────────────────────────────┐
│ Lambda: process-email │
│ 1. idempotency check │
│ 2. route to handler + │
│ hydrate data │
│ 3. bounce-list check │
│ 4. render MJML template │
│ 5. send via SES (or S3 │
│ preview in non-prod) │
│ 6. record sent email │
│ 7. record idempotency key │
└─────────┬──────────────────┘
│
▼
┌────────────────────┐
│ Amazon SES │
└─────────┬──────────┘
│ (bounce / complaint)
▼
┌─────────────────────┐ ┌─────────────────────┐
│ SNS Topic │────▶│ Lambda: │
│ (SES notifications)│ │ handle-bounce │
└─────────────────────┘ │ - suppress address │
└─────────────────────┘
The flow works like this:
- A business action publishes a domain event to EventBridge.
- An EventBridge rule routes email-relevant events to an SQS intake queue (SQS gives us buffering, retries, and a dead-letter queue for free).
- The process-email-queue Lambda maps the event to an email type and decides: send now, or send later?
- Immediate emails go straight to the email queue. Delayed emails get a one-time EventBridge Scheduler schedule that drops the message onto the same queue at the right moment.
- The process-email Lambda is the delivery engine: it deduplicates, hydrates data, checks the bounce list, renders the MJML template, and sends via SES.
- SES publishes bounce and complaint notifications to SNS, which a separate handle-bounce Lambda consumes to suppress bad addresses, or to prevent further emails to that user for a period of time (when it is not a dead email address, but a transient issue like mailbox full).
Each component does one thing. They communicate through events and messages, never direct calls.
%20(1).png)
💡This is event-driven architecture in action! Each component does one thing, communicates via events, and the pipeline flows naturally to sending of emails.
EDA is covered in this great course by James Eastham here: https://www.studyfromexperts.com/courses/serverless-integration-patterns/
Why Event-Driven? 🔄
Before the implementation, it’s worth saying why we didn’t just call SES from inside our use cases (we are a big fan of hexagonal architecture).
Decoupling: The code that creates a user has no business knowing how a welcome email is rendered. It publishes UserSignedUp and moves on. If we add a second email later, or a Slack message, or a CRM sync, none of that touches the sign-up code.
Resilience: SQS sits between the event and the work. If SES has a wobble or a downstream dependency is slow, messages wait safely in the queue and retry. Nothing is lost, and nothing blocks the user-facing request.
Backpressure and scale: When a popular course goes live, and we fan out thousands of notifications, the queue absorbs the spike, and Lambda scales out to drain it. The originating action returns in milliseconds regardless.
Graceful skips: Not every event needs an email. Events with no email mapping are simply ignored at the event target level — no error, no noise.
💡 A key principle of event-driven architecture: producers emit facts, consumers decide what to do with them. The email service is just one opinionated listener.
Stage 1: From Domain Event to a Scheduled Email
The first Lambda receives domain events from SQS and decides what to do. The first question is simply: does this event even produce an email?
We keep a single, declarative mapping from event types to email types. This is the only place that knowledge lives.
export enum EmailType {
Welcome = 'WELCOME',
CourseCompleted = 'COURSE_COMPLETED',
CourseRegistered = 'COURSE_REGISTERED',
CourseReviewed = 'COURSE_REVIEWED',
ModuleReviewed = 'MODULE_REVIEWED',
AccountLinked = 'ACCOUNT_LINKED',
CertificateReady = 'CERTIFICATE_READY',
CourseGoLive = 'COURSE_GO_LIVE',
}
/**
* The single source of truth for which events trigger which emails.
*/
const eventToEmailType: Record<string, EmailType> = {
UserSignedUp: EmailType.Welcome,
CourseCompleted: EmailType.CourseCompleted,
CourseRegistered: EmailType.CourseRegistered,
CourseReviewed: EmailType.CourseReviewed,
ModuleReviewed: EmailType.ModuleReviewed,
AccountLinked: EmailType.AccountLinked,
CertificateGenerated: EmailType.CertificateReady,
NotifyUsersOfCourseGoLive: EmailType.CourseGoLive,
};
export function getEmailTypeForEvent(eventType: string): EmailType | undefined {
return eventToEmailType[eventType];
}
Adding a new email is now a one-line change plus a template. If an event isn’t in the map, it’s skipped cleanly.
Immediate vs Delayed Delivery
Here’s the heart of stage one. Based on a delayMs value, we either send the message straight to the email queue (immediate) or create a one-time schedule (delayed). Both paths end up delivering the same message shape to the same email queue; the only difference is timing (immediate or in the future).
export async function processEmailQueueUseCase(
event: DomainEventRecord,
options: ProcessEmailQueueOptions,
): Promise<ProcessEmailQueueResult> {
const { emailQueueArn, emailQueueUrl, schedulerRoleArn, delayMs = 0 } = options;
const eventType = event['detail-type'];
const { metadata, data } = event.detail;
// Does this event produce an email at all?
const emailType = getEmailTypeForEvent(eventType);
if (!emailType) {
return { success: true, skipped: true, skipReason: `No mapping for${eventType}` };
}
const scheduledAt = new Date(Date.now() + delayMs);
// One message shape, regardless of immediate vs delayed
const message: ScheduledEmailMessage = {
emailType,
eventType,
eventData: data,
correlationId: metadata.correlationId || metadata.id,
scheduledAt: scheduledAt.toISOString(),
metadata: { eventId: metadata.id, source: metadata.source },
};
// Immediate: straight to the email queue
if (delayMs <= 0) {
const result = await sendMessage({ queueUrl: emailQueueUrl, messageBody: message });
return { success: result.success, messageId: result.messageId };
}
// Delayed: let EventBridge Scheduler drop it on the queue later
const result = await createSchedule({
scheduleName: generateScheduleName(eventType, metadata.id),
scheduleAt: scheduledAt,
targetArn: emailQueueArn,
payload: message,
roleArn: schedulerRoleArn,
});
return { success: result.success, scheduleArn: result.scheduleArn };
}
Why EventBridge Scheduler and not SQS delays?
This is the design decision that matters most, so it’s worth dwelling on.
SQS has a built-in DelaySeconds, but it maxes out at 15 minutes. That’s fine for smoothing out a burst, but useless for “email this person in 3 days.” EventBridge Scheduler, on the other hand, lets you schedule a one-time delivery at an arbitrary future timestamp, down to the minute, with no practical upper bound.
Our scheduler adapter creates a one-time schedule using an at() expression and, crucially, tells AWS to delete the schedule after it fires, so we don’t accumulate millions of dead schedules.
function formatScheduleExpression(date: Date): string {
// EventBridge Scheduler wants ISO 8601 without milliseconds: at(yyyy-MM-ddTHH:mm:ss)
const isoString = date.toISOString().split('.')[0];
return `at(${isoString})`;
}
export async function createSchedule(params: CreateScheduleParams): Promise<CreateScheduleResult> {
const { scheduleName, scheduleAt, targetArn, payload, roleArn } = params;
const input: CreateScheduleCommandInput = {
Name: scheduleName,
ScheduleExpression: formatScheduleExpression(scheduleAt),
ScheduleExpressionTimezone: 'UTC',
FlexibleTimeWindow: { Mode: FlexibleTimeWindowMode.OFF },
Target: {
Arn: targetArn, // the SQS email queue
RoleArn: roleArn, // a role Scheduler assumes to write to the queue
Input: JSON.stringify(payload),
},
// Self-cleaning: the schedule deletes itself once it has run
ActionAfterCompletion: ActionAfterCompletion.DELETE,
};
const response = await schedulerClient.send(new CreateScheduleCommand(input));
return { success: true, scheduleArn: response.ScheduleArn };
}
✔️ Arbitrary delay — minutes, hours, or days, all with the same code path.
✔️ Self-cleaning — ActionAfterCompletion: DELETE means no schedule sprawl.
✔️ Same destination — the schedule targets the same email queue, so the delivery engine downstream is identical whether the email was immediate or delayed.
✔️ Unique, sanitised names — schedule names are restricted to 1–64 characters of [a-zA-Z0-9-_], so we sanitise the event type and append a short UUID for uniqueness.
Stage 2: Rendering Highly Customised Emails with MJML 🎨
Now the message is on the email queue. The process-email Lambda picks it up and does the real work. Before we follow its steps, let’s talk about templates, because “highly customised emails” is where most teams either give up or end up with unmaintainable HTML.
MJML + Handlebars
Email HTML is notoriously hostile; nested tables, inline styles, inconsistent rendering across Outlook, Gmail, and Apple Mail. We don’t write that by hand. Instead, we author templates in MJML (a markup language that compiles to bulletproof responsive HTML) and inject dynamic data with Handlebars.
The rendering helper is tiny: compile the Handlebars placeholders against the data, then compile the resulting MJML to HTML.
import Handlebars from 'handlebars';
import mjml2html from 'mjml';
export async function renderEmail<T extends object>(
mjmlTemplate: string,
data: T,
options: RenderEmailOptions = {},
): Promise<RenderEmailResult> {
const { minify = false, validationLevel = 'soft' } = options;
// Step 1: inject dynamic data into the MJML source
const compiled = Handlebars.compile(mjmlTemplate)(data);
// Step 2: compile MJML to responsive, client-safe HTML
const result = mjml2html(compiled, { minify, validationLevel });
if (result.errors?.length) {
logger.warn('MJML validation warnings', {
errors: result.errors.map((e) => e.formattedMessage),
});
}
return { html: result.html, errors: result.errors?.map((e) => e.formattedMessage) };
}
A Shared Brand Wrapper
Every email shares the same header, footer, colours, and typography. Rather than repeat that in every template, we wrap the per-email content in a common shell. The wrapper pulls brand colours and typography from a central styles module, so a rebrand is a one-file change.
export function createEmailWrapper(content: string, options: WrapperOptions = {}): string {
const { title = 'Study From Experts', previewText = '', baseUrl } = options;
return `
<mjml>
<mj-head>
<mj-title>${title}</mj-title>
${previewText ? `<mj-preview>${previewText}</mj-preview>` : ''}
<mj-attributes>
<mj-all font-family="${typography.fontFamily}" />
<mj-text font-size="${typography.fontSize.base}" color="${brandColors.text.primary}" />
</mj-attributes>
</mj-head>
<mj-body background-color="${brandColors.background.primary}">
${/* brand header with logo */ ''}
${content}
${/* footer with "manage preferences" + privacy links */ ''}
</mj-body>
</mjml>`.trim();
}
Reusable Handlebars Helpers
Highly customised emails need formatting logic: currency, dates, truncation, conditional blocks, and loops with index. We register a small set of Handlebars helpers once at startup so every template can use them:
Handlebars.registerHelper('formatCurrency', (amount, currency) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: typeof currency === 'string' ? currency : 'USD',
}).format(amount),
);
Handlebars.registerHelper('formatDate', (dateString: string) =>
new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
);
Handlebars.registerHelper('truncate', (text: string, length: number) =>
!text || text.length <= length ? text || '' : `${text.substring(0, length)}...`,
);
A template can now express genuinely dynamic, personalised content: a greeting by first name, a conditional discount banner, a loop over the three latest courses with images and prices: all from clean, declarative markup:
<mj-text>Welcome to Study From Experts, {{givenName}}!</mj-text>
{{#if discountCode}}
<mj-text>Use code <span class="discount-code">{{discountCode}}</span>
for <strong>{{discountPercentage}}% off</strong> your first course!</mj-text>
{{/if}}
{{#each latestCourses}}
<mj-image src="{{imageUrl}}" alt="{{title}}" href="{{../baseUrl}}/courses/{{slug}}" />
<mj-text>{{title}} — {{formatCurrency price}}</mj-text>
{{/each}}
Hydrating the Data
The event payload alone is rarely enough to render a rich email. A UserSignedUp event gives us a user ID; the welcome email wants the user’s name, the latest titles, and any active discount. Each email type has a handler whose only job is to gather that data (we want this to be the most up-to-date data, so we choose not to use event-carried state transfer for certain data).
export async function handleWelcomeEmail(
eventData: UserSignedUpEventData,
options: WelcomeEmailHandlerOptions,
): Promise<WelcomeEmailHandlerResult> {
const { userId } = eventData;
const { baseUrl, maxCourses = 3 } = options;
// Fetch fresh user data rather than trusting a possibly-stale event payload
const user = await getUserById(userId);
// Cached lookups — see the scaling note below
const latestCourses = await getCachedLatestCourses(baseUrl, maxCourses);
const discount = await getCachedActiveDiscount();
return {
success: true,
data: {
givenName: user.givenName,
email: user.email,
latestCourses,
baseUrl,
...(discount.code && discount.percentage
? { discountCode: discount.code, discountPercentage: discount.percentage }
: {}),
},
};
}
Two things worth highlighting:
✔️ Re-fetch, don’t trust the payload. Events can sit in a queue or a schedule for hours. We read the latest user record at send time, so we never email a stale name or a since-changed address.
✔️ Cache shared lookups. “Latest courses” and “current discount” are identical for everyone who signs up in a given window. Caching them in memory across warm Lambda invocations means a fan-out of thousands of welcome emails doesn’t generate thousands of identical database reads.
Preview Mode for Non-Production
A neat trick for development and staging: instead of sending real emails, write the rendered HTML to an S3 bucket. A single sendEmails flag flips the behaviour. You get to eyeball exactly what every template produces, with real data, without risking a real send to a real person.
if (options.sendEmails) {
// Production: deliver via SES
await sendEmail({ to, from, subject, htmlBody, bcc });
} else {
// Non-prod: write an HTML preview to S3 instead of sending
await writeEmailPreview({ emailType, htmlContent: htmlBody, uniqueId: sentEmailId }, previewBucketName);
}
Stage 3: Sending Reliably 🛡️
Rendering is only half the job. Sending reliably is where the hard-won lessons live. The delivery engine runs a deliberate sequence of guard rails around the actual SES call.
Idempotency — No Duplicate Emails
SQS is at least once. EventBridge can redeliver. Lambdas retry. Sooner or later, the same email message will be processed twice, and nobody wants two welcome emails. We solve this with a deterministic idempotency key.
The trick is that the key must be identical for identical messages. We recursively sort the message’s keys (so field ordering can’t change the result), then hash it with UUID v5 against a fixed namespace. Same input, same key, every time (just the message keys, not the body, as that can change based on the hydrate).
import { v5 as uuidv5 } from 'uuid';
export function generateIdempotencyKey(message: unknown): string {
// Sort keys recursively so {a,b} and {b,a} produce the same string
const canonical = JSON.stringify(sortObjectKeys(message));
return uuidv5(canonical, EMAIL_NAMESPACE_UUID);
}
Before sending, we check whether that key has been seen. If it has, we skip and return success; the email was already sent, and skipping is the correct outcome, not an error.
const idempotencyKey = generateIdempotencyKey(message);
if (await checkIdempotency(idempotencyKey)) {
logger.info('Email already sent (idempotency check), skipping', { idempotencyKey });
return { success: true, skipped: true, skipReason: 'Email already sent (idempotency)' };
}
After a successful send, we record the key. (We record it after sending, so a crash mid-send results in a retry rather than a silently-swallowed email.)
Bounce Suppression — Protecting Sender Reputation
The single fastest way to wreck your email deliverability is to keep sending to addresses that bounce. SES tracks your bounce rate, and if it climbs too high, your sending is throttled or paused. So before every send, we check a suppression list:
if (await isEmailBounced(recipientEmail)) {
logger.info('Recipient email is bounced, skipping send', { recipientEmail });
return { success: true, skipped: true, skipReason: 'Recipient email address is bounced' };
}
We’ll see how that list gets populated in the next section.
The Send, and the Audit Trail
Only after passing the idempotency and bounce checks do we actually hand off to SES. Our SES adapter is a thin, generic wrapper: no business logic, just the SDK call and a clean result type.
const sendResult = await sendEmail({ to: recipientEmail, from: fromEmail, subject, htmlBody, bcc: bccEmail });
if (!sendResult.success) {
return { success: false, errorMessage: sendResult.errorMessage };
}
Finally, we write a sent-email record to DynamoDB capturing the recipient, type, subject, SES message ID, correlation ID, and status. This gives us an audit trail, powers a future “email history” UI, and links every send back to the event that caused it via the correlation ID, invaluable when debugging “did this user get their certificate email?”
await createSentEmail({
sentEmailId,
userId,
recipientEmail,
emailType,
subject,
sesMessageId: sendResult.messageId,
correlationId,
eventType,
sentAt: now,
status: options.sendEmails ? 'SENT' : 'PREVIEW',
// ...
});
The full happy path inside the delivery engine reads as a clear sequence:
1. Idempotency check → skip if already sent
2. Route to handler → hydrate the data this email needs
3. Bounce-list check → skip if recipient is suppressed
4. Render MJML template → personalised, responsive HTML
5. Send via SES (or S3) → deliver, or write a preview in non-prod
6. Record sent email → audit trail + correlation
7. Record idempotency key → prevent future duplicates
Handling Bounces 📉
When SES can’t deliver an email, it doesn’t fail your SendEmail call, delivery happens asynchronously. Instead, SES publishes a bounce notification to an SNS topic. A dedicated Lambda consumes those notifications and updates our suppression list. This is a completely separate flow from sending, which is exactly what we want, i.e. bounce handling should never be coupled to the send path.
The key nuance is that not all bounces are equal:
- Permanent bounces (hard bounces) mean the address is invalid: a typo, a deleted mailbox. We should never send to it again.
- Transient bounces (soft bounces) are temporary: a full mailbox, a momentary server issue. We can retry later.
- SES also reports Undetermined, which we conservatively treat as transient.
function normalizeBounceType(sesBounceType: 'Permanent' | 'Transient' | 'Undetermined'): 'Permanent' | 'Transient' {
return sesBounceType === 'Permanent' ? 'Permanent' : 'Transient';
}For each bounced recipient, we look up the matching user and record a bounce. The clever bit is in how long the suppression lasts:
- Permanent bounces are stored without expiry — a permanent block.
- Transient bounces are stored with a DynamoDB TTL, so they auto-expire, and the address becomes eligible again after a cooling-off period. No cron job, no cleanup code: DynamoDB simply removes the record when the TTL passes.
for (const recipient of bounce.bouncedRecipients) {
const email = recipient.emailAddress.toLowerCase();
// Skip addresses that don't belong to a known user (e.g. offboarded accounts)
const user = await getUserByEmail(email, false);
if (!user) {
skippedCount++;
continue;
}
await createBouncedEmail({
bounceId: uuid(),
userId: user.userId,
email,
bounceType, // 'Permanent' | 'Transient'
bounceSubType: bounce.bounceSubType,
diagnosticCode: recipient.diagnosticCode,
bouncedAt: bounce.timestamp,
originalMessageId: mail.messageId,
// TTL is applied for Transient bounces only (see repository)
});
}
That suppression list is precisely what the delivery engine’s isEmailBounced() check reads before every send. The two flows are independent but close the loop: SES tells us an address is bad, and we stop sending to it, automatically, and with the right permanence.
Managing Scale 📈
Pulling the threads together, here’s how the design stays reliable when volume spikes:
- SQS as a shock absorber. Bursts (a course go-live fanning out to thousands of subscribers) land in the queue and drain at a controlled rate. Producers never block.
- Lambda concurrency. The delivery engine scales horizontally to chew through the queue, bounded by reserved concurrency, so we never exceed SES’s send rate.
- Dead-letter queues. Messages that fail repeatedly land in a DLQ for inspection rather than being lost or retried forever.
- Caching shared data. Per-recipient hydration reuses cached “latest courses” and “active discount” lookups across warm invocations, so a fan-out doesn’t translate into a database read storm.
- Idempotency. At-least-once delivery is safe because duplicates are detected and skipped.
- Suppression. We never waste sends, or reputation, on known-bad addresses.
- EventBridge Scheduler for spreading load. Beyond just “delayed emails,” scheduling can deliberately spread a large batch (a course go-live notification to every subscriber) over time to stay under rate limits.
Wrapping Up
What started as “just call SES” turns into a proper little system once you take reliability and scale seriously, but each piece is small and does one job well:
- EventBridge + SQS decouple sending from the rest of the app and absorbs bursts.
- A single event-to-email mapping keeps trigger logic in one obvious place.
- EventBridge Scheduler unlocks arbitrary delayed delivery that SQS can’t offer, and cleans up after itself.
- MJML + Handlebars give us highly customised, on-brand, responsive emails without hand-writing fragile HTML.
- Per-type handlers hydrate exactly the data each email needs, with caching for fan-outs.
- UUID v5 idempotency makes at-least-once delivery safe.
- SES → SNS bounce handling protects sender reputation automatically, with permanent blocks and self-expiring transient suppression.
- Sent-email records give a full audit trail tied back to the originating event.
The result is an email service that sends the right message, to the right person, at the right time, exactly once — and gets out of the way of everything else. Best of all, adding a brand new email is now a mapping entry and a template, not a re-architecture.
I hope you found this article useful. If you have any questions or feedback, feel free to reach out!
Ready to level up your AWS skills?
Visit sign-up today and join a community of builders and architects dedicated to mastering the cloud.
