feat(email): SMTP mailer behind the EmailService seam (EMAIL_PROVIDER=smtp); Resend stays the hosted default (#1746)
SmtpEmailService (nodemailer 9.0.5, exact-pinned) behind the existing EmailService seam. Provider resolution: EMAIL_PROVIDER wins, else RESEND_API_KEY selects Resend (hosted byte-identical), else SMTP_HOST selects SMTP. From header is built exactly like the Resend service after #1956 (no 'via <app>', fromAddress honored, platform-sender retry). STARTTLS is required by default (requireTLS) with SMTP_REQUIRE_TLS=false as an explicit opt-out for a plaintext LAN relay. Docs, env examples and the generated extension registry updated.
This commit is contained in:
@@ -39,3 +39,17 @@ NEXT_PUBLIC_SELF_HOSTED=true
|
||||
# Defaults to NEXT_PUBLIC_SUPABASE_URL with https:// replaced by wss://
|
||||
# (http:// by ws://). Set only if Realtime is served from another origin.
|
||||
# NEXT_PUBLIC_SUPABASE_WS_URL=wss://your-project.supabase.co
|
||||
|
||||
# Optional: outbound email (invoices, reminders). Pick one provider.
|
||||
# 1. Resend (what hosted runs):
|
||||
# RESEND_API_KEY=
|
||||
# RESEND_FROM_EMAIL=
|
||||
# 2. Your own SMTP relay (Swedish mail provider, M365/Workspace relay, Postfix):
|
||||
# EMAIL_PROVIDER=smtp
|
||||
# SMTP_HOST=
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false # true = implicit TLS on 465, false = STARTTLS required (set SMTP_REQUIRE_TLS=false only for a plaintext LAN relay)
|
||||
# SMTP_USER=
|
||||
# SMTP_PASS=
|
||||
# SMTP_FROM_EMAIL=
|
||||
# SMTP_REQUIRE_TLS=true # false only for a plaintext relay on a trusted LAN
|
||||
|
||||
@@ -108,6 +108,19 @@ RECEIPT_HUNT_COMPANY_IDS=
|
||||
# AI_STRICT_JSON=false # openai-compatible only: response_format json_schema when the provider enforces it
|
||||
# AI_EXTRACTION_MAX_TOKENS=8192 # output cap for document extraction (legacy BEDROCK_MAX_TOKENS)
|
||||
# AI_PROVIDER=
|
||||
# Outbound email (invoices, reminders). Pick one provider. Resend is what
|
||||
# hosted runs; SMTP is for self-hosts that want mail on their own relay.
|
||||
# EMAIL_PROVIDER=resend|smtp # optional; RESEND_API_KEY wins, else SMTP_HOST
|
||||
# RESEND_API_KEY=
|
||||
# RESEND_FROM_EMAIL=
|
||||
# SMTP_HOST=
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false # true = implicit TLS on 465, false = STARTTLS required (set SMTP_REQUIRE_TLS=false only for a plaintext LAN relay)
|
||||
# SMTP_USER=
|
||||
# SMTP_PASS=
|
||||
# SMTP_FROM_EMAIL=
|
||||
# SMTP_REQUIRE_TLS=true # false only for a plaintext relay on a trusted LAN
|
||||
# SMTP_TLS_REJECT_UNAUTHORIZED=true
|
||||
# Bank connections (Enable Banking)
|
||||
# ENABLE_BANKING_APP_ID=
|
||||
# ENABLE_BANKING_PRIVATE_KEY=
|
||||
|
||||
@@ -1276,6 +1276,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-26] No ratchet on direct requireAuth() calls in app/api: requireAuth() is the MFA (AAL2) guard withRouteContext itself calls, and .claude/rules/api-routes.md sanctions it for routes without a company context (onboarding, account, user prefs). The 20 remaining direct callers skip request ids and the canonical envelope, not MFA; migrating them is a consistency campaign, not a security fix, so it was not folded into the bypass PR.
|
||||
[2026-08-26] defer_invoice_booking (#967) now gates booking on every door, not just the dashboard: MCP send_invoice / mark_invoice_sent / create_supplier_invoice_from_inbox, v1 invoices send / mark-sent and supplier-invoices create, and the inbox convert route all checked accounting_method === 'accrual' and posted a verifikat at issue for deferred companies. All six now call booksInvoicesOnIssue() (lib/bookkeeping/booking-mode.ts), the same helper the dashboard routes use, so the setting has one meaning. No data repair attempted: vouchers already posted for deferred companies through these doors are legitimate entries and stay.
|
||||
[2026-08-20] The swedish-e-invoicing skill now names Upphandlingsmyndigheten as Sweden Peppol Authority across all eight files, not just the one that was flagged: the handover completed 1 July 2026 (regeringsbeslut Fi2025/01826) and the skill was written in future tense, so a partial fix would have left the atom internally contradictory and still pointed agents at peppol@digg.se. Four digg.se URLs were repointed to their verified 301 targets on upphandlingsmyndigheten.se; the fifth, DIGG Peppol testbadd, is a hard 404 with no redirect and no successor page at the new authority, so it was replaced with the SFTI Validex verification service (https://sfti.validex.net/) rather than left dead or guessed at. Historical attributions (Q4 2025 traffic statistics, the 0007:2021006883 Peppol-ID example) deliberately still say DIGG because they were accurate when published.
|
||||
[2026-08-20] Outbound mail gets a second provider behind the existing EmailService seam (Sovereign plan WS2 PR2): SmtpEmailService over nodemailer 9.0.5 (MIT-0, zero dependencies, exact-pinned and guarded), selected by EMAIL_PROVIDER=smtp or auto-detected from SMTP_HOST only when no RESEND_API_KEY is set, so hosted stays on Resend byte-identical. The From header and its header-injection defence are shared verbatim with the Resend service so customers see the same sender shape. Resend's delivery-status webhook stays Resend-only (SMTP has no equivalent). The email extension manifest now lists every provider variable as optional: requiredEnvVars cannot express "one of two sets" (the same limitation document-extraction hit in 2026-08-13) and it only drives a build-time warning.
|
||||
[2026-08-26] SmtpEmailService honors options.from (a company's own verified sending domain, #1802) exactly like ResendEmailService: explicit "<name> <address>" when the address passes the same guard, platform default otherwise, and one retry as the platform sender when the relay refuses the company sender. The builder is mirrored in smtp-service.ts rather than imported because the platform fallback address differs per provider (RESEND_FROM_EMAIL vs SMTP_FROM_EMAIL); the injection defence and RFC 5322 quoting are identical.
|
||||
[2026-08-26] A dead Skatteverket connection gets ONE ochre line, on /skattekonto (page level, no longer nested in the saldo branch where a company with no snapshot never saw it) and on /transactions (all source filters, reversing the 2026-08-14 filter gate): deliberately a banner and nothing else: no page takeover, no removed sync button, nothing hidden. Prod on this date has 151 of 162 connected companies with every token dead (97 of the 101 tokens touched in 30 days are needs_reconsent), because SKV personal sessions live 65 minutes and the nightly cron flags the row the next morning. At that rate a flag that HIDES anything hides it from nearly everyone, so the first cut of this change (reconnect StartCard replacing the page, sync button swapped for a reconnect link, shortfall and empty-state suppressed) was rejected by all three skeptics: it would have removed the saldo, the Kronofogden line and the page's only route to the bankgiro and OCR at exactly the moment the account is short. The banner is additive and stays honest at 93%. Known and accepted: /status is caller-scoped while sync is company-scoped (resolve-auth prefers a live row), so in a multi-member company a member whose own token died sees the line while the company can still sync. Left alone because prod has 3 such companies and the banner hides nothing; fix it by adding a company-scoped field to /status if that ever changes.
|
||||
[2026-08-26] Reference data (fiscal periods, cash accounts, settings, accounts, dimensions, templates, customers, suppliers, articles) moves behind SWR hooks in lib/reference-data with company-scoped keys, a server seed from the dashboard layout (periods, cash accounts, settings only; the chart of accounts can be hundreds of KB and is warmed lazily instead), explicit invalidateReferenceData() after writes, and a raw-reference-fetch ratchet in check:guards. Chosen over Cache-Control on the API routes (a browser HTTP cache would keep serving stale bodies after a mutate) and over Next 16 cacheComponents/partialPrefetching (38 of 81 dashboard pages are client components whose data lives in client fetches, so an app shell prefetch cannot carry it). Expected side effect: period.list and settings.get volume in the op-completed logs drops toward zero because those reads become browser-side Supabase selects; that is by design, not a broken route.
|
||||
[2026-08-26] AGENTS.md now defers to CLAUDE.md for every shared rule instead of duplicating it: the copy had drifted within weeks (no inline-rättelse path, cookie-first tenancy order, 100+ MCP tools). The four Codex-only constraints Emil added 2026-07-21 (erp-base staging-only migrations, prod writes and main pushes need his explicit approval, no local Docker) were kept in a labelled section rather than removed, because the erp-base Supabase project exists (ref pwxtzglxptnnvjrpixpg) and they describe his environment, not stale product facts; only the project-name spelling was corrected from erpbase.
|
||||
@@ -1338,6 +1340,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
|
||||
[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring.
|
||||
[2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card.
|
||||
[2026-08-29] SMTP From builder (extensions/general/email/lib/smtp-service.ts) follows the 2026-08-05 no-"via <platform>" rule and honors fromAddress exactly like resend-service.ts (name alone as display name; a verified brand address rides fromAddress; the retry-as-platform-sender path drops both the company sender and the brand address so it always differs): the PR predated #1956, and a self-host must show a customer the same sender shape whichever provider the operator picked. nodemailer transport gets requireTLS by default (SMTP_SECURE=false path) with an explicit SMTP_REQUIRE_TLS=false opt-out: nodemailer's own STARTTLS is opportunistic, so a STARTTLS-stripping on-path attacker would otherwise receive AUTH credentials and every invoice PDF in cleartext; the opt-out exists only for the documented plaintext relay on a trusted Docker/LAN network.
|
||||
[2026-08-28] Company invite accept link returned in-band to the inviter (always, not only under NODE_ENV=development) rather than printed to the server log as #1710 suggested: tokens are SHA-256 hashed at rest (lib/auth/invite-tokens.ts), so the raw link exists exactly once, in the create response, and the inviter is the person who needs it; a log line would put a bearer credential in log storage and still require shell access to the container. Same contract POST /api/team/invite already ships (email_sent + inviteUrl, TeamPanel shareInvite). Acceptance stays email-bound (app/api/team/accept/route.ts requires the signed-in user's email to match the invitation, per the 2026-07-24 line above: invite recovery stays token/cookie based with mailbox possession), so sharing the link over another channel does not widen who can claim the membership. No re-send endpoint added for company invites (revoke + re-invite gives a fresh link; a re-send would be a separate surface). SMTP as a delivery path is deferred to #1746.
|
||||
[2026-08-30] Company invite toast title branches on whether mail actually went out ("Inbjudan skapad" when no provider is configured or the send failed, "Inbjudan skickad" otherwise) instead of the inherited TeamPanel wording that says sent next to a not-sent description; the share-link live region in CompanyMembersSection is always mounted (empty until a link exists) so screen readers announce the line when it appears, same reason as the roster block. TeamPanel keeps its copied wording and conditionally mounted region on purpose: the diff stays scoped to the company path (#1710), parity is a small follow-up.
|
||||
[2026-08-29] Migrated invoices are linked to their REGISTRATION voucher only (PR A of #1463; payment vouchers stay with bulk-reconcile-supplier-vouchers and a later PR): the provider names the booking voucher on the invoice (Visma `VoucherNumber` "A329", Fortnox `VoucherSeries` + `VoucherNumber`; `VoucherYear` is ignored, the invoice date picks the fiscal year), the SIE import preserved that source ref on `journal_entries.source_voucher_*`, and lib/invoices/link-migrated-registration-vouchers.ts joins the two through the existing voucher-ref-resolver. A link is written only when the ref resolves to exactly ONE posted verifikat in the invoice's fiscal year, its net credit on 244x (supplier) or net debit on 151x (customer) equals `total_sek` within 0.005, and no invoice already references it; a verifikat with no 244x/151x line at all (kontantmetod books on payment, or the provider named a payment voucher) is reported `unresolved`, never linked, and a credit note whose sign does not corroborate stays unlinked rather than being matched on absolute value. The only writes are `supplier_invoices.registration_journal_entry_id` / `invoices.journal_entry_id`, from NULL, company-scoped; journal tables are never touched. Nothing stores the provider ref on the invoice row, so the /reconcile re-run (`{ consentId }`) re-fetches both registers from the provider and joins on invoice number (sales; UNIQUE per company) or supplier invoice number + date (unique on both sides) before handing the pairs to the same linker: a stored ref would have been cheaper to re-run but is a schema change this PR deliberately avoids.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* failure never blocks the send; it surfaces as a PAYMENT_LINK_FAILED
|
||||
* warning on the response once the email is delivered.
|
||||
* 7. Final PDF render with the real number.
|
||||
* 8. Email send via Resend (the email extension). Fail → 502
|
||||
* 8. Email send via the email extension (Resend or SMTP). Fail → 502
|
||||
* INVOICE_SEND_PROVIDER_FAILED. The number IS consumed at this point;
|
||||
* same orphan-window as :mark-sent (architecturally tracked).
|
||||
* 9. POINT OF NO RETURN. Steps below are best-effort; failures surface
|
||||
@@ -123,14 +123,14 @@ registerEndpoint({
|
||||
path: '/api/v1/companies/:companyId/invoices/:id/send',
|
||||
summary: 'Send a draft invoice to the customer by email.',
|
||||
description:
|
||||
'The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via Resend (PDF attachment, copy to company) → flip status to sent → post journal entry (real invoice, unless kontantmetoden or defer_invoice_booking) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent.',
|
||||
'The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via the email extension (Resend or SMTP; PDF attachment, copy to company) → flip status to sent → post journal entry (real invoice, unless kontantmetoden or defer_invoice_booking) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent.',
|
||||
useWhen:
|
||||
'You want Accounted to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead.',
|
||||
doNotUseFor:
|
||||
'Re-sending an already-sent invoice (returns 409 INVOICE_UPDATE_NOT_DRAFT). Sending a delivery note (no F-series lifecycle). Sending a credit note (use the :credit endpoint to issue the kreditfaktura; subsequent re-send of the credit note via :mark-sent is the supported path).',
|
||||
pitfalls: [
|
||||
'Idempotency-Key is mandatory.',
|
||||
'Email service must be configured: without RESEND_API_KEY + RESEND_FROM_EMAIL the endpoint returns 503 INVOICE_SEND_EMAIL_NOT_CONFIGURED.',
|
||||
'Email service must be configured: without RESEND_API_KEY + RESEND_FROM_EMAIL (or an SMTP relay via EMAIL_PROVIDER=smtp) the endpoint returns 503 INVOICE_SEND_EMAIL_NOT_CONFIGURED.',
|
||||
'Customer must have an email address. 400 INVOICE_SEND_NO_CUSTOMER_EMAIL otherwise.',
|
||||
'A cancelled invoice is rejected (400 INVOICE_SEND_CANCELLED): its F-series number is preserved for compliance but the document is not a valid faktura.',
|
||||
'Email failure before the status flip leaves the F-series number consumed but the invoice in `draft` status. Same orphan window as :mark-sent (architecturally tracked, matches internal route).',
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ extensions/
|
||||
calendar/ ← Kalender: month/week/day views
|
||||
cloud-backup/ ← Molnsynkronisering: sync the säkerhetsbackup to the user's own cloud storage
|
||||
document-extraction/ ← AI-extrahering av underlag: reads receipts and invoices, fills supplier/amount/VAT/date
|
||||
email/ ← E-post (Resend): invoices and reminders by e-mail
|
||||
email/ ← E-post (Resend or SMTP): invoices and reminders by e-mail
|
||||
enable-banking/ ← Bankintegration (PSD2): automatic bank transaction sync
|
||||
invoice-inbox/ ← Dokumentinkorg: forward supplier invoices to a unique address
|
||||
mail/ ← Brevlådor: lets Kvittojakten (receipt hunt) search the user's mailboxes
|
||||
|
||||
+23
-1
@@ -298,12 +298,34 @@ npx tsx scripts/smoke-ai.ts ./receipt.pdf # also runs document extraction
|
||||
|
||||
### Email (Invoice Sending, Invitations and Reminders)
|
||||
|
||||
Outbound mail (invoices, reminders, payslips) goes through one of two providers. Auth/account mail is sent by Supabase Auth and is not affected.
|
||||
|
||||
**Option 1: Resend** (what hosted runs):
|
||||
|
||||
```bash
|
||||
RESEND_API_KEY=re_...
|
||||
RESEND_FROM_EMAIL=noreply@your-domain.com
|
||||
```
|
||||
|
||||
Requires a [Resend](https://resend.com) account with a verified sender domain. Without this, invoices can still be generated as PDFs but cannot be emailed.
|
||||
Requires a [Resend](https://resend.com) account with a verified sender domain.
|
||||
|
||||
**Option 2: your own SMTP relay** (a Swedish mail provider, a Microsoft 365 / Google Workspace relay, Postfix on the host):
|
||||
|
||||
```bash
|
||||
EMAIL_PROVIDER=smtp
|
||||
SMTP_HOST=smtp.example.se
|
||||
SMTP_PORT=587 # default 587; 465 with SMTP_SECURE=true
|
||||
SMTP_SECURE=false # true = implicit TLS, false = STARTTLS required (set SMTP_REQUIRE_TLS=false only for a plaintext LAN relay)
|
||||
SMTP_USER=... # optional for an internal relay
|
||||
SMTP_PASS=...
|
||||
SMTP_FROM_EMAIL=faktura@your-domain.se
|
||||
# SMTP_REQUIRE_TLS=false # only for a plaintext relay on a trusted LAN: without it a relay that cannot STARTTLS fails the send instead of leaking credentials and invoice PDFs in cleartext
|
||||
# SMTP_TLS_REJECT_UNAUTHORIZED=false # only for a LAN relay with a self-signed certificate
|
||||
```
|
||||
|
||||
`EMAIL_PROVIDER` is optional: with a `RESEND_API_KEY` present Resend is used, otherwise `SMTP_HOST` selects SMTP, so adding SMTP variables next to an existing Resend key never moves mail by accident. Set it explicitly when both are configured. The From header is built identically on both providers (the company or brand name as display name, the platform address from `RESEND_FROM_EMAIL` or `SMTP_FROM_EMAIL` unless the company has a verified sending domain); the delivery-status webhook is Resend-only.
|
||||
|
||||
Without either, invoices can still be generated as PDFs but cannot be emailed.
|
||||
|
||||
**Invitations do not require Resend.** When no mail provider is configured, the invite is still created and the accept link is returned to the inviter in the app (a copy button under the pending invitations list, plus a warn-level log record whose msg is `email service not configured: invite email skipped`; the Docker image logs JSON, so grep for the message text, not a `WARN` prefix; the token is never logged). Share the link manually; it is valid until the invitation expires. Resend (or plain SMTP once [#1746](https://github.com/erp-mafia/accounted/pull/1746) lands) is only needed if you want the invitation mailed automatically. There is no re-send for company invitations: revoke and invite again for a new link.
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { createEmailService, resolveEmailProvider } from '../lib/email-provider'
|
||||
import { ResendEmailService } from '../lib/resend-service'
|
||||
import { SmtpEmailService } from '../lib/smtp-service'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('resolveEmailProvider', () => {
|
||||
it('defaults to resend when nothing is configured (unconfigured = no-op, as before)', () => {
|
||||
vi.stubEnv('EMAIL_PROVIDER', '')
|
||||
vi.stubEnv('RESEND_API_KEY', '')
|
||||
vi.stubEnv('SMTP_HOST', '')
|
||||
expect(resolveEmailProvider()).toBe('resend')
|
||||
expect(createEmailService()).toBeInstanceOf(ResendEmailService)
|
||||
})
|
||||
|
||||
it('picks smtp from SMTP_HOST when no Resend key exists (self-host path)', () => {
|
||||
vi.stubEnv('EMAIL_PROVIDER', '')
|
||||
vi.stubEnv('RESEND_API_KEY', '')
|
||||
vi.stubEnv('SMTP_HOST', 'smtp.example.se')
|
||||
expect(resolveEmailProvider()).toBe('smtp')
|
||||
expect(createEmailService()).toBeInstanceOf(SmtpEmailService)
|
||||
})
|
||||
|
||||
// Hosted stays hosted: SMTP variables on the side never move mail.
|
||||
it('keeps Resend when both are present', () => {
|
||||
vi.stubEnv('EMAIL_PROVIDER', '')
|
||||
vi.stubEnv('RESEND_API_KEY', 're_x')
|
||||
vi.stubEnv('SMTP_HOST', 'smtp.example.se')
|
||||
expect(resolveEmailProvider()).toBe('resend')
|
||||
})
|
||||
|
||||
it('honours EMAIL_PROVIDER as the explicit choice, case-insensitively', () => {
|
||||
vi.stubEnv('RESEND_API_KEY', 're_x')
|
||||
vi.stubEnv('EMAIL_PROVIDER', ' SMTP ')
|
||||
expect(resolveEmailProvider()).toBe('smtp')
|
||||
vi.stubEnv('EMAIL_PROVIDER', 'resend')
|
||||
vi.stubEnv('RESEND_API_KEY', '')
|
||||
vi.stubEnv('SMTP_HOST', 'smtp.example.se')
|
||||
expect(resolveEmailProvider()).toBe('resend')
|
||||
})
|
||||
|
||||
it('ignores an unknown EMAIL_PROVIDER value', () => {
|
||||
vi.stubEnv('EMAIL_PROVIDER', 'sendgrid')
|
||||
vi.stubEnv('RESEND_API_KEY', '')
|
||||
vi.stubEnv('SMTP_HOST', 'smtp.example.se')
|
||||
expect(resolveEmailProvider()).toBe('smtp')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const sendMailMock = vi.fn()
|
||||
const createTransportMock = vi.fn(() => ({ sendMail: sendMailMock }))
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: { createTransport: (...args: unknown[]) => createTransportMock(...(args as [])) },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/branding/service', () => ({
|
||||
getBranding: () => ({ appName: 'Accounted <evil>\r\nBcc: x' }),
|
||||
}))
|
||||
|
||||
import {
|
||||
SmtpEmailService,
|
||||
readSmtpSettings,
|
||||
resetSmtpTransportForTests,
|
||||
} from '../lib/smtp-service'
|
||||
|
||||
const ENV = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_SECURE', 'SMTP_USER', 'SMTP_PASS', 'SMTP_FROM_EMAIL', 'SMTP_TLS_REJECT_UNAUTHORIZED', 'SMTP_REQUIRE_TLS'] as const
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetSmtpTransportForTests()
|
||||
for (const k of ENV) vi.stubEnv(k, '')
|
||||
sendMailMock.mockResolvedValue({ messageId: '<abc@relay>' })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
function configure(overrides: Partial<Record<(typeof ENV)[number], string>> = {}) {
|
||||
vi.stubEnv('SMTP_HOST', 'smtp.example.se')
|
||||
vi.stubEnv('SMTP_FROM_EMAIL', 'faktura@example.se')
|
||||
for (const [k, v] of Object.entries(overrides)) vi.stubEnv(k, v)
|
||||
}
|
||||
|
||||
describe('readSmtpSettings', () => {
|
||||
it('is null without host + from (the minimum)', () => {
|
||||
expect(readSmtpSettings()).toBeNull()
|
||||
vi.stubEnv('SMTP_HOST', 'smtp.example.se')
|
||||
expect(readSmtpSettings()).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults to STARTTLS on 587 and implicit TLS on 465 when SMTP_SECURE=true', () => {
|
||||
configure()
|
||||
expect(readSmtpSettings()).toMatchObject({ port: 587, secure: false, rejectUnauthorized: true, requireTLS: true, user: null })
|
||||
vi.stubEnv('SMTP_SECURE', 'true')
|
||||
expect(readSmtpSettings()).toMatchObject({ port: 465, secure: true })
|
||||
vi.stubEnv('SMTP_PORT', '2525')
|
||||
expect(readSmtpSettings()?.port).toBe(2525)
|
||||
})
|
||||
|
||||
it('reads credentials and the TLS overrides', () => {
|
||||
configure({ SMTP_USER: 'relay', SMTP_PASS: 's3cret', SMTP_TLS_REJECT_UNAUTHORIZED: 'false', SMTP_REQUIRE_TLS: 'false' })
|
||||
expect(readSmtpSettings()).toMatchObject({ user: 'relay', pass: 's3cret', rejectUnauthorized: false, requireTLS: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('SmtpEmailService', () => {
|
||||
it('reports not configured and sends nothing without settings', async () => {
|
||||
const svc = new SmtpEmailService()
|
||||
expect(svc.isConfigured()).toBe(false)
|
||||
const result = await svc.sendEmail({ to: 'a@b.se', subject: 's', html: '<p/>' })
|
||||
expect(result).toEqual({ success: false, error: 'Email service is not configured' })
|
||||
expect(createTransportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('builds the transport from the settings (auth only when a user is set, STARTTLS required by default)', async () => {
|
||||
configure()
|
||||
await new SmtpEmailService().sendEmail({ to: 'a@b.se', subject: 's', html: '<p/>' })
|
||||
// requireTLS: nodemailer's default is opportunistic STARTTLS, which a
|
||||
// STARTTLS-stripping on-path attacker downgrades to cleartext AUTH + mail.
|
||||
expect(createTransportMock).toHaveBeenCalledWith({
|
||||
host: 'smtp.example.se',
|
||||
port: 587,
|
||||
secure: false,
|
||||
requireTLS: true,
|
||||
tls: { rejectUnauthorized: true },
|
||||
})
|
||||
|
||||
resetSmtpTransportForTests()
|
||||
configure({ SMTP_USER: 'relay', SMTP_PASS: 'pw', SMTP_SECURE: 'true' })
|
||||
await new SmtpEmailService().sendEmail({ to: 'a@b.se', subject: 's', html: '<p/>' })
|
||||
expect(createTransportMock).toHaveBeenLastCalledWith({
|
||||
host: 'smtp.example.se',
|
||||
port: 465,
|
||||
secure: true,
|
||||
requireTLS: false,
|
||||
auth: { user: 'relay', pass: 'pw' },
|
||||
tls: { rejectUnauthorized: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('lets SMTP_REQUIRE_TLS=false opt a plaintext LAN relay out of mandatory STARTTLS', async () => {
|
||||
configure({ SMTP_REQUIRE_TLS: 'false' })
|
||||
await new SmtpEmailService().sendEmail({ to: 'a@b.se', subject: 's', html: '<p/>' })
|
||||
expect(createTransportMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secure: false, requireTLS: false }),
|
||||
)
|
||||
})
|
||||
|
||||
// Same From shape and the same header-injection defence as the Resend
|
||||
// service: CR/LF and angle brackets never reach the header.
|
||||
it('builds the From header like Resend does and strips injection characters', async () => {
|
||||
configure()
|
||||
const result = await new SmtpEmailService().sendEmail({
|
||||
to: ['a@b.se', 'c@d.se'],
|
||||
cc: 'e@f.se',
|
||||
subject: 'Faktura 1001',
|
||||
html: '<p>Hej</p>',
|
||||
text: 'Hej',
|
||||
replyTo: 'svar@example.se',
|
||||
fromName: 'Nordvik <Bygg>\r\nX-Injected: 1',
|
||||
attachments: [
|
||||
{ filename: 'f.pdf', content: Buffer.from('PDF').toString('base64'), contentType: 'application/pdf' },
|
||||
{ filename: 'g.pdf', content: Buffer.from('PDF2') },
|
||||
],
|
||||
})
|
||||
expect(result).toEqual({ success: true, provider: 'smtp', messageId: '<abc@relay>' })
|
||||
const mail = sendMailMock.mock.calls[0][0]
|
||||
// The colon in the sanitized name makes RFC 5322 require quoting, as in Resend.
|
||||
expect(mail.from).toBe('"Nordvik ByggX-Injected: 1" <faktura@example.se>')
|
||||
expect(mail.from).not.toMatch(/[\r\n<>].*</)
|
||||
expect(mail.to).toEqual(['a@b.se', 'c@d.se'])
|
||||
expect(mail.cc).toEqual(['e@f.se'])
|
||||
expect(mail.bcc).toBeUndefined()
|
||||
expect(mail.replyTo).toBe('svar@example.se')
|
||||
expect(mail.attachments[0]).toMatchObject({ filename: 'f.pdf', contentType: 'application/pdf' })
|
||||
expect(Buffer.isBuffer(mail.attachments[0].content)).toBe(true)
|
||||
expect(mail.attachments[0].content.toString()).toBe('PDF')
|
||||
expect(mail.attachments[1].content.toString()).toBe('PDF2')
|
||||
})
|
||||
|
||||
it('sends from the company sender when options.from is given, with the same injection guard', async () => {
|
||||
configure()
|
||||
const service = new SmtpEmailService()
|
||||
await service.sendEmail({
|
||||
to: 'a@b.se',
|
||||
subject: 'Faktura 1002',
|
||||
html: '<p>Hej</p>',
|
||||
fromName: 'ignored when from is explicit',
|
||||
from: { name: 'Nordvik Bygg AB', address: ' Faktura@Nordvik.se ' },
|
||||
})
|
||||
expect(sendMailMock.mock.calls[0][0].from).toBe('Nordvik Bygg AB <faktura@nordvik.se>')
|
||||
|
||||
await service.sendEmail({
|
||||
to: 'a@b.se',
|
||||
subject: 'Faktura 1003',
|
||||
html: '<p>Hej</p>',
|
||||
from: { name: 'Nordvik, Bygg <AB>\r\nBcc: x', address: 'faktura@nordvik.se' },
|
||||
})
|
||||
const injected = sendMailMock.mock.calls[1][0].from
|
||||
expect(injected).toBe('"Nordvik, Bygg ABBcc: x" <faktura@nordvik.se>')
|
||||
expect(injected).not.toMatch(/[\r\n<>].*</)
|
||||
})
|
||||
|
||||
it('falls back to the platform sender when options.from is malformed', async () => {
|
||||
configure()
|
||||
await new SmtpEmailService().sendEmail({
|
||||
to: 'a@b.se',
|
||||
subject: 'Faktura 1004',
|
||||
html: '<p>Hej</p>',
|
||||
fromName: 'Nordvik',
|
||||
from: { name: 'Nordvik', address: 'not an address' },
|
||||
})
|
||||
expect(sendMailMock.mock.calls[0][0].from).toBe('Nordvik <faktura@example.se>')
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the app name alone as the platform sender when no fromName is given', async () => {
|
||||
configure()
|
||||
await new SmtpEmailService().sendEmail({ to: 'a@b.se', subject: 's', html: '<p/>' })
|
||||
expect(sendMailMock.mock.calls[0][0].from).toBe('"Accounted evilBcc: x" <faktura@example.se>')
|
||||
})
|
||||
|
||||
// Same shape as the Resend service for a VERIFIED brand sender domain
|
||||
// (fromAddress is only set by lib/email/brand-sender.ts).
|
||||
it('sends as the brand sender when fromAddress is set and retries as the platform sender if refused', async () => {
|
||||
configure()
|
||||
const service = new SmtpEmailService()
|
||||
await service.sendEmail({
|
||||
to: 'a@b.se',
|
||||
subject: 'Faktura 1006',
|
||||
html: '<p>Hej</p>',
|
||||
fromName: 'Siffra',
|
||||
fromAddress: 'noreply@post.siffra.se',
|
||||
})
|
||||
expect(sendMailMock.mock.calls[0][0].from).toBe('Siffra <noreply@post.siffra.se>')
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
sendMailMock.mockRejectedValueOnce(new Error('5.7.60 SMTP; Client does not have permissions to send as this sender'))
|
||||
const result = await service.sendEmail({
|
||||
to: 'a@b.se',
|
||||
subject: 'Faktura 1007',
|
||||
html: '<p>Hej</p>',
|
||||
fromName: 'Siffra',
|
||||
fromAddress: 'noreply@post.siffra.se',
|
||||
})
|
||||
expect(result).toEqual({ success: true, provider: 'smtp', messageId: '<abc@relay>' })
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(3)
|
||||
expect(sendMailMock.mock.calls[1][0].from).toBe('Siffra <noreply@post.siffra.se>')
|
||||
expect(sendMailMock.mock.calls[2][0].from).toBe('Siffra <faktura@example.se>')
|
||||
})
|
||||
|
||||
it('strips header injection attempts from fromName and fromAddress', async () => {
|
||||
configure()
|
||||
await new SmtpEmailService().sendEmail({
|
||||
to: 'a@b.se',
|
||||
subject: 'Faktura 1008',
|
||||
html: '<p>Hej</p>',
|
||||
fromName: 'Evil\r\nName',
|
||||
fromAddress: 'noreply@post.siffra.se>\r\n<evil@x.se',
|
||||
})
|
||||
const from = sendMailMock.mock.calls[0][0].from as string
|
||||
expect(from).not.toMatch(/[\r\n]/)
|
||||
// The injected angle brackets are stripped; only the wrapper pair remains.
|
||||
expect(from.match(/</g)).toHaveLength(1)
|
||||
expect(from.match(/>/g)).toHaveLength(1)
|
||||
expect(from).toBe('EvilName <noreply@post.siffra.seevil@x.se>')
|
||||
})
|
||||
|
||||
it('retries once as the platform sender when the relay refuses the company sender', async () => {
|
||||
configure()
|
||||
sendMailMock.mockRejectedValueOnce(new Error('5.7.60 SMTP; Client does not have permissions to send as this sender'))
|
||||
const result = await new SmtpEmailService().sendEmail({
|
||||
to: 'a@b.se',
|
||||
subject: 'Faktura 1005',
|
||||
html: '<p>Hej</p>',
|
||||
fromName: 'Nordvik',
|
||||
from: { name: 'Nordvik Bygg AB', address: 'faktura@nordvik.se' },
|
||||
})
|
||||
expect(result).toEqual({ success: true, provider: 'smtp', messageId: '<abc@relay>' })
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(2)
|
||||
expect(sendMailMock.mock.calls[0][0].from).toBe('Nordvik Bygg AB <faktura@nordvik.se>')
|
||||
expect(sendMailMock.mock.calls[1][0].from).toBe('Nordvik <faktura@example.se>')
|
||||
})
|
||||
|
||||
it('does not retry when the platform sender itself is refused', async () => {
|
||||
configure()
|
||||
sendMailMock.mockRejectedValueOnce(new Error('421 relay busy'))
|
||||
const result = await new SmtpEmailService().sendEmail({ to: 'a@b.se', subject: 'x', html: 'x', fromName: 'Nordvik' })
|
||||
expect(result).toEqual({ success: false, provider: 'smtp', error: '421 relay busy' })
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses one transport across sends and rebuilds it when settings change', async () => {
|
||||
configure()
|
||||
const svc = new SmtpEmailService()
|
||||
await svc.sendEmail({ to: 'a@b.se', subject: 's', html: '<p/>' })
|
||||
await svc.sendEmail({ to: 'a@b.se', subject: 's', html: '<p/>' })
|
||||
expect(createTransportMock).toHaveBeenCalledTimes(1)
|
||||
vi.stubEnv('SMTP_PORT', '2525')
|
||||
await svc.sendEmail({ to: 'a@b.se', subject: 's', html: '<p/>' })
|
||||
expect(createTransportMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns the relay error instead of throwing', async () => {
|
||||
configure()
|
||||
sendMailMock.mockRejectedValueOnce(new Error('535 Authentication failed'))
|
||||
const result = await new SmtpEmailService().sendEmail({ to: 'a@b.se', subject: 's', html: '<p/>' })
|
||||
expect(result).toEqual({ success: false, provider: 'smtp', error: '535 Authentication failed' })
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import { createLogger } from '@/lib/logger'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { isSandboxCompany } from '@/lib/sandbox/guard'
|
||||
import { ResendEmailService } from './lib/resend-service'
|
||||
import { createEmailService } from './lib/email-provider'
|
||||
import {
|
||||
ResendDeliverySignatureError,
|
||||
isDeliveryWebhookConfigured,
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
updateSendingDomainSettings,
|
||||
} from './lib/sending-domains'
|
||||
|
||||
// Register the Resend implementation immediately when this extension is loaded
|
||||
registerEmailService(new ResendEmailService())
|
||||
// Register the implementation for this deployment immediately when the
|
||||
// extension is loaded: Resend (hosted default) or SMTP (EMAIL_PROVIDER=smtp,
|
||||
// the sovereign self-host path). See lib/email-provider.ts for precedence.
|
||||
registerEmailService(createEmailService())
|
||||
|
||||
const log = createLogger('email-delivery-webhook')
|
||||
|
||||
@@ -84,7 +86,7 @@ async function guardSendingDomainRoute(
|
||||
|
||||
export const emailExtension: Extension = {
|
||||
id: 'email',
|
||||
name: 'E-post (Resend)',
|
||||
name: 'E-post',
|
||||
version: '1.0.0',
|
||||
|
||||
apiRoutes: [
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { EmailService } from '@/lib/email/service'
|
||||
import { ResendEmailService } from './resend-service'
|
||||
import { SmtpEmailService } from './smtp-service'
|
||||
|
||||
export type EmailProviderKind = 'resend' | 'smtp'
|
||||
|
||||
/**
|
||||
* Which outbound-mail implementation this deployment uses.
|
||||
*
|
||||
* 1. EMAIL_PROVIDER=resend|smtp wins when set (the escape hatch for a
|
||||
* deployment that has both configured).
|
||||
* 2. RESEND_API_KEY present: Resend. This keeps hosted byte-identical:
|
||||
* adding SMTP variables on the side can never move hosted mail.
|
||||
* 3. SMTP_HOST present: SMTP. The sovereign self-host path.
|
||||
* 4. Otherwise Resend, whose isConfigured() is false, so email-dependent
|
||||
* features degrade exactly as before (PDF download, no send).
|
||||
*/
|
||||
export function resolveEmailProvider(): EmailProviderKind {
|
||||
const explicit = (process.env.EMAIL_PROVIDER ?? '').trim().toLowerCase()
|
||||
if (explicit === 'resend' || explicit === 'smtp') return explicit
|
||||
if (process.env.RESEND_API_KEY) return 'resend'
|
||||
if (process.env.SMTP_HOST) return 'smtp'
|
||||
return 'resend'
|
||||
}
|
||||
|
||||
export function createEmailService(provider: EmailProviderKind = resolveEmailProvider()): EmailService {
|
||||
return provider === 'smtp' ? new SmtpEmailService() : new ResendEmailService()
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* SMTP Email Service Implementation
|
||||
*
|
||||
* Implements EmailService over any SMTP relay via nodemailer: the provider a
|
||||
* sovereign self-host uses instead of Resend (US), e.g. a Swedish mail
|
||||
* provider, a byrå's own Microsoft 365 / Google Workspace relay, or Postfix
|
||||
* on the host. Selected by EMAIL_PROVIDER=smtp (or auto-detected from
|
||||
* SMTP_HOST when no RESEND_API_KEY is set): see email-provider.ts.
|
||||
*
|
||||
* Environment:
|
||||
* SMTP_HOST required
|
||||
* SMTP_PORT default 587
|
||||
* SMTP_SECURE "true" = implicit TLS (port 465); default
|
||||
* false = STARTTLS, required by default
|
||||
* SMTP_REQUIRE_TLS default true: with SMTP_SECURE=false the
|
||||
* session MUST upgrade to TLS via STARTTLS
|
||||
* before AUTH and mail (nodemailer's own
|
||||
* default is opportunistic, which lets an
|
||||
* on-path STARTTLS-stripping attacker read
|
||||
* the credentials and every invoice PDF).
|
||||
* "false" only for a plaintext LAN relay.
|
||||
* SMTP_USER / SMTP_PASS optional (an internal relay may be open to
|
||||
* the Docker network only)
|
||||
* SMTP_FROM_EMAIL required: the envelope/From address
|
||||
* SMTP_TLS_REJECT_UNAUTHORIZED default true; "false" accepts a relay's
|
||||
* self-signed certificate (LAN relays only)
|
||||
*
|
||||
* The From header is built exactly like the Resend service does it, with the
|
||||
* same header-injection defence, so a customer sees the same sender shape
|
||||
* whichever provider the operator picked.
|
||||
*/
|
||||
|
||||
import nodemailer, { type Transporter } from 'nodemailer'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import type { EmailService, SendEmailOptions, SendEmailResult } from '@/lib/email/service'
|
||||
|
||||
const log = createLogger('email-smtp')
|
||||
|
||||
function sanitizeHeaderPart(s: string): string {
|
||||
return s.replace(/[\r\n<>]/g, '').trim()
|
||||
}
|
||||
|
||||
// RFC 5322 "specials" that make a bare display name ambiguous (a comma splits
|
||||
// the mailbox list, a quote or parenthesis opens a token). Mirrors
|
||||
// resend-service.ts so both providers emit the same From shape.
|
||||
const DISPLAY_NAME_SPECIALS = /[()<>[\]:;@\\,."]/
|
||||
|
||||
/** Quote a display name only when RFC 5322 requires it; escape `\` and `"`. */
|
||||
function encodeDisplayName(name: string): string {
|
||||
if (!DISPLAY_NAME_SPECIALS.test(name)) return name
|
||||
return `"${name.replace(/[\\"]/g, (c) => `\\${c}`)}"`
|
||||
}
|
||||
|
||||
// Same conservative shape as resend-service.ts: a last-line guard against a
|
||||
// malformed company_sending_domains row, not an RFC 5322 parser.
|
||||
const FROM_ADDRESS_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}@[a-z0-9.-]{4,253}$/
|
||||
|
||||
/**
|
||||
* Builds the From header exactly like resend-service.ts buildFromHeader().
|
||||
* With an explicit `from` (the company's own verified sending domain, #1802)
|
||||
* the mail leaves as "<name> <address>" and the relay's default sender is not
|
||||
* involved. `fromAddress` is only ever set by lib/email/brand-sender.ts for
|
||||
* VERIFIED brand sender domains (WL-13): "<fromName> <fromAddress>". Otherwise
|
||||
* the platform default: "<fromName> <SMTP_FROM_EMAIL>" or
|
||||
* "<App> <SMTP_FROM_EMAIL>". A fromName WITHOUT an explicit address shows the
|
||||
* name ALONE (founder call 2026-08-05: no "via <platform>" in the display
|
||||
* name; the platform stays visible in the actual From address until a sender
|
||||
* domain is verified). A malformed explicit sender falls through to the
|
||||
* platform default rather than failing the send.
|
||||
*
|
||||
* Mirrored rather than imported because the platform fallback address differs
|
||||
* per provider (RESEND_FROM_EMAIL there, SMTP_FROM_EMAIL here). Same
|
||||
* header-injection defence: CRLF and angle brackets are stripped from every
|
||||
* name and address part. Exported for unit tests.
|
||||
*/
|
||||
export function buildSmtpFromHeader(input: {
|
||||
fromName?: string
|
||||
from?: { name: string; address: string }
|
||||
fromAddress?: string
|
||||
defaultFromEmail: string
|
||||
}): string {
|
||||
const safeAppName = sanitizeHeaderPart(getBranding().appName)
|
||||
|
||||
if (input.from) {
|
||||
const address = input.from.address.trim().toLowerCase()
|
||||
const name = sanitizeHeaderPart(input.from.name)
|
||||
if (FROM_ADDRESS_PATTERN.test(address) && name) {
|
||||
return `${encodeDisplayName(name)} <${address}>`
|
||||
}
|
||||
}
|
||||
|
||||
const safeFromName = input.fromName ? sanitizeHeaderPart(input.fromName) : null
|
||||
const safeFromAddress = input.fromAddress ? sanitizeHeaderPart(input.fromAddress) : null
|
||||
if (safeFromAddress) {
|
||||
return safeFromName
|
||||
? `${encodeDisplayName(safeFromName)} <${safeFromAddress}>`
|
||||
: safeFromAddress
|
||||
}
|
||||
return safeFromName
|
||||
? `${encodeDisplayName(safeFromName)} <${input.defaultFromEmail}>`
|
||||
: `${encodeDisplayName(safeAppName)} <${input.defaultFromEmail}>`
|
||||
}
|
||||
|
||||
function optionalAddressList(addresses: string | string[] | undefined): string[] | undefined {
|
||||
if (!addresses) return undefined
|
||||
const list = Array.isArray(addresses) ? addresses : [addresses]
|
||||
return list.length > 0 ? list : undefined
|
||||
}
|
||||
|
||||
function envBool(name: string, fallback: boolean): boolean {
|
||||
const v = process.env[name]?.trim().toLowerCase()
|
||||
if (v === undefined || v === '') return fallback
|
||||
if (v === 'true' || v === '1' || v === 'yes') return true
|
||||
if (v === 'false' || v === '0' || v === 'no') return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
export interface SmtpSettings {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
user: string | null
|
||||
pass: string | null
|
||||
fromEmail: string
|
||||
rejectUnauthorized: boolean
|
||||
/** STARTTLS is mandatory (default). Only false for a plaintext LAN relay. */
|
||||
requireTLS: boolean
|
||||
}
|
||||
|
||||
/** Read the SMTP settings from the environment; null when the minimum (host + from) is missing. */
|
||||
export function readSmtpSettings(): SmtpSettings | null {
|
||||
const host = process.env.SMTP_HOST?.trim()
|
||||
const fromEmail = process.env.SMTP_FROM_EMAIL?.trim()
|
||||
if (!host || !fromEmail) return null
|
||||
const parsedPort = Number(process.env.SMTP_PORT)
|
||||
const secure = envBool('SMTP_SECURE', false)
|
||||
return {
|
||||
host,
|
||||
port: Number.isFinite(parsedPort) && parsedPort > 0 ? Math.floor(parsedPort) : secure ? 465 : 587,
|
||||
secure,
|
||||
user: process.env.SMTP_USER?.trim() || null,
|
||||
pass: process.env.SMTP_PASS ?? null,
|
||||
fromEmail,
|
||||
rejectUnauthorized: envBool('SMTP_TLS_REJECT_UNAUTHORIZED', true),
|
||||
requireTLS: envBool('SMTP_REQUIRE_TLS', true),
|
||||
}
|
||||
}
|
||||
|
||||
export function isSmtpConfigured(): boolean {
|
||||
return readSmtpSettings() !== null
|
||||
}
|
||||
|
||||
let cachedTransport: { key: string; transport: Transporter } | null = null
|
||||
|
||||
function getTransport(settings: SmtpSettings): Transporter {
|
||||
const key = JSON.stringify({ ...settings, pass: settings.pass ? 'set' : 'unset' })
|
||||
if (cachedTransport && cachedTransport.key === key) return cachedTransport.transport
|
||||
const transport = nodemailer.createTransport({
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
secure: settings.secure,
|
||||
// nodemailer's default STARTTLS is opportunistic: it silently carries on in
|
||||
// cleartext when the relay (or an on-path attacker stripping the STARTTLS
|
||||
// capability) does not offer TLS. Refuse that unless the operator opted
|
||||
// out for a plaintext LAN relay. Irrelevant with implicit TLS (secure).
|
||||
requireTLS: !settings.secure && settings.requireTLS,
|
||||
...(settings.user ? { auth: { user: settings.user, pass: settings.pass ?? '' } } : {}),
|
||||
tls: { rejectUnauthorized: settings.rejectUnauthorized },
|
||||
})
|
||||
cachedTransport = { key, transport }
|
||||
return transport
|
||||
}
|
||||
|
||||
/** Tests only: forget the cached transport so the next send re-reads the environment. */
|
||||
export function resetSmtpTransportForTests(): void {
|
||||
cachedTransport = null
|
||||
}
|
||||
|
||||
export class SmtpEmailService implements EmailService {
|
||||
async sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const { to, cc, bcc, subject, html, text, replyTo, fromName, fromAddress, attachments } = options
|
||||
|
||||
const settings = readSmtpSettings()
|
||||
if (!settings) {
|
||||
return { success: false, error: 'Email service is not configured' }
|
||||
}
|
||||
|
||||
const from = buildSmtpFromHeader({ fromName, from: options.from, fromAddress, defaultFromEmail: settings.fromEmail })
|
||||
// The retry deliberately drops both the company sender and the brand
|
||||
// address so it differs from `from` whenever either was set.
|
||||
const platformFrom = buildSmtpFromHeader({ fromName, defaultFromEmail: settings.fromEmail })
|
||||
const mail = {
|
||||
to: Array.isArray(to) ? to : [to],
|
||||
cc: optionalAddressList(cc),
|
||||
bcc: optionalAddressList(bcc),
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
replyTo,
|
||||
attachments: attachments?.map((att) => ({
|
||||
filename: att.filename,
|
||||
content:
|
||||
typeof att.content === 'string' ? Buffer.from(att.content, 'base64') : Buffer.from(att.content),
|
||||
contentType: att.contentType,
|
||||
})),
|
||||
}
|
||||
|
||||
try {
|
||||
const transport = getTransport(settings)
|
||||
let info: { messageId?: string }
|
||||
try {
|
||||
info = await transport.sendMail({ from, ...mail })
|
||||
} catch (error) {
|
||||
// Same fallback as the Resend service: a relay may refuse to send as
|
||||
// an address it does not own (e.g. a Microsoft 365 "send as" policy).
|
||||
// The relay rejected the message, so nothing went out: retry once as
|
||||
// the platform sender rather than failing every invoice for that
|
||||
// company.
|
||||
if (from === platformFrom) throw error
|
||||
log.warn('SMTP relay rejected the company sender, retrying as the platform sender', {
|
||||
from,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
info = await transport.sendMail({ from: platformFrom, ...mail })
|
||||
}
|
||||
return { success: true, provider: 'smtp', messageId: info.messageId }
|
||||
} catch (error) {
|
||||
log.error('Failed to send email over SMTP:', error)
|
||||
return {
|
||||
success: false,
|
||||
provider: 'smtp',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return isSmtpConfigured()
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,36 @@
|
||||
"exportName": "emailExtension",
|
||||
"entryPoint": "@/extensions/general/email",
|
||||
"workspace": null,
|
||||
"requiredEnvVars": ["RESEND_API_KEY", "RESEND_FROM_EMAIL"],
|
||||
"optionalEnvVars": ["RESEND_DELIVERY_WEBHOOK_SECRET"],
|
||||
"npmDependencies": ["resend"],
|
||||
"requiredEnvVars": [],
|
||||
"optionalEnvVars": [
|
||||
"EMAIL_PROVIDER",
|
||||
"RESEND_API_KEY",
|
||||
"RESEND_FROM_EMAIL",
|
||||
"RESEND_DELIVERY_WEBHOOK_SECRET",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PORT",
|
||||
"SMTP_SECURE",
|
||||
"SMTP_USER",
|
||||
"SMTP_PASS",
|
||||
"SMTP_FROM_EMAIL",
|
||||
"SMTP_REQUIRE_TLS",
|
||||
"SMTP_TLS_REJECT_UNAUTHORIZED"
|
||||
],
|
||||
"npmDependencies": [
|
||||
"resend",
|
||||
"nodemailer"
|
||||
],
|
||||
"definition": {
|
||||
"name": "E-post (Resend)",
|
||||
"name": "E-post",
|
||||
"category": "operations",
|
||||
"icon": "Mail",
|
||||
"dataPattern": "core",
|
||||
"readsCoreTables": ["invoices", "customers", "company_settings"],
|
||||
"readsCoreTables": [
|
||||
"invoices",
|
||||
"customers",
|
||||
"company_settings"
|
||||
],
|
||||
"description": "Skicka fakturor och påminnelser via e-post",
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän."
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän eller en egen SMTP-server (EMAIL_PROVIDER=smtp)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7136,7 +7136,7 @@ export const tools: McpTool[] = [
|
||||
|
||||
const emailService = getEmailService()
|
||||
if (!emailService.isConfigured()) {
|
||||
throw new Error('Email service not configured. Ensure RESEND_API_KEY and RESEND_FROM_EMAIL are set.')
|
||||
throw new Error('Email service not configured. Ensure RESEND_API_KEY and RESEND_FROM_EMAIL are set (or SMTP_HOST and SMTP_FROM_EMAIL with EMAIL_PROVIDER=smtp).')
|
||||
}
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
|
||||
@@ -1070,10 +1070,10 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
INVOICE_SEND_EMAIL_NOT_CONFIGURED: {
|
||||
httpStatus: 503,
|
||||
message_sv:
|
||||
'E-posttjänsten är inte konfigurerad. Kontrollera att RESEND_API_KEY och RESEND_FROM_EMAIL är satta.',
|
||||
'E-posttjänsten är inte konfigurerad. Kontrollera att RESEND_API_KEY och RESEND_FROM_EMAIL är satta (eller SMTP_HOST och SMTP_FROM_EMAIL med EMAIL_PROVIDER=smtp).',
|
||||
message_en: 'Email service is not configured.',
|
||||
remediation: {
|
||||
description: 'Set RESEND_API_KEY and RESEND_FROM_EMAIL in the deployment environment.',
|
||||
description: 'Set RESEND_API_KEY and RESEND_FROM_EMAIL (or SMTP_HOST and SMTP_FROM_EMAIL with EMAIL_PROVIDER=smtp) in the deployment environment.',
|
||||
},
|
||||
},
|
||||
INVOICE_SEND_NO_CUSTOMER_EMAIL: {
|
||||
|
||||
+2
-2
@@ -32,13 +32,13 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
},
|
||||
{
|
||||
"slug": "email",
|
||||
"name": "E-post (Resend)",
|
||||
"name": "E-post",
|
||||
"sector": "general",
|
||||
"category": "operations",
|
||||
"icon": "Mail",
|
||||
"dataPattern": "core",
|
||||
"description": "Skicka fakturor och påminnelser via e-post",
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän eller en egen SMTP-server (EMAIL_PROVIDER=smtp).",
|
||||
"readsCoreTables": [
|
||||
"invoices",
|
||||
"customers",
|
||||
|
||||
+2
-2
@@ -5681,9 +5681,9 @@
|
||||
"ext_enable_banking_name": "Bank integration (PSD2)",
|
||||
"ext_enable_banking_description": "Automatic bank transaction sync via PSD2",
|
||||
"ext_enable_banking_long_description": "Connect your bank account directly and sync transactions automatically via secure PSD2 bank integration. Supports most Swedish banks.",
|
||||
"ext_email_name": "Email (Resend)",
|
||||
"ext_email_name": "Email",
|
||||
"ext_email_description": "Send invoices and reminders via email",
|
||||
"ext_email_long_description": "Enables email features: send invoices to customers, automatic payment reminders on your chosen schedule, and email notifications. Requires a Resend account with a verified domain.",
|
||||
"ext_email_long_description": "Enables email features: send invoices to customers, automatic payment reminders on your chosen schedule, and email notifications. Requires a Resend account with a verified domain or your own SMTP server.",
|
||||
"ext_arcim_migration_name": "System migration",
|
||||
"ext_arcim_registration_links_label": "Voucher links",
|
||||
"ext_arcim_registration_links_value": "{linked} of {scanned} invoices linked to their booking voucher",
|
||||
|
||||
+2
-2
@@ -5681,9 +5681,9 @@
|
||||
"ext_enable_banking_name": "Bankintegration (PSD2)",
|
||||
"ext_enable_banking_description": "Automatisk banktransaktionssynk via PSD2",
|
||||
"ext_enable_banking_long_description": "Koppla ditt bankkonto direkt och synka transaktioner automatiskt via säker PSD2-bankintegration. Stöder de flesta svenska banker.",
|
||||
"ext_email_name": "E-post (Resend)",
|
||||
"ext_email_name": "E-post",
|
||||
"ext_email_description": "Skicka fakturor och påminnelser via e-post",
|
||||
"ext_email_long_description": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
|
||||
"ext_email_long_description": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän eller en egen SMTP-server.",
|
||||
"ext_arcim_migration_name": "Systemmigration",
|
||||
"ext_arcim_registration_links_label": "Verifikatkoppling",
|
||||
"ext_arcim_registration_links_value": "{linked} av {scanned} fakturor kopplade till bokföringsverifikat",
|
||||
|
||||
Generated
+24
-3
@@ -47,6 +47,7 @@
|
||||
"next": "16.3.1",
|
||||
"next-intl": "^4.13.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "9.0.5",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"posthog-js": "^1.407.3",
|
||||
"posthog-node": "^5.46.1",
|
||||
@@ -73,6 +74,7 @@
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -7781,6 +7783,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
@@ -12748,6 +12760,15 @@
|
||||
"tlds": "1.261.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/nodemailer": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@@ -13896,9 +13917,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"next": "16.3.1",
|
||||
"next-intl": "^4.13.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "9.0.5",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"posthog-js": "^1.407.3",
|
||||
"posthog-node": "^5.46.1",
|
||||
@@ -94,6 +95,7 @@
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -686,6 +686,13 @@ const PINNED_DEPS = [
|
||||
'Paired with ai 6.x; the provider package follows its own major cadence and must move together with ' +
|
||||
'the core pin in one reviewed change.',
|
||||
},
|
||||
{
|
||||
name: 'nodemailer',
|
||||
version: '9.0.5',
|
||||
reason:
|
||||
'SMTP mailer for self-hosts (extensions/general/email/lib/smtp-service.ts). Zero-dependency MIT-0 ' +
|
||||
'package on the outbound-mail path; bumps are deliberate, reviewed PRs (audit surface), never silent.',
|
||||
},
|
||||
]
|
||||
|
||||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
@@ -614,14 +614,14 @@ Response `200` (`application/pdf`).
|
||||
**Send a draft invoice to the customer by email.**
|
||||
`scope:invoices:write · risk:high · idempotent · dry-run`
|
||||
|
||||
The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via Resend (PDF attachment, copy to company) → flip status to sent → post journal entry (real invoice, unless kontantmetoden or defer_invoice_booking) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent.
|
||||
The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via the email extension (Resend or SMTP; PDF attachment, copy to company) → flip status to sent → post journal entry (real invoice, unless kontantmetoden or defer_invoice_booking) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent.
|
||||
|
||||
**Use when:** You want Accounted to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead.
|
||||
**Do not use for:** Re-sending an already-sent invoice (returns 409 INVOICE_UPDATE_NOT_DRAFT). Sending a delivery note (no F-series lifecycle). Sending a credit note (use the :credit endpoint to issue the kreditfaktura; subsequent re-send of the credit note via :mark-sent is the supported path).
|
||||
|
||||
**Pitfalls:**
|
||||
- Idempotency-Key is mandatory.
|
||||
- Email service must be configured: without RESEND_API_KEY + RESEND_FROM_EMAIL the endpoint returns 503 INVOICE_SEND_EMAIL_NOT_CONFIGURED.
|
||||
- Email service must be configured: without RESEND_API_KEY + RESEND_FROM_EMAIL (or an SMTP relay via EMAIL_PROVIDER=smtp) the endpoint returns 503 INVOICE_SEND_EMAIL_NOT_CONFIGURED.
|
||||
- Customer must have an email address. 400 INVOICE_SEND_NO_CUSTOMER_EMAIL otherwise.
|
||||
- A cancelled invoice is rejected (400 INVOICE_SEND_CANCELLED): its F-series number is preserved for compliance but the document is not a valid faktura.
|
||||
- Email failure before the status flip leaves the F-series number consumed but the invoice in `draft` status. Same orphan window as :mark-sent (architecturally tracked, matches internal route).
|
||||
|
||||
Reference in New Issue
Block a user