Перейти к основному содержимому

Postbox

Введение

В этом примере мы не будем повторять инструкции по настройке адреса электронной почты в Yandex Cloud Postbox, которые вы можете найти в документации. Предполагается, что вы уже настроили адрес электронной почты и готовы отправлять сообщения через Yandex Cloud Postbox.

SDK

Для работы с Yandex Cloud Postbox в Go мы будем использовать библиотеку aws-sdk-go-v2. В этом примере мы используем EndpointResolverV2 — новый протокол разрешения эндпоинтов. Он позволяет задавать правила разрешения эндпоинтов для различных сервисов на уровне сервиса, не глобально для всего SDK. Подробнее в документации AWS SDK.

Код

Так как Yandex Cloud Postbox является совместимым с AWS сервисом, то для отправки сообщений мы можем использовать совместимый с AWS формат ключей — в Яндекс Облаке это статические ключи доступа, которые можно создать в IAM.

Но из-за того, что Yandex Cloud Postbox также поддерживает авторизацию при помощи IAM-токенов, я бы хотел показать и такой пример. Поэтому ниже будет рассказано про 2 варианта serverless функций с разными методами авторизации.

Константы

Для начала давайте заведем константы, которые будем использовать в примерах.

const.go
package main

const (
// Sender address must be verified with Amazon SES.
Sender = "noreply@yourdomain.com"

// Recipient address.
Recipient = "receiver@domain.com"

// Subject line for the email.
Subject = "Yandex Cloud Postbox Test via AWS SDK for Go"

// HtmlBody is the body for the email.
HtmlBody = "<h1>Amazon SES Test Email (AWS SDK for Go)</h1><p>This email was sent with " +
"<a href='https://yandex.cloud/ru/docs/postbox/quickstart'>Yandex Cloud Postbox</a> using the " +
"<a href='https://aws.amazon.com/sdk-for-go/'>AWS SDK for Go</a>.</p>"

// TextBody is the email body for recipients with non-HTML email clients.
TextBody = "This email was sent with Yandex Cloud Postbox using the AWS SDK for Go."

// CharSet The character encoding for the email.
CharSet = "UTF-8"
)

На 5 строке мы указываем email адрес, с которого будут отправляться сообщения. Он должен быть именно в том домене, который вы настроили в Yandex Cloud Postbox.

На 8 указываем email адрес, на который будут отправляться сообщения. Он может быть любым.

Резолвер

Следующим шагом создадим резолвер для разрешения кастомного эндпоинта Postbox.

resolver.go
package main

import (
"context"
"net/url"

"github.com/aws/aws-sdk-go-v2/service/sesv2"
"github.com/aws/smithy-go/endpoints"
)

type resolverV2 struct{}

func (*resolverV2) ResolveEndpoint(ctx context.Context, params sesv2.EndpointParameters) (
transport.Endpoint, error,
) {
u, err := url.Parse("https://postbox.cloud.yandex.net")
if err != nil {
return transport.Endpoint{}, err
}
return transport.Endpoint{
URI: *u,
}, nil
}

Как видно, тут ничего сложного. Мы просто создаем тип имплементирующий интерфейс sesv2.EndpointResolverV2.

AWS совместимый вариант

package main

import (
"context"
"encoding/json"

"github.com/aws/aws-sdk-go-v2/service/sesv2"
)

func AwsHandler(ctx context.Context) ([]byte, error) {
// Create an SES session.
client := sesv2.New(sesv2.Options{
Region: "ru-central1",
EndpointResolverV2: &resolverV2{},
// The following options are useful for debugging
//ClientLogMode: aws.LogRequestWithBody | aws.LogResponseWithBody,
//Logger: logging.NewStandardLogger(
// os.Stdout,
//),
})

res, err := sendEmail(ctx, client)

if err != nil {
return nil, err
}

// return response
resp := map[string]interface{}{
"result": "success",
"messageId": res.MessageId,
}

respBytes, err := json.Marshal(resp)
if err != nil {
panic(err)
}

return respBytes, nil

}

Как видно нам не пришлось ничего менять в коде, чтобы использовать Postbox вместо SES. Мы просто создали новый клиент для работы с Postbox, передав ему EndpointResolverV2 для разрешения кастомного эндпоинта Postbox.

Вы можете заметить, что мы не передавали никаких ключей доступа в клиент, так как при создании этой функции, мы укажем их в переменных окружения. Клиент сам возьмет их оттуда.

Собственно отправка сообщения происходит на строке в SendEmail, который вынесен в отдельный файл, так как переиспользуется в обоих примерах.

send.go
package main

import (
"context"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sesv2"
"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
)

func sendEmail(ctx context.Context, client *sesv2.Client) (*sesv2.SendEmailOutput, error) {
// Assemble the email.
input := &sesv2.SendEmailInput{
Destination: &types.Destination{
ToAddresses: []string{Recipient},
},
Content: &types.EmailContent{
Simple: &types.Message{
Subject: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(Subject),
},
Body: &types.Body{
Html: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(HtmlBody),
},
Text: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(TextBody),
},
},
},
},
FromEmailAddress: aws.String(Sender),
}

// Attempt to send the email.
res, err := client.SendEmail(ctx, input)
return res, err
}

В остальном никаких больше особенностей по работе с Postbox в Yandex Cloud нет, и вы можете использовать стандартные методы работы с SESv2 из aws-sdk-go-v2. Список поддерживаемых методов можно найти в документации.

Вариант с авторизацией через IAM-токен

Для этого нам понадобится реализовать middleware для авторизации через IAM-токен. Для этого нам понадобится реализовать структуру iamRequestMiddleware и метод HandleFinalze для реализации интерфейса FinalizeMiddleware.

package main

import (
"context"
"encoding/json"
"fmt"

"github.com/aws/aws-sdk-go-v2/service/sesv2"
"github.com/aws/smithy-go/middleware"
"github.com/aws/smithy-go/tracing"
smithyhttp "github.com/aws/smithy-go/transport/http"
)

type iamToken struct {
Token string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}

type iamRequestMiddleware struct {
options sesv2.Options
}

func (*iamRequestMiddleware) ID() string {
return "IamToken"
}

func (m *iamRequestMiddleware) HandleFinalize(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (
out middleware.FinalizeOutput, metadata middleware.Metadata, err error,
) {
_, span := tracing.StartSpan(ctx, "IamToken")
defer span.End()

req, ok := in.Request.(*smithyhttp.Request)
if !ok {
return out, metadata, fmt.Errorf("unexpected transport type %T", in.Request)
}

tokenValue := ctx.Value("lambdaRuntimeTokenJSON")
tokenStr, ok := tokenValue.(string)
if !ok {
return out, metadata, fmt.Errorf("unexpected token type %T", tokenValue)
}

var token iamToken
err = json.Unmarshal([]byte(tokenStr), &token)
if err != nil {
return middleware.FinalizeOutput{}, middleware.Metadata{}, err
}

req.Header.Set("X-YaCloud-SubjectToken", token.Token)

span.End()
return next.HandleFinalize(ctx, in)
}

func swapAuth() func(options *sesv2.Options) {
return func(options *sesv2.Options) {
options.APIOptions = append(options.APIOptions, func(stack *middleware.Stack) error {
_, err := stack.Finalize.Swap("Signing", &iamRequestMiddleware{})
if err != nil {
return err
}
return nil
})
}
}

Отдельно добавим метод swapAuth. Он реализует паттерн функциональных опций. В нем мы получаем возможность подменить middleware для авторизации, которая выполняет подпись запроса. Вместо нее мы будем использовать наш middleware, который извлекает IAM-токен из контекста и кладет его в заголовок X-YaCloud-SubjectToken запроса.

package main

import (
"context"
"encoding/json"

"github.com/aws/aws-sdk-go-v2/service/sesv2"
)

func YcHandler(ctx context.Context) ([]byte, error) {
// Create an SES session.
client := sesv2.New(
sesv2.Options{
Region: "ru-central1",
EndpointResolverV2: &resolverV2{},
},
swapAuth(),
)

res, err := sendEmail(ctx, client)

if err != nil {
return nil, err
}

// return response
resp := map[string]interface{}{
"result": "success",
"messageId": res.MessageId,
}

respBytes, err := json.Marshal(resp)
if err != nil {
panic(err)
}

return respBytes, nil
}

Собственно вариант функции с авторизацией через IAM-токен отличается только наличием 17 строки, где мы добавляем middleware для подмены метода авторизации.

Развертывание

Для развертывания этой функции в Yandex Cloud вы можете воспользоваться Terraform описанным в примере.

data "archive_file" "function_files" {
output_path = "./function.zip"
source_dir = "../function"
type = "zip"
}

resource "yandex_function" "postbox_aws" {
name = "postbox-aws"
user_hash = data.archive_file.function_files.output_sha256
runtime = "golang121"
entrypoint = "index.AwsHandler"
memory = "128"
execution_timeout = "10"
content {
zip_filename = data.archive_file.function_files.output_path
}
service_account_id = yandex_iam_service_account.postbox_sender.id
environment = {
AWS_SECRET_ACCESS_KEY = yandex_iam_service_account_static_access_key.postbox_sender_key.secret_key
AWS_ACCESS_KEY_ID = yandex_iam_service_account_static_access_key.postbox_sender_key.access_key
}
}

resource "yandex_function" "postbox_yc" {
name = "postbox-yc"
user_hash = data.archive_file.function_files.output_sha256
runtime = "golang121"
entrypoint = "index.YcHandler"
memory = "128"
execution_timeout = "10"
content {
zip_filename = data.archive_file.function_files.output_path
}
service_account_id = yandex_iam_service_account.postbox_sender.id
}

locals {
functions = {
aws = yandex_function.postbox_aws,
yc = yandex_function.postbox_yc,
}
}

// IAM binding for making function public
resource "yandex_function_iam_binding" "postbox_function_binding" {
for_each = local.functions
function_id = each.value.id
role = "functions.functionInvoker"
members = ["system:allUsers"]
}

Для этого мы сначала определяем ресурс archive_file для создания zip-архива с функцией.

data "archive_file" "function_files" {
output_path = "./function.zip"
source_dir = "../function"
type = "zip"
}

А затем ресурс yandex_function для создания функции в Yandex Cloud, куда передаем zip-архив с функцией.

resource "yandex_function" "postbox_aws" {
name = "postbox-aws"
user_hash = data.archive_file.function_files.output_sha256
runtime = "golang121"
entrypoint = "index.AwsHandler"
memory = "128"
execution_timeout = "10"
content {
zip_filename = data.archive_file.function_files.output_path
}
service_account_id = yandex_iam_service_account.postbox_sender.id
environment = {
AWS_SECRET_ACCESS_KEY = yandex_iam_service_account_static_access_key.postbox_sender_key.secret_key
AWS_ACCESS_KEY_ID = yandex_iam_service_account_static_access_key.postbox_sender_key.access_key
}
}

Дополнительно, мы передаем переменные окружения AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY для авторизации в Yandex Cloud Postbox.

Аналогично, мы создаем вторую функцию, которая будет использовать авторизацию по IAM-токену. В нее вы не передаем ключи, а привязываем к ней сервисный аккаунт, который имеет доступ к Postbox.

resource "yandex_function" "postbox_yc" {
name = "postbox-yc"
user_hash = data.archive_file.function_files.output_sha256
runtime = "golang121"
entrypoint = "index.YcHandler"
memory = "128"
execution_timeout = "10"
content {
zip_filename = data.archive_file.function_files.output_path
}
service_account_id = yandex_iam_service_account.postbox_sender.id
}

Чтобы сделать функции доступными из интернета, мы можем создать ресурс yandex_function_iam_binding что бы разрешить доступ к функциям всем пользователям, тем самым сделав их публичными.

locals {
functions = {
aws = yandex_function.postbox_aws,
yc = yandex_function.postbox_yc,
}
}

// IAM binding for making function public
resource "yandex_function_iam_binding" "postbox_function_binding" {
for_each = local.functions
function_id = each.value.id
role = "functions.functionInvoker"
members = ["system:allUsers"]
}

Запуск

Теперь, когда функции развернуты, вы можете отправить сообщение вызвав функцию:

SEND_FUNC_ID=$(terraform -chdir=./tf output -raw aws_function_id)
curl -XPOST \
"https://functions.yandexcloud.net/$SEND_FUNC_ID" \
-H "Content-Type: application/json"

Или, чтобы вызвать вторую функцию, которая использует IAM-токен:

SEND_FUNC_ID=$(terraform -chdir=./tf output -raw yc_function_id)
curl -XPOST \
"https://functions.yandexcloud.net/$SEND_FUNC_ID" \
-H "Content-Type: application/json"

После этого вы получите сообщение на указанный email адрес.