From ff111d619d6b291561b172c0d7005e1b2fd45b86 Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Wed, 26 Aug 2026 23:39:47 -0700 Subject: [PATCH] Polish answer notifications and editing controls. Brand notification emails, log accepted sends, and keep answer editing collapsed until explicitly opened. --- internal/mail/mail.go | 121 ++++++++++++++++++++++++++------ internal/mail/mail_test.go | 76 ++++++++++++++++++++ internal/mail/mark.png | Bin 0 -> 16541 bytes internal/web/server.go | 2 + internal/web/server_test.go | 22 ++++++ static/app.css | 38 ++++++++++ templates/partials/_answer.html | 16 +++++ templates/question.html | 6 +- 8 files changed, 258 insertions(+), 23 deletions(-) create mode 100644 internal/mail/mail_test.go create mode 100644 internal/mail/mark.png diff --git a/internal/mail/mail.go b/internal/mail/mail.go index f716ae9..86084d6 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -2,6 +2,7 @@ package mail import ( "context" + _ "embed" "fmt" "html" "os" @@ -10,6 +11,9 @@ import ( "github.com/resend/resend-go/v3" ) +//go:embed mark.png +var markPNG []byte + // QuestionAnswered is the payload for notifying a question author of a reply. type QuestionAnswered struct { ToEmail string @@ -66,7 +70,29 @@ func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswere if to == "" { return nil } - link := r.baseURL + "/questions/" + msg.QuestionID + text, htmlBody := questionAnsweredContent(r.baseURL, msg) + params := &resend.SendEmailRequest{ + From: r.from, + To: []string{to}, + Subject: "Your question was answered", + Text: text, + Html: htmlBody, + Attachments: []*resend.Attachment{{ + Content: markPNG, + Filename: "ask-a-plumber-first.png", + ContentType: "image/png", + ContentId: "answer-notification-mark", + }}, + } + opts := &resend.SendEmailOptions{ + IdempotencyKey: "answer-notify:" + msg.QuestionID, + } + _, err := r.client.Emails.SendWithOptions(ctx, params, opts) + return err +} + +func questionAnsweredContent(baseURL string, msg QuestionAnswered) (string, string) { + link := strings.TrimRight(baseURL, "/") + "/questions/" + msg.QuestionID title := strings.TrimSpace(msg.QuestionTitle) if title == "" { title = "your question" @@ -78,27 +104,82 @@ func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswere msg.AnswerBody, link, ) - htmlBody := fmt.Sprintf( - `

Hi%s,

Your question %s has an answer from a plumber:

%s

View the answer

`, - html.EscapeString(greetingName(msg.ToName)), - html.EscapeString(title), - html.EscapeString(msg.AnswerBody), - html.EscapeString(link), - ) - params := &resend.SendEmailRequest{ - From: r.from, - To: []string{to}, - Subject: "Your question was answered", - Text: text, - Html: htmlBody, - } - opts := &resend.SendEmailOptions{ - IdempotencyKey: "answer-notify:" + msg.QuestionID, - } - _, err := r.client.Emails.SendWithOptions(ctx, params, opts) - return err + htmlBody := strings.NewReplacer( + "{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."), + "{{GREETING}}", html.EscapeString(greetingName(msg.ToName)), + "{{TITLE}}", html.EscapeString(title), + "{{ANSWER}}", html.EscapeString(msg.AnswerBody), + "{{LINK}}", html.EscapeString(link), + "{{MARK}}", "cid:answer-notification-mark", + ).Replace(questionAnsweredHTML) + return text, htmlBody } +const questionAnsweredHTML = ` + + + + + + + Your question was answered + + +
{{PREHEADER}}
+ + + + +
+ + + + + + + + + + +
+ + + + + +
+ + +
Ask a Plumber First
+
Bay Area · Shop Dispatch
+
+
+
Shop response
+

Your question has an answer.

+

Hi{{GREETING}}, a plumber replied to:

+

“{{TITLE}}”

+ + + + +
+
The answer
+
{{ANSWER}}
+
+ + + + +
+ View the answer → +
+
+ You received this because you asked a question on Ask a Plumber First. +
+
+ +` + func greetingName(name string) string { name = strings.TrimSpace(name) if name == "" { diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go new file mode 100644 index 0000000..624505c --- /dev/null +++ b/internal/mail/mail_test.go @@ -0,0 +1,76 @@ +package mail + +import ( + "bytes" + "strings" + "testing" +) + +func TestEmbeddedMarkIsPNG(t *testing.T) { + t.Parallel() + + if !bytes.HasPrefix(markPNG, []byte("\x89PNG\r\n\x1a\n")) { + t.Fatal("embedded mark is not PNG data") + } +} + +func TestQuestionAnsweredContent(t *testing.T) { + t.Parallel() + + text, htmlBody := questionAnsweredContent("https://plumber.example/", QuestionAnswered{ + ToName: ``, + QuestionID: "question-123", + QuestionTitle: `Leaky sink`, + AnswerBody: "Replace the cartridge.\nThen test the handle. ", + }) + + for _, want := range []string{ + "Ask a Plumber First", + "Shop response", + "cid:answer-notification-mark", + "https://plumber.example/questions/question-123", + "white-space:pre-wrap", + "<Sam & Pat>", + "<b>Leaky sink</b>", + "<script>alert('x')</script>", + } { + if !strings.Contains(htmlBody, want) { + t.Errorf("HTML missing %q", want) + } + } + for _, unsafe := range []string{ + "", + "Leaky sink", + "", + } { + if strings.Contains(htmlBody, unsafe) { + t.Errorf("HTML contains unescaped content %q", unsafe) + } + } + if strings.Contains(htmlBody, "{{") { + t.Error("HTML contains an unresolved template token") + } + for _, want := range []string{ + `Hi ,`, + `Your question "Leaky sink"`, + "Replace the cartridge.\nThen test the handle.", + "https://plumber.example/questions/question-123", + } { + if !strings.Contains(text, want) { + t.Errorf("text missing %q", want) + } + } +} + +func TestQuestionAnsweredContentUsesFallbackTitle(t *testing.T) { + t.Parallel() + + text, htmlBody := questionAnsweredContent("https://plumber.example", QuestionAnswered{}) + if !strings.Contains(text, `"your question"`) { + t.Errorf("text missing fallback title") + } + if !strings.Contains(htmlBody, "a plumber replied to:

") || + !strings.Contains(htmlBody, "“your question”") { + t.Errorf("HTML missing fallback title") + } +} diff --git a/internal/mail/mark.png b/internal/mail/mark.png new file mode 100644 index 0000000000000000000000000000000000000000..59feb94133736ea448d3c004990d1fffc71c6fd4 GIT binary patch literal 16541 zcmZ|01yGw$*gYB{2?T9%D`~0V?iz}O4+3dSJdG|TbIcMK%swV17T|Q9>VF3w#aZWxy2|m8>;psVFg`CzM_6OQj1acoxsLb#;4KYk4WCLnurc5MCj z8jr56{hn)n2<<((N?$lG%T*UkWB7b5&G%&k!Ae(>rm;^shyXhAWzhR3Ib4; za0^=yTl6WfHUD)T-|P03XsH25GebxrIC?-sJZx-~DFr{)wsmL@rz>*#)kIYIi>aa` zT+C+9I*h3O{x-1d5+PzFQ@Q|e$P^ic0{+}u2KSfhK9%hVvtXMJYknV9M|VZ9k7|}5 z`o>FV*2=VcjoDyJi8=}sH-Rh71aEKVSXT9Dh#kqKz@id{<^XGN6G9;&$fTqY{VU?` z`!_sIyhz{ipbO@fh|X=a8#2_*Rj8TUO=-5Iq1c;1MkmPF9Q@tEynwddcH6GX6(KUo znstFHm zisv?$8fk?w*oZ`ua&OZ56}+YqV6@+4F;0T5d26g^mLux@@A)+o*?RZ<2%l;7uiiNI zlHp@LtWK{bCE*S`*!*Ot3l9WF_DhXTyoip)&!g zBj%Z`Ltzywu3z-gu{pu)o@ldPr(P8b&artK^2mk4ww-LmSFtK(HHe2;nd&T5bEb~$ zVJ9lHBUVKm?5JQml$a~$(NS5P0N#C^*PB^7=vuz?IQ?)l9Gq(aurVTm<5mpX1NRlSX=-5I8}taJzIIbFoBxq zaccQ)HO%f#RIOt5s`tg1vD9c$`FM^aME$q)jrM`=jMMHcK zFom$CZ;?>`Q*;%AT_i-re!{>^Z_l+fx}d4}4=u)5wb*1?Q1gMQ?UIP5zEUu=33Yo` zjGopES~>CGR(nD8O_*ihnV&u??1zx2-_*Y)7CG5aiGP}+grm|%rD3oQ6ODTp*GhcT z8InFrJbd6seelk3)1C8y>&M;iLX5I^(fUtmqfqyHgLmyT86-~>*fAs@ee}nX%y(vr zJuNgZx@;fjhHDEhHBOpRh*t%~j3XW!y(tGqDikc)Lyq+ejH?VR_vKkR-6>WcHgC?s zN0t_L#PKtAOF3mqd6Ydw)s8xUCguJ6$3Y|f z+uv_z^!&3Zqr3S6y81-FAdI1%MeMq^sk1Jvd$8Df4Sz4{~A#_@%(r;wpY?uny4 zs_OpgRN%JU^+N}fkB>30YP9dd!uik2xNy~bIXBaq(J-eubebtg{lcj)xq&L z=?{uHbd4Un9>o2ry_F}VX}j0)kSLC`pHLpe*-!J9ImPM5`B~f?F8NANu8&;kaE|a$ zI4o$-KaT!JHWzOHS zS}4vz|A6`G8A1Aftv3;66UfEx9@a}IwCqv$FPAwaOJc3f!b?7hHD2*FP+5V@umx@6 zDYK1MrJMuRx&4){=8^Tu!#TpOrWJ@V3$*WGRGPFaLSJ+Bq;4X7OYJyU4fLmrpFO_< z1Rk^ho!kQc9wyteuPRWtei&jI`bKNp@1XqY#JhOCe`!u)qzDU~j7|f6v^+mi&o1)) z$64CbN0A|e3z@m$@fnSqlOR3@>YI2gD)>p6YX6nsMocXtpl9C{nKXb@hqHBTzj^ou>K)AO* z`WYILTC68s7f2Ehv8*k0>GY)%696;RJa8Enr|@K^CQ@6|O?CF02x+3C*7?C6>cqOz zW`ZjG7kjQH%F{wAyQ|D& zFID;K>nOeSQ7*b!ywe6?AIH1Gp68OBzo|D5;hNYk*qk_e^+8y+6Y{i(kQ;X-FL-ki zw@538v9VoCSF+3r46LyW-}p_#PnD?0-}Z?nd|Ze>zjGH8VzFHQSh}^0w=v-FiDdvM zE?tRKE?}@y!sFtR_>tF5n8|E1CRFA15*VGOM}>e&LtE1M+HKko z5ger8d@g+uUmYh57oZ_#gDK06a=zSidSax}jR894OLA4mgS_WJL&Y$F%_r4|YG5sS z&T<(N6>Ed8*J&*bE?GXeiigCS(AUev$?sWB10WQpzRLVF$y%pMIh@6r+Je%lR4roA{$=-bc4C z4mWBSu=*lTjkWT6?dUKXv&!KeZq|=<($l_P>(B=%~4hhUzV>Wuc1w&pr~#5Q<5+uvA6Pl286W+x~M2?+`ksJ zGw~rN7gI{l^(Jy7oJ&OKoVv1oY#3j~XbI`S8KZo-U$}nmbd{4Y3U@HwRUAgfMLt@S z&CScr5I z41&G-A7;^aFT5&DHMl9W=YhAkwJfW|TrfrwZ8;@Lv^-UPUql=^#JxA84>}s9O!^5> zuu$CpN3E*}eq7*Y;tF}gwsZ@)QZaFK)Ve;?vk} z6mgO(`!kP_)<+|B$;FU~@o>5R-E3`8xc>EbQ4Tu4oLh+!?ut=fvVpUh`>nF%Ns=Q^ zgjj%%pI+;Y)uRawX~l}`fAgu3fT*bsJcZ`=H|JU#YF|0E5?iLTafmll^!v?rdFdce zjuNTV3A|#P1E&O$u&jJ+(f*D0Se6T9)!6BM$lb`P!O;|9Osf0XvloEXwGG(a2F#1t zz!fxX; zZ|qRuZiMEFt2G;{TzaKTgQ(8Sh%#KivBb5zhLL)Dd5_Cti?{!*bkSsbT=AHsa+s3u zU=iB#fmw676!_qHFXGT|3S84^F?I=0sqgjuoC*-16`Yf%c;V!6-SF75-*0_2uw_>N z%(oc$82h(&2KhKKoi`Ph(-(WnkLZp3!>MF!>iB(}I+1`gCIFGZD$>OSg-i&)_d|Kg z_wPTH%pX~JFmI}V5dB_>L~Z-XYyW#`?O~&N8ZrGv?34Y?kC4=VdQ)q)Xd!>3@fi1 zCvQ3F%FD=YuRvv0>n~4}F?-UhSJGtd!Cil~eqwfuWH7CdHIuufKaYUuvc8PJKzuM- zF*gSO4r=RIKjOZY?~8d&=0L@V?SI}EN%F?ojZ_iq`tK<~`F~O_Y!z!dnNQB>M|*O1 zNPj2&K(3W@@_qjP?GuI~@Lt4Jv-j&Dh7_gwt3o+Un9oJazafPCT#v1%96-p_j#hA6*^fpH&X>a_Vc602wdy`sfuy)J9louD{T zy6g)+OwLWjEkm4q^aVN%sh9mpfG+mF_=Y0{)c_0E!aYKRvvN5~n`mOpf0&l9aF`y>f! zhS83|AM&@s-QP+28!+T|PyS^F*t+eUSDh46UiGAQm{c1# zaL5JJXNZdSX@O0X^gX5M84M{_$4IR^V+G{6JYFfO5zrVva5nq&VN&8R znX)W>IZGZMJo2~DU6@akh3<^#mGwgE$cg2~dVXpHUH1#D`-Ws*Iz-( zR&CQZ`#PG!tdXOyT%d#SsP?FO`?fn@;hh$jB@JuGibDjdl@%F`Gw5%iDVt5p3UQsw zZZ*r!HCfLbx)sfk=rGw7Zj}Uh(`)26O4gGw*phXZftESNUnf$ZX=JO!5icAOfrmjr z?*pmLbb`S8LiD>OIX^pTA3oX@t08RWb3}S&x=$YF#mAoKF>pofZ(u5{WTWd{Al>xx ztRt6fdFciJANE20+()xO z6`S>s#r1NvqGignbej2!TmRfP{^FJ4)|;?&*Nm)0zA z%N>Z42?1K~gBK(o%_ctCM>?LEic&#~-pJj`b^A!nqND z^4v8C0elA3*r9W}m4_PJnf}8pV%KY}N0^X~eQWG! zjZ=x2FH7|?EeQmVbylNwFsD6@qUF^1GBQ_H7FwC*q&{L)>Z~48`W?pArQ;`@s(G)r zo&Yje>sfGS4}qBB_p81GVK*9;pa{U!W~G2GYf zbH-LQ)CIW}*(r?-5qHr4NQ7xu-i;iB@pmQNJ0OUBH+pT%Rr(@0Z>OlmOs{s~3c5nB z^wk{TS>k558DhCcJ=F6J(3zjg&{d5teswZmPjt*cbCg>4D)0uJ;F zLJ5tB@Au zhcAs=PFlO=X+wY8l`x$@iaZz$)QU&gzEYGVkBE1_i7kA{XAl&|J4g@`fQ?@kMA>~- zvjS6OSC#uCU6{PAmrONvmD9Q(wcK?{a^cpr(4{@%8H(9hjf~oYPLC!o`|Migd=8Gg z1#hqpCSBi0Yn@ALERcFS^`wShwiK?&Bshh8bW3l|@@USb`^|nv((j4UU$gJ$7@Gy< zg@d|9(2|W_EvBZ!D=&mn?wqd=Ns;2YD43hhB0?(|a;*7jYzs5HXGpnAfcx}=NLU?T zd__OM1iT)*u~%@n4G{Nw10<{UqAk3L^Prty?n}N{j3y^GmrNI^H77DWKE6>)3SY$< zOPd6Prx#apguBpmi&9IT;bQh5t8PK2syo>_uEkF$1inNdu%*-z*O+k)sn6rk#MbyD zs|9FfSoV{A7pRR=;+xo>u)bR3M z9c?eU+0{w9Ir32_tC>gZz>%;28bFf29i4WVV2+LLjt=6Oc|HG-K`G69KGRu#VAap_ zTfUfnXBQHX(SpT<3Ai5ud1U8^HZmT*Dx_6lbV4P_YOIR%#^ztmbH z5^lHsO*%YZSKcs&wi9l~x!}1Sg%uKtM}LSpLx@zAj>BKiBQC`96(L1i=~txi%LCfk z+BiKH5iHNn!M|*o|Mt5Z+_Z}c8K|#&OO;_BV5Qc6mhxe9*5wxR-Qq*Kwv{d?;ST?u zW|X~C1|86_Zn92n87)fpThkKZD$pvm!p+4E6-Sij6+Z4}Y>ZH;Lj4o>+v|CU*q`nZ z4Gkkyh(Ds9fKt>mS!WqXgj28c8Z2gI-2E;gckIpI+L+Z+t| znuTb1wlYSZo{JYhTklaK^mSY&YJ(?mo4Ba3Y_65DrI zDt1_CxyO9o-!?hpt3Cf_u*axz*GrUu&`B-F9$Mn@$sEVF+>e;* zc8`p3RF>asDB1Gll#Anv==E6D_&IwXGIWI781MGe_@R<4a7BUD=n-j=%0qUF+`={{@^JC) z@{u%RH1vztsXqBh&;l6oE?M0x+0-?zcsR7q=dG7;p0!nTkrXuX`!8Y+S-?^rF4~&r zS=1i=9*B=|T~CG?hM!=z-5{Rak3CdpyH&4UUl1Q2v!Lydz!I&;MEv^0?e$5{e9yvc z{9Zf1Q#dPZ^vYOzx|yX)nPIV)ZoX{rhyBgS7&&Xd_K2**$hl zlsQw)zq<>2dAeF3kcE+kcf6n#j{qJ|~Tah+D_{LH-(>vX?OO&2^6m0=s#W52>jZQ+?hyO0uz9`8^&2`Y05Zdjcb^ z#|18{5hdg=I17k7l};~fIa{PQii5G@7%$BClJE(GCP=hM=b0#~_WNFKD;M66?Dst$xU|%I`iT?S#8RwrRYyq$?(m6w`Sz0 zIUmaw^%LTl)!yMCiW1?rQAvxy%<1hav=!sC@jW9VOpgtLK<|0nwqGfUM$q>IX@qrS zrD_hEU)nEl&6DpM!aXzyoEobiWdhqqEd>U6wQTIAr%HlG^&V9cn?qOQyP^F2EmC7H zUpyd?Nvq9Qx4_X=e)LK(-$hr2nmX{;-p;zRB-#`^rR7cXqvS^oMkJ+8Rhe3ftgP|i znI##Z-ajXkwzRv(C>CmDT-Edtwt_A5=!lTJvEmw04!6%yG=`zH1rKv4w=k*ElZ@pA z?GC$weFiPy(WZneo1qpy3t2u5s(>M0h?mK~AEw}y?`8EWOvohqmoj9*7`1XPg4OwM ztcUe~*tY)8W)PKu82Qo_1FUA&;gHY!@` z4ip70QKk4u`_f-4qR{$ynwef)X$X9-FI@jTrO(p%FVIceM?u&b_0!>2D>!U8j@EL84!!FP>i ztz~RIrf&2x2mkpiGJY^M)(2>(a>-h?(NtH!M(u)CaNV81NRdo`t+*uRutG5#erRW- z@kT@Nmz6X_DH_99S2zx&f+c}#cx%AVhGA_%aFAkIFo2_z3SvU31G#7%g>j==Qfo_9#}eY603go+|xC z9+`7)aZCb~VC3Cq{>>X9-n|jl#M=Gf)^kWVPVid3(Hg}Q97$@Q?S{iaU5s#BAKyki z)y?>TR!G-$)qD#3une630jY4Tp>LBa_$L~fuywKVaeA>wy6L;p3l zuJ#n|z+ESJ3e^niYDhGMQcb>Z81X;RbN%ZIJE+}#V)r1@``h0DQul9%KThipP(gkU2Unn`Gx#pe9)8TnkVfR>^QN_1t5`zdcS=<-^!ZwYyW4Qj6^pX4z43j@g)JtAX?G!628C6p)o_*i8dK1->1 zl}+c+9r%Yyq2XWbIR7^NDz5G{)9OVNl@6x%#6CHV&fyqXVm0Y3=n^|_wSbQl}^aV zW|?PZ8O-jwx~Rk!_GaRhQ6%$1EM7G&K|_!Y;?t(&EBk`%2v;3*17i6AT>aC{v2XH5 zRr;1g8AA*=3SRwpSy}p}Z`9^vTfNW2_NAZ#zoncSAnp0FMxOYB&-()d`BRS^rH*j_ zXUusWps12e%WRu>QY_)Wcqf9EocQwB^kw6T1qQ0;%c1s&Ep|U(&ytrEN=bO|jkyT= z)h|;5)n9=h#t`M#YEoG(VyQC-LCWaD8jFG*Xh1>9mvv46mh4`H+1GaYt-YiiM6r)< zECcYjw|9&=jD)>7;gq{uW{vhjf=~52!#^lu^Q8KuAA%*1q2x8KZC=vP)|I}O&_+Yy zTCfxe^Z52aS5(tLhP?pbWkY<<9A_8IFbVa<+!XI|BNT^DSwL!J!rRbsV9B>rx*_z{ ze5%7dQN@p}XiUTF{G5HZW%T?Odmqzc>DQm!Fm=Led#L*+k%t_z`MEms}w6 zQL$`UT5s?IjT?BMN&^_l@B{eT>ICt$tAqY~jx32ptibWZWw%_`+;V-Om zf?GHZm}06&sgq!RbK=?gf0@1NW^1gQZ>t4pzmh&ZVhI7F2b`RMN!FTYs{iy?zE{SjZ<{5Fa=##7WLouz+qaoQ>j3 zCUE#!f!h>8&6r410OD(u*OJ-d{#ZNj(6=F=1)~$#0^sve4=sW$-eJE4`)qPsp#!Zx zr;sg${?f*QSjXN>8&SxQDp!)r{j2azu9S#8niiT-Ulgp&79rm#Sow+PdfNl<5FJYX z<@1yiIaS@$kK?`)*$a(Zc*o&TtQ5-`@5sb9VLT(o0$B}o3hU}ZKso&icmX;eAA|t9 zEgBp;`jFTIUv?hA`OiN_yfqi=imw~fk3=*XAKg>ghB(f(ul{651XryNvbXFVqISq$ z^<8FD4vTl7;X}?-IuhuG)jI(!gc&ZG*6=(ZO4sK_B#v3H@Bbu6MV1;pkT6YS!T&Cj z7gXC7R8|96;hh)m=tKYPl!4VWNEp^EZ93d8-t*dTRUQ3t8El zh0R9#S6;F1&rI5Vx7BgfHZad0rvrQ1At#Xs6T<|X?be7 zKIE77^{f?a>iIaK^J7EqddeRix8-&s&%WKF{J2YhJKH2`n?Lsw2dWJ}HgOD3VUEWJ zGAnq#eq_rzta;fTNUo`KJAZ4!b|*_Tr8zm&Y?5@vd;vRh57lYJ0Gc@Dx#T^#_C5Kj zMcD@y^YobF?I4yLJSIPh0acBBAZ9q+{-2aKDq3^lUYFXj}fu z1!X<`F6krUMEPMC6G1$)M{gyx{b=iwhSxwCWb|^4;a;4`?UQ*Q1b5S^lo$HZnz}W} zz0v>0PncERboU60f~ZRt&K7U*>Eh-EX+ULl?!oMf+Bs+BoU4wjhad;vd>R@#y2<-R z2dp3S85C8>7)pai?2bhj}0X!Znoff?d8`1w3o=haY^<2D^=+fFS~6Dl|}}N z-j;KQ{-7w)^e}LL!%{#1WnZjK2XobfTr#O_g#zlKJ{I3u%EnwN&o0jC=QGbSs>4Zy zB$c5XHIV4G+BuD2(lv(Ig8e4=-jI8pbyB#o=r4K4pi-&NA{dX5(!%0zBaY(s^_GCc7MrDFT5%w^>n)&vcK5y2-->-t{=)?p;52oAsJbPpbF@X2dOk?Cam-UPlJF8dxci;kM!~-G zTQcnx1Rafw_K>{qE^muXkD(~Fxv6*pSkuWaC+-6BPwQ`!PymA}BPWpO4-VVT0x{|_ z>dz*jy=L*E?Rvm9_Nn}D1qlu8r8zque05YJeP76P3f#SX!{UxW##82t4pD6)Bn0uF zt@fbVuI>GkwFr@)++52j3K{Klcxh)@k1cBCaiGudSN~bU9xMIlpL%%8*ZuVdzghBi zQU?d0=xBDWmWTy0tvKCO&c8IUql;8xHPrkgo@mg_n`k~Kn`kpbM{;f2`yAH&Ih|6` zB4U2kxn9;0O}FPvH^(q~^VwI=43@-G6_oZI@m9=M;SwFAlA!I;LsCMhAk9SXuCYO+ zNxackMeI7!p(}I6a>sOwSCO%w(t~%>dXybYaD^&#t`K5Qy+=_2V4pp34A>)UlY<;7Et{;;9EB{5?#IfN;=Tc!^e3VlIUB}SCY@o~ATqjT zIJ_)5yl~Sm*#XH3O#j^5I7mZZq^SAJ>)}=(Bl4~2y@vc?9 z=TIBBtI0$w3gEW;x(>qWJT?%Lq62@N5de*zfMdl!K?T~X=e=xTy9p2f69dIt%2;tH zN>Qzze7J*7BTVqd4mO`;WG`ch8K!g@UMRSYv`EDrwqD+MUuSWcu|r~J-lH#7kc0bV zY@RnqC%0yBVwvId*R^g;1khWLjbuAytmO6eed_*INLu+5JVj%%IY-j#z5ch@*7981 zCums`Kcb7?vOk5Ja>Kf#XRTb_(@Xj$KWV>+1LJ=m%ClNhwQJ6Ia`Crr62t=iSKYXq z0_(+|6(+LCo%1vWeP+VbNF6b_)dlti5SbCVL=LL%!QeA~xsQ=Se5*#`i^|GqNTBtt z7GG|Vf>uaeyrN&a2IPpcTGo%Q+d%r=2Xo+`o=}-g@?i~!B6F{EGB&kcHP+K)THWpv zDF;R6)*9*4s(v5KDvmLD7&zd!U)3nF(o^%`aU2Dk^v!ayw>lc9$Uri`G?NTvkw~+uM2< zf+=%RzUx)cshXO9=LCGo{z^2v;EOM9(cB2vGBTfI7)~?zmI}%JvEcI|0pT|sDuhUr z(?8Ip7;qoeMl9x6TqB}4f}FJqT9|^1X;2S>eObFA0bV(F%JHI+kseY+(el6i9{Is= zRqdO?`r$2dqtsLg)2GzMKjQxr_t=Kb2aCRZA@rlcHkk-wRcS+$>k4mh^U+@_O5Jw5(U1pcc(xlVnJ=`O9Xc2xN?W|uIZk3+= zBbvOZC3t~1h|Ipcu&rb;=J>Md_!zjXIT*R@)q2KpSrA=mImi{#KgVe3+~g1py5bP+ z`+`VtCQT)eaQwJykqyr~|K;|C7y4)TB2}91&Te2l=yg1I8{i$SJNBu&-O{hfaem$i z-QYA;7U0yMql%!18=~na%#P$L&bmW2Oqw)`Ju0$y3S*o7V@YXL-$p%dtMvMQH{bn{ zS`8-LDViscH+*v}qPN_6fGGU{TTbnXt-I7#4FfqGIdg3h2#lK)ykf`$Cs+@K5`DOr z-LZ`TRk7bOLfq4C9?e7tW_~FX%)10IC2SAaQiH@^%F3(^zA3sW^~l|m`g<6=&|+%7 zkKfYq|02o|9DbD~6{Ls7We(=8= z?$_u~L{=GI%Ewg0p@k>s!si%@WUW=JM`UZY7CFWurcZsoCp^~Gyfaupo@?-~+_|kM zY3(HZwfLHt>Ej2el0DcZ zpfv}$QQ~%@7!iGRqiM>IqC8oa|4%1R=4@R5a2npgzyEZ~gMvNgJYIh|UCsQfIgHEi z$B?rOLt(6iVD>JHrAJOhNj+)1p`FV_zzxI39YY?D#B<6oMXH4mulvK;m9pz^_lGxD z(k^8|UZ3sW%#fdE0gK)`3o~Xc$p~kHhXK;@y_ZwVE6~OIYa$F=k5J zyf_z4qvP|mk${3+99CatYMjeQIMAbN`0eF09T#g{A(RE(&{ki3<}mCwShu%0?O7uz zE`)bCveHe^+{7<)bhQx4e>X+869QRv5f=7<-yhcV44D{szQYYhUK4Zt4#538-a_5& zFB1l>U#1m8%L-1tj)W}>e`yR6h>z)%Y(V(tac-;U$7Wq-vJ0TX{?w zc*_az^~UFoZ&zv`-H>X}Q@)PF`%my**e{kMJRya6!i=Tk`sgy^rv3d!NR-@hWs_@j z6BB$D^gxM_?!GChi5yGYSXpakkLIpv&1(+R=af8o6pDk%X4`TEOn%DJfKFQRzXeiI z+o%Nf^C+Y~E`}`~a%gCo>Bh64IWLQlI}vFfoojKzYg(qofCm;=wwEOBBCEzYyTufd zKY_%NHc8x7)yQ^B8{)4rI=gUddz|{DL~KZNqq+ht_MEtVP(lZ^?Tl4 z$s-&KeEW1YPn2#bW=KcRiH>v@2r6y0-!bIMF|!1Pta7xJ7_&G*lOLcx9|fb>Y!kpQ z;8s%{CZ@uaq7gQpTPtq`kz@x=TC;G_t~z!hH~fpKwrn$FcduQu%ofnYjN)no1XU-5 zKZ=gL169gH3RF;kc6&};OZ-{$4W~mpPuE7CJ_e_nZrlgTsTAWWxleZ;6}&!(+T`7d zTLyVGU)ImgBbs^53?_+Q#D*20@-K#5$F^{jsnHs_5T6#$Py1@9OjN}U^hh$_ZPo;HZMfSCt#3JW$e-U( z*L+*+_(+C`WLTz3hVkO8+BAu$ld$gh8`76tCN4l}tc^u=lTbzwL3zyg}09s@OVpFA4N zBiPDMkU6(&Ii_Zo3In|;Fd1qDuGEh7V*UB%ZmQA)2n5~l5#w}#vs-s|Sv3cZP9y_i z8tYbv$b&AX7Zq=3TLfGEZvqeFv<+kvJ5Z9&m zCHLdL@dvJ}E)I1Y)*TUU)Yi@kcp-~VWDKupB6X^^nQ_RmrFC85ma_WJay4kg$qSq^ zV`oH}fM@Gvu^-5J)ErCYyCjVmL>Hed?|`42@cQfZzbTvGY61Z5W>LSbop`bd6nn znKVY;UynHB)Tx#9dtO&M`tCn-L(>%OvD+5m#rMM5h=iqaUYh*@3 zX1K3KCG~_5xbLU8+uuM;+H-M}34%YJGW?l)Hez>aZwjuGlNE`1y9&tLl1z96wCA~X z%O@f%nDP(37|tY_%}-UW3GKUPc7v&-w*uD7%h2dRTMQ{%h_%xr4VdcO@tN%-%cA)C zDkBkl#*tUc_b}t?dEe@CnCSlTnz!Bhz)f)Xmj};SYr3{~1qDxLnZ6!8rw~hwuit)z zx7COY9H0$zuN~>FHqQh~Jgi>|> zmlIy!;s4!2cpLV;RFbx8i+nzzkpP2QKgK z7}3k3o7t>~++fJCqm|f8VkApah)|Zp8)@@jV}A3*TX898E4~<4GWh&-<@ot_T?Fp4 zQuD$EM_wZ(`KUi}aYA&M-o@!&5!}Iuu6;6TY$NLA6y>6G#GQtPMwu^n*}9=5I0wH8 zz}ktN>R8h~Ys!tLJ_^`IEcjXO=TI-Yl%F3BWdPmkxrLLd!WYCU98`tqbi829vc#S) zM%g3$xAkRBR6Uh8irKL!eqrTT(FDyN`#0QPtuwAsVLgxctEQH2uISD}U~W?hD-J4)9`lonYNfZ%;3j`^h}iR58LFNsMh&{fGG9yK1hiagAR89&u!IW+wsui z60i9a9ysF7pMo|-=S!{`z8?<%i=|$ziv%^iUhfiA7{=JcoWS@9|#fD$a_X`W4sMmlYynGW^gJ(1=n%6firg5SWvSewX zKx>Bn$%AeZ3#CmZzWVwBFAh43RVrI9pxLVsUp5h{vNVXTb1S1A3qh^&(7q^0%cRK4 zh)~6Mw7+H*<9GneABXNXIqsSMg>oJJEfCoyOlml;RGUGMNMez_1q4rKO>RwPZiyg- zVc6aoMSn#?G~K&j&TVlyFJJW`HxIrCrIY@f?RdL5_<&8h(OA!)ZQUAg#%iwSf*#+o z3szPMkEAP}!yn-Zjb4CkrPR!D4nb`coK;qt@ZQtzm8W>Yw(vtEuzkJCBW6ghzglL8 z39?kW>(TXWk*4xnww2NOMCVqE6JqZmWQQ3vvKIDR5V%6LuzL@PXSOyWlr0-eu5smn zett+&xmT+{_ib0Xq?`y3He(&?m6KalFWT*q-h;KZEI#D&qeU_0TkN@!C}=6!;8VA4 zrwn*HcC1>&8Im;TkXDjLoTRhag$L!189&+?!&}09&f<{IK`k3Be;tsg~tZ+FGi;ma@c zL0`n|csu;S>Km>%m;tt~-vc?n0mr_2+q_c)}R?EkqQy|-|-qfzWp?C2| zy0Y!ET3_&r#f1o_s5Ur#$V7M8{@^D)GY*-)3&<|NzV6o5?Ci4BV<7JR+Rg0&My6%d zaO^6V27kR!&#J{p>x4XS`-e&Ud8n1eb2p9$vfp8?`bRGZlSnb-uiqi0%Y zO-|IGtHmiF6&jK8ty4G0Rg<(gl}E+;LOounmN5Zbbwr#((D!<`9bpvJ>^`HPTb{nu zO47IFGnjvM0yr=HVt)nV;%^q5XHBuW5~vJk)jE_`^m@4*RrT2Q>~E&MCDjpFr%<@wKxEYjp~W5J(37Xf%267%Rsc$vWA z(d0a0dNS+-VLJR~?vmoBXa;f{B>E12z|HGl6IwOfRk}cSbph5{08t&}fm|lSQa_(@3fS7{@et_^qSc4Yqo%le*J!uBx6OQsT$0iiQ4YCR-qq$&acPZu2~r) zLY?ATJ2Vb&_wBR16sHKC`5W^KAyCB49FK^ZUK3YFkF<_S@{?AK^)NejqQ~>L$Z_)o zyT2S+8D(HwXOsD#Il-TV#srQ8mT95o`ZZe~2hA$C13}21 zjDwchOzkgKA--M00&-DIucFEO3D&-{(9fX+1=2rLpXEIn-+$oV>}hU9gcms`M`TkB z_WVf{D7)}CiinJk-38(K!;trsa#PG^7)5<|v$RkO)DzaexHOQ2a#j*)dDlaUj z8d$6A%g=#QjJoV_td!mVa)PNMYG>G0pOHq0m;_ES|HqfdIzxMB?vz`vqqMs|r|k=} zEp()*(vxmsYmlFxAg}qWY;2xH^50Q2Edaj${^El1dL?Y#bMsa;Svg~L0_9AZqMH+5 zxL#r*@)fZ)Mu}WUHZHFI>6AwswLGw{31u#wY8avedZPFxDIjIq$fIjegR2zAfS`iu zC(k(9JI8h7w<>ub(Br)VK1{=^X<54fe&adk0Fa?sXh4NEO{D3z+$9x7ZqeVJG4$nU zsiNM64U3M0bBh|Oi!g2A4Hj@!T@uSn^aIW0cy)))^u`IV;JSQKDzz6lY9496)i7td by#;7s#Jg%p=&JGO^a0Nm)fI}LT8950257hp literal 0 HcmV?d00001 diff --git a/internal/web/server.go b/internal/web/server.go index 6d4a7b3..bc88f5b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -543,7 +543,9 @@ func (s *Server) notifyQuestionAnswered(q *store.RankedQuestion, answerBody, adm defer cancel() if err := s.cfg.Mail.NotifyQuestionAnswered(ctx, msg); err != nil { log.Printf("notify answer %s: %v", q.ID, err) + return } + log.Printf("notify answer %s: accepted", q.ID) }() } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 15945a2..b86ffa1 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -601,10 +601,32 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) { if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") { t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String()) } + if body := rec.Body.String(); !strings.Contains(body, `class="answer-editor"`) || + !strings.Contains(body, "Edit answer") || + !strings.Contains(body, ">Tighten the nuts.") || + !strings.Contains(body, `type="reset" class="btn btn-ghost"`) || + !strings.Contains(body, `removeAttribute('open')`) || + strings.Contains(body, `
Answer

{{.Answer.Body}}

+ {{if isAdmin .User}} +
+ Edit answer +
+ + + +
+ + +
+
+
+ {{end}} {{else}}

No answer yet. Check back after the hunt.

{{end}} diff --git a/templates/question.html b/templates/question.html index 4f83855..3e34841 100644 --- a/templates/question.html +++ b/templates/question.html @@ -23,12 +23,12 @@ {{template "answer" .}} - {{if isAdmin .User}} + {{if and (isAdmin .User) (not .Answer)}}
- - + +
{{end}}