thinwrap_notifications (thinwrap_notifications v1.2.0)
View SourceSend email, SMS, push and chat across 35 providers — one normalized surface, zero dependencies.
Every function takes a provider config and an input map and returns
{ok, Result} or {error, ConnectorError}:
Config = #{provider => sendgrid, api_key => <<"SG....">>, from => <<"hello@acme.com">>},
{ok, #{status := queued, provider_message_id := Id}} =
thinwrap_notifications:send_email(Config, #{
to => <<"user@example.com">>,
subject => <<"Welcome">>,
text => <<"Thanks for signing up!">>
}).The config's provider selects the vendor; switching vendor changes only the
config. Each provider serves exactly one channel (supports/2).
Two kinds of failure
- Send failures — a vendor error response, a transport failure, or request
data the connector rejects before sending (an invalid region, a header the
vendor forbids, a header value with a line break, a value that cannot be
encoded) — come back as
{error, connector_error()}, a plain map whosecodeyou branch on.invalid_requestis never worth retrying. - Misuse — a config or input that is not a map, a missing required config
key, an unknown key, a value of the wrong type, a provider used on the wrong
channel — raises
error({badarg, Description}). Folding those into{error, _}would make a bug in the calling code look like bad end-user input or, worse, a retryable outage.
The normalized surface
Every send returns #{success := true, status := send_status(), provider_message_id := binary(), raw := term()}. provider_message_id is
<<>> when the vendor returned none; raw is the decoded vendor body (JSON
null is the atom null, also used for an empty or non-JSON body).
Only fields at least 90% of a channel's providers support are first-class on
the input (the baseline-coverage rule). Provider-specific fields flow through
passthrough; provider-level settings live on the config.
Passthrough
Every input accepts passthrough => #{body => map(), headers => map(), query => map()}, forwarded as given and overriding what the connector set
(auth included): body deep-merged, headers and query shallow-merged,
header names case-insensitively. Write the vendor's own wire names. Seven
connectors rewrite only the TOP-LEVEL body keys to the vendor's casing:
Postmark and Twilio to PascalCase, Mailtrap, SparkPost, SendGrid, MailerSend
and Scaleway to snake_case; nested maps always ride verbatim.
Errors
connector_error() carries code (one of the six canonical codes shared with
the sibling libraries), message, and when known status,
provider_message and cause. For a vendor error cause is
#{<<"raw">> => DecodedBody} plus <<"retryAfter">> (the raw header) and
<<"retryAfterSeconds">> when the vendor sent Retry-After. There is
deliberately no top-level retry field: retry is the caller's policy.
Summary
Types
The chat baseline. to is the channel / room / recipient (unused by
webhook-bound connectors, whose URL encodes the target). Rich content
(blocks, attachments, threads) flows through passthrough.
Provider configuration. provider selects the connector; the other keys
depend on it (credentials are secret/0, everything else a binary; keys
not listed as required are optional)
The send-failure value. code is the normalized classification shared with
the sibling libraries; status is the HTTP status when the provider answered
(absent for a transport failure or a pre-flight rejection); cause is
#{<<"raw">> => DecodedVendorBody} (+ <<"retryAfter">>,
<<"retryAfterSeconds">>), or for a transport or encoding failure only its
kind, #{<<"raw">> => #{<<"exception">> => Kind}} — never the request.
An attachment. content is the raw bytes (the connector base64-encodes as the
vendor needs). With content_id set the attachment is inline, referenced as
cid:<content_id> from the HTML.
The ≥90%-baseline email payload. Every key is optional (empty by default, as
in the siblings); from overrides the config's default sender. Header names
and values are binaries.
Provider ids, byte-identical to the sibling libraries' values (hence the
quoted atoms such as 'one-signal').
The four-field push baseline. to is a single device token / recipient;
topic and multi-target delivery, badge, sound, TTL and platform blocks flow
through passthrough.
What every send_* function returns.
A credential, or a zero-arity fun returning it. The fun form keeps the secret out of crash reports and logs that print the config map (EEF secure-coding guidance).
Normalized delivery status on every result.
The ≥90%-baseline SMS payload; from overrides the config's sender.
A bring-your-own cache for the FCM and APNs auth tokens. The library holds no
token state: without a cache it signs a fresh token on every send. get(Key)
returns {ok, {Token, ExpiresAt}} or error; set(Key, Token, ExpiresAt) is
called after a fresh token is minted (its return value is ignored).
ExpiresAt is an absolute Unix time in seconds. Nothing is evicted on a
vendor 401/403 — eviction is the cache's policy. For APNs a cache is required
in production: Apple rejects a new provider token more often than once per
20 minutes (429 TooManyProviderTokenUpdates).
Functions
The providers serving Channel, sorted.
Send a chat message (Telegram, Slack, WhatsApp Business, Discord, MS Teams, Google Chat, Mattermost, Rocket.Chat, LINE).
Send an email (SES, Resend, Mailgun, SendGrid, Postmark, MailerSend, Mailtrap, Brevo, SparkPost, Scaleway).
Send a push notification (FCM, Expo, APNs, OneSignal, Pusher Beams, WonderPush).
Send an SMS (Vonage, Twilio, Plivo, SNS, Sinch, Telnyx, Infobip, MessageBird, TextMagic, D7 Networks).
Whether Provider serves Channel.
Types
-type channel() :: email | sms | push | chat.
-type chat_input() :: #{body => binary(), to => binary(), passthrough => passthrough()}.
The chat baseline. to is the channel / room / recipient (unused by
webhook-bound connectors, whose URL encodes the target). Rich content
(blocks, attachments, threads) flows through passthrough.
-type config() :: #{provider := provider(), transport => thinwrap_notifications_transport:transport(), token_cache => token_cache(), atom() => secret() | binary()}.
Provider configuration. provider selects the connector; the other keys
depend on it (credentials are secret/0, everything else a binary; keys
not listed as required are optional):
ses: requiresregion,access_key_id,secret_access_key; optionalsession_token,from,sender_name,configuration_set_name.resend,sendgrid,brevo: requiresapi_key; optionalfrom,sender_name.mailgun: requiresapi_key,domain; optionalfrom,sender_name,region(<<"eu">>),username,base_url.postmark: requiresserver_token; optionalfrom,sender_name,message_stream.mailersend: requiresapi_token; optionalfrom,sender_name.mailtrap: requiresapi_token; optionalmode(<<"sandbox">>/<<"production">>),inbox_id,from,sender_name.sparkpost: requiresapi_key; optionalfrom,sender_name,region(<<"eu">>).scaleway: requiressecret_key,project_id; optionalfrom,sender_name,region(default<<"fr-par">>).vonage: requiresapi_key,api_secret; optionalfrom.twilio: requiresaccount_sid,auth_token; optionalfrom,region,edge.plivo: requiresauth_id,auth_token; optionalfrom.sns: requiresregion,access_key_id,secret_access_key; optionalsession_token.sinch: requiresservice_plan_id,api_token; optionalfrom,region.telnyx,messagebird,d7networks: requiresapi_key/access_key/api_token; optionalfrom.infobip: requiresbase_url,api_key; optionalfrom.textmagic: requiresusername,api_key; optionalfrom.fcm: requiresproject_id,client_email,private_key; optionaltoken_cache.expo: no required keys; optionalaccess_token.apns: requiresteam_id,key_id,private_key,bundle_id; optionalenv,token_cache.'one-signal': requiresapp_id,api_key.'pusher-beams': requiresinstance_id,secret_key.wonderpush: requiresaccess_token; optionalapplication_id.telegram: requiresbot_token.slack,discord,'ms-teams','google-chat',mattermost,'rocket-chat': requireswebhook_url.'whatsapp-business': requiresaccess_token,phone_number_id; optionalgraph_api_version.line: requireschannel_access_token.
from is the default sender; an input from overrides it. A chat webhook URL
embeds its token, so it is a credential. Optional on every provider:
transport (see thinwrap_notifications_transport; default
{thinwrap_notifications_httpc, #{}}).
-type connector_error() :: #{code := thinwrap_notifications_error:code(), message := binary(), status => integer(), provider_message => binary(), cause => term()}.
The send-failure value. code is the normalized classification shared with
the sibling libraries; status is the HTTP status when the provider answered
(absent for a transport failure or a pre-flight rejection); cause is
#{<<"raw">> => DecodedVendorBody} (+ <<"retryAfter">>,
<<"retryAfterSeconds">>), or for a transport or encoding failure only its
kind, #{<<"raw">> => #{<<"exception">> => Kind}} — never the request.
-type email_attachment() :: #{filename := binary(), content := binary(), content_type => binary(), content_id => binary()}.
An attachment. content is the raw bytes (the connector base64-encodes as the
vendor needs). With content_id set the attachment is inline, referenced as
cid:<content_id> from the HTML.
-type email_input() :: #{to => binary(), subject => binary(), from => binary(), cc => [binary()], bcc => [binary()], reply_to => binary(), text => binary(), html => binary(), attachments => [email_attachment()], headers => #{binary() => binary()}, tags => [binary()], passthrough => passthrough()}.
The ≥90%-baseline email payload. Every key is optional (empty by default, as
in the siblings); from overrides the config's default sender. Header names
and values are binaries.
-type provider() ::
ses | resend | mailgun | sendgrid | postmark | mailersend | mailtrap | brevo | sparkpost |
scaleway | vonage | twilio | plivo | sns | sinch | telnyx | infobip | messagebird |
textmagic | d7networks | fcm | expo | apns | 'one-signal' | 'pusher-beams' | wonderpush |
telegram | slack | 'whatsapp-business' | discord | 'ms-teams' | 'google-chat' | mattermost |
'rocket-chat' | line.
Provider ids, byte-identical to the sibling libraries' values (hence the
quoted atoms such as 'one-signal').
-type push_input() :: #{to => binary(), title => binary(), body => binary(), data => #{binary() => binary()}, passthrough => passthrough()}.
The four-field push baseline. to is a single device token / recipient;
topic and multi-target delivery, badge, sound, TTL and platform blocks flow
through passthrough.
-type result() :: {ok, send_result()} | {error, connector_error()}.
What every send_* function returns.
A credential, or a zero-arity fun returning it. The fun form keeps the secret out of crash reports and logs that print the config map (EEF secure-coding guidance).
-type send_result() :: #{success := true, status := send_status(), provider_message_id := binary(), raw := term()}.
-type send_status() :: sent | queued | rejected | suppressed | unknown.
Normalized delivery status on every result.
-type sms_input() :: #{to => binary(), body => binary(), from => binary(), passthrough => passthrough()}.
The ≥90%-baseline SMS payload; from overrides the config's sender.
-type token_cache() :: #{get := fun((Key :: binary()) -> {ok, {Token :: binary(), ExpiresAt :: number()}} | error), set := fun((Key :: binary(), Token :: binary(), ExpiresAt :: number()) -> term())}.
A bring-your-own cache for the FCM and APNs auth tokens. The library holds no
token state: without a cache it signs a fresh token on every send. get(Key)
returns {ok, {Token, ExpiresAt}} or error; set(Key, Token, ExpiresAt) is
called after a fresh token is minted (its return value is ignored).
ExpiresAt is an absolute Unix time in seconds. Nothing is evicted on a
vendor 401/403 — eviction is the cache's policy. For APNs a cache is required
in production: Apple rejects a new provider token more often than once per
20 minutes (429 TooManyProviderTokenUpdates).
Cache = #{
get => fun(Key) -> case ets:lookup(tokens, Key) of
[{_, Tok, Exp}] -> {ok, {Tok, Exp}};
[] -> error
end end,
set => fun(Key, Tok, Exp) -> ets:insert(tokens, {Key, Tok, Exp}) end
}.
Functions
The providers serving Channel, sorted.
-spec send_chat(config(), chat_input()) -> result().
Send a chat message (Telegram, Slack, WhatsApp Business, Discord, MS Teams, Google Chat, Mattermost, Rocket.Chat, LINE).
-spec send_email(config(), email_input()) -> result().
Send an email (SES, Resend, Mailgun, SendGrid, Postmark, MailerSend, Mailtrap, Brevo, SparkPost, Scaleway).
-spec send_push(config(), push_input()) -> result().
Send a push notification (FCM, Expo, APNs, OneSignal, Pusher Beams, WonderPush).
Send an SMS (Vonage, Twilio, Plivo, SNS, Sinch, Telnyx, Infobip, MessageBird, TextMagic, D7 Networks).
Whether Provider serves Channel.