Coverage for apps/communications/delivery.py: 95%

66 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 17:24 +0000

1"""Outbound email delivery through Twilio SendGrid (add-sendgrid-email). 

2 

3Same port-and-stub pattern as the other external seams (``underwriting/providers.py``): 

4 

5- ``EmailSender`` is the port; ``SendGridEmailSender`` is the real adapter and 

6 ``SimulatedEmailSender`` is used when no SendGrid key is configured (local dev, tests). 

7- ``deliver_email`` is the one place every email goes through: it records the ``Communication`` 

8 (provider, provider message id, status) and its first event, so Staff LOS → Communications shows 

9 every email and the SendGrid webhook can later move it to Delivered / Opened. 

10- Every email is sent as plain text plus the branded HTML layout (``email_content.py``, 

11 brand-email-template). 

12""" 

13 

14from __future__ import annotations 

15 

16import logging 

17from dataclasses import dataclass 

18from typing import Protocol 

19 

20from django.conf import settings 

21 

22from apps.communications.email_content import EmailContent, render_html 

23from apps.communications.models import Communication, CommunicationEvent 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28class EmailSendError(Exception): 

29 """The provider refused or failed to accept the email.""" 

30 

31 

32@dataclass(frozen=True) 

33class SendResult: 

34 provider: str 

35 message_id: str | None 

36 

37 

38class EmailSender(Protocol): 

39 provider: str 

40 

41 def send( 

42 self, *, to: str, subject: str, body: str, html: str, comm_id: int, track_clicks: bool 

43 ) -> SendResult: ... 

44 

45 

46class SimulatedEmailSender: 

47 """No SendGrid key: nothing leaves the server; the message is only recorded.""" 

48 

49 provider = "simulated" 

50 

51 def send(self, *, to, subject, body, html, comm_id, track_clicks) -> SendResult: 

52 return SendResult(provider=self.provider, message_id=None) 

53 

54 

55class SendGridEmailSender: 

56 provider = "sendgrid" 

57 

58 def __init__(self, api_key: str, from_email: str, from_name: str): 

59 self.api_key = api_key 

60 self.from_email = from_email 

61 self.from_name = from_name 

62 

63 def send(self, *, to, subject, body, html, comm_id, track_clicks) -> SendResult: 

64 from sendgrid import SendGridAPIClient 

65 from sendgrid.helpers.mail import ( 

66 ClickTracking, 

67 CustomArg, 

68 Email, 

69 Mail, 

70 OpenTracking, 

71 TrackingSettings, 

72 ) 

73 

74 message = Mail( 

75 from_email=Email(self.from_email, self.from_name), 

76 to_emails=to, 

77 subject=subject, 

78 plain_text_content=body, 

79 html_content=html, 

80 ) 

81 # Open tracking feeds "Opened"; links are only rewritten for click tracking where it is 

82 # safe (never for password / reset emails). 

83 message.tracking_settings = TrackingSettings( 

84 click_tracking=ClickTracking(track_clicks, track_clicks), 

85 open_tracking=OpenTracking(True), 

86 ) 

87 # Every webhook event carries this back, so it maps to our Communication row. 

88 message.custom_arg = CustomArg("comm_id", str(comm_id)) 

89 try: 

90 response = SendGridAPIClient(self.api_key).send(message) 

91 except Exception as exc: # network error or 4xx/5xx raised by the SDK 

92 raise EmailSendError(str(exc)) from exc 

93 if response.status_code not in (200, 202): 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 raise EmailSendError(f"SendGrid answered {response.status_code}") 

95 headers = getattr(response, "headers", {}) or {} 

96 message_id = headers.get("X-Message-Id") or headers.get("x-message-id") 

97 return SendResult(provider=self.provider, message_id=message_id) 

98 

99 

100def get_email_sender() -> EmailSender: 

101 """SendGrid when a key and a from-address are configured, otherwise simulated.""" 

102 if settings.SENDGRID_API_KEY and settings.SENDGRID_FROM_EMAIL: 

103 return SendGridEmailSender( 

104 settings.SENDGRID_API_KEY, settings.SENDGRID_FROM_EMAIL, settings.SENDGRID_FROM_NAME 

105 ) 

106 return SimulatedEmailSender() 

107 

108 

109def deliver_email( 

110 *, 

111 to: str, 

112 subject: str, 

113 body: str | None = None, 

114 content: EmailContent | None = None, 

115 trigger: str | None = None, 

116 loan=None, 

117 automated: bool = False, 

118 template=None, 

119 track_clicks: bool = True, 

120 raise_on_failure: bool = False, 

121) -> Communication: 

122 """Send one email and record it. 

123 

124 Pass ``content`` (system emails, see ``emails.py``) or plain ``body`` text (staff Compose); 

125 either way the email goes out as plain text plus the branded HTML layout, and the plain text is 

126 what is stored on the ``Communication``. 

127 

128 The ``Communication`` is created first (its id goes to SendGrid as ``comm_id``), then sent. 

129 Success → status Sent + a ``sent`` event (+ the provider message id). Failure → status Failed 

130 + a ``failed`` event; the error is logged, never shown to users. With ``raise_on_failure`` the 

131 caller gets ``EmailSendError`` (e.g. an invite that must not be created without its email). 

132 """ 

133 if content is None: 

134 content = EmailContent.from_text(body or "") 

135 body = content.to_text() 

136 html = render_html(content, subject) 

137 sender = get_email_sender() 

138 comm = Communication.objects.create( 

139 loan=loan, 

140 channel="email", 

141 template=template, 

142 to=to, 

143 subject=subject, 

144 body=body, 

145 status="Queued", 

146 provider=sender.provider, 

147 automated=automated, 

148 trigger=trigger, 

149 ) 

150 try: 

151 result = sender.send( 

152 to=to, subject=subject, body=body, html=html, comm_id=comm.id, 

153 track_clicks=track_clicks, 

154 ) 

155 except EmailSendError as exc: 

156 logger.warning("Email %s (%s) to %s failed: %s", comm.id, trigger, to, exc) 

157 comm.status = "Failed" 

158 comm.save(update_fields=["status", "updated_at"]) 

159 CommunicationEvent.objects.create(communication=comm, event_type="failed") 

160 if raise_on_failure: 

161 raise 

162 return comm 

163 comm.provider_message_id = result.message_id 

164 comm.status = "Sent" 

165 comm.save(update_fields=["provider_message_id", "status", "updated_at"]) 

166 CommunicationEvent.objects.create(communication=comm, event_type="sent") 

167 return comm