aboutsummaryrefslogtreecommitdiff
path: root/lib/Emails.Transactional.Plugin/src/Api Endpoints/SendEndpoint.cs
blob: 1ce6acf12ecedc76acf5ae3e57a6a6f36975e00c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
/*
* Copyright (c) 2023 Vaughn Nugent
* 
* Library: VNLib
* Package: Transactional Emails
* File: SendEndpoint.cs 
*
* SendEndpoint.cs is part of Transactional Emails which is part of the larger 
* VNLib collection of libraries and utilities.
*
* Transactional Emails is free software: you can redistribute it and/or modify 
* it under the terms of the GNU General Public License as published
* by the Free Software Foundation, either version 2 of the License,
* or (at your option) any later version.
*
* Transactional Emails is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License 
* along with Transactional Emails. If not, see http://www.gnu.org/licenses/.
*/

using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Collections.Generic;

using Fluid;

using Minio;
using Minio.DataModel;
using Minio.Exceptions;

using MimeKit.Text;

using VNLib.Utils.Extensions;
using VNLib.Utils.Logging;
using VNLib.Utils.Memory.Caching;
using VNLib.Plugins;
using VNLib.Plugins.Essentials;
using VNLib.Plugins.Essentials.Extensions;
using VNLib.Plugins.Essentials.Oauth;
using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Sql;

using Emails.Transactional.Transactions;
using static Emails.Transactional.TEmailEntryPoint;

namespace Emails.Transactional.Endpoints
{
    [ConfigurationName("transaction_endpoint")]
    internal class SendEndpoint : O2EndpointBase
    {
        public const string OAUTH2_USER_SCOPE_PERMISSION = "email:send";

        private class FluidCache : ICacheable
        {
            public FluidCache(IFluidTemplate temp)
            {
                this.Template = temp;
            }

            public IFluidTemplate Template { get; }

            DateTime ICacheable.Expires { get; set; }

            bool IEquatable<ICacheable>.Equals(ICacheable? other)
            {
                return ReferenceEquals(Template, (other as FluidCache)?.Template);
            }

            void ICacheable.Evicted()
            {}
        }

        private static readonly EmailTransactionValidator Validator = new();
        private static readonly FluidParser Parser = new();

        private readonly string FromAddress;
        private readonly string? BaseObjectPath;
      
        private readonly TransactionStore Transactions;

        private readonly Dictionary<string, FluidCache> TemplateCache;
        private readonly TimeSpan TemplateCacheTimeout;
        private readonly MinioClient Client;
        private readonly S3Config S3Config;

        private SmtpProvider? EmailService;

        public SendEndpoint(PluginBase plugin, IConfigScope config)
        {
            string? path = config["path"].GetString();
            FromAddress = config["from_address"].GetString() ?? throw new KeyNotFoundException("Missing required key 'from_address' in 'transaction_endpoint'");
            TemplateCacheTimeout = config["cache_valid_sec"].GetTimeSpan(TimeParseType.Seconds);
            BaseObjectPath = config["base_object_path"].GetString();

            InitPathAndLog(path, plugin.Log);

            //Smtp setup
            {
                IConfigScope smtp = plugin.GetConfig("smtp");
                Uri serverUri = new(smtp["server_address"].GetString()!);
                string username = smtp["username"].GetString() ?? throw new KeyNotFoundException("Missing required key 'usename' in 'smtp' config");
                TimeSpan timeout = smtp["timeout_sec"].GetTimeSpan(TimeParseType.Seconds);

                //Load SMTP 
                _ = plugin.ObserveWork(async () =>
                {
                    //Get the password from the secret store
                    string password = await plugin.GetSecretAsync("smtp_password").ToLazy(static r => r.Result.ToString());

                    //Copy the secre to the network credential
                    NetworkCredential cred = new(username, password);

                    //Init email service
                    EmailService = new(serverUri, cred, timeout);
                });
            }
            
            //S3 minio setup
            {
                //Init minio s3 client
                S3Config = plugin.TryGetS3Config() ?? throw new KeyNotFoundException("Missing required 's3_config' configuration variable");
                //Init minio from config
                Client = new();

                //Load the client when the secret finishes loading
                _ = plugin.ObserveWork(async () =>
                {
                    string s3Secret = await plugin.GetSecretAsync("s3_secret").ToLazy(static r => r.Result.ToString());

                    Client.WithEndpoint(S3Config.ServerAddress)
                            .WithCredentials(S3Config.ClientId, s3Secret)
                            .WithSSL(S3Config.UseSsl.HasValue && S3Config.UseSsl.Value);

                    //Accept optional region
                    if (!string.IsNullOrWhiteSpace(S3Config.Region))
                    {
                        Client.WithRegion(S3Config.Region);
                    }

                    //Build client
                    Client.Build();

                    //If the plugin is in debug mode, log requests to logger
                    if (plugin.IsDebug())
                    {
                        Client.SetTraceOn(new ReqLogger(Log));
                    }
                });
            }
            
            //Load transactions
            Transactions = new(plugin.GetContextOptions());          

            TemplateCache = new(20, StringComparer.OrdinalIgnoreCase);
        }


        protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
        {
            //Make sure the user has the required scope to send an email
            if (!entity.Session.HasScope(OAUTH2_USER_SCOPE_PERMISSION))
            {
                entity.CloseResponseError(HttpStatusCode.Forbidden, ErrorType.InvalidScope, "Your account does not have the required permissions scope");
                return VfReturnType.VirtualSkip;
            }

            //Get the transaction request from the client
            EmailTransaction? transaction = await entity.GetJsonFromFileAsync<EmailTransaction>();
            if(transaction == null)
            {
                entity.CloseResponseError(HttpStatusCode.BadRequest, ErrorType.InvalidRequest, "No request body was received");
                return VfReturnType.VirtualSkip;
            }
                        
            TransactionResult webm = new()
            {
                Success = false
            };

            //Set a default from name/address if not set by user
            transaction.From ??= FromAddress;
            transaction.FromName ??= FromAddress;
            //Specify user-id
            transaction.UserId = entity.Session.UserID;
            //validate the form
            if (!Validator.Validate(transaction, webm))
            {
                //return errors
                entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
                return VfReturnType.VirtualSkip;
            }

            IFluidTemplate fTemp;

            //See if the template is in cache
            if (TemplateCache.TryGetOrEvictRecord(transaction.TemplateId!, out FluidCache? cache) == 1)
            {
                fTemp = cache.Template;
            }
            //Record was evicted or not found
            else 
            {
                string? templateData = null;
                try
                {
                    //Combine base obj path
                    string objPath = string.Concat(BaseObjectPath, transaction.TemplateId);
                    //Recover the template from the store
                    GetObjectArgs args = new();
                    args.WithBucket(S3Config.BaseBucket)
                        .WithObject(objPath)
                        .WithCallbackStream((stream) =>
                        {
                            //Read template from stream
                            using StreamReader reader = new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
                            templateData = reader.ReadToEnd();
                        });
                    //get the template object
                    ObjectStat status = await Client.GetObjectAsync(args, entity.EventCancellation);
                    
                }
                catch(ObjectNotFoundException)
                {
                    entity.CloseResponseError(HttpStatusCode.NotFound, ErrorType.InvalidRequest, "The requested template does not exist");
                    return VfReturnType.VirtualSkip;
                }
                catch(MinioException me)
                {
                    Log.Error(me);
                    return VfReturnType.Error;
                }

                //Make sure template data was found
                if (string.IsNullOrWhiteSpace(templateData))
                {
                    entity.CloseResponseError(HttpStatusCode.NotFound, ErrorType.InvalidRequest, "The requested template does not exist");
                    return VfReturnType.VirtualSkip;
                }

                //Try to parse the template
                if (!Parser.TryParse(templateData, out fTemp, out string error))
                {
                    Log.Error(error, "Liquid template parsing error");
                    return VfReturnType.Error;
                }
                
                //Cache the new template
                TemplateCache.StoreRecord(transaction.TemplateId!, new FluidCache(fTemp), TemplateCacheTimeout);
            }

            //Allways add the template name to the model
            transaction.Variables!["template_name"] = transaction.TemplateId!;

            //Create a template model
            TemplateContext ctx = new(transaction.Variables);
            //render the template
            string rendered = fTemp.Render(ctx);
            try
            {
                //Send the template
                _ = await EmailService.SendAsync(transaction, rendered, TextFormat.Html, entity.EventCancellation);
                //Set success flag
                webm.Success = true;
            }
            catch (Exception ex)
            {
                Log.Error(ex);
                //Write an error status message to the transaction store
                transaction.Result = ex.Message;
            }
            //Write transaction to db (we need to return the transaction id)
            _ = await Transactions.AddOrUpdateAsync(transaction, entity.EventCancellation);
            //Store the results object
            webm.SmtpStatus = transaction.Result;
            webm.TransactionId = transaction.Id;
            //Return the response object
            entity.CloseResponse(webm);
            return VfReturnType.VirtualSkip;
        }
    }
}