create-easy-stack 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. package/bin/create.mjs +244 -0
  2. package/infra/email/files/src/core/config/email.env.ts +73 -0
  3. package/infra/email/files/src/core/infrastructure/email/email-dispatcher.port.ts +13 -0
  4. package/infra/email/files/src/core/infrastructure/email/email-layout.spec.ts +83 -0
  5. package/infra/email/files/src/core/infrastructure/email/email-layout.ts +88 -0
  6. package/infra/email/files/src/core/infrastructure/email/email-sender.factory.spec.ts +87 -0
  7. package/infra/email/files/src/core/infrastructure/email/email-sender.factory.ts +35 -0
  8. package/infra/email/files/src/core/infrastructure/email/email-sender.port.ts +19 -0
  9. package/infra/email/files/src/core/infrastructure/email/email.job.spec.ts +101 -0
  10. package/infra/email/files/src/core/infrastructure/email/email.job.ts +64 -0
  11. package/infra/email/files/src/core/infrastructure/email/email.module.spec.ts +60 -0
  12. package/infra/email/files/src/core/infrastructure/email/email.module.ts +34 -0
  13. package/infra/email/files/src/core/infrastructure/email/log-email-sender.spec.ts +34 -0
  14. package/infra/email/files/src/core/infrastructure/email/log-email-sender.ts +17 -0
  15. package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.spec.ts +99 -0
  16. package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.ts +63 -0
  17. package/infra/email/infra.json +58 -0
  18. package/infra/prisma-postgres/docker.service.yml +12 -0
  19. package/infra/prisma-postgres/files/prisma/schema.prisma +10 -0
  20. package/infra/prisma-postgres/files/prisma.config.mjs +10 -0
  21. package/infra/prisma-postgres/files/src/core/config/database.env.ts +13 -0
  22. package/infra/prisma-postgres/files/src/core/infrastructure/database/database.module.ts +9 -0
  23. package/infra/prisma-postgres/files/src/core/infrastructure/database/prisma.service.ts +26 -0
  24. package/infra/prisma-postgres/infra.json +60 -0
  25. package/infra/rabbitmq/docker.service.yml +16 -0
  26. package/infra/rabbitmq/files/src/core/config/messaging.env.ts +29 -0
  27. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.spec.ts +60 -0
  28. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.ts +33 -0
  29. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.spec.ts +23 -0
  30. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.ts +19 -0
  31. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.spec.ts +80 -0
  32. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.ts +66 -0
  33. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.boundary.spec.ts +34 -0
  34. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.factory.ts +21 -0
  35. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.spec.ts +75 -0
  36. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.ts +30 -0
  37. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.port.ts +59 -0
  38. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.spec.ts +510 -0
  39. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.ts +353 -0
  40. package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.module.ts +9 -0
  41. package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.spec.ts +289 -0
  42. package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.ts +207 -0
  43. package/infra/rabbitmq/infra.json +55 -0
  44. package/infra/redis/docker.service.yml +10 -0
  45. package/infra/redis/files/src/core/http/decorators/rate-limit.decorator.ts +26 -0
  46. package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.spec.ts +180 -0
  47. package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.ts +105 -0
  48. package/infra/redis/files/src/core/infrastructure/redis/cache.constants.ts +67 -0
  49. package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.module.ts +9 -0
  50. package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.spec.ts +146 -0
  51. package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.ts +131 -0
  52. package/infra/redis/files/src/core/infrastructure/redis/lock/lock.module.ts +9 -0
  53. package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.integration-spec.ts +111 -0
  54. package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.ts +82 -0
  55. package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.module.ts +15 -0
  56. package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.spec.ts +45 -0
  57. package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.ts +38 -0
  58. package/infra/redis/files/src/core/infrastructure/redis/redis.module.ts +9 -0
  59. package/infra/redis/files/src/core/infrastructure/redis/redis.service.ts +196 -0
  60. package/infra/redis/infra.json +53 -0
  61. package/infra/storage-cloudinary/files/src/core/config/storage.env.ts +43 -0
  62. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.spec.ts +275 -0
  63. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.ts +158 -0
  64. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.factory.ts +30 -0
  65. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.module.ts +17 -0
  66. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.port.ts +38 -0
  67. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.spec.ts +48 -0
  68. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.ts +25 -0
  69. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/unconfigured-image-storage.ts +19 -0
  70. package/infra/storage-cloudinary/infra.json +73 -0
  71. package/lib/fs-utils.mjs +115 -0
  72. package/lib/registry.mjs +50 -0
  73. package/lib/scaffold.mjs +157 -0
  74. package/package.json +37 -0
  75. package/templates/nest-ddd/files/.env.example +5 -0
  76. package/templates/nest-ddd/files/.husky/commit-msg +1 -0
  77. package/templates/nest-ddd/files/.husky/pre-commit +1 -0
  78. package/templates/nest-ddd/files/.oxlintrc.json +10 -0
  79. package/templates/nest-ddd/files/.prettierrc +4 -0
  80. package/templates/nest-ddd/files/_gitignore +40 -0
  81. package/templates/nest-ddd/files/commitlint.config.js +3 -0
  82. package/templates/nest-ddd/files/nest-cli.json +8 -0
  83. package/templates/nest-ddd/files/package.json +66 -0
  84. package/templates/nest-ddd/files/plop-templates/command/command.ts.hbs +5 -0
  85. package/templates/nest-ddd/files/plop-templates/command/handler.ts.hbs +30 -0
  86. package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.command.ts.hbs +5 -0
  87. package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.spec.ts.hbs +35 -0
  88. package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.ts.hbs +25 -0
  89. package/templates/nest-ddd/files/plop-templates/module/application/index.ts.hbs +5 -0
  90. package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.handler.ts.hbs +29 -0
  91. package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.query.ts.hbs +5 -0
  92. package/templates/nest-ddd/files/plop-templates/module/domain/entities/{{kebab}}.entity.ts.hbs +37 -0
  93. package/templates/nest-ddd/files/plop-templates/module/domain/events/{{kebab}}-created.event.ts.hbs +10 -0
  94. package/templates/nest-ddd/files/plop-templates/module/domain/exceptions/{{kebab}}.exception.ts.hbs +11 -0
  95. package/templates/nest-ddd/files/plop-templates/module/domain/index.ts.hbs +4 -0
  96. package/templates/nest-ddd/files/plop-templates/module/domain/repositories/{{kebab}}.repository.ts.hbs +7 -0
  97. package/templates/nest-ddd/files/plop-templates/module/presentation/controllers/{{kebab}}.controller.ts.hbs +53 -0
  98. package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/create-{{kebab}}.dto.ts.hbs +10 -0
  99. package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/{{kebab}}-response.dto.ts.hbs +15 -0
  100. package/templates/nest-ddd/files/plop-templates/module/presentation/index.ts.hbs +4 -0
  101. package/templates/nest-ddd/files/plop-templates/module/presentation/mappers/{{kebab}}.mapper.ts.hbs +13 -0
  102. package/templates/nest-ddd/files/plop-templates/module/{{kebab}}.module.ts.hbs +40 -0
  103. package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/database/repositories/in-memory-{{kebab}}.repository.ts.hbs +17 -0
  104. package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/index.ts.hbs +1 -0
  105. package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/mappers/{{kebab}}.mapper.ts.hbs +29 -0
  106. package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/repositories/prisma-{{kebab}}.repository.ts.hbs +28 -0
  107. package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/index.ts.hbs +2 -0
  108. package/templates/nest-ddd/files/plop-templates/query/handler.ts.hbs +28 -0
  109. package/templates/nest-ddd/files/plop-templates/query/query.ts.hbs +5 -0
  110. package/templates/nest-ddd/files/plop-templates/snippets/error-codes.hbs +14 -0
  111. package/templates/nest-ddd/files/plop-templates/snippets/prisma-model.hbs +7 -0
  112. package/templates/nest-ddd/files/plopfile.mjs +186 -0
  113. package/templates/nest-ddd/files/src/app.controller.ts +13 -0
  114. package/templates/nest-ddd/files/src/app.module.ts +26 -0
  115. package/templates/nest-ddd/files/src/core/config/app.config.ts +9 -0
  116. package/templates/nest-ddd/files/src/core/config/env.validation.ts +30 -0
  117. package/templates/nest-ddd/files/src/core/domain/base/aggregate-root.base.ts +18 -0
  118. package/templates/nest-ddd/files/src/core/domain/base/domain-event.base.ts +7 -0
  119. package/templates/nest-ddd/files/src/core/domain/base/entity.base.ts +36 -0
  120. package/templates/nest-ddd/files/src/core/domain/base/value-object.base.ts +17 -0
  121. package/templates/nest-ddd/files/src/core/domain/exceptions/domain.exception.ts +16 -0
  122. package/templates/nest-ddd/files/src/core/domain/exceptions/error-codes.ts +28 -0
  123. package/templates/nest-ddd/files/src/core/domain/types/pagination.ts +9 -0
  124. package/templates/nest-ddd/files/src/core/http/decorators/client-info.decorator.ts +24 -0
  125. package/templates/nest-ddd/files/src/core/http/decorators/current-user.decorator.ts +19 -0
  126. package/templates/nest-ddd/files/src/core/http/dtos/paginated-response.dto.ts +29 -0
  127. package/templates/nest-ddd/files/src/core/http/dtos/pagination-query.dto.ts +17 -0
  128. package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.spec.ts +163 -0
  129. package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.ts +97 -0
  130. package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.spec.ts +47 -0
  131. package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.ts +31 -0
  132. package/templates/nest-ddd/files/src/core/http/interceptors/logging.interceptor.ts +37 -0
  133. package/templates/nest-ddd/files/src/core/http/interceptors/response.interceptor.ts +53 -0
  134. package/templates/nest-ddd/files/src/core/http/logging/app.logger.ts +34 -0
  135. package/templates/nest-ddd/files/src/core/index.ts +33 -0
  136. package/templates/nest-ddd/files/src/core/utils/correlation-id.util.spec.ts +29 -0
  137. package/templates/nest-ddd/files/src/core/utils/correlation-id.util.ts +15 -0
  138. package/templates/nest-ddd/files/src/core/utils/hash.util.spec.ts +61 -0
  139. package/templates/nest-ddd/files/src/core/utils/hash.util.ts +47 -0
  140. package/templates/nest-ddd/files/src/core/utils/snowflake.util.spec.ts +40 -0
  141. package/templates/nest-ddd/files/src/core/utils/snowflake.util.ts +99 -0
  142. package/templates/nest-ddd/files/src/core/utils/string.util.spec.ts +20 -0
  143. package/templates/nest-ddd/files/src/core/utils/string.util.ts +21 -0
  144. package/templates/nest-ddd/files/src/core/utils/time.util.spec.ts +25 -0
  145. package/templates/nest-ddd/files/src/core/utils/time.util.ts +25 -0
  146. package/templates/nest-ddd/files/src/core/utils/ttl.util.ts +22 -0
  147. package/templates/nest-ddd/files/src/main.ts +61 -0
  148. package/templates/nest-ddd/files/test/setup.ts +6 -0
  149. package/templates/nest-ddd/files/tsconfig.build.json +9 -0
  150. package/templates/nest-ddd/files/tsconfig.json +28 -0
  151. package/templates/nest-ddd/files/vitest.config.e2e.ts +15 -0
  152. package/templates/nest-ddd/files/vitest.config.ts +16 -0
  153. package/templates/nest-ddd/template.json +8 -0
  154. package/templates/node-express/files/.env.example +28 -0
  155. package/templates/node-express/files/docker-compose.yml +52 -0
  156. package/templates/node-express/files/package-lock.json +11880 -0
  157. package/templates/node-express/files/package.json +43 -0
  158. package/templates/node-express/files/server.js +13 -0
  159. package/templates/node-express/files/src/app.js +72 -0
  160. package/templates/node-express/files/src/auth/authUtils.js +116 -0
  161. package/templates/node-express/files/src/auth/checkAuth.js +61 -0
  162. package/templates/node-express/files/src/bucket_pattern/comment.model.js +27 -0
  163. package/templates/node-express/files/src/bucket_pattern/comment.service.js +50 -0
  164. package/templates/node-express/files/src/config/config.aws-s3.js +28 -0
  165. package/templates/node-express/files/src/config/config.cloudinary.js +13 -0
  166. package/templates/node-express/files/src/config/config.mongodb.js +25 -0
  167. package/templates/node-express/files/src/config/config.multer.js +25 -0
  168. package/templates/node-express/files/src/config/config.nodemailer.js +16 -0
  169. package/templates/node-express/files/src/controllers/access.controller.js +49 -0
  170. package/templates/node-express/files/src/controllers/cart.controller.js +38 -0
  171. package/templates/node-express/files/src/controllers/checkout.controller.js +15 -0
  172. package/templates/node-express/files/src/controllers/comment.controller.js +29 -0
  173. package/templates/node-express/files/src/controllers/discount.controller.js +68 -0
  174. package/templates/node-express/files/src/controllers/email.controller.js +16 -0
  175. package/templates/node-express/files/src/controllers/inventory.controller.js +15 -0
  176. package/templates/node-express/files/src/controllers/notification.controller.js +15 -0
  177. package/templates/node-express/files/src/controllers/product.controller.js +175 -0
  178. package/templates/node-express/files/src/controllers/profile.controller.js +65 -0
  179. package/templates/node-express/files/src/controllers/rbac.controller.js +41 -0
  180. package/templates/node-express/files/src/controllers/template.controller.js +20 -0
  181. package/templates/node-express/files/src/controllers/upload.controller.js +56 -0
  182. package/templates/node-express/files/src/controllers/user.controller.js +36 -0
  183. package/templates/node-express/files/src/core/error.response.js +84 -0
  184. package/templates/node-express/files/src/core/success.response.js +44 -0
  185. package/templates/node-express/files/src/database/init.mongodb.level0.js +16 -0
  186. package/templates/node-express/files/src/database/init.mongodb.level1.js +36 -0
  187. package/templates/node-express/files/src/database/init.redis.js +119 -0
  188. package/templates/node-express/files/src/helpers/asyncHandler.js +7 -0
  189. package/templates/node-express/files/src/helpers/check.connect.js +29 -0
  190. package/templates/node-express/files/src/keys/private_key.pem +28 -0
  191. package/templates/node-express/files/src/keys/public_key.pem +9 -0
  192. package/templates/node-express/files/src/logger/discord.log.js +26 -0
  193. package/templates/node-express/files/src/logger/discord.log.v2.js +56 -0
  194. package/templates/node-express/files/src/logger/mylogger.log.js +130 -0
  195. package/templates/node-express/files/src/logger/winston.log.js +27 -0
  196. package/templates/node-express/files/src/middlewares/index.js +21 -0
  197. package/templates/node-express/files/src/middlewares/rbac.js +37 -0
  198. package/templates/node-express/files/src/middlewares/role.middleware.js +12 -0
  199. package/templates/node-express/files/src/models/KeyToken.model.js +37 -0
  200. package/templates/node-express/files/src/models/apiKey.model.js +31 -0
  201. package/templates/node-express/files/src/models/cart.model.js +47 -0
  202. package/templates/node-express/files/src/models/comment.model.js +43 -0
  203. package/templates/node-express/files/src/models/discount.model.js +92 -0
  204. package/templates/node-express/files/src/models/inventory.model.js +42 -0
  205. package/templates/node-express/files/src/models/notification.model.js +52 -0
  206. package/templates/node-express/files/src/models/order.model.js +68 -0
  207. package/templates/node-express/files/src/models/otp.model.js +32 -0
  208. package/templates/node-express/files/src/models/product.model.js +144 -0
  209. package/templates/node-express/files/src/models/repository/cart.repo.js +71 -0
  210. package/templates/node-express/files/src/models/repository/discount.repo.js +63 -0
  211. package/templates/node-express/files/src/models/repository/inventory.repo.js +45 -0
  212. package/templates/node-express/files/src/models/repository/product.repo.js +145 -0
  213. package/templates/node-express/files/src/models/repository/shop.repo.js +18 -0
  214. package/templates/node-express/files/src/models/repository/spu.repo.js +18 -0
  215. package/templates/node-express/files/src/models/repository/user.repo.js +20 -0
  216. package/templates/node-express/files/src/models/resource.model.js +32 -0
  217. package/templates/node-express/files/src/models/role.model.js +57 -0
  218. package/templates/node-express/files/src/models/shop.model.js +43 -0
  219. package/templates/node-express/files/src/models/sku.model.js +81 -0
  220. package/templates/node-express/files/src/models/spu.model.js +101 -0
  221. package/templates/node-express/files/src/models/template.model.js +25 -0
  222. package/templates/node-express/files/src/models/user.model.js +82 -0
  223. package/templates/node-express/files/src/postman/access.post.http +22 -0
  224. package/templates/node-express/files/src/routes/access/index.route.js +21 -0
  225. package/templates/node-express/files/src/routes/cart/index.route.js +20 -0
  226. package/templates/node-express/files/src/routes/checkout/index.route.js +10 -0
  227. package/templates/node-express/files/src/routes/comment/index.route.js +16 -0
  228. package/templates/node-express/files/src/routes/discount/index.route.js +26 -0
  229. package/templates/node-express/files/src/routes/index.route.js +27 -0
  230. package/templates/node-express/files/src/routes/inventory/index.route.js +11 -0
  231. package/templates/node-express/files/src/routes/notification/index.route.js +14 -0
  232. package/templates/node-express/files/src/routes/product/index.route.js +38 -0
  233. package/templates/node-express/files/src/routes/profile/index.route.js +15 -0
  234. package/templates/node-express/files/src/routes/rbac/index.route.js +21 -0
  235. package/templates/node-express/files/src/routes/template/index.route.js +11 -0
  236. package/templates/node-express/files/src/routes/upload/index.route.js +28 -0
  237. package/templates/node-express/files/src/routes/user/index.route.js +13 -0
  238. package/templates/node-express/files/src/services/access.service.js +257 -0
  239. package/templates/node-express/files/src/services/apiKey.service.js +15 -0
  240. package/templates/node-express/files/src/services/cart.service.js +115 -0
  241. package/templates/node-express/files/src/services/checkout.service.js +218 -0
  242. package/templates/node-express/files/src/services/comment.service.js +185 -0
  243. package/templates/node-express/files/src/services/discount.service.js +282 -0
  244. package/templates/node-express/files/src/services/email.service.js +71 -0
  245. package/templates/node-express/files/src/services/inventory.service.js +35 -0
  246. package/templates/node-express/files/src/services/keyToken.service.js +77 -0
  247. package/templates/node-express/files/src/services/notification.service.js +58 -0
  248. package/templates/node-express/files/src/services/otp.service.js +36 -0
  249. package/templates/node-express/files/src/services/product.service.js +228 -0
  250. package/templates/node-express/files/src/services/rbac.service.js +143 -0
  251. package/templates/node-express/files/src/services/redis.service.js +57 -0
  252. package/templates/node-express/files/src/services/redisPubSub.service.js +72 -0
  253. package/templates/node-express/files/src/services/shop.service.js +21 -0
  254. package/templates/node-express/files/src/services/sku.service.js +52 -0
  255. package/templates/node-express/files/src/services/spu.service.js +89 -0
  256. package/templates/node-express/files/src/services/template.service.js +41 -0
  257. package/templates/node-express/files/src/services/upload.s3.service.js +55 -0
  258. package/templates/node-express/files/src/services/upload.service.js +74 -0
  259. package/templates/node-express/files/src/services/user.service.js +92 -0
  260. package/templates/node-express/files/src/temp/1734420604996-ci-cd.png +0 -0
  261. package/templates/node-express/files/src/temp/1734421222213-cover-fb.jpg +0 -0
  262. package/templates/node-express/files/src/temp/1734421222214-cover-fb-01.jpg +0 -0
  263. package/templates/node-express/files/src/temp/1734421293437-cover-fb-01.jpg +0 -0
  264. package/templates/node-express/files/src/temp/1734421293437-cover-fb.jpg +0 -0
  265. package/templates/node-express/files/src/tests/inventory.test.js +21 -0
  266. package/templates/node-express/files/src/tests/message_queue/kafka/consumer.js +23 -0
  267. package/templates/node-express/files/src/tests/message_queue/kafka/producer.js +21 -0
  268. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/consumer.js +28 -0
  269. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.consumer.js +26 -0
  270. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.producer.js +31 -0
  271. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producer.js +26 -0
  272. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producerDLX.js +44 -0
  273. package/templates/node-express/files/src/tests/mysql/connect.js +59 -0
  274. package/templates/node-express/files/src/tests/product.test.js +14 -0
  275. package/templates/node-express/files/src/utils/index.js +78 -0
  276. package/templates/node-express/files/src/utils/reasonPhrases.js +344 -0
  277. package/templates/node-express/files/src/utils/statusCodes.js +344 -0
  278. package/templates/node-express/files/src/utils/template.html.js +38 -0
  279. package/templates/node-express/template.json +11 -0
  280. package/templates/react-fsd/files/index.html +13 -0
  281. package/templates/react-fsd/files/package.json +15 -0
  282. package/templates/react-fsd/files/public/favicon.svg +1 -0
  283. package/templates/react-fsd/files/public/icons.svg +24 -0
  284. package/templates/react-fsd/files/src/assets/hero.png +0 -0
  285. package/templates/react-fsd/files/src/assets/typescript.svg +1 -0
  286. package/templates/react-fsd/files/src/assets/vite.svg +1 -0
  287. package/templates/react-fsd/files/src/counter.ts +9 -0
  288. package/templates/react-fsd/files/src/main.ts +60 -0
  289. package/templates/react-fsd/files/src/style.css +296 -0
  290. package/templates/react-fsd/files/tsconfig.json +24 -0
  291. package/templates/react-fsd/template.json +11 -0
  292. package/templates/react-monolithic/files/index.html +13 -0
  293. package/templates/react-monolithic/files/package.json +15 -0
  294. package/templates/react-monolithic/files/public/favicon.svg +1 -0
  295. package/templates/react-monolithic/files/public/icons.svg +24 -0
  296. package/templates/react-monolithic/files/src/assets/hero.png +0 -0
  297. package/templates/react-monolithic/files/src/assets/typescript.svg +1 -0
  298. package/templates/react-monolithic/files/src/assets/vite.svg +1 -0
  299. package/templates/react-monolithic/files/src/counter.ts +9 -0
  300. package/templates/react-monolithic/files/src/main.ts +60 -0
  301. package/templates/react-monolithic/files/src/style.css +296 -0
  302. package/templates/react-monolithic/files/tsconfig.json +24 -0
  303. package/templates/react-monolithic/template.json +11 -0
  304. package/templates/vue-fsd/files/index.html +13 -0
  305. package/templates/vue-fsd/files/package.json +15 -0
  306. package/templates/vue-fsd/files/public/favicon.svg +1 -0
  307. package/templates/vue-fsd/files/public/icons.svg +24 -0
  308. package/templates/vue-fsd/files/src/assets/hero.png +0 -0
  309. package/templates/vue-fsd/files/src/assets/typescript.svg +1 -0
  310. package/templates/vue-fsd/files/src/assets/vite.svg +1 -0
  311. package/templates/vue-fsd/files/src/counter.ts +9 -0
  312. package/templates/vue-fsd/files/src/main.ts +60 -0
  313. package/templates/vue-fsd/files/src/style.css +296 -0
  314. package/templates/vue-fsd/files/tsconfig.json +24 -0
  315. package/templates/vue-fsd/template.json +11 -0
  316. package/templates/vue-monolithic/files/index.html +13 -0
  317. package/templates/vue-monolithic/files/package.json +15 -0
  318. package/templates/vue-monolithic/files/public/favicon.svg +1 -0
  319. package/templates/vue-monolithic/files/public/icons.svg +24 -0
  320. package/templates/vue-monolithic/files/src/assets/hero.png +0 -0
  321. package/templates/vue-monolithic/files/src/assets/typescript.svg +1 -0
  322. package/templates/vue-monolithic/files/src/assets/vite.svg +1 -0
  323. package/templates/vue-monolithic/files/src/counter.ts +9 -0
  324. package/templates/vue-monolithic/files/src/main.ts +60 -0
  325. package/templates/vue-monolithic/files/src/style.css +296 -0
  326. package/templates/vue-monolithic/files/tsconfig.json +24 -0
  327. package/templates/vue-monolithic/template.json +11 -0
package/bin/create.mjs ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import prompts from 'prompts';
6
+ import { loadInfra, loadTemplates } from '../lib/registry.mjs';
7
+ import {
8
+ addInfraToProject,
9
+ createProject,
10
+ initGit,
11
+ installDependencies,
12
+ isValidProjectName,
13
+ } from '../lib/scaffold.mjs';
14
+
15
+ const c = {
16
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
17
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
18
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
19
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
20
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
21
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
22
+ };
23
+
24
+ const HELP = `
25
+ ${c.bold('create-my-stack')} — personal project generator
26
+
27
+ ${c.bold('Usage')}
28
+ create-my-stack [name] [options] create a project
29
+ create-my-stack add <infra...> add infra to the project in --dir (default: .)
30
+ create-my-stack list show templates and infra
31
+
32
+ ${c.bold('Options')}
33
+ -t, --template <id> template id (see "list")
34
+ -i, --infra <a,b,c> infra modules, comma separated (backend templates)
35
+ --no-infra skip the infra question
36
+ --pm <pnpm|npm|yarn|bun> package manager (default: the one that launched the CLI, else pnpm)
37
+ --install / --no-install run the install step
38
+ --git / --no-git run "git init"
39
+ -d, --dir <path> target directory for "add"
40
+ -y, --yes accept defaults, never prompt
41
+ -h, --help
42
+ `;
43
+
44
+ const { values, positionals } = parseArgs({
45
+ allowPositionals: true,
46
+ options: {
47
+ template: { type: 'string', short: 't' },
48
+ infra: { type: 'string', short: 'i' },
49
+ 'no-infra': { type: 'boolean' },
50
+ pm: { type: 'string' },
51
+ install: { type: 'boolean' },
52
+ 'no-install': { type: 'boolean' },
53
+ git: { type: 'boolean' },
54
+ 'no-git': { type: 'boolean' },
55
+ dir: { type: 'string', short: 'd' },
56
+ yes: { type: 'boolean', short: 'y' },
57
+ help: { type: 'boolean', short: 'h' },
58
+ },
59
+ });
60
+
61
+ const interactive = !values.yes && process.stdin.isTTY && process.stdout.isTTY;
62
+ const onCancel = () => {
63
+ console.log(c.yellow('\nCancelled.'));
64
+ process.exit(1);
65
+ };
66
+ const log = (message) => console.log(c.dim(message));
67
+
68
+ function detectPackageManager() {
69
+ const agent = process.env.npm_config_user_agent ?? '';
70
+ const match = /^(pnpm|yarn|bun|npm)\//.exec(agent);
71
+ return match ? match[1] : 'pnpm';
72
+ }
73
+
74
+ function printList() {
75
+ const templates = loadTemplates();
76
+ const infra = loadInfra();
77
+ console.log(`\n${c.bold('Templates')}`);
78
+ for (const t of templates) {
79
+ console.log(` ${c.cyan(t.id.padEnd(18))} ${t.label} ${c.dim(`— ${t.description}`)}`);
80
+ }
81
+ console.log(`\n${c.bold('Infra')} ${c.dim('(backend templates)')}`);
82
+ for (const i of infra) {
83
+ const requires = i.requires?.length ? c.dim(` (needs ${i.requires.join(', ')})`) : '';
84
+ console.log(` ${c.cyan(i.id.padEnd(18))} ${i.label}${requires}`);
85
+ }
86
+ console.log();
87
+ }
88
+
89
+ async function runAdd() {
90
+ const projectDir = path.resolve(values.dir ?? '.');
91
+ const requested = positionals.slice(1);
92
+ if (requested.length === 0) {
93
+ console.error(c.red('Usage: create-my-stack add <infra...>'));
94
+ process.exit(1);
95
+ }
96
+ const result = addInfraToProject({
97
+ projectDir,
98
+ infra: requested,
99
+ availableInfra: loadInfra(),
100
+ log,
101
+ });
102
+ for (const { id, because } of result.autoAdded) {
103
+ console.log(c.yellow(` ${id} was added too because ${because} needs it`));
104
+ }
105
+ console.log(
106
+ c.green(`\nAdded: ${result.added.map((i) => i.id).join(', ') || 'nothing new'}`),
107
+ );
108
+ console.log(c.dim('Run the install step again, then review .env.example / docker-compose.yml.\n'));
109
+ }
110
+
111
+ async function runCreate() {
112
+ const templates = loadTemplates();
113
+ const availableInfra = loadInfra();
114
+
115
+ // 1. Project name
116
+ let name = positionals[0];
117
+ if (!name && interactive) {
118
+ ({ name } = await prompts(
119
+ {
120
+ type: 'text',
121
+ name: 'name',
122
+ message: 'Project name',
123
+ initial: 'my-app',
124
+ validate: (v) => isValidProjectName(v) || 'Use lowercase letters, digits, - . _ ~',
125
+ },
126
+ { onCancel },
127
+ ));
128
+ }
129
+ name ??= 'my-app';
130
+ if (!isValidProjectName(path.basename(name))) {
131
+ console.error(c.red(`Invalid project name "${name}"`));
132
+ process.exit(1);
133
+ }
134
+ const projectDir = path.resolve(name);
135
+ if (fs.existsSync(projectDir) && fs.readdirSync(projectDir).length > 0) {
136
+ console.error(c.red(`Directory ${projectDir} already exists and is not empty.`));
137
+ process.exit(1);
138
+ }
139
+
140
+ // 2. Template
141
+ let templateId = values.template;
142
+ if (!templateId && interactive) {
143
+ ({ templateId } = await prompts(
144
+ {
145
+ type: 'select',
146
+ name: 'templateId',
147
+ message: 'Template',
148
+ choices: templates.map((t) => ({
149
+ title: `${t.label}`,
150
+ description: t.description,
151
+ value: t.id,
152
+ })),
153
+ },
154
+ { onCancel },
155
+ ));
156
+ }
157
+ const template = templates.find((t) => t.id === templateId);
158
+ if (!template) {
159
+ console.error(c.red(`Pick a template with --template. Available: ${templates.map((t) => t.id).join(', ')}`));
160
+ process.exit(1);
161
+ }
162
+
163
+ // 3. Infra (backend templates only)
164
+ let infra = [];
165
+ if (template.supportsInfra) {
166
+ const choices = availableInfra.filter(
167
+ (i) => !i.templates || i.templates.includes(template.id),
168
+ );
169
+ if (values.infra) {
170
+ infra = values.infra.split(',').map((s) => s.trim()).filter(Boolean);
171
+ } else if (!values['no-infra'] && interactive) {
172
+ ({ infra } = await prompts(
173
+ {
174
+ type: 'multiselect',
175
+ name: 'infra',
176
+ message: 'Infra to include',
177
+ hint: '- space to select, enter to confirm',
178
+ instructions: false,
179
+ choices: choices.map((i) => ({
180
+ title: i.label,
181
+ value: i.id,
182
+ selected: i.default === true,
183
+ })),
184
+ },
185
+ { onCancel },
186
+ ));
187
+ } else if (!values['no-infra']) {
188
+ infra = choices.filter((i) => i.default).map((i) => i.id);
189
+ }
190
+ }
191
+
192
+ // 4. Install / git
193
+ const packageManager = values.pm ?? detectPackageManager();
194
+ const ask = async (flagOn, flagOff, message, initial) => {
195
+ if (values[flagOn]) return true;
196
+ if (values[flagOff]) return false;
197
+ if (!interactive) return initial;
198
+ const { answer } = await prompts(
199
+ { type: 'confirm', name: 'answer', message, initial },
200
+ { onCancel },
201
+ );
202
+ return answer;
203
+ };
204
+ const doGit = await ask('git', 'no-git', 'Initialise a git repository?', true);
205
+ const doInstall = await ask('install', 'no-install', `Install dependencies with ${packageManager}?`, false);
206
+
207
+ // 5. Go
208
+ console.log(`\n${c.bold(`Creating ${name}`)} ${c.dim(`from ${template.id}`)}`);
209
+ const result = createProject({ template, infra, projectDir, availableInfra, log });
210
+ for (const { id, because } of result.autoAdded) {
211
+ console.log(c.yellow(` ${id} was added too because ${because} needs it`));
212
+ }
213
+ if (doGit) initGit(projectDir);
214
+ if (doInstall) {
215
+ console.log(c.dim(`\n${packageManager} install ...`));
216
+ if (!installDependencies(projectDir, packageManager)) {
217
+ console.log(c.yellow('Install failed — run it yourself after fixing the error above.'));
218
+ }
219
+ }
220
+
221
+ const rel = path.relative(process.cwd(), projectDir) || '.';
222
+ const run = (script) => (packageManager === 'npm' ? `npm run ${script}` : `${packageManager} ${script}`);
223
+ console.log(`\n${c.green('Done!')} Next steps:\n`);
224
+ console.log(` cd ${rel}`);
225
+ if (!doInstall) console.log(` ${packageManager} install`);
226
+ if (result.infra.some((i) => i.docker)) console.log(' docker compose up -d');
227
+ for (const step of template.nextSteps ?? []) console.log(` ${run(step)}`);
228
+ console.log(`\n${c.dim('Generators: ')}${run('gen')} ${c.dim('(plop) — see README.md in the project.')}\n`);
229
+ }
230
+
231
+ try {
232
+ if (values.help) {
233
+ console.log(HELP);
234
+ } else if (positionals[0] === 'list') {
235
+ printList();
236
+ } else if (positionals[0] === 'add') {
237
+ await runAdd();
238
+ } else {
239
+ await runCreate();
240
+ }
241
+ } catch (error) {
242
+ console.error(c.red(`\n${error.message}`));
243
+ process.exit(1);
244
+ }
@@ -0,0 +1,73 @@
1
+ const JOB_PAYLOAD_SECRET_MIN_LENGTH = 32;
2
+ // `log` only prints messages, so it is for development; `smtp` works everywhere.
3
+ const EMAIL_PROVIDERS = ['log', 'smtp'];
4
+ const DEV_ONLY_EMAIL_PROVIDERS = ['log'];
5
+
6
+ const present = (config: Record<string, unknown>, name: string) =>
7
+ typeof config[name] === 'string' && (config[name] as string).trim() !== '';
8
+
9
+ export function validateEmailEnv(
10
+ config: Record<string, unknown>,
11
+ errors: string[],
12
+ ): void {
13
+ const secret = config.JOB_PAYLOAD_SECRET;
14
+ if (typeof secret !== 'string' || secret.length < JOB_PAYLOAD_SECRET_MIN_LENGTH) {
15
+ errors.push(
16
+ `JOB_PAYLOAD_SECRET must be set to a string of at least ${JOB_PAYLOAD_SECRET_MIN_LENGTH} characters`,
17
+ );
18
+ }
19
+
20
+ const provider = (config.EMAIL_PROVIDER ?? 'log') as string;
21
+ if (!EMAIL_PROVIDERS.includes(provider)) {
22
+ errors.push(
23
+ `EMAIL_PROVIDER must be one of: ${EMAIL_PROVIDERS.join(', ')} (got "${provider}")`,
24
+ );
25
+ return;
26
+ }
27
+ if (
28
+ config.NODE_ENV === 'production' &&
29
+ DEV_ONLY_EMAIL_PROVIDERS.includes(provider)
30
+ ) {
31
+ // The log adapter prints one-time codes; it must never run in production
32
+ errors.push(
33
+ `EMAIL_PROVIDER=${provider} only prints emails and is not allowed in production; configure a real provider`,
34
+ );
35
+ }
36
+ if (provider === 'smtp') validateSmtp(config, errors);
37
+ }
38
+
39
+ /** What `EMAIL_PROVIDER=smtp` needs: where to connect, who to send as, and (optionally) a login. */
40
+ function validateSmtp(config: Record<string, unknown>, errors: string[]): void {
41
+ if (!present(config, 'SMTP_HOST')) {
42
+ errors.push('SMTP_HOST must be set when EMAIL_PROVIDER=smtp');
43
+ }
44
+
45
+ if (config.SMTP_PORT !== undefined) {
46
+ const port = Number(config.SMTP_PORT);
47
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
48
+ errors.push('SMTP_PORT must be a valid integer between 1 and 65535');
49
+ }
50
+ }
51
+
52
+ if (
53
+ config.SMTP_SECURE !== undefined &&
54
+ config.SMTP_SECURE !== 'true' &&
55
+ config.SMTP_SECURE !== 'false'
56
+ ) {
57
+ errors.push('SMTP_SECURE must be "true" or "false"');
58
+ }
59
+
60
+ // A login is a pair: a user without a password (or the reverse) can never work
61
+ if (present(config, 'SMTP_USER') !== present(config, 'SMTP_PASS')) {
62
+ errors.push('SMTP_USER and SMTP_PASS must be set together');
63
+ }
64
+
65
+ if (
66
+ !present(config, 'EMAIL_FROM') ||
67
+ !(config.EMAIL_FROM as string).includes('@')
68
+ ) {
69
+ errors.push(
70
+ 'EMAIL_FROM must be set to a sender address when EMAIL_PROVIDER=smtp, e.g. "App <no-reply@example.com>"',
71
+ );
72
+ }
73
+ }
@@ -0,0 +1,13 @@
1
+ import type { EmailMessage } from './email-sender.port.js';
2
+
3
+ /**
4
+ * Hands an email over for delivery. Unlike `IEmailSender.send`, it returns once the message
5
+ * is accepted, not once it is delivered: the actual sending happens in the background, with
6
+ * retries. Rejects only when the message could not be accepted (the queue is unavailable).
7
+ */
8
+ export interface IEmailDispatcher {
9
+ dispatch(message: EmailMessage): Promise<void>;
10
+ }
11
+
12
+ /** DI token for the active IEmailDispatcher. */
13
+ export const EMAIL_DISPATCHER = 'IEmailDispatcher';
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderEmail } from './email-layout.js';
3
+
4
+ const CONTENT = {
5
+ appName: 'Ecom',
6
+ heading: 'Xác thực địa chỉ email của bạn',
7
+ paragraphs: ['Dùng mã dưới đây để hoàn tất đăng ký.'],
8
+ highlight: '482915',
9
+ afterParagraphs: ['Mã có hiệu lực trong 10 phút.'],
10
+ reason: 'Bạn nhận email này vì đã đăng ký tại Ecom.',
11
+ };
12
+
13
+ describe('renderEmail', () => {
14
+ it('puts the shop name, heading, paragraphs, highlighted value and footer in both versions', () => {
15
+ const { html, text } = renderEmail(CONTENT);
16
+
17
+ for (const part of [
18
+ 'Ecom',
19
+ CONTENT.heading,
20
+ CONTENT.paragraphs[0],
21
+ '482915',
22
+ CONTENT.afterParagraphs[0],
23
+ CONTENT.reason,
24
+ ]) {
25
+ expect(html).toContain(part);
26
+ expect(text).toContain(part);
27
+ }
28
+ });
29
+
30
+ it('produces a complete HTML document a mail client can render', () => {
31
+ const { html } = renderEmail(CONTENT);
32
+
33
+ expect(html.startsWith('<!doctype html>')).toBe(true);
34
+ expect(html).toContain('<html lang="vi">');
35
+ expect(html).toContain('charset="utf-8"');
36
+ expect(html).toContain('name="viewport"');
37
+ expect(html).toContain('</html>');
38
+ });
39
+
40
+ it('escapes everything it was given, so markup in a value cannot change the page', () => {
41
+ const { html, text } = renderEmail({
42
+ ...CONTENT,
43
+ appName: '<b>Shop & Co</b>',
44
+ paragraphs: ['<script>alert("x")</script>'],
45
+ highlight: '<img src=x>',
46
+ reason: "it's <i>fine</i>",
47
+ });
48
+
49
+ expect(html).not.toContain('<script>');
50
+ expect(html).not.toContain('<img src=x>');
51
+ expect(html).not.toContain('<b>Shop');
52
+ expect(html).toContain('&lt;b&gt;Shop &amp; Co&lt;/b&gt;');
53
+ expect(html).toContain('&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;');
54
+ expect(html).toContain('it&#39;s &lt;i&gt;fine&lt;/i&gt;');
55
+ // the plain-text version has no markup to escape, so it keeps the raw text
56
+ expect(text).toContain('<b>Shop & Co</b>');
57
+ });
58
+
59
+ it('works without a highlighted value or trailing paragraphs', () => {
60
+ const { html, text } = renderEmail({
61
+ appName: 'Ecom',
62
+ heading: 'Hello',
63
+ paragraphs: ['Just a note.'],
64
+ reason: 'Because.',
65
+ });
66
+
67
+ expect(html).toContain('Just a note.');
68
+ expect(html).not.toContain('letter-spacing');
69
+ expect(text).toBe(
70
+ ['Ecom', '', 'Hello', '', 'Just a note.', '', '--', 'Because.'].join(
71
+ '\n',
72
+ ),
73
+ );
74
+ });
75
+
76
+ it('writes the plain-text version readable on its own, with the footer set apart', () => {
77
+ const { text } = renderEmail(CONTENT);
78
+
79
+ expect(text.startsWith('Ecom\n\n' + CONTENT.heading)).toBe(true);
80
+ expect(text).toContain(' 482915');
81
+ expect(text.endsWith(`--\n${CONTENT.reason}`)).toBe(true);
82
+ });
83
+ });
@@ -0,0 +1,88 @@
1
+ export interface EmailLayoutContent {
2
+ /** The shop's name, shown at the top and used in the footer. */
3
+ appName: string;
4
+ heading: string;
5
+ /** Paragraphs before the highlighted value. */
6
+ paragraphs: string[];
7
+ /** A value the reader has to copy (e.g. a one-time code), shown large on its own line. */
8
+ highlight?: string;
9
+ /** Paragraphs after the highlighted value. */
10
+ afterParagraphs?: string[];
11
+ /** Why the reader got this email, shown in the footer. */
12
+ reason: string;
13
+ }
14
+
15
+ export interface RenderedEmail {
16
+ html: string;
17
+ text: string;
18
+ }
19
+
20
+ const escapeHtml = (value: string): string =>
21
+ value
22
+ .replaceAll('&', '&amp;')
23
+ .replaceAll('<', '&lt;')
24
+ .replaceAll('>', '&gt;')
25
+ .replaceAll('"', '&quot;')
26
+ .replaceAll("'", '&#39;');
27
+
28
+ const FONT = 'Arial, Helvetica, sans-serif';
29
+
30
+ /**
31
+ * The one look every email shares: the shop's name on top, the content, and a footer
32
+ * saying why it was sent. Each kind of email only supplies its words; the HTML (inline
33
+ * styles and tables, because mail clients ignore most of modern CSS) and the plain-text
34
+ * twin both come from here, so they can't drift apart. All text is escaped, so a value
35
+ * with markup in it can't change the page.
36
+ */
37
+ export function renderEmail(content: EmailLayoutContent): RenderedEmail {
38
+ const { appName, heading, paragraphs, highlight, afterParagraphs, reason } =
39
+ content;
40
+ const after = afterParagraphs ?? [];
41
+
42
+ const paragraph = (value: string) =>
43
+ `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#374151;">${escapeHtml(value)}</p>`;
44
+
45
+ const highlightBlock = highlight
46
+ ? `<div style="margin:8px 0 24px;padding:16px;background:#f3f4f6;border-radius:6px;text-align:center;font-size:32px;font-weight:bold;letter-spacing:8px;color:#111827;">${escapeHtml(highlight)}</div>`
47
+ : '';
48
+
49
+ const html = `<!doctype html>
50
+ <html lang="vi">
51
+ <head>
52
+ <meta charset="utf-8">
53
+ <meta name="viewport" content="width=device-width, initial-scale=1">
54
+ <title>${escapeHtml(heading)}</title>
55
+ </head>
56
+ <body style="margin:0;padding:0;background:#f4f5f7;">
57
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f5f7;padding:24px 0;">
58
+ <tr><td align="center">
59
+ <table role="presentation" width="480" cellspacing="0" cellpadding="0" style="width:100%;max-width:480px;background:#ffffff;border-radius:8px;">
60
+ <tr><td style="padding:32px;font-family:${FONT};">
61
+ <div style="margin:0 0 24px;font-size:18px;font-weight:bold;color:#111827;">${escapeHtml(appName)}</div>
62
+ <h1 style="margin:0 0 16px;font-size:20px;line-height:1.3;color:#111827;">${escapeHtml(heading)}</h1>
63
+ ${paragraphs.map(paragraph).join('\n')}
64
+ ${highlightBlock}
65
+ ${after.map(paragraph).join('\n')}
66
+ <hr style="margin:24px 0 16px;border:0;border-top:1px solid #e5e7eb;">
67
+ <p style="margin:0;font-size:12px;line-height:1.5;color:#6b7280;">${escapeHtml(reason)}</p>
68
+ </td></tr>
69
+ </table>
70
+ </td></tr>
71
+ </table>
72
+ </body>
73
+ </html>`;
74
+
75
+ const text = [
76
+ appName,
77
+ '',
78
+ heading,
79
+ '',
80
+ ...paragraphs.flatMap((value) => [value, '']),
81
+ ...(highlight ? [` ${highlight}`, ''] : []),
82
+ ...after.flatMap((value) => [value, '']),
83
+ '--',
84
+ reason,
85
+ ].join('\n');
86
+
87
+ return { html, text };
88
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ createEmailSender,
4
+ smtpOptionsFromEnv,
5
+ } from './email-sender.factory.js';
6
+ import { LogEmailSender } from './log-email-sender.js';
7
+ import { SmtpEmailSender } from './smtp-email-sender.js';
8
+
9
+ const SMTP_ENV = {
10
+ EMAIL_PROVIDER: 'smtp',
11
+ SMTP_HOST: 'smtp.example.com',
12
+ EMAIL_FROM: 'Ecom <no-reply@example.com>',
13
+ };
14
+
15
+ describe('createEmailSender', () => {
16
+ it('uses the log adapter for "log"', () => {
17
+ expect(createEmailSender({ EMAIL_PROVIDER: 'log' })).toBeInstanceOf(
18
+ LogEmailSender,
19
+ );
20
+ });
21
+
22
+ it('defaults to the log adapter when EMAIL_PROVIDER is not set', () => {
23
+ expect(createEmailSender({})).toBeInstanceOf(LogEmailSender);
24
+ });
25
+
26
+ it('uses the SMTP adapter for "smtp"', () => {
27
+ expect(createEmailSender(SMTP_ENV)).toBeInstanceOf(SmtpEmailSender);
28
+ });
29
+
30
+ it('fails loudly for a provider it does not know, instead of silently not sending', () => {
31
+ expect(() =>
32
+ createEmailSender({ EMAIL_PROVIDER: 'carrier-pigeon' }),
33
+ ).toThrow(/carrier-pigeon/);
34
+ });
35
+ });
36
+
37
+ describe('smtpOptionsFromEnv', () => {
38
+ it('defaults to port 587 with STARTTLS (not TLS from the first byte)', () => {
39
+ expect(smtpOptionsFromEnv(SMTP_ENV)).toEqual({
40
+ host: 'smtp.example.com',
41
+ port: 587,
42
+ secure: false,
43
+ user: undefined,
44
+ pass: undefined,
45
+ from: 'Ecom <no-reply@example.com>',
46
+ });
47
+ });
48
+
49
+ it('turns on TLS from the first byte for port 465', () => {
50
+ expect(smtpOptionsFromEnv({ ...SMTP_ENV, SMTP_PORT: '465' })).toMatchObject(
51
+ {
52
+ port: 465,
53
+ secure: true,
54
+ },
55
+ );
56
+ });
57
+
58
+ it('lets SMTP_SECURE override what the port suggests', () => {
59
+ expect(
60
+ smtpOptionsFromEnv({
61
+ ...SMTP_ENV,
62
+ SMTP_PORT: '465',
63
+ SMTP_SECURE: 'false',
64
+ }),
65
+ ).toMatchObject({ secure: false });
66
+ expect(
67
+ smtpOptionsFromEnv({
68
+ ...SMTP_ENV,
69
+ SMTP_PORT: '2465',
70
+ SMTP_SECURE: 'true',
71
+ }),
72
+ ).toMatchObject({ port: 2465, secure: true });
73
+ });
74
+
75
+ it('passes the login through, and treats empty values as no login', () => {
76
+ expect(
77
+ smtpOptionsFromEnv({
78
+ ...SMTP_ENV,
79
+ SMTP_USER: 'apikey',
80
+ SMTP_PASS: 'secret',
81
+ }),
82
+ ).toMatchObject({ user: 'apikey', pass: 'secret' });
83
+ expect(
84
+ smtpOptionsFromEnv({ ...SMTP_ENV, SMTP_USER: '', SMTP_PASS: '' }),
85
+ ).toMatchObject({ user: undefined, pass: undefined });
86
+ });
87
+ });
@@ -0,0 +1,35 @@
1
+ import type { IEmailSender } from './email-sender.port.js';
2
+ import { LogEmailSender } from './log-email-sender.js';
3
+ import { SmtpEmailSender, type SmtpOptions } from './smtp-email-sender.js';
4
+
5
+ const DEFAULT_SMTP_PORT = 587;
6
+ const IMPLICIT_TLS_PORT = 465;
7
+
8
+ /** Reads the SMTP settings (already checked by `validateEnv`) from the environment. */
9
+ export function smtpOptionsFromEnv(env: NodeJS.ProcessEnv): SmtpOptions {
10
+ const port = env.SMTP_PORT ? Number(env.SMTP_PORT) : DEFAULT_SMTP_PORT;
11
+ return {
12
+ host: env.SMTP_HOST as string,
13
+ port,
14
+ // Port 465 speaks TLS from the first byte; other ports start plain and upgrade
15
+ secure: env.SMTP_SECURE
16
+ ? env.SMTP_SECURE === 'true'
17
+ : port === IMPLICIT_TLS_PORT,
18
+ user: env.SMTP_USER || undefined,
19
+ pass: env.SMTP_PASS || undefined,
20
+ from: env.EMAIL_FROM as string,
21
+ };
22
+ }
23
+
24
+ /** Picks the adapter for `EMAIL_PROVIDER` (validated at boot by `validateEnv`). */
25
+ export function createEmailSender(env: NodeJS.ProcessEnv): IEmailSender {
26
+ const provider = env.EMAIL_PROVIDER ?? 'log';
27
+ switch (provider) {
28
+ case 'log':
29
+ return new LogEmailSender();
30
+ case 'smtp':
31
+ return new SmtpEmailSender(smtpOptionsFromEnv(env));
32
+ default:
33
+ throw new Error(`Unknown EMAIL_PROVIDER "${provider}"`);
34
+ }
35
+ }
@@ -0,0 +1,19 @@
1
+ export interface EmailMessage {
2
+ to: string;
3
+ subject: string;
4
+ /** Plain-text body. Always present: it is what text-only clients show and what spam filters like to see alongside the HTML. */
5
+ text: string;
6
+ /** HTML body. Sent together with `text` as a multipart message. */
7
+ html?: string;
8
+ }
9
+
10
+ /**
11
+ * Sends an email. One adapter per provider; the rest of the app only knows this port.
12
+ * `send` rejects when the provider fails, so callers decide whether that matters.
13
+ */
14
+ export interface IEmailSender {
15
+ send(message: EmailMessage): Promise<void>;
16
+ }
17
+
18
+ /** DI token for the active IEmailSender. */
19
+ export const EMAIL_SENDER = 'IEmailSender';